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:
parent
242becd9a4
commit
7cd576911b
7 changed files with 921 additions and 355 deletions
49
apps/backend/src/lib/api-key.ts
Normal file
49
apps/backend/src/lib/api-key.ts
Normal 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;
|
||||
}
|
||||
24
apps/backend/src/models/api-key.ts
Normal file
24
apps/backend/src/models/api-key.ts
Normal 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);
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
};
|
||||
})
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue