auth: two-step sign-in (TOTP) for admins, with recovery codes
ci / check (push) Successful in 1m1s
ci / images (push) Successful in 27s

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:
iamdoubz committed 2026-10-19 15:41:53 -05:00
1 parent 65e777ab41
commit 8c750c67ca
31 files changed
+4842 -43

No files matched your search

@@ -0,0 +1,2 @@
ALTER TABLE "users" ADD COLUMN "totp_recovery_hashes" text[] DEFAULT '{}' NOT NULL;--> statement-breakpoint
ALTER TABLE "users" ADD COLUMN "totp_last_step" bigint;
File diff suppressed because it is too large. Load diff
@@ -22,6 +22,13 @@
"when": 1792371506100,
"tag": "0002_queue_calls",
"breakpoints": true
},
{
"idx": 3,
"version": "7",
"when": 1792439261267,
"tag": "0003_users_totp",
"breakpoints": true
}
]
}
+4
View File
@@ -57,6 +57,10 @@ export const users = pgTable('users', {
role: userRole().notNull().default('user'),
isActive: boolean().notNull().default(true),
totpSecretEnc: text(),
/** sha256 of the unused recovery codes (08 §2.4). */
totpRecoveryHashes: emptyTextArray(),
/** The last 30 s step a code was accepted for: the same code can't sign in twice. */
totpLastStep: bigint({ mode: 'number' }),
mustChangePassword: boolean().notNull().default(false),
lastLoginAt: tstz(),
...timestamps,