Two-factor TOTP is opt-in per user in fullstackhero. Once enrolled, the user must provide a six-digit TOTP code from their authenticator app (Google Authenticator, Authy, 1Password, Bitwarden, etc.) on every login. Three endpoints handle the lifecycle: enrol (gets a QR code + secret), verify (activates the enrolment), disable (turns it off).
The flow
1. User: POST /api/v1/identity/2fa/enroll ↓2. Identity module: - Generates a TOTP shared secret - Generates a QR-code data-URI containing the otpauth URL - Returns { secretKey, qrCodeDataUri } ↓3. User: opens authenticator app, scans QR, app starts generating 6-digit codes ↓4. User: POST /api/v1/identity/2fa/verify { code } - If the code matches the secret's current TOTP window, 2FA is activated - User.TwoFactorEnabled = true persists ↓5. From now on, POST /tokens/generate requires { email, password, twoFactorCode } (the third field is required iff enrolled) ↓6. To turn off: DELETE /api/v1/identity/2fa - Requires re-authentication (password) for safetyEndpoints
| Verb | Route | What |
|---|---|---|
| POST | /api/v1/identity/2fa/enroll | Generate secret + QR; return for client to display |
| POST | /api/v1/identity/2fa/verify | Verify the first TOTP code; activate |
| DELETE | /api/v1/identity/2fa | Disable 2FA (requires re-auth) |
All three require the authenticated user — TOTP is a per-user setting, so callers must already be logged in.
What the kit does NOT ship
- SMS / email 2FA. Only TOTP. SMS is widely-acknowledged-insecure (SIM-swap attacks); email mostly defeats the second-factor purpose since it shares the same compromised channel as password recovery.
- Backup / recovery codes. The kit doesn’t generate one-time backup codes. If a user loses their authenticator device, support has to reset 2FA manually (disable, then re-enrol). For production with non-technical users, this is the biggest gap to fill — implement 8-10 single-use recovery codes generated at enrolment time, hashed-and-stored, displayed to the user once.
- WebAuthn / passkeys. The
AspNetUserPasskeystable exists from ASP.NET Identity but there’s no end-to-end passkey UX in v10. Roadmap.
TOTP integration
The kit uses ASP.NET Identity’s built-in TOTP support through UserManager<FshUser>.GenerateTwoFactorTokenAsync(user, "Authenticator") and VerifyTwoFactorTokenAsync(...). The shared secret is stored as a token on the user record; you never write to it directly.
// EnrollTwoFactorCommandHandler (simplified)public async ValueTask<TwoFactorEnrollmentResponse> Handle(EnrollTwoFactorCommand cmd, CancellationToken ct){ var user = await _users.GetByIdAsync(_current.GetUserId(), ct).ConfigureAwait(false);
// Reset and regenerate the authenticator key (idempotent) await _users.ResetAuthenticatorKeyAsync(user).ConfigureAwait(false); var secret = await _users.GetAuthenticatorKeyAsync(user).ConfigureAwait(false); var otpauthUrl = BuildOtpAuthUrl(issuer: "fullstackhero", account: user.Email, secret); var qr = QrCodeGenerator.GenerateDataUri(otpauthUrl);
return new TwoFactorEnrollmentResponse(secret, qr);}The otpauth:// URL is the RFC 6238 standard format that every authenticator app understands.
Validating during login
// GenerateTokenCommandHandler (simplified — full check is in Identity module)public async ValueTask<TokenResponse> Handle(GenerateTokenCommand cmd, CancellationToken ct){ var user = await _users.FindByEmailAsync(cmd.Email); if (user is null) throw new UnauthorizedException("Invalid credentials.");
if (!await _users.CheckPasswordAsync(user, cmd.Password)) throw new UnauthorizedException("Invalid credentials.");
if (await _users.GetTwoFactorEnabledAsync(user)) { if (string.IsNullOrWhiteSpace(cmd.TwoFactorCode)) throw new UnauthorizedException("Two-factor code required."); if (!await _users.VerifyTwoFactorTokenAsync(user, "Authenticator", cmd.TwoFactorCode)) throw new UnauthorizedException("Invalid two-factor code."); }
return await _tokens.IssueAsync(user, ct).ConfigureAwait(false);}The TOTP check stacks on top of the lockout policy (failed 2FA attempts count toward lockout) and the rate-limit auth policy.
Operational considerations
-
Time skew. TOTP codes are valid for a small window (typically 30 seconds + 1 step on either side). Servers and users’ devices must have accurate clocks. NTP everywhere.
-
Device loss. Without backup codes, a lost device means admin intervention. Build a tested support runbook before turning 2FA on for non-technical users.
-
Recovery codes (recommended add-on). Add backup-code generation to the enrolment flow and store them hashed. Pattern:
1. At enrol: generate 10 random alphanumeric codes2. Hash them via Identity password-hasher3. Display the plaintext codes ONCE to the user4. On login, accept a code in place of the TOTP code; mark the used codeThis isn’t shipping in v10 but it’s a ~200 LoC addition you’d add in your fork.
-
Audit every 2FA event. Enrol, verify, disable —
Audit.ForSecurity(SecurityAction.TwoFactorEnrolled / TwoFactorDisabled). Spikes of disable events from one account are a red flag (account takeover trying to remove the second factor).
Related
- Authentication — the login flow 2FA stacks onto.
- Identity module — full endpoint inventory.
- Production checklist — when to make 2FA mandatory.