diff --git a/services/auth/src/repository.ts b/services/auth/src/repository.ts index 7ee1f30..3e01ca2 100644 --- a/services/auth/src/repository.ts +++ b/services/auth/src/repository.ts @@ -318,22 +318,39 @@ export async function dashboardStats(): Promise> { }; } -export async function listDomains(): Promise { +export interface AdminScope { + role: string; + domainId: string; +} + +/** SUPER_ADMIN sees everything; a DOMAIN_ADMIN is confined to its own domain. + * PLAN.md section 14.3 requires this at the query level rather than in the UI, + * so calling the API directly does not widen the view. */ +function scopeClause(scope: AdminScope, column: string): { sql: string; params: unknown[] } { + if (scope.role === "SUPER_ADMIN") return { sql: "TRUE", params: [] }; + return { sql: `${column} = $1`, params: [scope.domainId] }; +} + +export async function listDomains(scope: AdminScope): Promise { + const { sql, params } = scopeClause(scope, "d.id"); return query( - `SELECT id, name, dns_status, dmarc_policy, mta_sts_mode, dane_enabled, - is_active, dns_last_checked_at, + `SELECT d.id, d.name, d.dns_status, d.dmarc_policy, d.mta_sts_mode, d.dane_enabled, + d.is_active, d.dns_last_checked_at, (SELECT count(*) FROM mailboxes m WHERE m.domain_id = d.id)::int AS mailbox_count - FROM domains d ORDER BY name`, + FROM domains d WHERE ${sql} ORDER BY d.name`, + params, ); } -export async function listMailboxes(): Promise { +export async function listMailboxes(scope: AdminScope): Promise { + const { sql, params } = scopeClause(scope, "m.domain_id"); return query( `SELECT m.id, m.email_address, m.role, m.is_active, m.quota_bytes, m.used_bytes, m.locale, m.locked_until, d.name AS domain_name, EXISTS (SELECT 1 FROM mfa_totp t WHERE t.mailbox_id = m.id AND t.status = 'CONFIRMED') AS mfa_enrolled FROM mailboxes m JOIN domains d ON d.id = m.domain_id - ORDER BY m.email_address LIMIT 200`, + WHERE ${sql} ORDER BY m.email_address LIMIT 200`, + params, ); } @@ -393,3 +410,154 @@ export async function preflightSummary(): Promise> { note: "Socket-level checks (port 25 egress, PTR, RBL) are reported by the protocols service preflight", }; } + +// -------------------------------------------------- account self-service + +export interface SessionSummary { + id: string; + surface: string; + ip_address: string | null; + user_agent: string | null; + created_at: Date; + expires_at: Date; + current: boolean; +} + +/** Lists the account's live sessions so a user can spot one they do not + * recognise (PLAN.md section 14.2). */ +export async function listSessions(mailboxId: string, currentTokenHash: string): Promise { + const rows = await query & { refresh_token_hash: string }>( + `SELECT id, surface, ip_address::text AS ip_address, user_agent, created_at, expires_at, refresh_token_hash + FROM web_sessions + WHERE mailbox_id = $1 AND revoked_at IS NULL AND expires_at > NOW() + ORDER BY created_at DESC`, + [mailboxId], + ); + return rows.map(({ refresh_token_hash, ...rest }) => ({ + ...rest, + current: refresh_token_hash === currentTokenHash, + })); +} + +export async function revokeSession(mailboxId: string, sessionId: string): Promise { + const rows = await query<{ id: string }>( + `UPDATE web_sessions SET revoked_at = NOW() + WHERE id = $1 AND mailbox_id = $2 AND revoked_at IS NULL RETURNING id`, + [sessionId, mailboxId], + ); + return rows.length === 1; +} + +/** Revokes every session except the caller's, which is what "sign out + * everywhere else" has to mean for it to be useful after a password leak. */ +export async function revokeOtherSessions(mailboxId: string, currentTokenHash: string): Promise { + const rows = await query<{ id: string }>( + `UPDATE web_sessions SET revoked_at = NOW() + WHERE mailbox_id = $1 AND revoked_at IS NULL AND refresh_token_hash <> $2 RETURNING id`, + [mailboxId, currentTokenHash], + ); + return rows.length; +} + +export async function isSessionRevoked(tokenHash: string): Promise { + const row = await queryOne<{ revoked: boolean }>( + `SELECT (revoked_at IS NOT NULL) AS revoked FROM web_sessions WHERE refresh_token_hash = $1`, + [tokenHash], + ); + // An unknown hash is treated as live: tokens issued before this table was + // populated must not all stop working at once. + return row?.revoked ?? false; +} + +/** + * Changes the password after proving the current one, then revokes every other + * session: a password change that leaves the attacker's session alive has not + * actually locked anyone out. + */ +export async function changePassword( + mailboxId: string, + currentPassword: string, + newPassword: string, + currentTokenHash: string, +): Promise<"OK" | "WRONG_PASSWORD" | "NOT_FOUND"> { + const row = await queryOne<{ password_hash: string }>( + `SELECT password_hash FROM mailboxes WHERE id = $1`, + [mailboxId], + ); + if (!row) return "NOT_FOUND"; + if (!(await verifyPassword(currentPassword, row.password_hash))) return "WRONG_PASSWORD"; + + await query( + `UPDATE mailboxes SET password_hash = $2, password_updated_at = NOW(), updated_at = NOW() WHERE id = $1`, + [mailboxId, await hashPassword(newPassword)], + ); + await revokeOtherSessions(mailboxId, currentTokenHash); + return "OK"; +} + +// ------------------------------------------------------------- audit log + +/** Records an administrative action. PLAN.md section 14.3 requires every + * mutation under /admin to leave a trace with actor, IP and what changed. */ +export async function recordAudit( + actorId: string | null, + actorIp: string | null, + action: string, + targetType: string | null, + targetId: string | null, + metadata: unknown, +): Promise { + await query( + `INSERT INTO audit_log (actor_id, actor_ip, action, target_type, target_id, metadata) + VALUES ($1, $2, $3, $4, $5, $6)`, + [actorId, actorIp, action, targetType, targetId, JSON.stringify(metadata ?? {})], + ); +} + +export async function listAudit(limit = 100): Promise { + return query( + `SELECT a.id, a.action, a.target_type, a.target_id, a.actor_ip::text AS actor_ip, + a.metadata, a.created_at, m.email_address AS actor_email + FROM audit_log a LEFT JOIN mailboxes m ON m.id = a.actor_id + ORDER BY a.id DESC LIMIT $1`, + [limit], + ); +} + +/** Enables or disables a mailbox, refusing anything outside the caller's scope. */ +export async function setMailboxActive(scope: AdminScope, mailboxId: string, isActive: boolean): Promise { + const rows = + scope.role === "SUPER_ADMIN" + ? await query<{ id: string }>( + `UPDATE mailboxes SET is_active = $2, updated_at = NOW() WHERE id = $1 RETURNING id`, + [mailboxId, isActive], + ) + : await query<{ id: string }>( + `UPDATE mailboxes SET is_active = $2, updated_at = NOW() + WHERE id = $1 AND domain_id = $3 RETURNING id`, + [mailboxId, isActive, scope.domainId], + ); + return rows.length === 1; +} + +/** + * Retries or cancels one queued delivery. + * + * Retry only moves a job that is not already finished, and clears the next + * attempt time so the worker picks it up on its next pass rather than waiting + * out the backoff the operator is trying to skip. + */ +export async function updateQueueJob(scope: AdminScope, jobId: string, action: "retry" | "cancel"): Promise { + const scoped = scope.role === "SUPER_ADMIN" ? "TRUE" : "o.mailbox_id IN (SELECT id FROM mailboxes WHERE domain_id = $2)"; + const params: unknown[] = scope.role === "SUPER_ADMIN" ? [jobId] : [jobId, scope.domainId]; + + const sql = + action === "retry" + ? `UPDATE outbound_jobs o SET status = 'QUEUED', next_attempt_at = NOW(), last_error = NULL + WHERE o.id = $1 AND o.status IN ('DEFERRED','BOUNCED') AND ${scoped} RETURNING o.id` + : `UPDATE outbound_jobs o SET status = 'CANCELLED' + WHERE o.id = $1 AND o.status IN ('QUEUED','DEFERRED') AND ${scoped} RETURNING o.id`; + + const rows = await query<{ id: string }>(sql, params); + return rows.length === 1; +} diff --git a/services/auth/src/router.test.ts b/services/auth/src/router.test.ts index b2a8805..f41645d 100644 --- a/services/auth/src/router.test.ts +++ b/services/auth/src/router.test.ts @@ -75,6 +75,18 @@ async function post(path: string, body: unknown, token?: string) { return { status: res.status, body: await res.json().catch(() => ({})) as Record }; } +async function put(path: string, body: unknown, token?: string) { + const res = await fetch(`${base}${path}`, { + method: "PUT", + headers: { + "Content-Type": "application/json", + ...(token ? { Authorization: `Bearer ${token}` } : {}), + }, + body: JSON.stringify(body), + }); + return { status: res.status, body: await res.json().catch(() => ({})) as Record }; +} + async function get(path: string, token?: string) { const res = await fetch(`${base}${path}`, { headers: token ? { Authorization: `Bearer ${token}` } : {}, @@ -256,6 +268,72 @@ describeDb("auth API against a real database", () => { const res = await post("/api/v1/user/mfa/totp/enroll", {}); expect(res.status).toBe(401); }); + + // A JWT carries its own validity, so revoking a session must be enforced on + // every request or "sign out this device" would change nothing. + it("stops accepting a token once its session is revoked", async () => { + const email = `revoke@authtest-${DOMAIN_ID}.invalid`; + await query( + `INSERT INTO mailboxes (domain_id, local_part, email_address, password_hash) + VALUES ($1, 'revoke', $2, $3)`, + [DOMAIN_ID, email, await hashPassword(PASSWORD)], + ); + + const login = await post("/api/v1/auth/user/login", { email, password: PASSWORD }); + const token = login.body.token as unknown as string; + + const before = await get("/api/v1/user/me", token); + expect(before.status).toBe(200); + + const sessions = await get("/api/v1/user/sessions", token); + expect(sessions.status).toBe(200); + const list = sessions.body as unknown as Array<{ id: string; current: boolean }>; + expect(list.length).toBeGreaterThan(0); + expect(list.some((s) => s.current)).toBe(true); + + const revoked = await fetch(`${base}/api/v1/user/sessions/${list[0].id}`, { + method: "DELETE", + headers: { Authorization: `Bearer ${token}` }, + }); + expect(revoked.status).toBe(200); + + const after = await get("/api/v1/user/me", token); + expect(after.status).toBe(401); + expect(after.body.error).toBe("SESSION_REVOKED"); + }, 60000); + + it("changes the password only with the current one, and revokes other sessions", async () => { + const email = `pw@authtest-${DOMAIN_ID}.invalid`; + await query( + `INSERT INTO mailboxes (domain_id, local_part, email_address, password_hash) + VALUES ($1, 'pw', $2, $3)`, + [DOMAIN_ID, email, await hashPassword(PASSWORD)], + ); + + const first = await post("/api/v1/auth/user/login", { email, password: PASSWORD }); + const second = await post("/api/v1/auth/user/login", { email, password: PASSWORD }); + + const wrong = await put("/api/v1/user/password", + { current_password: "not-it", new_password: "a-brand-new-password-1" }, first.body.token); + expect(wrong.status).toBe(401); + + const tooShort = await put("/api/v1/user/password", + { current_password: PASSWORD, new_password: "short" }, first.body.token); + expect(tooShort.status).toBe(400); + + const ok = await put("/api/v1/user/password", + { current_password: PASSWORD, new_password: "a-brand-new-password-1" }, first.body.token); + expect(ok.status).toBe(200); + + // The other session is gone; the one that made the change still works. + const other = await get("/api/v1/user/me", second.body.token); + expect(other.status).toBe(401); + const mine = await get("/api/v1/user/me", first.body.token); + expect(mine.status).toBe(200); + + const relogin = await post("/api/v1/auth/user/login", { email, password: "a-brand-new-password-1" }); + expect(relogin.status).toBe(200); + }, 90000); }); // Guards that need no database, so they still run in a bare checkout. diff --git a/services/auth/src/router.ts b/services/auth/src/router.ts index b1bfeb0..21d40d8 100644 --- a/services/auth/src/router.ts +++ b/services/auth/src/router.ts @@ -98,6 +98,15 @@ async function route(req: IncomingMessage, res: ServerResponse, url: string): Pr return sendJson(res, 401, { error: "UNAUTHORIZED", message: "User session required" }); } + // A JWT carries its own validity, so revoking a session in the database + // would otherwise change nothing until it expired. Checking here is what + // makes "sign out this device" mean anything. + if (session && (url.startsWith("/api/v1/admin/") || url.startsWith("/api/v1/user/"))) { + if (await repo.isSessionRevoked(crypto.createHash("sha256").update(token).digest("hex"))) { + return sendJson(res, 401, { error: "SESSION_REVOKED", message: "This session has been signed out" }); + } + } + if (url === "/api/v1/auth/user/login" && method === "POST") return login(req, res, "user"); if (url === "/api/v1/auth/admin/login" && method === "POST") return login(req, res, "admin"); if (url === "/api/v1/auth/mfa/verify" && method === "POST") return mfaVerify(req, res); @@ -113,9 +122,27 @@ async function route(req: IncomingMessage, res: ServerResponse, url: string): Pr return appPasswordDelete(res, session!, url.substring("/api/v1/user/app-passwords/".length)); } + if (url === "/api/v1/user/sessions" && method === "GET") return listSessions(req, res, session!); + if (url === "/api/v1/user/sessions/others" && method === "DELETE") return revokeOthers(req, res, session!); + if (url.startsWith("/api/v1/user/sessions/") && method === "DELETE") { + return revokeSession(res, session!, url.substring("/api/v1/user/sessions/".length)); + } + if (url === "/api/v1/user/password" && method === "PUT") return changePassword(req, res, session!); + + if (url === "/api/v1/admin/audit" && method === "GET") return adminAudit(res); + if (url.startsWith("/api/v1/admin/mailboxes/") && url.endsWith("/active") && method === "PUT") { + const id = url.slice("/api/v1/admin/mailboxes/".length, -"/active".length); + return adminSetMailboxActive(req, res, session!, id); + } + if (url.startsWith("/api/v1/admin/queue/") && url.endsWith("/retry") && method === "POST") { + return adminQueueAction(req, res, session!, url.slice("/api/v1/admin/queue/".length, -"/retry".length), "retry"); + } + if (url.startsWith("/api/v1/admin/queue/") && url.endsWith("/cancel") && method === "POST") { + return adminQueueAction(req, res, session!, url.slice("/api/v1/admin/queue/".length, -"/cancel".length), "cancel"); + } if (url === "/api/v1/admin/dashboard" && method === "GET") return adminDashboard(res); - if (url === "/api/v1/admin/domains" && method === "GET") return adminDomains(res); - if (url === "/api/v1/admin/mailboxes" && method === "GET") return adminMailboxes(res); + if (url === "/api/v1/admin/domains" && method === "GET") return adminDomains(res, session!); + if (url === "/api/v1/admin/mailboxes" && method === "GET") return adminMailboxes(res, session!); if (url === "/api/v1/admin/dmarc" && method === "GET") return adminDmarc(res); if (url === "/api/v1/admin/queue" && method === "GET") return adminQueue(res); if (url === "/api/v1/admin/preflight" && method === "GET") return adminPreflight(res); @@ -350,19 +377,100 @@ async function appPasswordDelete(res: ServerResponse, session: SessionTokenPaylo sendJson(res, 200, { ok: true }); } +// The session token is the credential, so its hash identifies the row in +// web_sessions that belongs to this caller. +function tokenHash(req: IncomingMessage, url: string): string { + return crypto.createHash("sha256").update(extractToken(req, url)).digest("hex"); +} + +async function listSessions(req: IncomingMessage, res: ServerResponse, session: SessionTokenPayload): Promise { + sendJson(res, 200, await repo.listSessions(session.sub, tokenHash(req, "/api/v1/user/"))); +} + +async function revokeSession(res: ServerResponse, session: SessionTokenPayload, id: string): Promise { + if (!(await repo.revokeSession(session.sub, id))) { + return sendJson(res, 404, { error: "NOT_FOUND", message: "Session not found" }); + } + sendJson(res, 200, { ok: true }); +} + +async function revokeOthers(req: IncomingMessage, res: ServerResponse, session: SessionTokenPayload): Promise { + const revoked = await repo.revokeOtherSessions(session.sub, tokenHash(req, "/api/v1/user/")); + sendJson(res, 200, { revoked }); +} + +async function changePassword(req: IncomingMessage, res: ServerResponse, session: SessionTokenPayload): Promise { + const body = await parseJsonBody(req); + const current = typeof body?.current_password === "string" ? body.current_password : ""; + const next = typeof body?.new_password === "string" ? body.new_password : ""; + + // Twelve characters is the floor; length beats composition rules, which push + // people towards predictable substitutions. + if (next.length < 12) { + return sendJson(res, 400, { error: "WEAK_PASSWORD", message: "The new password must be at least 12 characters" }); + } + + const outcome = await repo.changePassword(session.sub, current, next, tokenHash(req, "/api/v1/user/")); + if (outcome === "WRONG_PASSWORD") { + return sendJson(res, 401, { error: "INVALID_CREDENTIALS", message: "Current password is incorrect" }); + } + if (outcome === "NOT_FOUND") { + return sendJson(res, 404, { error: "NOT_FOUND", message: "Mailbox not found" }); + } + sendJson(res, 200, { ok: true }); +} + // ------------------------------------------------------------- admin area +async function adminAudit(res: ServerResponse): Promise { + sendJson(res, 200, await repo.listAudit()); +} + +async function adminSetMailboxActive( + req: IncomingMessage, res: ServerResponse, session: SessionTokenPayload, id: string, +): Promise { + const body = await parseJsonBody(req); + const isActive = body?.is_active === true; + + const updated = await repo.setMailboxActive(scopeOf(session), id, isActive); + if (!updated) { + // Out of scope and non-existent give the same answer, so a DOMAIN_ADMIN + // cannot probe for mailboxes in domains it does not administer. + return sendJson(res, 404, { error: "NOT_FOUND", message: "Mailbox not found" }); + } + + await repo.recordAudit(session.sub, clientIp(req), isActive ? "mailbox.enable" : "mailbox.disable", + "mailbox", id, { is_active: isActive }); + sendJson(res, 200, { ok: true }); +} + +async function adminQueueAction( + req: IncomingMessage, res: ServerResponse, session: SessionTokenPayload, id: string, action: "retry" | "cancel", +): Promise { + const updated = await repo.updateQueueJob(scopeOf(session), id, action); + if (!updated) { + return sendJson(res, 404, { error: "NOT_FOUND", message: "Queue job not found" }); + } + await repo.recordAudit(session.sub, clientIp(req), `queue.${action}`, "outbound_job", id, {}); + sendJson(res, 200, { ok: true }); +} + async function adminDashboard(res: ServerResponse): Promise { const stats = await repo.dashboardStats(); sendJson(res, 200, stats); } -async function adminDomains(res: ServerResponse): Promise { - sendJson(res, 200, await repo.listDomains()); +async function adminDomains(res: ServerResponse, session: SessionTokenPayload): Promise { + sendJson(res, 200, await repo.listDomains(scopeOf(session))); } -async function adminMailboxes(res: ServerResponse): Promise { - sendJson(res, 200, await repo.listMailboxes()); +async function adminMailboxes(res: ServerResponse, session: SessionTokenPayload): Promise { + sendJson(res, 200, await repo.listMailboxes(scopeOf(session))); +} + +/** The scope travels from the verified session, never from the request. */ +function scopeOf(session: SessionTokenPayload): repo.AdminScope { + return { role: session.role, domainId: session.domainId }; } async function adminDmarc(res: ServerResponse): Promise {