diff --git a/src/content/docs/changelog/index.mdx b/src/content/docs/changelog/index.mdx index ce8b29f0..edc27108 100644 --- a/src/content/docs/changelog/index.mdx +++ b/src/content/docs/changelog/index.mdx @@ -13,6 +13,7 @@ Notable changes to the kit, newest first. ## 2026-11-03 +- **Identity: the two-factor issuer name is configurable (fix).** `EnrollTwoFactorCommandHandler` hardcoded `FullStackHero` as the issuer of the `otpauth://` URI, so every product built on the kit showed up under that name in the user's authenticator app. A new **`TwoFactorOptions:Issuer`** setting chooses it; it defaults to `FullStackHero`, so existing deployments behave exactly as before, and a blank value fails options validation at startup. The issuer is percent-encoded in both the label and the `issuer=` parameter, so names with spaces or accents produce a valid URI. Only new enrolments get the new name: existing enrolments keep working unchanged, because the shared secret does not change, and no migration is needed. See [Two-factor authentication](/docs/security/two-factor/#issuer-name), [#1440](https://github.com/fullstackhero/dotnet-starter-kit/issues/1440) and [#1443](https://github.com/fullstackhero/dotnet-starter-kit/pull/1443). - **Dashboard: no more `403` polls for users without the permission (fix).** The topbar notification bell (`GET /notifications/unread-count`), the topbar chat unread pill (`GET /chat/channels`) and the overview usage card (`GET /billing/usage`) queried their endpoints for every user, so a role without `Permissions.Notifications.Inbox.View`, `Permissions.Chat.Channels.View` or `Permissions.Billing.View` got a `403` on each poll and the API logged an error line for each one. Each query now runs only when the user holds that permission. Without it, the bell and the chat pill are hidden, and the usage card and the Resources stat card say there is no access. The `/chat` and `/subscription` pages are unchanged, because the nav already hides them from users without the permission. See [Add a dashboard page](/docs/guides/add-a-dashboard-page/#step-5---permission-gating-nav--actions-not-routes) and [#1445](https://github.com/fullstackhero/dotnet-starter-kit/pull/1445). - **Frontend containers: the inactivity timeout is configurable from the environment (fix).** Both apps read `inactivityIdleMs` and `inactivityWarningMs` from `/config.json`, but the container's `config.json.template` never carried them, so an operator could not change the auto sign-out time without rebuilding the image. The template now renders them from `FSH_INACTIVITY_IDLE_MS` and `FSH_INACTIVITY_WARNING_MS`. Unset or empty keeps the built-in timings (admin 10 minutes, dashboard 20, each with a 60-second warning), so existing deployments behave as before; a value that is not a positive integer stops the container at start with a clear error rather than rendering invalid JSON. `deploy/docker` exposes them as `FSH_ADMIN_INACTIVITY_IDLE_MS`, `FSH_DASHBOARD_INACTIVITY_IDLE_MS` and `FSH_INACTIVITY_WARNING_MS` in `.env`. The Terraform static-site `runtime_config` and the Aspire dev setup are unchanged. See [Frontend development](/docs/frontend/development/#the-container-pattern) and [#1442](https://github.com/fullstackhero/dotnet-starter-kit/pull/1442). - **Dashboard: the overview welcomes the tenant by its name (fix).** The first-run card on a tenant with no subscription read "Welcome to" followed by the tenant identifier (for example `qpb2edshbj`), and the header caption showed the same identifier, because both came from the token's `tenant` claim. Both now use the tenant name from `GET /api/v1/tenants/me/status`, which the overview already loads for its "Valid for" card, and fall back to the identifier while that call is loading or has failed. No backend change. See [Tenant dashboard](/docs/frontend/dashboard/#page-inventory) and [#1437](https://github.com/fullstackhero/dotnet-starter-kit/pull/1437). diff --git a/src/content/docs/security/two-factor.mdx b/src/content/docs/security/two-factor.mdx index d20b34fe..253c91c9 100644 --- a/src/content/docs/security/two-factor.mdx +++ b/src/content/docs/security/two-factor.mdx @@ -1,6 +1,6 @@ --- title: Two-factor authentication (TOTP) -lastUpdated: 2026-06-11 +lastUpdated: 2026-10-14 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) @@ -25,7 +25,7 @@ The kit doesn't force 2FA. A reasonable production posture is to make TOTP **man ↓ 2. Identity module: - Generates (or rotates) the user's authenticator shared secret - - Builds the otpauth:// URI (issuer "FullStackHero") + - Builds the otpauth:// URI (issuer from TwoFactorOptions:Issuer) - Returns { sharedKey, authenticatorUri } ↓ 3. Client renders authenticatorUri as a QR code; user scans it, @@ -78,8 +78,10 @@ public async ValueTask Handle(EnrollTwoFactorComman await _userManager.ResetAuthenticatorKeyAsync(user); var sharedKey = await _userManager.GetAuthenticatorKeyAsync(user); - var authenticatorUri = $"otpauth://totp/FullStackHero:{user.Email}" + - $"?secret={sharedKey}&issuer=FullStackHero&digits=6"; + // _options is IOptions.Value; the issuer is percent-encoded in both places + var issuer = UrlEncoder.Default.Encode(_options.Issuer); + var authenticatorUri = $"otpauth://totp/{issuer}:{UrlEncoder.Default.Encode(user.Email)}" + + $"?secret={sharedKey}&issuer={issuer}&digits=6"; return new TwoFactorEnrollmentResponse(sharedKey, authenticatorUri); } @@ -87,6 +89,20 @@ public async ValueTask Handle(EnrollTwoFactorComman 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. +### Issuer name + +The issuer is the name the authenticator app shows next to the account. It comes from `TwoFactorOptions:Issuer` and defaults to `FullStackHero` (a project created with `fsh new` or `dotnet new fsh` gets its own display name instead), so set it to your product name: + +```json +{ + "TwoFactorOptions": { + "Issuer": "Acme" + } +} +``` + +Or through the environment: `TwoFactorOptions__Issuer=Acme`. The value is percent-encoded in both the label and the `issuer=` parameter, so names with spaces or accents (`Canal Correto`, `Ação`) produce a valid URI. A blank issuer fails options validation at startup. Changing it only affects new enrolments: users who enrolled earlier keep the old name in their app, and their codes keep working because the shared secret does not change. They see the new name after they disable and enrol again. + ## Validating during login During `token/issue`, after the password check passes: