forked from admin/runners-calendar
feat: complete account and dashboard sprint
This commit is contained in:
4
backend/package-lock.json
generated
4
backend/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "calendar-run-backend",
|
||||
"version": "1.4.2",
|
||||
"version": "1.5.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user