Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/content/docs/changelog/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ Notable changes to the kit, newest first.

## 2026-11-03

- **Admin & dashboard: sign-in now asks for the authenticator code when two-factor is on (fix).** Both apps let a user enable two-factor authentication under Settings > Security, but neither login page could send the code, so the next sign-in failed with `401 two_factor_required` and locked the user out. When `POST /api/v1/identity/token/issue` answers with the ProblemDetails `code` `Identity.TwoFactorRequired`, the login card now switches to a code step (six digits, `autocomplete="one-time-code"` so the browser or password manager can fill it), then repeats the call with `twoFactorCode`. A wrong code (`Identity.TwoFactorInvalid`) shows an inline error and keeps the user on the step; "Back to sign in" returns to the password step. The password is held only in the login page's memory for the retry, never in browser storage, and is dropped on back, success or leaving the page. The clients branch on the `code` extension, not the localized `detail` text. Recovery codes are still not supported. See [Two-factor authentication](/docs/security/two-factor/#signing-in-from-the-react-apps) and [#1446](https://github.com/fullstackhero/dotnet-starter-kit/issues/1446).
- **Eventing: one failing integration-event handler no longer blocks the others (fix).** `InMemoryEventBus` ran an event's handlers in order and stopped at the first exception, so the rest waited for the outbox retry, and if the failing one kept failing until the row dead-lettered they never ran. For `FileStorageKeyChangedIntegrationEvent` that meant a store rejecting Files' superseded-object delete also skipped Catalog's product-image URL rewrite. The bus now runs every handler, then rethrows (the single exception, or an `AggregateException` when several fail) so the outbox still retries; the inbox already records each handler that succeeded, so a retry re-runs only the failed ones. See [Eventing](/docs/building-blocks/eventing/#event-bus-implementations) and [#1423](https://github.com/fullstackhero/dotnet-starter-kit/issues/1423).
- **Files: finalize no longer answers `500` for a file it already committed (fix).** `FinalizeUpload` moves an upload presigned before visibility roots existed after the finalize commit; when that move failed the client got a `500` for a file that was already `Available`, and a retried finalize answered `409`. The move is now best-effort: finalize returns the file and logs a warning, the file stays at its legacy key, and `MigrateLegacyPublicFileKeysJob` moves a public one on its next run (a private one stays presigned-only). See [Files](/docs/modules/files/#visibility-and-storage-moves).
- **Files: a storage move whose commit fails no longer leaves an orphaned, quota-charged copy (fix).** `FileStorageRelocator` copies the object before committing the row; if the commit then failed, the copy stayed in the bucket, charged to the tenant's `StorageBytes`, with nothing pointing at it. The copy is now deleted again (refunding its bytes), best-effort and logged.
Expand Down
4 changes: 3 additions & 1 deletion src/content/docs/frontend/admin.mdx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
---
title: Admin console
lastUpdated: 2026-06-11
lastUpdated: 2026-11-03
description: The operator-facing React + Vite app at clients/admin - users, roles, tenants, impersonation, audits, billing, webhooks, health, notifications.
sidebar:
label: Admin console
Expand Down Expand Up @@ -80,6 +80,8 @@ await login({ email, password, tenant });
// stores both tokens, then navigates to the post-login destination.
```

If the account has two-factor authentication on, the first call answers `401` with `code: "Identity.TwoFactorRequired"` and the login card switches to a six-digit code step; the page then calls `login({ email, password, tenant, twoFactorCode })`. The password stays in component memory only for that retry. The dashboard login works the same way. See [Two-factor authentication](/docs/security/two-factor/#signing-in-from-the-react-apps).

Token refresh is **reactive, not timer-based**: when any API call returns 401, the API client runs a single-flight refresh (`POST /api/v1/identity/token/refresh`) and retries the original request. At boot, if the stored access token is missing or expired but a refresh token exists, the auth context attempts one silent refresh before rendering protected routes. If a refresh fails (refresh token expired / revoked), the user is signed out and redirected to `/login`.

### Inactivity auto sign-out
Expand Down
32 changes: 28 additions & 4 deletions src/content/docs/security/two-factor.mdx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
---
title: Two-factor authentication (TOTP)
lastUpdated: 2026-10-14
lastUpdated: 2026-11-03
description: Opt-in TOTP enrolment for any user - shared secret + otpauth URI, verify, password-confirmed disable, with TOTP-required token issuance once enrolled.
sidebar:
label: Two-factor (TOTP)
Expand Down Expand Up @@ -37,8 +37,8 @@ The kit doesn't force 2FA. A reasonable production posture is to make TOTP **man
↓
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"
- Missing code → 401, ProblemDetails code "Identity.TwoFactorRequired"
- Wrong code → 401, ProblemDetails code "Identity.TwoFactorInvalid"
↓
6. To turn off: POST /api/v1/identity/2fa/disable { currentPassword }
- Requires the current password, so a stolen access token alone
Expand Down Expand Up @@ -122,7 +122,31 @@ if (user.TwoFactorEnabled)
}
```

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.
Both exceptions carry a `MessageKey`, which the global exception handler writes to the ProblemDetails body as a machine-readable `code` next to the localized `detail`:

```json
{
"title": "Unauthorized",
"status": 401,
"detail": "two_factor_required: An authenticator code is required to complete sign-in.",
"code": "Identity.TwoFactorRequired"
}
```

Branch on `code` (`Identity.TwoFactorRequired` / `Identity.TwoFactorInvalid`), not on `detail`: the detail text follows the request culture and changes with `Accept-Language`. 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.

## Signing in from the React apps

The admin and dashboard login pages handle the second factor as a two-step card:

1. The user submits tenant, email and password. For an account without 2FA, `token/issue` returns tokens and the user is in, in one step.
2. If the API answers `401` with `code: "Identity.TwoFactorRequired"`, the card switches to a code step: a six-digit field with `inputmode="numeric"` and `autocomplete="one-time-code"` (so the browser or a password manager can fill it), focused on arrival.
3. On submit, the page repeats `token/issue` with `{ email, password, twoFactorCode }` and the same `tenant` header.
4. A wrong or expired code (`code: "Identity.TwoFactorInvalid"`) shows an inline error, clears the field and keeps the user on the step. "Back to sign in" returns to the password step.

The password is kept only in the login page's component state for that retry. It never goes to `localStorage` or `sessionStorage`, the password field is cleared when the code step opens, and the held credentials are dropped on back, on success and when the page unmounts. The detection lives in `isTwoFactorRequiredError` / `isTwoFactorInvalidError` in each app's `src/auth/api.ts`, and the step itself is `src/components/auth/two-factor-step.tsx`.

The code step accepts authenticator codes only. Recovery codes are not part of the kit (see below), so there is no "use a recovery code" link.

## Operational considerations

Expand Down