Skip to content

Server, Admin: Add two-factor authentication with TOTP - #342

Merged
lehni merged 1 commit into
feature/passkeysfrom
feature/totp
Oct 17, 2026
Merged

lehni merged 1 commit into
feature/passkeysfrom
feature/totp

Conversation

@lehni

@lehni lehni commented Oct 16, 2026 •

Copy link
Copy Markdown
Contributor

Builds on #340. Users can protect password logins with a second factor: time-based one-time codes (TOTP) from an authenticator app, plus backup codes.

Server:

  • app.auth.totp: { issuer }, with app.auth.credentials.secret required to encrypt the secrets. The secret is never derived from app.keys, so rotating those doesn't make the secrets unreadable.
  • The TOTP secret is stored in the shared credential table, encrypted with AES-256-GCM. 10 backup codes are stored as HMAC hashes and used up once.
  • Login: for users with TOTP, UserMixin.login() keeps a pending login in the session (5 minutes, with a new session id) and resolves to null. After 10 wrong codes in a row, logins and code checks of the account are locked for 15 minutes; codes are checked with the credential rows locked, so parallel requests can't reuse a code. The encrypted secret is bound to its user. post login then answers { success: false, codeRequired: true }, and post login-code with a TOTP or backup code completes the login. Codes can't be used twice. Passkey logins skip TOTP.
  • Actions for the logged-in user: get totp, post totp (otpauth URI), post totp-confirm (returns the backup codes once), delete totp (needs a code). They're only routed with app.auth.totp.
  • otpauth is an optional peer, imported once in UsersController.setup().

Admin:

  • The login asks for the code when the server answers codeRequired.
  • login: { totp: true } adds a Two-Factor Authentication section to the Security dialog, built from schema components: set up with a QR code (lean-qr, a required peer) or the key and a first code, then the backup codes are shown once; turning it off asks for a code.
  • New dialog setting minDuration: dialogs submitted right after they opened stay visible that long. The code dialog of the login stays for a second, so users see their password manager fill in and submit the code.

Behaviour change: UserMixin.login() resolves to null and post login answers codeRequired for users with TOTP. Users without TOTP aren't affected.

@lehni
lehni added this pull request to stack #341 October 16, 2026 23:25
@lehni
lehni force-pushed the feature/totp branch 2 times, most recently from 6e27314 to f77b6fb Compare October 17, 2026 01:25
- Add app.auth.totp and app.auth.credentials.secret, checked on setup, through the optional peer otpauth
- Store the TOTP secret encrypted and bound to its user, and the backup codes hashed, as credentials
- Answer codeRequired to password logins of users with TOTP, with a new session id, completed through 'post login-code'; passkey logins skip it
- Add the user's own TOTP actions to UsersController, only routed with TOTP; lock accounts for 15 minutes after ten wrong codes in a row, and verify codes with the credentials locked
- Ask for the code after the password in the admin's login dialog, and set up and turn off TOTP in its Security dialog with login.totp, through the peer lean-qr
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant