mirror of
https://github.com/ZHANGTIANYAO1/teamspeak-music-bot.git
synced 2026-10-06 15:02:49 +08:00
feat(web): add API-key authentication for the REST API
- api_keys table + hashed key store (src/data/api-keys.ts), tsmb_-prefixed plaintext shown once, per-user cap of 20, lastUsedAt tracking - requireAuth accepts Authorization: Bearer / X-API-Key headers as an alternative to the session cookie; key inherits the owner user's role/capabilities/bot scope - csrf origin check skipped for key-only requests (no ambient credentials); requests that also carry the session cookie stay gated - /api/keys management endpoints (session-only, guests excluded, keys themselves rejected) with audit logging - user deletion / password reset cascade-revoke the user's keys - Settings page: API key management section (create/copy-once/revoke) - docs: README section + full endpoint reference in docs/API.md
This commit is contained in:
1 parent
2ea02f54d9
commit
aab8a004ae
16 files changed
+1272
-7
No files matched your search
@@ -0,0 +1,145 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import express from "express";
|
||||
import cookieParser from "cookie-parser";
|
||||
import request from "supertest";
|
||||
import { createDatabase, type BotDatabase } from "../../data/database.js";
|
||||
import { createUserStore } from "../../data/users.js";
|
||||
import { createSessionStore, type SessionStore } from "../../data/sessions.js";
|
||||
import { createAuditStore } from "../../data/audit.js";
|
||||
import { createApiKeyStore, MAX_API_KEYS_PER_USER, type ApiKeyStore } from "../../data/api-keys.js";
|
||||
import { createPermissionStore } from "../../data/permissions.js";
|
||||
import { createRequireAuth } from "../middleware/requireAuth.js";
|
||||
import { createApiKeysRouter } from "./api-keys.js";
|
||||
import { SESSION_COOKIE_NAME } from "../auth/validateSession.js";
|
||||
|
||||
describe("api-keys router", () => {
|
||||
let botDb: BotDatabase;
|
||||
let app: express.Express;
|
||||
let sessions: SessionStore;
|
||||
let apiKeys: ApiKeyStore;
|
||||
let adminId: string;
|
||||
let memberId: string;
|
||||
let adminToken: string;
|
||||
let memberToken: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
botDb = createDatabase(":memory:");
|
||||
const users = createUserStore(botDb.db);
|
||||
sessions = createSessionStore(botDb.db);
|
||||
const audit = createAuditStore(botDb.db);
|
||||
const permissions = createPermissionStore(botDb.db);
|
||||
apiKeys = createApiKeyStore(botDb.db);
|
||||
const admin = await users.createUser("alice", "pw-alice", "admin");
|
||||
const member = await users.createUser("bob", "pw-bob", "member");
|
||||
adminId = admin.id;
|
||||
memberId = member.id;
|
||||
adminToken = sessions.createSession(adminId).token;
|
||||
memberToken = sessions.createSession(memberId).token;
|
||||
|
||||
app = express();
|
||||
app.use(express.json());
|
||||
app.use(cookieParser());
|
||||
app.use(
|
||||
createRequireAuth(sessions, permissions, () => ({
|
||||
enabled: false,
|
||||
bots: "all",
|
||||
permissions: {} as any,
|
||||
}), apiKeys)
|
||||
);
|
||||
app.use("/api/keys", createApiKeysRouter(apiKeys, audit, { info: () => {}, warn: () => {}, error: () => {}, child: () => ({}) } as any));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
botDb.close();
|
||||
});
|
||||
|
||||
const authed = (token: string) => {
|
||||
const cookie = `${SESSION_COOKIE_NAME}=${token}`;
|
||||
return {
|
||||
get: (url: string) => request(app).get(url).set("Cookie", cookie),
|
||||
post: (url: string) => request(app).post(url).set("Cookie", cookie),
|
||||
delete: (url: string) => request(app).delete(url).set("Cookie", cookie),
|
||||
};
|
||||
};
|
||||
const asAdmin = () => authed(adminToken);
|
||||
const asMember = () => authed(memberToken);
|
||||
|
||||
it("lists only the caller's own keys", async () => {
|
||||
apiKeys.create(adminId, "mine");
|
||||
apiKeys.create(memberId, "theirs");
|
||||
const res = await asAdmin().get("/api/keys");
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.keys).toHaveLength(1);
|
||||
expect(res.body.keys[0].name).toBe("mine");
|
||||
expect(res.body.keys[0].rawKey).toBeUndefined();
|
||||
});
|
||||
|
||||
it("creates a key and returns the plaintext exactly once", async () => {
|
||||
const res = await asAdmin().post("/api/keys").send({ name: "ci" });
|
||||
expect(res.status).toBe(201);
|
||||
expect(res.body.rawKey).toMatch(/^tsmb_/);
|
||||
expect(apiKeys.validateAndTouch(res.body.rawKey)?.userId).toBe(adminId);
|
||||
// The list view never exposes the plaintext again.
|
||||
const list = await asAdmin().get("/api/keys");
|
||||
expect(JSON.stringify(list.body)).not.toContain(res.body.rawKey);
|
||||
});
|
||||
|
||||
it("rejects creation without a valid name", async () => {
|
||||
expect((await asAdmin().post("/api/keys").send({})).status).toBe(400);
|
||||
expect((await asAdmin().post("/api/keys").send({ name: "" })).status).toBe(400);
|
||||
expect((await asAdmin().post("/api/keys").send({ name: "x".repeat(65) })).status).toBe(400);
|
||||
});
|
||||
|
||||
it("rejects creation beyond the per-user cap with 409", async () => {
|
||||
for (let i = 0; i < MAX_API_KEYS_PER_USER; i++) {
|
||||
apiKeys.create(memberId, `k${i}`);
|
||||
}
|
||||
const res = await asMember().post("/api/keys").send({ name: "overflow" });
|
||||
expect(res.status).toBe(409);
|
||||
});
|
||||
|
||||
it("deletes own key and it stops validating", async () => {
|
||||
const { key } = apiKeys.create(memberId, "ci")!;
|
||||
const res = await asMember().delete(`/api/keys/${key.id}`);
|
||||
expect(res.status).toBe(200);
|
||||
expect(apiKeys.listForUser(memberId)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("a member cannot delete another user's key", async () => {
|
||||
const { key } = apiKeys.create(adminId, "admin-key")!;
|
||||
const res = await asMember().delete(`/api/keys/${key.id}`);
|
||||
expect(res.status).toBe(404);
|
||||
expect(apiKeys.listForUser(adminId)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("an admin can delete another user's key", async () => {
|
||||
const { key } = apiKeys.create(memberId, "member-key")!;
|
||||
const res = await asAdmin().delete(`/api/keys/${key.id}`);
|
||||
expect(res.status).toBe(200);
|
||||
expect(apiKeys.listForUser(memberId)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("admin can list all keys with ?all=1, members cannot", async () => {
|
||||
apiKeys.create(adminId, "a");
|
||||
apiKeys.create(memberId, "b");
|
||||
const adminAll = await asAdmin().get("/api/keys?all=1");
|
||||
expect(adminAll.body.keys).toHaveLength(2);
|
||||
expect(adminAll.body.keys.map((k: any) => k.username).sort()).toEqual(["alice", "bob"]);
|
||||
const memberAll = await asMember().get("/api/keys?all=1");
|
||||
expect(memberAll.body.keys).toHaveLength(1);
|
||||
expect(memberAll.body.keys[0].name).toBe("b");
|
||||
});
|
||||
|
||||
it("a request authenticated by an API key cannot manage keys", async () => {
|
||||
const { rawKey } = apiKeys.create(adminId, "self-mgmt")!;
|
||||
const res = await request(app)
|
||||
.post("/api/keys")
|
||||
.set("Authorization", `Bearer ${rawKey}`)
|
||||
.send({ name: "proliferate" });
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
|
||||
it("requires authentication", async () => {
|
||||
expect((await request(app).get("/api/keys")).status).toBe(401);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,90 @@
|
||||
import { Router } from "express";
|
||||
import type { Request, Response, NextFunction } from "express";
|
||||
import type { ApiKeyStore } from "../../data/api-keys.js";
|
||||
import { MAX_API_KEYS_PER_USER } from "../../data/api-keys.js";
|
||||
import type { AuditStore } from "../../data/audit.js";
|
||||
import type { Logger } from "../../logger.js";
|
||||
|
||||
/**
|
||||
* API-key management (list / create / revoke), mounted at /api/keys.
|
||||
* Only interactive sessions may manage keys: a leaked key must never be able
|
||||
* to mint its own replacements.
|
||||
*/
|
||||
export function createApiKeysRouter(apiKeys: ApiKeyStore, audit: AuditStore, logger: Logger): Router {
|
||||
const router = Router();
|
||||
|
||||
const rejectApiKeyAuth = (req: Request, res: Response, next: NextFunction): void => {
|
||||
if (req.authMethod === "api-key") {
|
||||
res.status(403).json({ error: "API keys cannot manage API keys — log in to the WebUI" });
|
||||
return;
|
||||
}
|
||||
next();
|
||||
};
|
||||
router.use(rejectApiKeyAuth);
|
||||
|
||||
// GET /api/keys — the caller's keys; admins may pass ?all=1 for every user's.
|
||||
router.get("/", (req, res) => {
|
||||
const user = req.user!;
|
||||
if (req.query.all === "1" && user.role === "admin") {
|
||||
res.json({ keys: apiKeys.listAll() });
|
||||
return;
|
||||
}
|
||||
res.json({ keys: apiKeys.listForUser(user.id) });
|
||||
});
|
||||
|
||||
// POST /api/keys — create a key; the plaintext is returned exactly once.
|
||||
router.post("/", (req, res) => {
|
||||
const user = req.user!;
|
||||
const name = typeof req.body?.name === "string" ? req.body.name.trim() : "";
|
||||
if (!name || name.length > 64) {
|
||||
res.status(400).json({ error: "name is required (1-64 characters)" });
|
||||
return;
|
||||
}
|
||||
const created = apiKeys.create(user.id, name);
|
||||
if (!created) {
|
||||
res.status(409).json({ error: `每个用户最多创建 ${MAX_API_KEYS_PER_USER} 个 API Key` });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
audit.record({
|
||||
actorId: user.id,
|
||||
actorUsername: user.username,
|
||||
targetUserId: user.id,
|
||||
targetUsername: user.username,
|
||||
action: "api_key.created",
|
||||
});
|
||||
} catch (auditErr) {
|
||||
logger.warn({ err: auditErr, action: "api_key.created" }, "audit insert failed");
|
||||
}
|
||||
logger.info({ userId: user.id, keyId: created.key.id }, "API key created");
|
||||
res.status(201).json(created);
|
||||
});
|
||||
|
||||
// DELETE /api/keys/:id — revoke; members only their own, admins any.
|
||||
router.delete("/:id", (req, res) => {
|
||||
const user = req.user!;
|
||||
const ok =
|
||||
user.role === "admin"
|
||||
? apiKeys.delete(req.params.id)
|
||||
: apiKeys.delete(req.params.id, user.id);
|
||||
if (!ok) {
|
||||
res.status(404).json({ error: "API key not found" });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
audit.record({
|
||||
actorId: user.id,
|
||||
actorUsername: user.username,
|
||||
targetUserId: user.id,
|
||||
targetUsername: user.username,
|
||||
action: "api_key.deleted",
|
||||
});
|
||||
} catch (auditErr) {
|
||||
logger.warn({ err: auditErr, action: "api_key.deleted" }, "audit insert failed");
|
||||
}
|
||||
logger.info({ userId: user.id, keyId: req.params.id }, "API key deleted");
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import type { Logger } from "../../logger.js";
|
||||
import type { UserStore } from "../../data/users.js";
|
||||
import { UsernameTakenError, GUEST_USER_ID } from "../../data/users.js";
|
||||
import type { SessionStore } from "../../data/sessions.js";
|
||||
import type { ApiKeyStore } from "../../data/api-keys.js";
|
||||
import type { AuditStore } from "../../data/audit.js";
|
||||
import { isCapability, BASIC_TIER_CAPABILITIES, type PermissionStore } from "../../data/permissions.js";
|
||||
import { extractSessionToken } from "../auth/validateSession.js";
|
||||
@@ -20,7 +21,8 @@ export function createUsersRouter(
|
||||
sessions: SessionStore,
|
||||
audit: AuditStore,
|
||||
logger: Logger,
|
||||
permissions: PermissionStore
|
||||
permissions: PermissionStore,
|
||||
apiKeys?: ApiKeyStore
|
||||
): Router {
|
||||
const router = Router();
|
||||
|
||||
@@ -85,6 +87,7 @@ export function createUsersRouter(
|
||||
}
|
||||
// FK CASCADE removes sessions; explicit call is belt-and-suspenders
|
||||
sessions.deleteAllForUser(targetId);
|
||||
apiKeys?.deleteAllForUser(targetId);
|
||||
try {
|
||||
audit.record({
|
||||
actorId: req.user!.id, actorUsername: req.user!.username,
|
||||
@@ -117,6 +120,9 @@ export function createUsersRouter(
|
||||
? (extractSessionToken(req.headers.cookie) ?? undefined)
|
||||
: undefined;
|
||||
sessions.deleteAllForUser(targetId, exceptToken);
|
||||
// A password reset must also kill the target's API keys — they are
|
||||
// long-lived credentials that otherwise survive credential rotation.
|
||||
apiKeys?.deleteAllForUser(targetId);
|
||||
try {
|
||||
audit.record({
|
||||
actorId: req.user!.id, actorUsername: req.user!.username,
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { Request } from "express";
|
||||
import { SESSION_COOKIE_NAME } from "./validateSession.js";
|
||||
|
||||
/**
|
||||
* Extract a raw API key from the `X-API-Key` header or an
|
||||
* `Authorization: Bearer <key>` header. Returns null when neither is present.
|
||||
*/
|
||||
export function extractApiKey(req: Request): string | null {
|
||||
const header = req.headers["x-api-key"];
|
||||
if (typeof header === "string" && header.trim()) {
|
||||
return header.trim();
|
||||
}
|
||||
const auth = req.headers.authorization;
|
||||
if (typeof auth === "string") {
|
||||
const match = /^bearer\s+(.+)$/i.exec(auth);
|
||||
if (match) {
|
||||
const key = match[1].trim();
|
||||
if (key) return key;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function hasApiKeyCredential(req: Request): boolean {
|
||||
return extractApiKey(req) !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* API-key clients (no session cookie) skip the origin check entirely. Requests
|
||||
* that ALSO carry the session cookie must NOT rely on this — an attacker page
|
||||
* can set arbitrary headers while the victim's cookie rides along ambiently,
|
||||
* so the cookie keeps the request under the origin check.
|
||||
*/
|
||||
export function isApiKeyOnlyRequest(req: Request): boolean {
|
||||
return hasApiKeyCredential(req) && !req.headers.cookie?.includes(`${SESSION_COOKIE_NAME}=`);
|
||||
}
|
||||
@@ -70,4 +70,28 @@ describe("csrfOriginCheck middleware", () => {
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body).toEqual({ error: "bad origin" });
|
||||
});
|
||||
|
||||
// API-key clients authenticate via a header the browser never attaches
|
||||
// automatically, so CSRF cannot abuse them — the origin check is skipped.
|
||||
it("allows POST with an X-API-Key header and no session cookie", async () => {
|
||||
const res = await request(app).post("/").set("X-API-Key", "tsmb_abc");
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it("allows POST with an Authorization: Bearer key and no session cookie", async () => {
|
||||
const res = await request(app).post("/").set("Authorization", "Bearer tsmb_abc");
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it("does NOT skip the origin check when a session cookie rides along with an API key", async () => {
|
||||
// An attacker page can set arbitrary headers while the victim's cookie is
|
||||
// attached ambiently — the cookie keeps the request under the gate.
|
||||
const res = await request(app)
|
||||
.post("/")
|
||||
.set("Host", "example.com")
|
||||
.set("Origin", "https://evil.com")
|
||||
.set("Cookie", "tsmb_session=whatever")
|
||||
.set("X-API-Key", "tsmb_abc");
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { Request, Response, NextFunction } from "express";
|
||||
import { isApiKeyOnlyRequest } from "../auth/api-key-header.js";
|
||||
|
||||
const SAFE_METHODS = new Set(["GET", "HEAD", "OPTIONS"]);
|
||||
|
||||
@@ -10,7 +11,7 @@ const SAFE_METHODS = new Set(["GET", "HEAD", "OPTIONS"]);
|
||||
* this header check covers the remaining attack surface.
|
||||
*/
|
||||
export function csrfOriginCheck(req: Request, res: Response, next: NextFunction): void {
|
||||
if (SAFE_METHODS.has(req.method)) {
|
||||
if (SAFE_METHODS.has(req.method) || isApiKeyOnlyRequest(req)) {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import request from "supertest";
|
||||
import { createDatabase, type BotDatabase } from "../../data/database.js";
|
||||
import { createUserStore } from "../../data/users.js";
|
||||
import { createSessionStore } from "../../data/sessions.js";
|
||||
import { createApiKeyStore } from "../../data/api-keys.js";
|
||||
import { createPermissionStore } from "../../data/permissions.js";
|
||||
import { createRequireAuth } from "./requireAuth.js";
|
||||
import { SESSION_COOKIE_NAME } from "../auth/validateSession.js";
|
||||
@@ -114,3 +115,112 @@ describe("requireAuth middleware", () => {
|
||||
expect(req.user.bots instanceof Set && req.user.bots.has("bot1")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("requireAuth middleware with API keys", () => {
|
||||
let botDb: BotDatabase;
|
||||
let app: express.Express;
|
||||
let adminKey: string;
|
||||
let memberKey: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
botDb = createDatabase(":memory:");
|
||||
const users = createUserStore(botDb.db);
|
||||
const sessions = createSessionStore(botDb.db);
|
||||
const permissions = createPermissionStore(botDb.db);
|
||||
const apiKeys = createApiKeyStore(botDb.db);
|
||||
const admin = await users.createUser("alice", "pw-alice", "admin");
|
||||
const member = await users.createUser("bob", "pw-bob", "member");
|
||||
permissions.setPermissions(member.id, { capabilities: ["player.control"], bots: ["bot1"] });
|
||||
adminKey = apiKeys.create(admin.id, "ci")!.rawKey;
|
||||
memberKey = apiKeys.create(member.id, "deploy")!.rawKey;
|
||||
|
||||
app = express();
|
||||
app.use(cookieParser());
|
||||
app.use(
|
||||
createRequireAuth(sessions, permissions, () => ({
|
||||
enabled: false,
|
||||
bots: "all",
|
||||
permissions: {} as any,
|
||||
}), apiKeys)
|
||||
);
|
||||
app.get("/protected", (req, res) => {
|
||||
const u: any = (req as any).user;
|
||||
res.json({
|
||||
ok: true,
|
||||
authMethod: (req as any).authMethod,
|
||||
user: u
|
||||
? {
|
||||
username: u.username,
|
||||
role: u.role,
|
||||
capabilities: u.capabilities ? [...u.capabilities] : [],
|
||||
bots: u.bots === "all" ? "all" : [...(u.bots ?? [])],
|
||||
}
|
||||
: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
botDb.close();
|
||||
});
|
||||
|
||||
it("authenticates a valid X-API-Key header and attaches the owner user", async () => {
|
||||
const res = await request(app).get("/protected").set("X-API-Key", adminKey);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.ok).toBe(true);
|
||||
expect(res.body.user.username).toBe("alice");
|
||||
expect(res.body.user.role).toBe("admin");
|
||||
expect(res.body.authMethod).toBe("api-key");
|
||||
});
|
||||
|
||||
it("authenticates an Authorization: Bearer key", async () => {
|
||||
const res = await request(app).get("/protected").set("Authorization", `Bearer ${adminKey}`);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.user.username).toBe("alice");
|
||||
});
|
||||
|
||||
it("rejects an unknown key with 401", async () => {
|
||||
const res = await request(app).get("/protected").set("X-API-Key", "tsmb_bogus");
|
||||
expect(res.status).toBe(401);
|
||||
expect(res.body).toEqual({ error: "invalid api key" });
|
||||
});
|
||||
|
||||
it("ignores the session cookie when a key header is present", async () => {
|
||||
// Garbage cookie + valid key → key wins.
|
||||
const res = await request(app)
|
||||
.get("/protected")
|
||||
.set("Cookie", `${SESSION_COOKIE_NAME}=garbage`)
|
||||
.set("X-API-Key", memberKey);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.user.username).toBe("bob");
|
||||
});
|
||||
|
||||
it("a member key inherits the member's capabilities and bot scope", async () => {
|
||||
const res = await request(app).get("/protected").set("X-API-Key", memberKey);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.user.role).toBe("member");
|
||||
expect(res.body.user.capabilities).toContain("player.control");
|
||||
expect(res.body.user.bots).toContain("bot1");
|
||||
expect(res.body.user.capabilities).not.toContain("bot.manage");
|
||||
});
|
||||
|
||||
it("returns 401 when a key header is present but no store is wired", async () => {
|
||||
const sessions: any = { validateAndTouch: () => null };
|
||||
const permissions: any = { getCapabilities: () => [], getBotAccess: () => [] };
|
||||
const mw = createRequireAuth(sessions, permissions, () => ({ enabled: false, bots: "all", permissions: {} as any }));
|
||||
const req: any = { headers: { "x-api-key": "tsmb_x" } };
|
||||
const res: any = { status(c: number) { this.statusCode = c; return this; }, json() { return this; } };
|
||||
const next = vi.fn();
|
||||
mw(req, res, next);
|
||||
expect(res.statusCode).toBe(401);
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("a key whose owner was deleted stops working", async () => {
|
||||
const users = createUserStore(botDb.db);
|
||||
const member = users.findByUsername("bob")!;
|
||||
users.deleteUser(member.id);
|
||||
const res = await request(app).get("/protected").set("X-API-Key", memberKey);
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { Request, Response, NextFunction, RequestHandler } from "express";
|
||||
import type { SessionStore } from "../../data/sessions.js";
|
||||
import { SESSION_TTL_MS } from "../../data/sessions.js";
|
||||
import type { ApiKeyStore } from "../../data/api-keys.js";
|
||||
import { resolvePermissionContext, type PermissionStore, type GuestPermissions } from "../../data/permissions.js";
|
||||
import type { GuestModeConfig } from "../../data/config.js";
|
||||
import {
|
||||
@@ -8,6 +9,7 @@ import {
|
||||
extractSessionToken,
|
||||
SESSION_COOKIE_NAME,
|
||||
} from "../auth/validateSession.js";
|
||||
import { extractApiKey } from "../auth/api-key-header.js";
|
||||
|
||||
declare module "express-serve-static-core" {
|
||||
interface Request {
|
||||
@@ -19,15 +21,42 @@ declare module "express-serve-static-core" {
|
||||
bots?: "all" | Set<string>;
|
||||
guest?: GuestPermissions;
|
||||
};
|
||||
/** How this request authenticated: browser session cookie or API key. */
|
||||
authMethod?: "session" | "api-key";
|
||||
}
|
||||
}
|
||||
|
||||
export function createRequireAuth(
|
||||
sessions: SessionStore,
|
||||
permissions: PermissionStore,
|
||||
getGuestConfig: () => GuestModeConfig
|
||||
getGuestConfig: () => GuestModeConfig,
|
||||
apiKeys?: ApiKeyStore
|
||||
): RequestHandler {
|
||||
return function requireAuth(req: Request, res: Response, next: NextFunction) {
|
||||
// ─── API-key path ──────────────────────────────────────────────────────
|
||||
// A key in a header authenticates on its own; cookies are ignored on this
|
||||
// path so the two credential types can never be mixed.
|
||||
const rawKey = extractApiKey(req);
|
||||
if (rawKey !== null) {
|
||||
const validation = apiKeys?.validateAndTouch(rawKey) ?? null;
|
||||
if (!validation) {
|
||||
res.status(401).json({ error: "invalid api key" });
|
||||
return;
|
||||
}
|
||||
const ctx = resolvePermissionContext(validation.role, validation.userId, permissions);
|
||||
req.user = {
|
||||
id: validation.userId,
|
||||
username: validation.username,
|
||||
role: validation.role,
|
||||
capabilities: ctx.capabilities,
|
||||
bots: ctx.bots,
|
||||
};
|
||||
req.authMethod = "api-key";
|
||||
next();
|
||||
return;
|
||||
}
|
||||
|
||||
// ─── Session-cookie path (browser) ─────────────────────────────────────
|
||||
const result = validateSessionFromHeaders(req.headers.cookie, sessions);
|
||||
if (!result) {
|
||||
res.clearCookie(SESSION_COOKIE_NAME, { path: "/" });
|
||||
@@ -56,6 +85,7 @@ export function createRequireAuth(
|
||||
bots: ctx.bots,
|
||||
guest: ctx.guest,
|
||||
};
|
||||
req.authMethod = "session";
|
||||
const token = extractSessionToken(req.headers.cookie);
|
||||
if (token) {
|
||||
res.cookie(SESSION_COOKIE_NAME, token, {
|
||||
|
||||
+9
-2
@@ -21,6 +21,7 @@ import { createAuditRouter } from "./api/audit.js";
|
||||
import { createFavoritesRouter } from "./api/favorites.js";
|
||||
import { createSavedQueuesRouter } from "./api/saved-queues.js";
|
||||
import { createSpotifyRouter } from "./api/spotify.js";
|
||||
import { createApiKeysRouter } from "./api/api-keys.js";
|
||||
import type { SpotifyOAuth } from "../music/spotify/spotify-oauth.js";
|
||||
import type { SpotifyProvider } from "../music/spotify/provider.js";
|
||||
import type { JellyfinProvider } from "../music/jellyfin.js";
|
||||
@@ -32,6 +33,7 @@ import {
|
||||
import { setupWebSocket } from "./websocket.js";
|
||||
import { createUserStore } from "../data/users.js";
|
||||
import { createSessionStore } from "../data/sessions.js";
|
||||
import { createApiKeyStore } from "../data/api-keys.js";
|
||||
import { createPermissionStore } from "../data/permissions.js";
|
||||
import { createRequireAuth } from "./middleware/requireAuth.js";
|
||||
import { requireAdmin } from "./middleware/requireAdmin.js";
|
||||
@@ -101,6 +103,7 @@ export function createWebServer(options: WebServerOptions): WebServer {
|
||||
const sessions = createSessionStore(options.database.db);
|
||||
const audit = createAuditStore(options.database.db);
|
||||
const permissions = createPermissionStore(options.database.db);
|
||||
const apiKeys = createApiKeyStore(options.database.db);
|
||||
|
||||
// ─── Public routes (no auth, no CSRF) ───────────────────────────────────
|
||||
// Disallow every crawler (issue #128). Declared before the static SPA
|
||||
@@ -130,7 +133,7 @@ export function createWebServer(options: WebServerOptions): WebServer {
|
||||
app.use("/api/session", createSessionRouter(users, sessions, audit, logger, permissions, () => options.config.guestMode));
|
||||
|
||||
// ─── Gates for everything else under /api ───────────────────────────────
|
||||
const requireAuth = createRequireAuth(sessions, permissions, () => options.config.guestMode);
|
||||
const requireAuth = createRequireAuth(sessions, permissions, () => options.config.guestMode, apiKeys);
|
||||
app.use("/api", csrfOriginCheck);
|
||||
app.use("/api", requireAuth);
|
||||
|
||||
@@ -217,9 +220,13 @@ export function createWebServer(options: WebServerOptions): WebServer {
|
||||
);
|
||||
|
||||
// admin-only routes
|
||||
app.use("/api/users", requireAdmin, createUsersRouter(users, sessions, audit, logger, permissions));
|
||||
app.use("/api/users", requireAdmin, createUsersRouter(users, sessions, audit, logger, permissions, apiKeys));
|
||||
app.use("/api/audit", requireAdmin, createAuditRouter(audit));
|
||||
|
||||
// API-key management — interactive sessions only (guests excluded; the
|
||||
// router itself rejects key-authenticated requests).
|
||||
app.use("/api/keys", requireNotGuest, createApiKeysRouter(apiKeys, audit, logger));
|
||||
|
||||
// ─── Static SPA (public) ────────────────────────────────────────────────
|
||||
if (options.staticDir) {
|
||||
app.use(express.static(options.staticDir));
|
||||
|
||||
Reference in new issue
Block a user