mirror of
https://github.com/ZHANGTIANYAO1/teamspeak-music-bot.git
synced 2026-10-02 04:52:50 +08:00
feat(auth): add /api/session router (setup, login, logout, me, change-password)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
1 parent
d1c9e14bf0
commit
ee5673a22f
2 files changed
+299
No files matched your search
@@ -0,0 +1,149 @@
|
|||||||
|
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||||
|
import express from "express";
|
||||||
|
import cookieParser from "cookie-parser";
|
||||||
|
import request from "supertest";
|
||||||
|
import pino from "pino";
|
||||||
|
import { createDatabase, type BotDatabase } from "../../data/database.js";
|
||||||
|
import { createUserStore, type UserStore } from "../../data/users.js";
|
||||||
|
import { createSessionStore, type SessionStore } from "../../data/sessions.js";
|
||||||
|
import { createSessionRouter } from "./session.js";
|
||||||
|
import { SESSION_COOKIE_NAME } from "../auth/validateSession.js";
|
||||||
|
|
||||||
|
function makeApp(users: UserStore, sessions: SessionStore) {
|
||||||
|
const app = express();
|
||||||
|
app.use(express.json());
|
||||||
|
app.use(cookieParser());
|
||||||
|
app.use("/api/session", createSessionRouter(users, sessions, pino({ level: "silent" })));
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractCookie(res: request.Response): string {
|
||||||
|
const header = res.headers["set-cookie"];
|
||||||
|
const arr = Array.isArray(header) ? header : header ? [header] : [];
|
||||||
|
const found = arr.find((c) => c.startsWith(`${SESSION_COOKIE_NAME}=`));
|
||||||
|
if (!found) throw new Error("no session cookie set");
|
||||||
|
return found.split(";")[0]; // "tsmb_session=xxxx"
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("session router", () => {
|
||||||
|
let botDb: BotDatabase;
|
||||||
|
let users: UserStore;
|
||||||
|
let sessions: SessionStore;
|
||||||
|
let app: express.Express;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
botDb = createDatabase(":memory:");
|
||||||
|
users = createUserStore(botDb.db);
|
||||||
|
sessions = createSessionStore(botDb.db);
|
||||||
|
app = makeApp(users, sessions);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => botDb.close());
|
||||||
|
|
||||||
|
it("GET /needs-setup returns true on an empty db", async () => {
|
||||||
|
const res = await request(app).get("/api/session/needs-setup");
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body).toEqual({ needsSetup: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("POST /setup creates the first admin, logs them in, and returns false from /needs-setup afterwards", async () => {
|
||||||
|
const setupRes = await request(app)
|
||||||
|
.post("/api/session/setup")
|
||||||
|
.send({ username: "alice", password: "hunter2-hunter2" });
|
||||||
|
expect(setupRes.status).toBe(200);
|
||||||
|
expect(setupRes.body.username).toBe("alice");
|
||||||
|
extractCookie(setupRes);
|
||||||
|
|
||||||
|
const needs = await request(app).get("/api/session/needs-setup");
|
||||||
|
expect(needs.body).toEqual({ needsSetup: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("POST /setup returns 409 once a user already exists", async () => {
|
||||||
|
await users.createUser("admin", "pw");
|
||||||
|
const res = await request(app)
|
||||||
|
.post("/api/session/setup")
|
||||||
|
.send({ username: "alice", password: "pw" });
|
||||||
|
expect(res.status).toBe(409);
|
||||||
|
expect(res.body).toEqual({ error: "already initialized" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("POST /login returns 401 with constant-time delay on bad credentials", async () => {
|
||||||
|
await users.createUser("alice", "correct");
|
||||||
|
const start = Date.now();
|
||||||
|
const res = await request(app)
|
||||||
|
.post("/api/session/login")
|
||||||
|
.send({ username: "alice", password: "wrong" });
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
expect(res.body).toEqual({ error: "invalid credentials" });
|
||||||
|
expect(Date.now() - start).toBeGreaterThanOrEqual(200);
|
||||||
|
}, 10_000);
|
||||||
|
|
||||||
|
it("POST /login sets a session cookie on success", async () => {
|
||||||
|
await users.createUser("alice", "pw");
|
||||||
|
const res = await request(app)
|
||||||
|
.post("/api/session/login")
|
||||||
|
.send({ username: "alice", password: "pw" });
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.username).toBe("alice");
|
||||||
|
extractCookie(res);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("GET /me returns the current user when cookie is present, 401 otherwise", async () => {
|
||||||
|
await users.createUser("alice", "pw");
|
||||||
|
const loginRes = await request(app)
|
||||||
|
.post("/api/session/login")
|
||||||
|
.send({ username: "alice", password: "pw" });
|
||||||
|
const cookie = extractCookie(loginRes);
|
||||||
|
|
||||||
|
const me = await request(app).get("/api/session/me").set("Cookie", cookie);
|
||||||
|
expect(me.status).toBe(200);
|
||||||
|
expect(me.body.username).toBe("alice");
|
||||||
|
|
||||||
|
const anon = await request(app).get("/api/session/me");
|
||||||
|
expect(anon.status).toBe(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("POST /logout deletes the session and clears the cookie", async () => {
|
||||||
|
await users.createUser("alice", "pw");
|
||||||
|
const loginRes = await request(app)
|
||||||
|
.post("/api/session/login")
|
||||||
|
.send({ username: "alice", password: "pw" });
|
||||||
|
const cookie = extractCookie(loginRes);
|
||||||
|
|
||||||
|
const logout = await request(app).post("/api/session/logout").set("Cookie", cookie);
|
||||||
|
expect(logout.status).toBe(204);
|
||||||
|
|
||||||
|
const me = await request(app).get("/api/session/me").set("Cookie", cookie);
|
||||||
|
expect(me.status).toBe(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("POST /change-password requires old password and invalidates other sessions", async () => {
|
||||||
|
const u = await users.createUser("alice", "old");
|
||||||
|
const cookieA = extractCookie(
|
||||||
|
await request(app).post("/api/session/login").send({ username: "alice", password: "old" })
|
||||||
|
);
|
||||||
|
const cookieB = extractCookie(
|
||||||
|
await request(app).post("/api/session/login").send({ username: "alice", password: "old" })
|
||||||
|
);
|
||||||
|
|
||||||
|
const wrongOld = await request(app)
|
||||||
|
.post("/api/session/change-password")
|
||||||
|
.set("Cookie", cookieA)
|
||||||
|
.send({ oldPassword: "WRONG", newPassword: "new" });
|
||||||
|
expect(wrongOld.status).toBe(401);
|
||||||
|
|
||||||
|
const ok = await request(app)
|
||||||
|
.post("/api/session/change-password")
|
||||||
|
.set("Cookie", cookieA)
|
||||||
|
.send({ oldPassword: "old", newPassword: "newpassword" });
|
||||||
|
expect(ok.status).toBe(204);
|
||||||
|
|
||||||
|
const meA = await request(app).get("/api/session/me").set("Cookie", cookieA);
|
||||||
|
expect(meA.status).toBe(200);
|
||||||
|
|
||||||
|
const meB = await request(app).get("/api/session/me").set("Cookie", cookieB);
|
||||||
|
expect(meB.status).toBe(401);
|
||||||
|
|
||||||
|
expect(u.id).toBe(meA.body.id);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
import { Router } from "express";
|
||||||
|
import type { Request, Response, NextFunction } from "express";
|
||||||
|
import type { Logger } from "../../logger.js";
|
||||||
|
import type { UserStore } from "../../data/users.js";
|
||||||
|
import { UsernameTakenError } from "../../data/users.js";
|
||||||
|
import type { SessionStore } from "../../data/sessions.js";
|
||||||
|
import { SESSION_TTL_MS } from "../../data/sessions.js";
|
||||||
|
import { SESSION_COOKIE_NAME, validateSessionFromHeaders } from "../auth/validateSession.js";
|
||||||
|
|
||||||
|
const FAILED_LOGIN_DELAY_MS = 250;
|
||||||
|
|
||||||
|
function setSessionCookie(res: Response, token: string): void {
|
||||||
|
res.cookie(SESSION_COOKIE_NAME, token, {
|
||||||
|
httpOnly: true,
|
||||||
|
sameSite: "lax",
|
||||||
|
secure: res.req.secure,
|
||||||
|
path: "/",
|
||||||
|
maxAge: SESSION_TTL_MS,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearSessionCookie(res: Response): void {
|
||||||
|
res.clearCookie(SESSION_COOKIE_NAME, { path: "/" });
|
||||||
|
}
|
||||||
|
|
||||||
|
function delay(ms: number): Promise<void> {
|
||||||
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
}
|
||||||
|
|
||||||
|
function isValidUsername(v: unknown): v is string {
|
||||||
|
return typeof v === "string" && /^[A-Za-z0-9_\-.]{3,32}$/.test(v);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isValidPassword(v: unknown): v is string {
|
||||||
|
return typeof v === "string" && v.length >= 8 && v.length <= 200;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseTokenFromCookie(cookieHeader: string | undefined): string | null {
|
||||||
|
if (!cookieHeader) return null;
|
||||||
|
const match = cookieHeader
|
||||||
|
.split(";")
|
||||||
|
.map((p) => p.trim())
|
||||||
|
.find((p) => p.startsWith(`${SESSION_COOKIE_NAME}=`));
|
||||||
|
if (!match) return null;
|
||||||
|
return decodeURIComponent(match.slice(SESSION_COOKIE_NAME.length + 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createSessionRouter(
|
||||||
|
users: UserStore,
|
||||||
|
sessions: SessionStore,
|
||||||
|
logger: Logger
|
||||||
|
): Router {
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
const requireAuthInline = (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
const result = validateSessionFromHeaders(req.headers.cookie, sessions);
|
||||||
|
if (!result) {
|
||||||
|
clearSessionCookie(res);
|
||||||
|
res.status(401).json({ error: "unauthenticated" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
req.user = { id: result.userId, username: result.username };
|
||||||
|
next();
|
||||||
|
};
|
||||||
|
|
||||||
|
router.get("/needs-setup", (_req, res) => {
|
||||||
|
res.json({ needsSetup: users.countUsers() === 0 });
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post("/setup", async (req, res) => {
|
||||||
|
const { username, password } = req.body ?? {};
|
||||||
|
if (users.countUsers() !== 0) {
|
||||||
|
res.status(409).json({ error: "already initialized" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!isValidUsername(username) || !isValidPassword(password)) {
|
||||||
|
res.status(400).json({ error: "invalid username or password" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const user = await users.createUser(username, password);
|
||||||
|
const { token } = sessions.createSession(user.id);
|
||||||
|
setSessionCookie(res, token);
|
||||||
|
logger.info({ userId: user.id, username }, "First admin created");
|
||||||
|
res.json({ id: user.id, username: user.username });
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof UsernameTakenError) {
|
||||||
|
res.status(409).json({ error: "already initialized" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
logger.error({ err }, "setup failed");
|
||||||
|
res.status(500).json({ error: "internal" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post("/login", async (req, res) => {
|
||||||
|
const { username, password } = req.body ?? {};
|
||||||
|
if (typeof username !== "string" || typeof password !== "string") {
|
||||||
|
res.status(400).json({ error: "invalid request" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const user = users.findByUsername(username);
|
||||||
|
const ok = user ? await users.verifyPassword(password, user.passwordHash) : false;
|
||||||
|
if (!user || !ok) {
|
||||||
|
await delay(FAILED_LOGIN_DELAY_MS);
|
||||||
|
res.status(401).json({ error: "invalid credentials" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { token } = sessions.createSession(user.id);
|
||||||
|
setSessionCookie(res, token);
|
||||||
|
res.json({ id: user.id, username: user.username });
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post("/logout", (req, res) => {
|
||||||
|
const token = parseTokenFromCookie(req.headers.cookie);
|
||||||
|
if (token) {
|
||||||
|
sessions.deleteSession(token);
|
||||||
|
}
|
||||||
|
clearSessionCookie(res);
|
||||||
|
res.status(204).end();
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get("/me", requireAuthInline, (req, res) => {
|
||||||
|
res.json(req.user);
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post("/change-password", requireAuthInline, async (req, res) => {
|
||||||
|
const { oldPassword, newPassword } = req.body ?? {};
|
||||||
|
if (typeof oldPassword !== "string") {
|
||||||
|
res.status(400).json({ error: "invalid request" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const u = users.findById(req.user!.id);
|
||||||
|
if (!u || !(await users.verifyPassword(oldPassword, u.passwordHash))) {
|
||||||
|
await delay(FAILED_LOGIN_DELAY_MS);
|
||||||
|
res.status(401).json({ error: "invalid credentials" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!isValidPassword(newPassword)) {
|
||||||
|
res.status(400).json({ error: "invalid request" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await users.changePassword(u.id, newPassword);
|
||||||
|
const currentToken = parseTokenFromCookie(req.headers.cookie);
|
||||||
|
sessions.deleteAllForUser(u.id, currentToken ?? undefined);
|
||||||
|
res.status(204).end();
|
||||||
|
});
|
||||||
|
|
||||||
|
return router;
|
||||||
|
}
|
||||||
Reference in new issue
Block a user