feat: implement API key management features

- Add API key model and schema for MongoDB.
- Create utility functions for API key generation, hashing, and authentication.
- Implement endpoints for creating, retrieving, and revoking API keys in the auth route.
- Add API key management section in the dashboard with UI for creating and revoking keys.
- Update user authentication to support API key usage.
- Enhance dashboard to display API keys and their statuses.
This commit is contained in:
암냥 2026-06-25 20:26:15 +09:00
commit 7cd576911b
No known key found for this signature in database
7 changed files with 921 additions and 355 deletions

View file

@ -0,0 +1,49 @@
import { createHash, randomBytes } from "node:crypto";
import { ApiKey } from "@/models/api-key";
import { User } from "@/models/user";
import { ulid } from "ulid";
type HeaderBag = Record<string, string | undefined>;
export function hashApiKey(key: string) {
return createHash("sha256").update(key).digest("hex");
}
export function createPlainApiKey() {
const keyId = ulid();
const secret = randomBytes(32).toString("base64url");
const key = `mizuki_${keyId}_${secret}`;
return {
key,
keyId,
prefix: `mizuki_${keyId.slice(0, 8)}`,
keyHash: hashApiKey(key),
};
}
export function getApiKeyFromHeaders(headers: HeaderBag) {
const authorization = headers.authorization;
if (authorization?.startsWith("Bearer ")) {
return authorization.slice("Bearer ".length).trim();
}
return headers["x-api-key"]?.trim() || null;
}
export async function authenticateApiKey(headers: HeaderBag) {
const rawKey = getApiKeyFromHeaders(headers);
if (!rawKey) return null;
const keyHash = hashApiKey(rawKey);
const apiKey = await ApiKey.findOne({ keyHash, revokedAt: { $exists: false } });
if (!apiKey) return null;
const user = await User.findOne({ userId: apiKey.owner.userId });
if (!user || (user.role !== "admin" && user.role !== "writer")) return null;
apiKey.lastUsedAt = new Date();
await apiKey.save();
return user;
}

View file

@ -0,0 +1,24 @@
import * as mongoose from "mongoose";
import { ulid } from "ulid";
const apiKeySchema = new mongoose.Schema({
keyId: { type: String, required: true, unique: true, default: () => ulid() },
name: { type: String, required: true },
keyHash: { type: String, required: true, unique: true },
prefix: { type: String, required: true },
owner: {
userId: { type: String, required: true },
discordId: { type: String, required: true },
username: { type: String, required: true },
role: { type: String, enum: ["admin", "writer", "reader"], required: true },
},
lastUsedAt: { type: Date },
revokedAt: { type: Date },
}, {
timestamps: true,
});
apiKeySchema.index({ "owner.userId": 1, createdAt: -1 });
apiKeySchema.index({ revokedAt: 1 });
export const ApiKey = mongoose.model("ApiKey", apiKeySchema);

View file

@ -2,8 +2,10 @@ import { Elysia, t } from "elysia";
import config from "@/../config.toml";
import { jwt } from '@elysiajs/jwt';
import { AuditLog } from "@/models/audit";
import { ApiKey } from "@/models/api-key";
import { USER_ROLES, User } from "@/models/user";
import { createAuditLog } from "@/lib/audit";
import { createPlainApiKey } from "@/lib/api-key";
import { ulid } from "ulid";
const hardcodedOwners = new Set<string>(
@ -12,6 +14,29 @@ const hardcodedOwners = new Set<string>(
: [],
);
async function getSessionUser(jwt: { verify: (token: string) => Promise<unknown> }, mizuki: { value?: unknown }) {
const rawToken = mizuki.value;
if (typeof rawToken !== "string" || rawToken.length === 0) {
return null;
}
const payload = await jwt.verify(rawToken);
if (!payload || typeof payload !== "object" || !("id" in payload) || typeof payload.id !== "string") {
return null;
}
return await User.findOne({ userId: payload.id });
}
async function getAdminSessionUser(jwt: { verify: (token: string) => Promise<unknown> }, mizuki: { value?: unknown }) {
const requester = await getSessionUser(jwt, mizuki);
if (!requester || requester.role !== "admin") {
return null;
}
return requester;
}
export default new Elysia({ prefix: "/auth" })
.use(
jwt({
@ -418,6 +443,115 @@ export default new Elysia({ prefix: "/auth" })
}),
})
.get("/api-keys", async ({ jwt, cookie: { mizuki }, status }) => {
const requester = await getAdminSessionUser(jwt, mizuki);
if (!requester) {
return status(403, "Forbidden");
}
const apiKeys = await ApiKey.find().sort({ createdAt: -1 });
return apiKeys.map((apiKey) => ({
id: apiKey.keyId,
name: apiKey.name,
prefix: apiKey.prefix,
owner: apiKey.owner,
createdAt: apiKey.createdAt,
lastUsedAt: apiKey.lastUsedAt,
revokedAt: apiKey.revokedAt,
}));
})
.post("/api-keys", async ({ jwt, body, cookie: { mizuki }, status }) => {
const requester = await getAdminSessionUser(jwt, mizuki);
if (!requester) {
return status(403, "Forbidden");
}
const plain = createPlainApiKey();
const created = await ApiKey.create({
keyId: plain.keyId,
name: body.name.trim(),
keyHash: plain.keyHash,
prefix: plain.prefix,
owner: {
userId: requester.userId,
discordId: requester.discordId,
username: requester.username,
role: requester.role,
},
});
await createAuditLog({
actor: {
userId: requester.userId,
discordId: requester.discordId,
username: requester.username,
role: requester.role,
},
action: "auth.apiKey.create",
targetType: "apiKey",
targetId: created.keyId,
summary: `${requester.username} created API key ${created.name}`,
detail: {
name: created.name,
prefix: created.prefix,
},
});
return {
id: created.keyId,
name: created.name,
prefix: created.prefix,
key: plain.key,
owner: created.owner,
createdAt: created.createdAt,
};
}, {
body: t.Object({
name: t.String({ minLength: 1, maxLength: 80 }),
}),
})
.delete("/api-keys/:id", async ({ jwt, params, cookie: { mizuki }, status }) => {
const requester = await getAdminSessionUser(jwt, mizuki);
if (!requester) {
return status(403, "Forbidden");
}
const apiKey = await ApiKey.findOne({ keyId: params.id });
if (!apiKey) {
return status(404, "API key not found");
}
if (!apiKey.revokedAt) {
apiKey.revokedAt = new Date();
await apiKey.save();
}
await createAuditLog({
actor: {
userId: requester.userId,
discordId: requester.discordId,
username: requester.username,
role: requester.role,
},
action: "auth.apiKey.revoke",
targetType: "apiKey",
targetId: apiKey.keyId,
summary: `${requester.username} revoked API key ${apiKey.name}`,
detail: {
name: apiKey.name,
prefix: apiKey.prefix,
},
});
return { ok: true };
}, {
params: t.Object({
id: t.String(),
}),
})
.get("/audit-logs", async ({ jwt, cookie: { mizuki }, query, status }) => {
const rawToken = mizuki.value;
if (typeof rawToken !== "string" || rawToken.length === 0) {

View file

@ -5,6 +5,7 @@ import { MediaUpload } from "@/models/media";
import { Tag } from "@/models/tag";
import { User } from "@/models/user";
import { createAuditLog } from "@/lib/audit";
import { authenticateApiKey } from "@/lib/api-key";
import { normalizeQueryTags, normalizeTags } from "@/lib/tag";
import { fetchTweetData } from "@/lib/tweet";
import { fetchPixivData } from "@/lib/pixiv";
@ -216,16 +217,19 @@ export default new Elysia({ prefix: "/post" })
set.headers["Expires"] = "0";
set.headers["Surrogate-Control"] = "no-store";
})
.derive(async ({ jwt, cookie: { mizuki } }) => {
.derive(async ({ jwt, cookie: { mizuki }, headers }) => {
return {
getAuthenticatedUser: async () => {
const token = mizuki.value;
if (typeof token !== "string" || !token) return null;
if (typeof token === "string" && token) {
const payload = await jwt.verify(token);
if (payload && typeof payload === "object" && "id" in payload) {
const user = await User.findOne({ userId: payload.id });
if (user) return user;
}
}
const payload = await jwt.verify(token);
if (!payload || typeof payload !== "object" || !("id" in payload)) return null;
return await User.findOne({ userId: payload.id });
return await authenticateApiKey(headers);
}
};
})