mirror of
https://github.com/ZHANGTIANYAO1/teamspeak-music-bot.git
synced 2026-10-02 04:52:50 +08:00
feat(auth): rate-limit /login+/setup, per-user session cap, periodic /me poll
This commit is contained in:
1 parent
1a11489f2e
commit
a39fc25104
6 files changed
+161
-1
No files matched your search
@@ -2,7 +2,7 @@ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
|||||||
import { createHash } from "node:crypto";
|
import { createHash } from "node:crypto";
|
||||||
import { createDatabase, type BotDatabase } from "./database.js";
|
import { createDatabase, type BotDatabase } from "./database.js";
|
||||||
import { createUserStore, type UserStore } from "./users.js";
|
import { createUserStore, type UserStore } from "./users.js";
|
||||||
import { createSessionStore, type SessionStore, SESSION_TTL_MS, SESSION_TOUCH_INTERVAL_MS } from "./sessions.js";
|
import { createSessionStore, type SessionStore, SESSION_TTL_MS, SESSION_TOUCH_INTERVAL_MS, MAX_SESSIONS_PER_USER } from "./sessions.js";
|
||||||
|
|
||||||
function sha256(token: string) {
|
function sha256(token: string) {
|
||||||
return createHash("sha256").update(token).digest("hex");
|
return createHash("sha256").update(token).digest("hex");
|
||||||
@@ -101,4 +101,19 @@ describe("SessionStore", () => {
|
|||||||
const remaining = (botDb.db.prepare("SELECT COUNT(*) AS n FROM sessions").get() as { n: number }).n;
|
const remaining = (botDb.db.prepare("SELECT COUNT(*) AS n FROM sessions").get() as { n: number }).n;
|
||||||
expect(remaining).toBe(1);
|
expect(remaining).toBe(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("createSession caps concurrent sessions per user at MAX_SESSIONS_PER_USER, evicting oldest", async () => {
|
||||||
|
// Create MAX + 2 sessions for the same user.
|
||||||
|
const tokens: string[] = [];
|
||||||
|
for (let i = 0; i < MAX_SESSIONS_PER_USER + 2; i++) {
|
||||||
|
tokens.push(sessions.createSession(userId).token);
|
||||||
|
await new Promise((r) => setTimeout(r, 2)); // stagger createdAt
|
||||||
|
}
|
||||||
|
const count = (botDb.db.prepare("SELECT COUNT(*) AS n FROM sessions").get() as { n: number }).n;
|
||||||
|
expect(count).toBe(MAX_SESSIONS_PER_USER);
|
||||||
|
// The first two should have been evicted, the last MAX remain
|
||||||
|
expect(sessions.validateAndTouch(tokens[0])).toBeNull();
|
||||||
|
expect(sessions.validateAndTouch(tokens[1])).toBeNull();
|
||||||
|
expect(sessions.validateAndTouch(tokens[tokens.length - 1])).not.toBeNull();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -3,6 +3,7 @@ import type Database from "better-sqlite3";
|
|||||||
|
|
||||||
export const SESSION_TTL_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
|
export const SESSION_TTL_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
|
||||||
export const SESSION_TOUCH_INTERVAL_MS = 60 * 60 * 1000; // 1 hour
|
export const SESSION_TOUCH_INTERVAL_MS = 60 * 60 * 1000; // 1 hour
|
||||||
|
export const MAX_SESSIONS_PER_USER = 10;
|
||||||
|
|
||||||
export interface SessionValidation {
|
export interface SessionValidation {
|
||||||
userId: string;
|
userId: string;
|
||||||
@@ -40,9 +41,18 @@ export function createSessionStore(db: Database.Database): SessionStore {
|
|||||||
"DELETE FROM sessions WHERE userId = ? AND id != ?"
|
"DELETE FROM sessions WHERE userId = ? AND id != ?"
|
||||||
);
|
);
|
||||||
const cleanupStmt = db.prepare("DELETE FROM sessions WHERE expiresAt < ?");
|
const cleanupStmt = db.prepare("DELETE FROM sessions WHERE expiresAt < ?");
|
||||||
|
const countForUserStmt = db.prepare("SELECT COUNT(*) AS n FROM sessions WHERE userId = ?");
|
||||||
|
const deleteOldestForUserStmt = db.prepare(
|
||||||
|
"DELETE FROM sessions WHERE id IN (SELECT id FROM sessions WHERE userId = ? ORDER BY createdAt ASC LIMIT ?)"
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
createSession(userId) {
|
createSession(userId) {
|
||||||
|
// Cap concurrent sessions per user — oldest gets evicted on overflow.
|
||||||
|
const existing = (countForUserStmt.get(userId) as { n: number }).n;
|
||||||
|
if (existing >= MAX_SESSIONS_PER_USER) {
|
||||||
|
deleteOldestForUserStmt.run(userId, existing - MAX_SESSIONS_PER_USER + 1);
|
||||||
|
}
|
||||||
const token = randomBytes(32).toString("base64url");
|
const token = randomBytes(32).toString("base64url");
|
||||||
const id = hashToken(token);
|
const id = hashToken(token);
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { describe, it, expect, beforeEach } from "vitest";
|
||||||
|
import express from "express";
|
||||||
|
import request from "supertest";
|
||||||
|
import { createRateLimit } from "./rateLimit.js";
|
||||||
|
|
||||||
|
describe("createRateLimit", () => {
|
||||||
|
let app: express.Express;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
app = express();
|
||||||
|
// capacity=3, refill=1/sec → first 3 succeed, then 429 until refill.
|
||||||
|
app.use(createRateLimit({ capacity: 3, refillPerSec: 1 }));
|
||||||
|
app.get("/", (_req, res) => res.json({ ok: true }));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows up to capacity bursts then rejects with 429", async () => {
|
||||||
|
expect((await request(app).get("/")).status).toBe(200);
|
||||||
|
expect((await request(app).get("/")).status).toBe(200);
|
||||||
|
expect((await request(app).get("/")).status).toBe(200);
|
||||||
|
const denied = await request(app).get("/");
|
||||||
|
expect(denied.status).toBe(429);
|
||||||
|
expect(denied.body).toEqual({ error: "rate limit exceeded" });
|
||||||
|
expect(denied.headers["retry-after"]).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses per-key buckets when keyFn is provided", async () => {
|
||||||
|
const customApp = express();
|
||||||
|
customApp.use(
|
||||||
|
createRateLimit({
|
||||||
|
capacity: 1,
|
||||||
|
refillPerSec: 0.001,
|
||||||
|
keyFn: (req) => req.get("x-user") ?? "anon",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
customApp.get("/", (_req, res) => res.json({ ok: true }));
|
||||||
|
expect((await request(customApp).get("/").set("X-User", "alice")).status).toBe(200);
|
||||||
|
expect((await request(customApp).get("/").set("X-User", "alice")).status).toBe(429);
|
||||||
|
// Different user, separate bucket → still has a token.
|
||||||
|
expect((await request(customApp).get("/").set("X-User", "bob")).status).toBe(200);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import type { Request, Response, NextFunction, RequestHandler } from "express";
|
||||||
|
|
||||||
|
interface Bucket {
|
||||||
|
tokens: number;
|
||||||
|
lastRefillMs: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RateLimitOptions {
|
||||||
|
/** Bucket capacity (max burst). */
|
||||||
|
capacity: number;
|
||||||
|
/** Tokens refilled per second. */
|
||||||
|
refillPerSec: number;
|
||||||
|
/** Optional key function; defaults to req.ip. */
|
||||||
|
keyFn?: (req: Request) => string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* In-memory token-bucket rate limiter.
|
||||||
|
*
|
||||||
|
* Each unique key (default: req.ip) gets its own bucket. Refills continuously
|
||||||
|
* at `refillPerSec` up to `capacity`. Each request consumes 1 token; if no
|
||||||
|
* token is available, returns 429 with Retry-After.
|
||||||
|
*
|
||||||
|
* Buckets evict themselves after 10 minutes of inactivity to bound memory.
|
||||||
|
*/
|
||||||
|
export function createRateLimit(options: RateLimitOptions): RequestHandler {
|
||||||
|
const buckets = new Map<string, Bucket>();
|
||||||
|
const EVICT_AFTER_MS = 10 * 60 * 1000;
|
||||||
|
// Periodic eviction to bound memory under attack.
|
||||||
|
const evict = setInterval(() => {
|
||||||
|
const cutoff = Date.now() - EVICT_AFTER_MS;
|
||||||
|
for (const [k, b] of buckets) {
|
||||||
|
if (b.lastRefillMs < cutoff) buckets.delete(k);
|
||||||
|
}
|
||||||
|
}, 60_000);
|
||||||
|
// Unref the timer so it doesn't keep the process alive in tests.
|
||||||
|
if (typeof (evict as { unref?: () => void }).unref === "function") {
|
||||||
|
(evict as { unref: () => void }).unref();
|
||||||
|
}
|
||||||
|
|
||||||
|
const keyFn = options.keyFn ?? ((req) => req.ip ?? "unknown");
|
||||||
|
|
||||||
|
return function rateLimit(req: Request, res: Response, next: NextFunction): void {
|
||||||
|
const key = keyFn(req);
|
||||||
|
const now = Date.now();
|
||||||
|
let b = buckets.get(key);
|
||||||
|
if (!b) {
|
||||||
|
b = { tokens: options.capacity, lastRefillMs: now };
|
||||||
|
buckets.set(key, b);
|
||||||
|
}
|
||||||
|
const elapsedSec = (now - b.lastRefillMs) / 1000;
|
||||||
|
b.tokens = Math.min(options.capacity, b.tokens + elapsedSec * options.refillPerSec);
|
||||||
|
b.lastRefillMs = now;
|
||||||
|
if (b.tokens < 1) {
|
||||||
|
const waitSec = Math.ceil((1 - b.tokens) / options.refillPerSec);
|
||||||
|
res.setHeader("Retry-After", String(waitSec));
|
||||||
|
res.status(429).json({ error: "rate limit exceeded" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
b.tokens -= 1;
|
||||||
|
next();
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -24,6 +24,7 @@ import { createSessionStore } from "../data/sessions.js";
|
|||||||
import { createRequireAuth } from "./middleware/requireAuth.js";
|
import { createRequireAuth } from "./middleware/requireAuth.js";
|
||||||
import { requireAdmin } from "./middleware/requireAdmin.js";
|
import { requireAdmin } from "./middleware/requireAdmin.js";
|
||||||
import { csrfOriginCheck } from "./middleware/csrf.js";
|
import { csrfOriginCheck } from "./middleware/csrf.js";
|
||||||
|
import { createRateLimit } from "./middleware/rateLimit.js";
|
||||||
import { validateSessionFromHeaders } from "./auth/validateSession.js";
|
import { validateSessionFromHeaders } from "./auth/validateSession.js";
|
||||||
|
|
||||||
const SESSION_CLEANUP_INTERVAL_MS = 60 * 60 * 1000; // 1 hour
|
const SESSION_CLEANUP_INTERVAL_MS = 60 * 60 * 1000; // 1 hour
|
||||||
@@ -74,6 +75,14 @@ export function createWebServer(options: WebServerOptions): WebServer {
|
|||||||
res.json({ publicUrl: raw ? raw.replace(/\/+$/, "") : null });
|
res.json({ publicUrl: raw ? raw.replace(/\/+$/, "") : null });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Anti-DoS: throttle expensive (bcrypt) auth endpoints.
|
||||||
|
// 5 req per minute per IP for /login (capacity 5, refill 5/60 = ~0.083/sec).
|
||||||
|
// 3 req per minute per IP for /setup (more limited; first-run is rare).
|
||||||
|
const loginLimit = createRateLimit({ capacity: 5, refillPerSec: 5 / 60 });
|
||||||
|
const setupLimit = createRateLimit({ capacity: 3, refillPerSec: 3 / 60 });
|
||||||
|
app.use("/api/session/login", loginLimit);
|
||||||
|
app.use("/api/session/setup", setupLimit);
|
||||||
|
|
||||||
app.use("/api/session", createSessionRouter(users, sessions, audit, logger));
|
app.use("/api/session", createSessionRouter(users, sessions, audit, logger));
|
||||||
|
|
||||||
// ─── Gates for everything else under /api ───────────────────────────────
|
// ─── Gates for everything else under /api ───────────────────────────────
|
||||||
|
|||||||
@@ -10,6 +10,26 @@ const currentUser = ref<User | null>(null);
|
|||||||
const needsSetup = ref<boolean | null>(null); // null = unknown / not fetched yet
|
const needsSetup = ref<boolean | null>(null); // null = unknown / not fetched yet
|
||||||
const ready = ref(false);
|
const ready = ref(false);
|
||||||
|
|
||||||
|
let pollTimer: ReturnType<typeof setInterval> | null = null;
|
||||||
|
const POLL_INTERVAL_MS = 60_000;
|
||||||
|
|
||||||
|
function ensurePollStarted() {
|
||||||
|
if (pollTimer !== null) return;
|
||||||
|
pollTimer = setInterval(() => {
|
||||||
|
if (currentUser.value !== null) {
|
||||||
|
// Best-effort refresh; ignore errors (network blips etc.)
|
||||||
|
refreshMe().catch(() => {});
|
||||||
|
}
|
||||||
|
}, POLL_INTERVAL_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopPoll() {
|
||||||
|
if (pollTimer !== null) {
|
||||||
|
clearInterval(pollTimer);
|
||||||
|
pollTimer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function refreshNeedsSetup(): Promise<void> {
|
async function refreshNeedsSetup(): Promise<void> {
|
||||||
const res = await fetch("/api/session/needs-setup", { credentials: "same-origin" });
|
const res = await fetch("/api/session/needs-setup", { credentials: "same-origin" });
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
@@ -35,6 +55,7 @@ async function refresh(): Promise<void> {
|
|||||||
await refreshMe();
|
await refreshMe();
|
||||||
}
|
}
|
||||||
ready.value = true;
|
ready.value = true;
|
||||||
|
ensurePollStarted();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function login(username: string, password: string): Promise<void> {
|
async function login(username: string, password: string): Promise<void> {
|
||||||
@@ -67,6 +88,7 @@ async function setup(username: string, password: string): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function logout(): Promise<void> {
|
async function logout(): Promise<void> {
|
||||||
|
stopPoll();
|
||||||
await fetch("/api/session/logout", { method: "POST", credentials: "same-origin" });
|
await fetch("/api/session/logout", { method: "POST", credentials: "same-origin" });
|
||||||
currentUser.value = null;
|
currentUser.value = null;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user