#29: add reset-mfa, so a broken enrolment does not cost the mailbox

An enrolment can end up confirmed against a secret the authenticator no longer
holds - the account is then locked behind codes nobody can produce, and the
only documented recovery was a DELETE against mfa_totp. That SQL was also
incomplete: it left the recovery codes valid and the sessions live.

reset-mfa removes the authenticator secrets and the recovery codes, and signs
the account's sessions out, because a live session would keep the console open
without the factor that was just removed. The password is untouched, so the
command alone grants nobody access.

The alternative people reach for - delete the mailbox and create it again -
takes its mail, folders and aliases with it, and none of those are the problem.
The docs now say so.

Also corrects the advice printed after create-admin, which still told the
operator to enrol from the webmail settings before the console would open. The
console has enrolled inline since #23.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Lucas RayanandClaude Opus 5 committed 2026-08-03 10:20:21 -03:00
1 parent 57198e0263
commit c7251211b5
3 files changed
+68 -5

No files matched your search

+9 -1
View File
@@ -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. # Local development builds from source; production pulls what CI published.
up: up:
@@ -56,3 +56,11 @@ reset-password:
@test -n "$(EMAIL)" || (echo "EMAIL is required" && exit 1) @test -n "$(EMAIL)" || (echo "EMAIL is required" && exit 1)
@test -n "$(PASSWORD)" || (echo "PASSWORD 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)" 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)"
+15 -2
View File
@@ -90,16 +90,29 @@ restore is a hypothesis.
```bash ```bash
make create-admin EMAIL=… PASSWORD='…' # first admin, or --force for another make create-admin EMAIL=… PASSWORD='…' # first admin, or --force for another
make reset-password EMAIL=… PASSWORD='…' # recovers a lockout, ends its sessions 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 An account locks itself for 15 minutes after 5 failed passwords. That is the
lockout working, not a fault; `reset-password` clears it. lockout working, not a fault; `reset-password` clears it.
If somebody loses their second factor and their recovery codes, there is no `reset-mfa` is the lost-phone command, and the answer to a subtler case: an
self-service path today. Clear the enrolment directly and have them enrol again: 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 ```sql
DELETE FROM mfa_totp WHERE mailbox_id = (SELECT id FROM mailboxes WHERE email_address = 'them@example.com'); 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 ## Delivery
+44 -2
View File
@@ -10,6 +10,7 @@
* Usage: * Usage:
* node dist/cli.js create-admin <email> <password> [--force] * node dist/cli.js create-admin <email> <password> [--force]
* node dist/cli.js reset-password <email> <password> * node dist/cli.js reset-password <email> <password>
* node dist/cli.js reset-mfa <email>
*/ */
import { query, queryOne, closePool } from "./db.js"; 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(`created administrator ${email}`);
console.log(`system aliases: ${SYSTEM_ALIASES.map((a) => `${a}@${domain}`).join(", ")}`); console.log(`system aliases: ${SYSTEM_ALIASES.map((a) => `${a}@${domain}`).join(", ")}`);
console.log(""); console.log("");
console.log("Sign in at /admin/login. The console requires a second factor, so enroll"); console.log("Sign in at /admin/login. If the account has no second factor yet, the");
console.log("one from the webmail settings at /user/settings first."); console.log("console enrolls one there and then - no need to visit another screen.");
} }
async function resetPassword(email: string, password: string): Promise<void> { async function resetPassword(email: string, password: string): Promise<void> {
@@ -110,6 +111,42 @@ async function resetPassword(email: string, password: string): Promise<void> {
console.log(`password reset for ${email}; all its sessions were signed out`); 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<void> {
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<void> { async function main(): Promise<void> {
const [command, ...args] = process.argv.slice(2); const [command, ...args] = process.argv.slice(2);
@@ -125,9 +162,14 @@ async function main(): Promise<void> {
if (args.length < 2) fail("usage: reset-password <email> <password>"); if (args.length < 2) fail("usage: reset-password <email> <password>");
await resetPassword(args[0], args[1]); await resetPassword(args[0], args[1]);
break; break;
case "reset-mfa":
if (args.length < 1) fail("usage: reset-mfa <email>");
await resetMfa(args[0]);
break;
default: default:
console.log("commands: create-admin <email> <password> [--force]"); console.log("commands: create-admin <email> <password> [--force]");
console.log(" reset-password <email> <password>"); console.log(" reset-password <email> <password>");
console.log(" reset-mfa <email>");
process.exit(command ? 1 : 0); process.exit(command ? 1 : 0);
} }
} }