auth: two-step sign-in (TOTP) for admins, with recovery codes
auth/totp.ts per 08 §2.4: RFC 6238 (HMAC-SHA1, 30 s, 6 digits, ±1 step) with node:crypto only, checked against the RFC's test vectors. Migration 0003: users.totp_recovery_hashes and totp_last_step. Admins turn it on from My account (the header's "Account"): scan the QR or type the key, confirm a code, get 10 recovery codes once. Login asks for the code only after the password is right (401 totp_required, no session); a wrong code is a failed login (rate limit, audit). A step signs in once (totp_last_step updated atomically) and a recovery code once (array_remove guarded by any()). Off and new codes need the password; another admin can turn it off for a lost phone, which signs that user out. Users list shows it (twoStep). Also, found by the full e2e chain on one shared database: - Linphone tokens: only the exact token issued is accepted. The last base64 character can carry ignored bits, so another spelling decoded to the same signed token. - Test scripts no longer assume a fresh system: m3-t3 picks Dan in the test box, m8-check dials whatever slot the park took (findslot = next), m10-t4 uses the admin's existing extension, m11-t1 clears the public IP on its own first (local nets are in the transports while one is set, and transports change only on a restart). e2e m11-t1-totp; the full chain M1-M11 (33 scripts) passes. Task: M11-T1
This commit is contained in:
1 parent
65e777ab41
commit
8c750c67ca
31 files changed
+4842
-43
No files matched your search
@@ -6,10 +6,8 @@ export const PASSWORD_MIN = 10;
|
||||
export const LoginSchema = z.object({
|
||||
email: z.string().trim().min(3, 'Enter your email').max(254),
|
||||
password: z.string().min(1, 'Enter your password').max(200),
|
||||
totp: z
|
||||
.string()
|
||||
.regex(/^\d{6}$/)
|
||||
.optional(), // M11
|
||||
/** The authenticator code, or a recovery code ("k7m3q-x9tpw"), when two-step sign-in is on. */
|
||||
totp: z.string().trim().max(20).optional(),
|
||||
});
|
||||
|
||||
export const ChangePasswordSchema = z.object({
|
||||
@@ -31,3 +29,16 @@ export type AuthUser = {
|
||||
mustChangePassword: boolean;
|
||||
extension: { id: string; number: string; name: string } | null;
|
||||
};
|
||||
|
||||
/** Two-step sign-in (TOTP, 08 §2.4) for the signed-in user. */
|
||||
export type TotpStatus = { enabled: boolean; recoveryCodesLeft: number };
|
||||
/** Start: the secret to scan (not stored until a code confirms it). */
|
||||
export type TotpSetup = { secret: string; otpauthUrl: string; qr: string };
|
||||
export const TotpEnableSchema = z.object({
|
||||
secret: z.string().regex(/^[A-Z2-7]{32}$/),
|
||||
code: z
|
||||
.string()
|
||||
.trim()
|
||||
.regex(/^\d{6}$/, 'The 6-digit code from the app'),
|
||||
});
|
||||
export const PasswordConfirmSchema = z.object({ password: z.string().min(1).max(200) });
|
||||
@@ -29,6 +29,8 @@ export type UserDto = {
|
||||
role: 'admin' | 'user';
|
||||
isActive: boolean;
|
||||
mustChangePassword: boolean;
|
||||
/** Two-step sign-in (TOTP) is on. */
|
||||
twoStep: boolean;
|
||||
lastLoginAt: string | null;
|
||||
createdAt: string;
|
||||
extension: { id: string; number: string } | null;
|
||||
|
||||
Reference in new issue
Block a user