Files
HealthLog/scripts/disable-mfa.ts
T
Marc-André Bombeck b446a66753 feat(auth): add TOTP second factor and recovery codes
Add the TOTP enroll/confirm/disable surface and Argon2id-hashed recovery
codes as the v1.23 second factor.

- totp.ts wraps otpauth (zero-dep) pinned to the interop floor (SHA-1, 6
  digits, 30 s) with a +/-1 drift window. Replay protection persists the last
  accepted time-step per account (User.totpLastStep, migration
  0202_v123_totp_laststep) and rejects any step that is not strictly newer,
  so a captured code dies the instant it is accepted once.
- recovery-codes.ts issues ten single-use, high-entropy codes, Argon2id-
  hashed at rest, shown once; regeneration replaces the entire prior set in a
  transaction and verification burns the matched code with a guarded update.
- The TOTP secret is stored AES-256-GCM encrypted (already registered in the
  encrypted-columns registry + rotation script); decrypt is fail-closed.
- Routes: setup (pending secret), confirm (activate + issue recovery codes),
  disable (step-up + current factor), recovery-codes/regenerate (step-up).
  Setup/confirm are cookie-only; disable/regenerate add requireFreshMfa.
- scripts/disable-mfa.ts is the operator escape hatch for a locked-out
  self-hoster, audited, run via pnpm dlx tsx.
2026-06-27 19:56:16 +02:00

91 lines
2.5 KiB
TypeScript

/**
* scripts/disable-mfa.ts <email-or-username>
*
* Operator escape hatch: clear the second factor for an account whose owner
* has locked themselves out (lost authenticator AND recovery codes). Mirrors
* the password-reset CLI — it runs out-of-band against the database, so it is
* the recovery path of last resort for a self-hoster.
*
* It clears the TOTP secret, the confirmed-at stamp and the replay counter,
* and deletes every recovery code, then writes an `auth.mfa.disabled` audit
* row (with `details.source = "cli"`) so the action is traceable.
*
* Run via the bundled tsx (the production standalone image strips tsx):
* pnpm dlx tsx scripts/disable-mfa.ts user@example.com
*/
import "dotenv/config";
import { PrismaClient } from "@/generated/prisma/client";
import { PrismaPg } from "@prisma/adapter-pg";
const DATABASE_URL = process.env.DATABASE_URL;
if (!DATABASE_URL) {
console.error("DATABASE_URL must be set");
process.exit(1);
}
const identifier = process.argv[2];
if (!identifier) {
console.error(
"Usage: pnpm dlx tsx scripts/disable-mfa.ts <email-or-username>",
);
process.exit(2);
}
const prisma = new PrismaClient({
adapter: new PrismaPg({ connectionString: DATABASE_URL }),
});
async function main(): Promise<void> {
const user = await prisma.user.findFirst({
where: {
OR: [
{ email: { equals: identifier, mode: "insensitive" } },
{ username: { equals: identifier, mode: "insensitive" } },
],
},
select: { id: true, username: true, totpConfirmedAt: true },
});
if (!user) {
console.error(`No account matches '${identifier}'.`);
process.exit(3);
}
if (!user.totpConfirmedAt) {
console.log(
`Account '${user.username}' has no active second factor — nothing to do.`,
);
return;
}
await prisma.$transaction(async (tx) => {
await tx.user.update({
where: { id: user.id },
data: {
totpSecretEncrypted: null,
totpConfirmedAt: null,
totpLastStep: null,
},
});
await tx.mfaRecoveryCode.deleteMany({ where: { userId: user.id } });
await tx.auditLog.create({
data: {
action: "auth.mfa.disabled",
userId: user.id,
details: JSON.stringify({ source: "cli", factor: "totp" }),
},
});
});
console.log(`Second factor cleared for account '${user.username}'.`);
}
main()
.catch((error) => {
console.error(error);
process.exit(1);
})
.finally(() => {
void prisma.$disconnect();
});