mirror of
https://github.com/ZHANGTIANYAO1/teamspeak-music-bot.git
synced 2026-10-02 04:52:50 +08:00
feat(auth): gate /api/* behind requireAuth + csrf; gate /ws via upgrade handler
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
1 parent
ee5673a22f
commit
486979841e
1 file changed
+59
-6
+59
-6
@@ -1,6 +1,7 @@
|
|||||||
import express from "express";
|
import express from "express";
|
||||||
import http from "node:http";
|
import http from "node:http";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
|
import cookieParser from "cookie-parser";
|
||||||
import { WebSocketServer } from "ws";
|
import { WebSocketServer } from "ws";
|
||||||
import type { BotManager } from "../bot/manager.js";
|
import type { BotManager } from "../bot/manager.js";
|
||||||
import type { MusicProvider } from "../music/provider.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 { createMusicRouter } from "./api/music.js";
|
||||||
import { createPlayerRouter } from "./api/player.js";
|
import { createPlayerRouter } from "./api/player.js";
|
||||||
import { createAuthRouter } from "./api/auth.js";
|
import { createAuthRouter } from "./api/auth.js";
|
||||||
|
import { createSessionRouter } from "./api/session.js";
|
||||||
import { setupWebSocket } from "./websocket.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 {
|
export interface WebServerOptions {
|
||||||
port: number;
|
port: number;
|
||||||
@@ -41,17 +50,33 @@ export function createWebServer(options: WebServerOptions): WebServer {
|
|||||||
const logger = options.logger.child({ component: "web" });
|
const logger = options.logger.child({ component: "web" });
|
||||||
|
|
||||||
if (options.config.trustProxy) {
|
if (options.config.trustProxy) {
|
||||||
// Honor X-Forwarded-* from a reverse proxy (nginx/Caddy/Cloudflare).
|
|
||||||
app.set("trust proxy", true);
|
app.set("trust proxy", true);
|
||||||
}
|
}
|
||||||
|
|
||||||
app.use(express.json({ limit: "400kb" }));
|
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) => {
|
app.get("/api/config/public-url", (_req, res) => {
|
||||||
const raw = (options.config.publicUrl ?? "").trim();
|
const raw = (options.config.publicUrl ?? "").trim();
|
||||||
res.json({ publicUrl: raw ? raw.replace(/\/+$/, "") : null });
|
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(
|
app.use(
|
||||||
"/api/bot",
|
"/api/bot",
|
||||||
createBotRouter(
|
createBotRouter(
|
||||||
@@ -76,10 +101,7 @@ export function createWebServer(options: WebServerOptions): WebServer {
|
|||||||
createAuthRouter(options.neteaseProvider, options.qqProvider, options.bilibiliProvider, logger, options.cookieStore)
|
createAuthRouter(options.neteaseProvider, options.qqProvider, options.bilibiliProvider, logger, options.cookieStore)
|
||||||
);
|
);
|
||||||
|
|
||||||
app.get("/api/health", (_req, res) => {
|
// ─── Static SPA (public) ────────────────────────────────────────────────
|
||||||
res.json({ status: "ok", version: "0.1.0" });
|
|
||||||
});
|
|
||||||
|
|
||||||
if (options.staticDir) {
|
if (options.staticDir) {
|
||||||
app.use(express.static(options.staticDir));
|
app.use(express.static(options.staticDir));
|
||||||
app.get(/^(?!\/api|\/ws)/, (_req, res) => {
|
app.get(/^(?!\/api|\/ws)/, (_req, res) => {
|
||||||
@@ -91,22 +113,53 @@ export function createWebServer(options: WebServerOptions): WebServer {
|
|||||||
logger.error({ err }, "HTTP server error");
|
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) => {
|
wss.on("error", (err) => {
|
||||||
logger.error({ err }, "WebSocket server error");
|
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);
|
const cleanupWs = setupWebSocket(wss, options.botManager, logger);
|
||||||
|
|
||||||
|
// ─── Session cleanup interval ──────────────────────────────────────────
|
||||||
|
let cleanupTimer: ReturnType<typeof setInterval> | null = null;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
async start(): Promise<void> {
|
async start(): Promise<void> {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
server.listen(options.port, () => {
|
server.listen(options.port, () => {
|
||||||
logger.info({ port: options.port }, "Web server started");
|
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();
|
resolve();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
stop(): void {
|
stop(): void {
|
||||||
|
if (cleanupTimer) {
|
||||||
|
clearInterval(cleanupTimer);
|
||||||
|
cleanupTimer = null;
|
||||||
|
}
|
||||||
cleanupWs();
|
cleanupWs();
|
||||||
wss.close();
|
wss.close();
|
||||||
server.close();
|
server.close();
|
||||||
|
|||||||
Reference in new issue
Block a user