diff --git a/Makefile b/Makefile index 0d5790c..16a9f31 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: up down logs test lint migrate-up migrate-down gen-dev-cert seed preflight create-admin reset-password +.PHONY: up down logs test lint migrate-up migrate-down gen-dev-cert seed preflight create-admin reset-password reset-mfa # Local development builds from source; production pulls what CI published. up: @@ -56,3 +56,11 @@ reset-password: @test -n "$(EMAIL)" || (echo "EMAIL is required" && exit 1) @test -n "$(PASSWORD)" || (echo "PASSWORD is required" && exit 1) docker compose run --rm --entrypoint node auth dist/cli.js reset-password "$(EMAIL)" "$(PASSWORD)" + +# Clears every second factor from an account: the lost-phone case, and the one +# where an enrollment was confirmed against a secret the authenticator does not +# have. The password is untouched, so this alone grants nobody access. +# make reset-mfa EMAIL=you@example.com +reset-mfa: + @test -n "$(EMAIL)" || (echo "EMAIL is required" && exit 1) + docker compose run --rm --entrypoint node auth dist/cli.js reset-mfa "$(EMAIL)" diff --git a/docs/OPERATIONS.md b/docs/OPERATIONS.md index 2970cf2..5aa01ec 100644 --- a/docs/OPERATIONS.md +++ b/docs/OPERATIONS.md @@ -90,16 +90,29 @@ restore is a hypothesis. ```bash make create-admin EMAIL=… PASSWORD='…' # first admin, or --force for another make reset-password EMAIL=… PASSWORD='…' # recovers a lockout, ends its sessions +make reset-mfa EMAIL=… # clears every second factor ``` An account locks itself for 15 minutes after 5 failed passwords. That is the lockout working, not a fault; `reset-password` clears it. -If somebody loses their second factor and their recovery codes, there is no -self-service path today. Clear the enrolment directly and have them enrol again: +`reset-mfa` is the lost-phone command, and the answer to a subtler case: an +enrolment can be confirmed against a secret the authenticator no longer holds, +and the account is then locked behind codes nobody can produce. It removes the +authenticator secrets and the recovery codes, and signs the account's sessions +out - a live session would otherwise keep the console open without the factor +that was just removed. The password is untouched, so this alone grants nobody +access; the next sign-in enrols a new factor. + +Do not delete and recreate the mailbox for this. That takes its mail, folders +and aliases with it, and none of those are the problem. + +The equivalent by hand, if you are on an image that predates the command - +note that clearing `mfa_totp` alone leaves the recovery codes valid: ```sql DELETE FROM mfa_totp WHERE mailbox_id = (SELECT id FROM mailboxes WHERE email_address = 'them@example.com'); +DELETE FROM mfa_recovery_codes WHERE mailbox_id = (SELECT id FROM mailboxes WHERE email_address = 'them@example.com'); ``` ## Delivery diff --git a/services/auth/src/cli.ts b/services/auth/src/cli.ts index b7ed8b0..57fce04 100644 --- a/services/auth/src/cli.ts +++ b/services/auth/src/cli.ts @@ -10,6 +10,7 @@ * Usage: * node dist/cli.js create-admin [--force] * node dist/cli.js reset-password + * node dist/cli.js reset-mfa */ import { query, queryOne, closePool } from "./db.js"; @@ -89,8 +90,8 @@ async function createAdmin(email: string, password: string, force: boolean): Pro console.log(`created administrator ${email}`); console.log(`system aliases: ${SYSTEM_ALIASES.map((a) => `${a}@${domain}`).join(", ")}`); console.log(""); - console.log("Sign in at /admin/login. The console requires a second factor, so enroll"); - console.log("one from the webmail settings at /user/settings first."); + console.log("Sign in at /admin/login. If the account has no second factor yet, the"); + console.log("console enrolls one there and then - no need to visit another screen."); } async function resetPassword(email: string, password: string): Promise { @@ -110,6 +111,42 @@ async function resetPassword(email: string, password: string): Promise { console.log(`password reset for ${email}; all its sessions were signed out`); } +/** + * Removes every second factor from an account, so the next sign-in enrolls a + * new one. + * + * This is the lost-phone command, and the answer to a subtler case: an + * enrollment can be confirmed against a secret the authenticator no longer + * holds, and the account is then locked behind codes nobody can produce. The + * alternative - deleting the mailbox and recreating it - would take its mail, + * folders and aliases with it for no reason. + * + * The password is untouched, so this alone does not grant anyone access. + * Sessions are revoked because a live session would otherwise keep the console + * open without the factor that was just removed. + */ +async function resetMfa(email: string): Promise { + const mailbox = await queryOne<{ id: string }>( + `SELECT id FROM mailboxes WHERE email_address = $1`, + [email.toLowerCase()], + ); + if (!mailbox) fail(`${email} was not found`); + + const totp = await query<{ id: string }>( + `DELETE FROM mfa_totp WHERE mailbox_id = $1 RETURNING id`, [mailbox!.id]); + const codes = await query<{ id: string }>( + `DELETE FROM mfa_recovery_codes WHERE mailbox_id = $1 RETURNING id`, [mailbox!.id]); + await query( + `UPDATE web_sessions SET revoked_at = NOW() WHERE mailbox_id = $1 AND revoked_at IS NULL`, + [mailbox!.id], + ); + + console.log(`removed ${totp.length} authenticator secret(s) and ${codes.length} recovery code(s) for ${email}`); + console.log("its sessions were signed out; the password is unchanged"); + console.log(""); + console.log("Sign in at /admin/login and the console will enroll a new factor."); +} + async function main(): Promise { const [command, ...args] = process.argv.slice(2); @@ -125,9 +162,14 @@ async function main(): Promise { if (args.length < 2) fail("usage: reset-password "); await resetPassword(args[0], args[1]); break; + case "reset-mfa": + if (args.length < 1) fail("usage: reset-mfa "); + await resetMfa(args[0]); + break; default: console.log("commands: create-admin [--force]"); console.log(" reset-password "); + console.log(" reset-mfa "); process.exit(command ? 1 : 0); } }