credential-totp-enrollment

Credential Signup and Mandatory TOTP Enrollment

This is YouTeacher's username/password path: how an account is created, how a second factor gets bound to it, and what happens when that account signs in later. Three separate application services carry three phases, and the design choice worth remembering is that they are decoupled — provisioning a TOTP secret, creating the user, and verifying enrollment are distinct steps that coordinate through a short-lived pending record rather than one monolithic call.

Signup

SignupWithCredentials will not run on a password alone. It first requires a six-digit email one-time code, which it consumes — verified and deleted in a single atomic operation — so proof of email control is spent, not merely checked. Email, password, and user type each pass through domain value objects that reject malformed or weak input before anything is written. There is an admin-domain override: if the email's domain matches a configured admin domain, the account is elevated to the admin user type regardless of what was requested.

Signup then looks for a pending TOTP record left by an earlier provisioning step. If one exists and has not expired, it reuses the secret and the hashed password stored there — re-checking that the password presented at signup matches the one used during provisioning — and then deletes the pending record. If none exists, it falls back to generating a fresh secret inline. Account creation itself is idempotent about one prior path: a user the passkey registration flow pre-created (a user row with no second-factor secret yet) is completed rather than rejected as a duplicate. The result always reports that a second factor is required.

Provisioning the second factor

ProvisionTotpSecret generates the TOTP secret and its enrollment URI. It is rate-limited per email so the endpoint cannot be hammered. It branches on whether the account already exists: an existing account must prove its password before its secret is replaced; a not-yet-created account has its secret and a hashed password parked in a pending record with a short expiry (on the order of minutes), to be claimed by signup. The enrollment URI is the standard otpauth provisioning format any authenticator app understands, built from an issuer label and the account name.

Verifying enrollment

VerifyTotpEnrollment confirms the user's authenticator actually produces valid codes. It mirrors the same two-world split: an existing account is checked against its stored secret and marked verified; a not-yet-created account is checked against the pending record and flagged verified there, so signup can later trust it without a second challenge. Failed checks are rate-limited on a per-target key, so a wrong code costs an attempt rather than being free.

Login

PasswordAndTotpLogin validates input, rate-limits by email, and verifies the account exists, is active, and matches its stored password hash. On success it mints a session token, persists a session with a TTL, and returns the user's profile — including its strong-factor status (whether a strong second factor is complete, by which method, and when). Notably, the second-factor state travels on the returned profile rather than being re-challenged inside this service. Every outcome — rate-limited, invalid credentials, disabled account, and success — writes a login-audit row, and each successful login also triggers non-blocking cleanup of expired sessions and aged audit rows.

At the HTTP edge, the login route runs a captcha check first (with an exemption for trusted service clients that prove themselves with a service token) and sets the session as an HTTP-only, SameSite=lax cookie, marked secure in production.

The shape worth keeping

The recurring pattern across all four services is the existing-user vs. pending-user fork, reconciled through one expiring pending record. It lets the second factor be set up before the account exists without ever holding a half-made account in the database, and it lets a password/passkey account reset its TOTP through the same code paths.

about this entry

One of sijie's wiki entries. The AI on this site is grounded in the same corpus and answers in sijie's voice, with citations back to entries like this one — answering costs sijie money, so it waits behind a code: enter an access code →