feat: complete account and dashboard sprint

This commit is contained in:
Vakanaut
2026-07-12 15:56:26 +03:00
parent a6108ea927
commit 793d51fdce
19 changed files with 781 additions and 114 deletions

View File

@@ -1,12 +1,12 @@
{
"name": "calendar-run-backend",
"version": "1.4.2",
"version": "1.5.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "calendar-run-backend",
"version": "1.4.2",
"version": "1.5.0",
"dependencies": {
"argon2": "^0.44.0",
"cookie-parser": "^1.4.7",

View File

@@ -1,6 +1,6 @@
{
"name": "calendar-run-backend",
"version": "1.4.2",
"version": "1.5.0",
"private": true,
"scripts": {
"build": "tsc",

View File

@@ -37,6 +37,22 @@ interface SessionRow {
email_verified_at: Date | string | null;
}
interface SessionListRow {
id: string;
created_at: Date | string;
last_seen_at: Date | string;
expires_at: Date | string;
current: boolean;
}
export interface ActiveSession {
id: string;
createdAt: string;
lastSeenAt: string;
expiresAt: string;
current: boolean;
}
function toIso(value: Date | string | null): string | null {
if (!value) {
return null;
@@ -198,6 +214,84 @@ export async function revokeSession(sessionToken: string): Promise<void> {
securityLog("session.revoked");
}
export async function listActiveSessions(userId: string, sessionToken: string): Promise<ActiveSession[]> {
const { rows } = await pool.query<SessionListRow>(
`SELECT id, created_at, last_seen_at, expires_at, token_hash = $2 AS current
FROM sessions
WHERE user_id = $1 AND revoked_at IS NULL AND expires_at > NOW()
ORDER BY created_at DESC`,
[userId, sha256Hex(sessionToken)],
);
return rows.map((row) => ({
id: row.id,
createdAt: toIso(row.created_at)!,
lastSeenAt: toIso(row.last_seen_at)!,
expiresAt: toIso(row.expires_at)!,
current: row.current,
}));
}
export async function revokeSessionById(userId: string, sessionId: string, sessionToken: string): Promise<{ revoked: boolean; current: boolean }> {
const { rows } = await pool.query<{ current: boolean }>(
`UPDATE sessions
SET revoked_at = NOW()
WHERE id = $1 AND user_id = $2 AND revoked_at IS NULL AND expires_at > NOW()
RETURNING token_hash = $3 AS current`,
[sessionId, userId, sha256Hex(sessionToken)],
);
const row = rows[0];
if (!row) {
return { revoked: false, current: false };
}
securityLog("session.revoked", { userId });
return { revoked: true, current: row.current };
}
export async function revokeOtherSessions(userId: string, sessionToken: string): Promise<void> {
await pool.query(
`UPDATE sessions
SET revoked_at = NOW()
WHERE user_id = $1 AND token_hash <> $2 AND revoked_at IS NULL AND expires_at > NOW()`,
[userId, sha256Hex(sessionToken)],
);
securityLog("session.others_revoked", { userId });
}
export async function changePassword(
userId: string,
sessionToken: string,
currentPassword: string,
newPassword: string,
): Promise<boolean> {
const newPasswordHash = await hashPassword(newPassword);
const client = await pool.connect();
try {
await client.query("BEGIN");
const { rows } = await client.query<{ password_hash: string }>(
"SELECT password_hash FROM users WHERE id = $1 FOR UPDATE",
[userId],
);
const user = rows[0];
if (!user || !(await verifyPassword(user.password_hash, currentPassword))) {
await client.query("ROLLBACK");
return false;
}
await client.query("UPDATE users SET password_hash = $2, updated_at = NOW() WHERE id = $1", [userId, newPasswordHash]);
await client.query(
"UPDATE sessions SET revoked_at = NOW() WHERE user_id = $1 AND token_hash <> $2 AND revoked_at IS NULL",
[userId, sha256Hex(sessionToken)],
);
await client.query("COMMIT");
securityLog("password.changed", { userId });
return true;
} catch (error) {
await client.query("ROLLBACK");
throw error;
} finally {
client.release();
}
}
export async function rotateCsrf(sessionToken: string): Promise<string | null> {
const csrfToken = randomToken(32);
const { rowCount } = await pool.query(

View File

@@ -2,16 +2,20 @@ import { Router, Request, Response } from "express";
import rateLimit from "express-rate-limit";
import { z } from "zod";
import {
changePassword,
listActiveSessions,
loginUser,
registerUser,
requestPasswordReset,
resendVerification,
resetPassword,
revokeOtherSessions,
revokeSessionById,
rotateCsrf,
revokeSession,
verifyEmailToken,
} from "../authService";
import { clearSessionCookie, setSessionCookie } from "../authMiddleware";
import { clearSessionCookie, requireAuth, setSessionCookie } from "../authMiddleware";
import { isValidPassword, normalizeEmail } from "../security";
import { verifyTurnstileToken } from "../turnstile";
@@ -37,6 +41,13 @@ const emailAddressLimiter = rateLimit({
legacyHeaders: false,
keyGenerator: (req) => `email:${normalizeEmail(String(req.body?.email ?? ""))}`,
});
const passwordChangeLimiter = rateLimit({
windowMs: 60 * 60 * 1000,
limit: 5,
standardHeaders: true,
legacyHeaders: false,
keyGenerator: (req) => `password:${req.auth?.user.id ?? req.ip}`,
});
const registerSchema = z.object({
email: z.string().email(),
@@ -62,6 +73,13 @@ const resetSchema = z.object({
password: z.string().refine(isValidPassword, "Password must be at least 15 characters"),
});
const passwordChangeSchema = z.object({
currentPassword: z.string().min(1),
newPassword: z.string().refine(isValidPassword, "Password must be at least 15 characters"),
}).strict();
const sessionIdSchema = z.string().uuid();
function validationError(res: Response): void {
res.status(400).json({ error: "validation_error", details: ["Invalid request body"] });
}
@@ -129,6 +147,62 @@ router.get("/auth/me", async (req: Request, res: Response, next) => {
}
});
router.get("/auth/sessions", requireAuth, async (req: Request, res: Response, next) => {
try {
res.json({ sessions: await listActiveSessions(req.auth!.user.id, req.auth!.sessionToken) });
} catch (error) {
next(error);
}
});
router.post("/auth/password", requireAuth, passwordChangeLimiter, async (req: Request, res: Response, next) => {
const parsed = passwordChangeSchema.safeParse(req.body);
if (!parsed.success) {
validationError(res);
return;
}
try {
const changed = await changePassword(req.auth!.user.id, req.auth!.sessionToken, parsed.data.currentPassword, parsed.data.newPassword);
if (!changed) {
res.status(400).json({ error: "invalid_current_password", details: ["Current password is incorrect"] });
return;
}
res.json(genericOk);
} catch (error) {
next(error);
}
});
router.delete("/auth/sessions/:id", requireAuth, async (req: Request, res: Response, next) => {
const parsed = sessionIdSchema.safeParse(req.params.id);
if (!parsed.success) {
validationError(res);
return;
}
try {
const result = await revokeSessionById(req.auth!.user.id, parsed.data, req.auth!.sessionToken);
if (!result.revoked) {
res.status(404).json({ error: "not_found", details: ["Session not found"] });
return;
}
if (result.current) {
clearSessionCookie(res);
}
res.status(204).end();
} catch (error) {
next(error);
}
});
router.post("/auth/sessions/revoke-others", requireAuth, async (req: Request, res: Response, next) => {
try {
await revokeOtherSessions(req.auth!.user.id, req.auth!.sessionToken);
res.json(genericOk);
} catch (error) {
next(error);
}
});
router.post("/auth/verify-email", emailIpLimiter, async (req: Request, res: Response, next) => {
const parsed = tokenSchema.safeParse(req.body);
if (!parsed.success) {

View File

@@ -64,6 +64,12 @@ async function createUser(email: string, password: string, verified: boolean) {
return inserted.rows[0].id;
}
async function loginAgent(email: string, password: string) {
const agent = request.agent(app);
const login = await agent.post("/api/auth/login").send({ email, password }).expect(200);
return { agent, csrfToken: login.body.csrfToken as string };
}
async function countByTokenHash(table: "sessions" | "email_verification_tokens" | "password_reset_tokens", tokenHash: string) {
const { rows } = await pool.query<{ count: string }>(
`SELECT COUNT(*)::text AS count FROM ${table} WHERE token_hash = $1`,
@@ -183,6 +189,76 @@ test("login uses generic response for missing user and wrong password", async ()
assert.deepEqual(missingUser.body, wrongPassword.body);
});
test("account session endpoints list and revoke only the current user's sessions", async () => {
userCounter += 1;
const email = `sessions${userCounter}@example.com`;
const password = "correct horse battery staple";
await createVerifiedUser(email, password);
const first = await loginAgent(email, password);
const second = await loginAgent(email, password);
const listed = await first.agent.get("/api/auth/sessions").expect(200);
assert.equal(listed.body.sessions.length, 2);
assert.ok(listed.body.sessions.every((session: { tokenHash?: unknown }) => session.tokenHash === undefined));
const other = listed.body.sessions.find((session: { current: boolean }) => !session.current);
assert.ok(other);
const outsider = await authAgent();
const foreign = (await outsider.agent.get("/api/auth/sessions").expect(200)).body.sessions[0];
await first.agent
.delete(`/api/auth/sessions/${foreign.id}`)
.set("X-CSRF-Token", first.csrfToken)
.expect(404);
await first.agent
.delete(`/api/auth/sessions/${other.id}`)
.set("X-CSRF-Token", first.csrfToken)
.expect(204);
await second.agent.get("/api/auth/me").expect(401);
await first.agent.get("/api/auth/sessions").expect(200).then((res) => assert.equal(res.body.sessions.length, 1));
});
test("password change checks the current password and revokes other sessions", async () => {
userCounter += 1;
const email = `password${userCounter}@example.com`;
const password = "correct horse battery staple";
const nextPassword = "another correct horse battery staple";
await createVerifiedUser(email, password);
const first = await loginAgent(email, password);
const second = await loginAgent(email, password);
await first.agent
.post("/api/auth/password")
.set("X-CSRF-Token", first.csrfToken)
.send({ currentPassword: "wrong password", newPassword: nextPassword })
.expect(400);
await first.agent
.post("/api/auth/password")
.set("X-CSRF-Token", first.csrfToken)
.send({ currentPassword: password, newPassword: nextPassword })
.expect(200);
await second.agent.get("/api/auth/me").expect(401);
await request(app).post("/api/auth/login").send({ email, password }).expect(401);
await request(app).post("/api/auth/login").send({ email, password: nextPassword }).expect(200);
});
test("revoke other sessions retains the current session", async () => {
userCounter += 1;
const email = `revoke-others${userCounter}@example.com`;
const password = "correct horse battery staple";
await createVerifiedUser(email, password);
const first = await loginAgent(email, password);
const second = await loginAgent(email, password);
await first.agent
.post("/api/auth/sessions/revoke-others")
.set("X-CSRF-Token", first.csrfToken)
.expect(200);
await first.agent.get("/api/auth/me").expect(200);
await second.agent.get("/api/auth/me").expect(401);
});
test("GET /api/races/:id returns not_found for another user's race", async () => {
const first = await authAgent();
const created = await first.agent