- Per-account lockout: five failed attempts (password, MFA code or a step-up confirmation) lock the account for 15 minutes, on top of the per-IP throttle - guesses spread over many addresses no longer help. - A TOTP code is accepted once: the time step is stored and that step or any earlier one is refused (also between two concurrent requests). - Login runs bcrypt on every path and answers unknown email, wrong password, deactivated and locked accounts with the same message; failed logins for unknown emails no longer write attacker-chosen text into the audit log. - Turning MFA off needs password and a current code; turning it on needs the password. Both end all other sessions. - New audit action ACCOUNT_LOCKED. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
50 lines
1.4 KiB
TypeScript
50 lines
1.4 KiB
TypeScript
import { NobleCryptoPlugin, ScureBase32Plugin, TOTP } from 'otplib';
|
|
|
|
const ISSUER = 'ApiCore';
|
|
// Allows one 30s step of clock drift in either direction between the
|
|
// authenticator app and the server, matching common 2FA implementations.
|
|
const EPOCH_TOLERANCE_SECONDS = 30;
|
|
|
|
const crypto = new NobleCryptoPlugin();
|
|
const base32 = new ScureBase32Plugin();
|
|
|
|
function createTotp(secret: string, label: string): TOTP {
|
|
return new TOTP({ secret, label, issuer: ISSUER, crypto, base32 });
|
|
}
|
|
|
|
export function generateTotpSecret(): string {
|
|
return new TOTP({ crypto, base32 }).generateSecret();
|
|
}
|
|
|
|
export function buildTotpUri(secret: string, label: string): string {
|
|
return createTotp(secret, label).toURI();
|
|
}
|
|
|
|
/**
|
|
* The time step a code belongs to, or null if it is wrong - or not newer
|
|
* than afterTimeStep (a code that was already used, or an older one).
|
|
*/
|
|
export async function verifyTotpStep(
|
|
secret: string,
|
|
label: string,
|
|
code: string,
|
|
afterTimeStep?: number,
|
|
): Promise<number | null> {
|
|
const result = await createTotp(secret, label).verify(code, {
|
|
epochTolerance: EPOCH_TOLERANCE_SECONDS,
|
|
afterTimeStep,
|
|
});
|
|
return result.valid ? result.timeStep : null;
|
|
}
|
|
|
|
export async function verifyTotpCode(
|
|
secret: string,
|
|
label: string,
|
|
code: string,
|
|
): Promise<boolean> {
|
|
const result = await createTotp(secret, label).verify(code, {
|
|
epochTolerance: EPOCH_TOLERANCE_SECONDS,
|
|
});
|
|
return result.valid;
|
|
}
|