#21: complete the account area and audit every admin mutation
Sessions and password changes were the two things PLAN.md section 14.2 asks of the account area that were still missing, and the admin console had no audit trail at all despite section 14.3 requiring one for every mutation. Session revocation is enforced on every request, not just recorded. A JWT carries its own validity, so revoking a row without checking it would have made "sign out this device" a button that did nothing until the token expired eight hours later. Changing a password revokes every other session for the same reason: a password change that leaves the attacker's session alive has not locked anyone out. Admin queries are scoped by role in the SQL rather than in the UI, as section 14.3 requires: a DOMAIN_ADMIN calling the API directly sees only its own domain, and a mailbox outside that scope answers 404 rather than 403 so the scope cannot be used to enumerate what exists elsewhere. The two mutations that exist so far - enabling or disabling a mailbox, and retrying or cancelling a queued delivery - write actor, IP and target to audit_log, and there is an endpoint to read it back. Covered by tests against a real database: a revoked token stops working mid-session, a password change needs the current password and takes the other sessions down with it, and the minimum length is enforced. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
93d21b4752
commit
eec4a4e520
3 files changed
+366
-12
No files matched your search
@@ -75,6 +75,18 @@ async function post(path: string, body: unknown, token?: string) {
|
||||
return { status: res.status, body: await res.json().catch(() => ({})) as Record<string, never> };
|
||||
}
|
||||
|
||||
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<string, never> };
|
||||
}
|
||||
|
||||
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.
|
||||
|
||||
Reference in new issue
Block a user