Skip to content
fullstackhero

Concept

Two-factor authentication (TOTP)

Opt-in TOTP enrolment for any user — shared secret + otpauth URI, verify, password-confirmed disable, with TOTP-required token issuance once enrolled.

views 0 Last updated

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 (returns the shared secret + otpauth URI), verify (activates the enrolment), disable (turns it off, after confirming the password).

The flow

1. User: POST /api/v1/identity/2fa/enroll
2. Identity module:
- Generates (or rotates) the user's authenticator shared secret
- Builds the otpauth:// URI (issuer "FullStackHero")
- Returns { sharedKey, authenticatorUri }
3. Client renders authenticatorUri as a QR code; user scans it,
app starts generating 6-digit codes
4. User: POST /api/v1/identity/2fa/verify { code }
- If the code matches the current TOTP window, 2FA is activated
- User.TwoFactorEnabled = true persists
5. From now on, POST /token/issue requires
{ email, password, twoFactorCode } (the third field is required iff enrolled)
- Missing code → 401 "two_factor_required"
- Wrong code → 401 "two_factor_invalid"
6. To turn off: POST /api/v1/identity/2fa/disable { currentPassword }
- Requires the current password, so a stolen access token alone
can't downgrade account security; also rotates the secret

Endpoints

All under /api/v1/identity, all requiring an authenticated caller — TOTP is a per-user setting.

VerbRouteWhat
POST/2fa/enrollGenerate (or rotate) the secret; return sharedKey + authenticatorUri for the client to render as a QR
POST/2fa/verifyVerify the first TOTP code; activate 2FA
POST/2fa/disableDisable 2FA after confirming the current password; rotates the secret

Calling enroll again before verifying rotates the secret — a stale code from a prior incomplete enrolment can’t silently succeed. The verify endpoint strips spaces from the code, so “123 456” works.

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. No passkey support in v10. Roadmap.

TOTP integration

The kit uses ASP.NET Identity’s built-in authenticator support — the shared secret is managed by UserManager token providers; you never write to it directly.

// EnrollTwoFactorCommandHandler (simplified)
public async ValueTask<TwoFactorEnrollmentResponse> Handle(EnrollTwoFactorCommand command, CancellationToken ct)
{
var user = await _userManager.FindByIdAsync(_currentUser.GetUserId().ToString())
?? throw new NotFoundException("user not found");
// Always reset so calling enroll twice rotates the secret
await _userManager.ResetAuthenticatorKeyAsync(user);
var sharedKey = await _userManager.GetAuthenticatorKeyAsync(user);
var authenticatorUri = $"otpauth://totp/FullStackHero:{user.Email}" +
$"?secret={sharedKey}&issuer=FullStackHero&digits=6";
return new TwoFactorEnrollmentResponse(sharedKey, authenticatorUri);
}

The otpauth:// URI is the RFC 6238 standard format that every authenticator app understands. The client (not the server) renders it as a QR code; the raw sharedKey is returned too, for users who can’t scan.

Validating during login

During token/issue, after the password check passes:

// IdentityService (simplified)
if (user.TwoFactorEnabled)
{
if (string.IsNullOrWhiteSpace(twoFactorCode))
throw new CustomException("two_factor_required: ...", null, HttpStatusCode.Unauthorized);
var valid = await _userManager.VerifyTwoFactorTokenAsync(
user, _userManager.Options.Tokens.AuthenticatorTokenProvider, twoFactorCode);
if (!valid)
throw new UnauthorizedException("two_factor_invalid: ...");
}

The error prefixes (two_factor_required / two_factor_invalid) are stable strings the React clients key off to show the code-entry step. The whole login — including 2FA attempts — sits behind the rate-limit auth policy, which is the throttle on TOTP brute-forcing. Failed TOTP codes do not increment the password-lockout counter; only failed passwords do.

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 codes
    2. Hash them via Identity password-hasher
    3. Display the plaintext codes ONCE to the user
    4. On login, accept a code in place of the TOTP code; mark the used code

    This isn’t shipping in v10 but it’s a ~200 LoC addition you’d add in your fork.

  • Watch disable events. A spike of 2FA disables on one account is a red flag (account takeover trying to remove the second factor). The disable endpoint requires the password precisely to raise that bar — alert on it anyway.