mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
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.
91 lines
2.5 KiB
TypeScript
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();
|
|
});
|