#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:
1 parent
57198e0263
commit
c7251211b5
3 files changed
+68
-5
No files matched your search
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user