# Two-factor authentication

> How time-based one-time password (TOTP) two-factor authentication works, and how to enroll, verify and recover.

Slicekit ships optional two-factor authentication using TOTP (Time-based One-Time Password, RFC 6238):
the six-digit codes that authenticator apps like 1Password, Google Authenticator or Aegis generate.
It builds directly on ASP.NET Core Identity, which owns the secret, the recovery codes and the code
verification. The feature layers a login challenge and enrollment flow on top. For the password and
session machinery underneath, see [Authentication](/docs/authentication).

## The feature gate

The whole feature is gated by `Auth:TotpEnabled` (default `true`). When it is off, the TOTP endpoints
are never mapped and the frontend hides the 2FA section entirely.

```json
// appsettings.json
"Auth": {
  "TotpEnabled": true,
  "TotpRequired": false
}
```

Override at runtime with `Auth__TotpEnabled=false`. In `RegisterEndpoints.cs`, the registration is
conditional:

```csharp
if (auth.TotpEnabled) v1.MapV1TotpEndpoints();
```

`GET /api/v1/auth/features` reflects the same flags, so the SPA reads `totpEnabled` from its features
hook and only renders the setup card when the feature is on. Setting `TotpRequired: true` additionally
forces enrollment: an endpoint filter (`TotpSetupRequiredEndpointFilter`) blocks normal endpoints for
users who have not set up 2FA, except the few setup routes that opt out with `AllowWithoutTotpSetup()`.

## Five slices, five endpoints

Each operation is a vertical slice in `Slicekit.Core/Features/Auth/` with a thin endpoint in
`Slicekit.Api/Endpoints/v1/`. See [Adding a vertical slice](/docs/vertical-slices) for the pattern.

| Method   | Route                       | Auth      | Slice                    |
| -------- | --------------------------- | --------- | ------------------------ |
| `POST`   | `/api/v1/me/totp/setup`     | Bearer    | `SetupTotp`              |
| `POST`   | `/api/v1/me/totp/confirm`   | Bearer    | `ConfirmTotp`            |
| `DELETE` | `/api/v1/me/totp`           | Bearer    | `DisableTotp`            |
| `POST`   | `/api/v1/auth/totp/verify`  | Anonymous | `VerifyTotp`             |
| `POST`   | `/api/v1/auth/totp/recover` | Anonymous | `RedeemTotpRecoveryCode` |

## Enrollment

Enrollment is two calls. The user is already signed in, so both setup routes require a bearer session
(`Allow.UserGetMe`), a confirmed email, and CSRF.

**Step 1: begin setup.** `POST /api/v1/me/totp/setup` resets the user's authenticator key in Identity
and returns the raw secret plus an `otpauth://` URI that the SPA renders as a QR code.

```csharp
// SetupTotp/Handler.cs
await userManager.ResetAuthenticatorKeyAsync(user);
var key = await userManager.GetAuthenticatorKeyAsync(user);

var qr = $"otpauth://totp/{Uri.EscapeDataString(Issuer)}:{Uri.EscapeDataString(user.Email!)}"
       + $"?secret={key}&issuer={Uri.EscapeDataString(Issuer)}&digits=6&period=30";

return new SetupTotpResult(key, qr);
```

```json
// Response
{ "secretBase32": "JBSWY3DPEHPK3PXP", "qrCodeUri": "otpauth://totp/Slicekit:user@example.com?secret=..." }
```

Setting `TwoFactorEnabled` does not happen here. The key is provisioned but inert until the user proves
they can read codes from it.

**Step 2: confirm.** The user scans the QR code, then sends back the first six-digit code. `POST
/api/v1/me/totp/confirm` verifies it, flips `TwoFactorEnabled` on, generates ten one-time recovery
codes, and raises `TotpEnabledEvent`.

```csharp
// ConfirmTotp/Handler.cs
var ok = await userManager.VerifyTwoFactorTokenAsync(
    user, TokenOptions.DefaultAuthenticatorProvider, command.Code);
if (!ok) return (new TotpInvalidError(), messages);

await userManager.SetTwoFactorEnabledAsync(user, true);
var codes = await userManager.GenerateNewTwoFactorRecoveryCodesAsync(user, 10);
messages.Add(new TotpEnabledEvent(user.Id));
```

```json
// Request                  // Response
{ "code": "123456" }        { "recoveryCodes": ["a1b2-c3d4", "..."] }
```

The recovery codes are returned exactly once. Identity stores only their hashes, so the API can never
show them again. The SPA displays them in a one-time dialog and tells the user to save them.

## Login challenge

When a user with 2FA enabled signs in, the login handler detects `TwoFactorEnabled` and refuses to
issue a session. Instead the login endpoint returns a `TotpRequiredError` carrying a short-lived pending
token (a JWT, not a session cookie):

```csharp
// LoginEndpoint.cs
if (result.Value.TotpRequired)
{
    var pendingToken = tokenService.GenerateTotpPendingToken(result.Value.UserId);
    return new TotpRequiredError(
        pendingToken, TotpAvailable: true,
        PasskeyAvailable: result.Value.PasskeyAvailable).ToProblem();
}
```

The pending token only unlocks the verify and recover endpoints. It cannot authenticate any normal
endpoint, so possession of it alone gets an attacker nowhere.

The client completes login by posting that token plus a code. `POST /api/v1/auth/totp/verify` validates
the token, verifies the code via Identity, and only then issues the real session:

```csharp
// TotpVerifyEndpoint.cs
var pending = await tokenService.ValidateTotpPendingTokenAsync(request.PendingToken);
if (pending is null) return new UnauthorizedError().ToProblem();

var result = await bus.InvokeAsync<Result>(
    new VerifyTotpCommand(pending.UserId, request.Code), ct);
if (!result.IsSuccess) return result.Error.ToProblem();

await sessionIssuer.IssueAsync(httpContext, response, result.Value.UserId, result.Value.Permissions, ct);
await tokenService.MarkTotpPendingTokenUsedAsync(pending.TokenId);
return TypedResults.NoContent();
```

```json
// Request
{ "pendingToken": "eyJ...", "code": "123456" }
// 204 No Content, session cookies set
```

A successful verify resets the access-failed count, records the login on the domain user, and issues
the session. The response is a bare `204` with the session cookies attached.

## Recovery

If the user loses their authenticator, they redeem one of the codes from enrollment instead. `POST
/api/v1/auth/totp/recover` takes the same pending token plus a recovery code:

```csharp
// RedeemTotpRecoveryCode/Handler.cs
var result = await userManager.RedeemTwoFactorRecoveryCodeAsync(appUser, command.Code);
if (!result.Succeeded) { /* throttle, then */ return (new TotpInvalidError(), messages); }
```

```json
// Request
{ "pendingToken": "eyJ...", "recoveryCode": "a1b2-c3d4" }
// 204 No Content, session cookies set
```

Identity consumes the redeemed code, so each one works exactly once. Recovery codes are not auto
regenerated. A user who burns through them should disable and re-enroll to get a fresh set.

## Disabling

`DELETE /api/v1/me/totp` turns 2FA off. It requires the account password in the body, even though the
caller is already authenticated, so a hijacked session cannot quietly strip protection:

```csharp
// DisableTotp/Handler.cs
if (user.PasswordHash is null || !await userManager.CheckPasswordAsync(user, command.Password))
    return (Errors.InvalidCredentials, messages);

await userManager.SetTwoFactorEnabledAsync(user, false);
await userManager.ResetAuthenticatorKeyAsync(user);
messages.Add(new TotpDisabledEvent(user.Id));
```

Disabling clears the authenticator key as well, so re-enrolling later starts from a brand new secret.

## Throttling

The verify and recover endpoints sit behind the shared `auth` rate-limit policy (the same bucket as
login). On top of that, both handlers keep a per-account attempt counter in the hybrid cache so a
single account cannot be ground down without tripping a per-account lock:

- `Auth:MaxTotpAttemptsPerAccountWindow` (default `5`) caps verify attempts.
- `Auth:MaxRecoveryCodeAttemptsPerAccountWindow` (default `5`) caps recovery redemptions.

Hitting either limit returns a `LockedError` until the window (`Auth:LockoutMinutes`) elapses; a
successful attempt clears the counter. For the broader policy, see [Rate limiting](/docs/rate-limiting).

## Checklist

- [ ] `Auth:TotpEnabled` is `true` (the default); set `TotpRequired` if enrollment must be mandatory.
- [ ] Enroll with `POST /me/totp/setup`, render the `qrCodeUri`, then `POST /me/totp/confirm` with the first code.
- [ ] Capture the ten recovery codes from the confirm response; they are shown once.
- [ ] At login, handle `TotpRequiredError`, collect a code, and `POST /auth/totp/verify` with the pending token.
- [ ] Offer `POST /auth/totp/recover` as the lost-device path.
- [ ] Require the password on `DELETE /me/totp` to disable.
