From 486979841ec590f876a6fa555bfad1f72f9b356e Mon Sep 17 00:00:00 2001 From: saopig1 <4x7sw862st@gmail.com> Date: Wed, 27 May 2026 13:46:59 +0800 Subject: [PATCH] feat(auth): gate /api/* behind requireAuth + csrf; gate /ws via upgrade handler Co-Authored-By: Claude Sonnet 4.6 --- src/web/server.ts | 65 ++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 59 insertions(+), 6 deletions(-) diff --git a/src/web/server.ts b/src/web/server.ts index b496c02..a3cd227 100755 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -1,6 +1,7 @@ import express from "express"; import http from "node:http"; import path from "node:path"; +import cookieParser from "cookie-parser"; import { WebSocketServer } from "ws"; import type { BotManager } from "../bot/manager.js"; import type { MusicProvider } from "../music/provider.js"; @@ -13,7 +14,15 @@ import { createBotRouter } from "./api/bot.js"; import { createMusicRouter } from "./api/music.js"; import { createPlayerRouter } from "./api/player.js"; import { createAuthRouter } from "./api/auth.js"; +import { createSessionRouter } from "./api/session.js"; import { setupWebSocket } from "./websocket.js"; +import { createUserStore } from "../data/users.js"; +import { createSessionStore } from "../data/sessions.js"; +import { createRequireAuth } from "./middleware/requireAuth.js"; +import { csrfOriginCheck } from "./middleware/csrf.js"; +import { validateSessionFromHeaders } from "./auth/validateSession.js"; + +const SESSION_CLEANUP_INTERVAL_MS = 60 * 60 * 1000; // 1 hour export interface WebServerOptions { port: number; @@ -41,17 +50,33 @@ export function createWebServer(options: WebServerOptions): WebServer { const logger = options.logger.child({ component: "web" }); if (options.config.trustProxy) { - // Honor X-Forwarded-* from a reverse proxy (nginx/Caddy/Cloudflare). app.set("trust proxy", true); } app.use(express.json({ limit: "400kb" })); + app.use(cookieParser()); + + const users = createUserStore(options.database.db); + const sessions = createSessionStore(options.database.db); + + // ─── Public routes (no auth, no CSRF) ─────────────────────────────────── + app.get("/api/health", (_req, res) => { + res.json({ status: "ok", version: "0.1.0" }); + }); app.get("/api/config/public-url", (_req, res) => { const raw = (options.config.publicUrl ?? "").trim(); res.json({ publicUrl: raw ? raw.replace(/\/+$/, "") : null }); }); + app.use("/api/session", createSessionRouter(users, sessions, logger)); + + // ─── Gates for everything else under /api ─────────────────────────────── + const requireAuth = createRequireAuth(sessions); + app.use("/api", csrfOriginCheck); + app.use("/api", requireAuth); + + // ─── Protected routes ─────────────────────────────────────────────────── app.use( "/api/bot", createBotRouter( @@ -76,10 +101,7 @@ export function createWebServer(options: WebServerOptions): WebServer { createAuthRouter(options.neteaseProvider, options.qqProvider, options.bilibiliProvider, logger, options.cookieStore) ); - app.get("/api/health", (_req, res) => { - res.json({ status: "ok", version: "0.1.0" }); - }); - + // ─── Static SPA (public) ──────────────────────────────────────────────── if (options.staticDir) { app.use(express.static(options.staticDir)); app.get(/^(?!\/api|\/ws)/, (_req, res) => { @@ -91,22 +113,53 @@ export function createWebServer(options: WebServerOptions): WebServer { logger.error({ err }, "HTTP server error"); }); - const wss = new WebSocketServer({ server, path: "/ws" }); + // ─── WebSocket with manual upgrade auth ──────────────────────────────── + const wss = new WebSocketServer({ noServer: true }); wss.on("error", (err) => { logger.error({ err }, "WebSocket server error"); }); + server.on("upgrade", (req, socket, head) => { + if (req.url !== "/ws") { + socket.destroy(); + return; + } + const result = validateSessionFromHeaders(req.headers.cookie as string | undefined, sessions); + if (!result) { + socket.write("HTTP/1.1 401 Unauthorized\r\nConnection: close\r\n\r\n"); + socket.destroy(); + return; + } + wss.handleUpgrade(req, socket, head, (ws) => { + (ws as unknown as { userId: string }).userId = result.userId; + wss.emit("connection", ws, req); + }); + }); const cleanupWs = setupWebSocket(wss, options.botManager, logger); + // ─── Session cleanup interval ────────────────────────────────────────── + let cleanupTimer: ReturnType | null = null; + return { async start(): Promise { return new Promise((resolve) => { server.listen(options.port, () => { logger.info({ port: options.port }, "Web server started"); + cleanupTimer = setInterval(() => { + try { + sessions.cleanupExpired(); + } catch (err) { + logger.error({ err }, "session cleanup failed"); + } + }, SESSION_CLEANUP_INTERVAL_MS); resolve(); }); }); }, stop(): void { + if (cleanupTimer) { + clearInterval(cleanupTimer); + cleanupTimer = null; + } cleanupWs(); wss.close(); server.close();