feat(auth): guest-aware requireAuth + disable invalidates guest sessions

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
saopig1andClaude Opus 4.8 committed 2026-06-25 11:25:26 +08:00
1 parent 0514162824
commit c9a0719128
7 files changed
+79 -12

No files matched your search

+2 -1
View File
@@ -7,6 +7,7 @@ import { createUserStore } from "../../data/users.js";
import { createSessionStore } from "../../data/sessions.js";
import { createAuditStore } from "../../data/audit.js";
import { createPermissionStore } from "../../data/permissions.js";
import { getDefaultConfig } from "../../data/config.js";
import { createRequireAuth } from "../middleware/requireAuth.js";
import { createAuditRouter } from "./audit.js";
import { SESSION_COOKIE_NAME } from "../auth/validateSession.js";
@@ -34,7 +35,7 @@ describe("audit router", () => {
app = express();
app.use(express.json());
app.use(cookieParser());
app.use("/api", createRequireAuth(sessions, permissions));
app.use("/api", createRequireAuth(sessions, permissions, () => getDefaultConfig().guestMode));
app.use("/api/audit", createAuditRouter(audit));
});
+1 -1
View File
@@ -60,7 +60,7 @@ describe("bot router /settings", () => {
app = express();
app.use(express.json());
app.use(cookieParser());
app.use("/api", createRequireAuth(sessions, createPermissionStore(botDb.db)));
app.use("/api", createRequireAuth(sessions, createPermissionStore(botDb.db), () => getDefaultConfig().guestMode));
app.use(
"/api/bot",
createBotRouter(fakeManager, config, configPath, pino({ level: "silent" }), botDb, avatarStore),
+3 -2
View File
@@ -8,6 +8,7 @@ import { createUserStore, type UserStore } from "../../data/users.js";
import { createSessionStore, type SessionStore } from "../../data/sessions.js";
import { createAuditStore, type AuditStore } from "../../data/audit.js";
import { createPermissionStore, type PermissionStore } from "../../data/permissions.js";
import { getDefaultConfig } from "../../data/config.js";
import { createRequireAuth } from "../middleware/requireAuth.js";
import { createUsersRouter } from "./users.js";
import { SESSION_COOKIE_NAME } from "../auth/validateSession.js";
@@ -17,7 +18,7 @@ function makeApp(botDb: BotDatabase, users: UserStore, sessions: SessionStore) {
app.use(express.json());
app.use(cookieParser());
const permissions = createPermissionStore(botDb.db);
const requireAuth = createRequireAuth(sessions, permissions);
const requireAuth = createRequireAuth(sessions, permissions, () => getDefaultConfig().guestMode);
const audit = createAuditStore(botDb.db);
app.use("/api", requireAuth);
app.use("/api/users", createUsersRouter(users, sessions, audit, pino({ level: "silent" }), permissions));
@@ -152,7 +153,7 @@ describe("users router", () => {
const localApp = express();
localApp.use(express.json());
localApp.use(cookieParser());
localApp.use("/api", createRequireAuth(sessions, createPermissionStore(botDb.db)));
localApp.use("/api", createRequireAuth(sessions, createPermissionStore(botDb.db), () => getDefaultConfig().guestMode));
localApp.use(
"/api/users",
createUsersRouter(users, sessions, brokenAudit, pino({ level: "silent" }), createPermissionStore(botDb.db))
+2 -1
View File
@@ -6,6 +6,7 @@ import { createDatabase, type BotDatabase } from "../../data/database.js";
import { createUserStore } from "../../data/users.js";
import { createSessionStore } from "../../data/sessions.js";
import { createPermissionStore } from "../../data/permissions.js";
import { getDefaultConfig } from "../../data/config.js";
import { createRequireAuth } from "./requireAuth.js";
import { requireAdmin } from "./requireAdmin.js";
import { SESSION_COOKIE_NAME } from "../auth/validateSession.js";
@@ -27,7 +28,7 @@ describe("requireAdmin middleware", () => {
memberCookie = `${SESSION_COOKIE_NAME}=${sessions.createSession(member.id).token}`;
app = express();
app.use(cookieParser());
app.use(createRequireAuth(sessions, permissions));
app.use(createRequireAuth(sessions, permissions, () => getDefaultConfig().guestMode));
app.use(requireAdmin);
app.get("/admin-only", (_req, res) => res.json({ ok: true }));
});
+46 -2
View File
@@ -1,4 +1,4 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import express from "express";
import cookieParser from "cookie-parser";
import request from "supertest";
@@ -24,7 +24,21 @@ describe("requireAuth middleware", () => {
app = express();
app.use(cookieParser());
app.use(createRequireAuth(sessions, permissions));
app.use(
createRequireAuth(sessions, permissions, () => ({
enabled: true,
bots: "all",
permissions: {
addToQueue: true,
playNext: true,
playNow: true,
skip: true,
transport: true,
removeClear: true,
playMode: true,
},
}))
);
app.get("/protected", (req, res) => {
res.json({ ok: true, user: (req as any).user });
});
@@ -68,4 +82,34 @@ describe("requireAuth middleware", () => {
expect(refreshed).toBeDefined();
expect(refreshed!).toMatch(/Max-Age=\d+/);
});
// A guest session is rejected (401) when guest mode is disabled.
it("rejects a guest session when guest mode is disabled", () => {
const sessions: any = { validateAndTouch: () => ({ userId: "__guest__", username: "游客", role: "guest" }) };
const permissions: any = { getCapabilities: () => [], getBotAccess: () => [] };
const getGuestConfig = () => ({ enabled: false, bots: "all" as const, permissions: {} as any });
const mw = createRequireAuth(sessions, permissions, getGuestConfig);
const req: any = { headers: { cookie: "tsmb_session=x" } };
const res: any = { statusCode: 0, cleared: false, clearCookie() { this.cleared = true; }, status(c: number) { this.statusCode = c; return this; }, json() { return this; }, cookie() {} };
const next = vi.fn();
mw(req, res, next);
expect(res.statusCode).toBe(401);
expect(next).not.toHaveBeenCalled();
});
it("attaches guest permissions when guest mode is enabled", () => {
const sessions: any = { validateAndTouch: () => ({ userId: "__guest__", username: "游客", role: "guest" }) };
const permissions: any = { getCapabilities: () => [], getBotAccess: () => [] };
const perms = { addToQueue: true, playNext: false, playNow: false, skip: false, transport: false, removeClear: false, playMode: false };
const getGuestConfig = () => ({ enabled: true, bots: ["bot1"], permissions: perms });
const mw = createRequireAuth(sessions, permissions, getGuestConfig);
const req: any = { headers: { cookie: "tsmb_session=x" }, secure: false };
const res: any = { status() { return this; }, json() { return this; }, cookie() {}, clearCookie() {} };
const next = vi.fn();
mw(req, res, next);
expect(next).toHaveBeenCalled();
expect(req.user.role).toBe("guest");
expect(req.user.guest.addToQueue).toBe(true);
expect(req.user.bots instanceof Set && req.user.bots.has("bot1")).toBe(true);
});
});
+24 -4
View File
@@ -1,7 +1,8 @@
import type { Request, Response, NextFunction, RequestHandler } from "express";
import type { SessionStore } from "../../data/sessions.js";
import { SESSION_TTL_MS } from "../../data/sessions.js";
import { resolvePermissionContext, type PermissionStore } from "../../data/permissions.js";
import { resolvePermissionContext, type PermissionStore, type GuestPermissions } from "../../data/permissions.js";
import type { GuestModeConfig } from "../../data/config.js";
import {
validateSessionFromHeaders,
extractSessionToken,
@@ -13,14 +14,19 @@ declare module "express-serve-static-core" {
user?: {
id: string;
username: string;
role: "admin" | "member";
role: "admin" | "member" | "guest";
capabilities?: Set<string>;
bots?: "all" | Set<string>;
guest?: GuestPermissions;
};
}
}
export function createRequireAuth(sessions: SessionStore, permissions: PermissionStore): RequestHandler {
export function createRequireAuth(
sessions: SessionStore,
permissions: PermissionStore,
getGuestConfig: () => GuestModeConfig
): RequestHandler {
return function requireAuth(req: Request, res: Response, next: NextFunction) {
const result = validateSessionFromHeaders(req.headers.cookie, sessions);
if (!result) {
@@ -28,13 +34,27 @@ export function createRequireAuth(sessions: SessionStore, permissions: Permissio
res.status(401).json({ error: "unauthenticated" });
return;
}
const ctx = resolvePermissionContext(result.role, result.userId, permissions);
// A guest session is only valid while guest mode is enabled. Disabling it
// immediately invalidates any in-flight guest sessions.
const guestCfg = getGuestConfig();
if (result.role === "guest" && !guestCfg.enabled) {
res.clearCookie(SESSION_COOKIE_NAME, { path: "/" });
res.status(401).json({ error: "unauthenticated" });
return;
}
const ctx = resolvePermissionContext(
result.role,
result.userId,
permissions,
result.role === "guest" ? { bots: guestCfg.bots, permissions: guestCfg.permissions } : undefined
);
req.user = {
id: result.userId,
username: result.username,
role: result.role,
capabilities: ctx.capabilities,
bots: ctx.bots,
guest: ctx.guest,
};
const token = extractSessionToken(req.headers.cookie);
if (token) {
+1 -1
View File
@@ -98,7 +98,7 @@ export function createWebServer(options: WebServerOptions): WebServer {
app.use("/api/session", createSessionRouter(users, sessions, audit, logger, permissions));
// ─── Gates for everything else under /api ───────────────────────────────
const requireAuth = createRequireAuth(sessions, permissions);
const requireAuth = createRequireAuth(sessions, permissions, () => options.config.guestMode);
app.use("/api", csrfOriginCheck);
app.use("/api", requireAuth);