Skip to content
fullstackhero

Concept

Two-factor authentication (TOTP)

Opt-in TOTP enrolment for any user — QR code, verify, 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 (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 safety

Endpoints

VerbRouteWhat
POST/api/v1/identity/2fa/enrollGenerate secret + QR; return for client to display
POST/api/v1/identity/2fa/verifyVerify the first TOTP code; activate
DELETE/api/v1/identity/2faDisable 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 AspNetUserPasskeys table 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 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.

  • 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).