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
3 changes: 2 additions & 1 deletion src/content/docs/changelog/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,9 @@ seo:

Notable changes to the kit, newest first.

## 2026-10-02
## 2026-10-09

- **Identity: a failed sign-in no longer turns an open profile form into a `412` (fix).** `GET /api/v1/identity/profile` published the user's `ConcurrencyStamp` as the `ETag`, and ASP.NET Identity rotates that stamp on every write to the user row, including the `AccessFailedCount` bump of a wrong password and its reset on the next good sign-in. Anyone who typed a wrong password for the account made the user's next profile save answer `412 Precondition Failed` although nothing in the profile had changed. The `ETag` is now an HMAC-SHA256 of the fields `PUT /profile` writes (first name, last name, phone number and stored avatar URL), keyed with the user's `SecurityStamp` so a logged tag cannot be used to confirm a guessed name and phone number, and `If-Match` is compared against it. Sign-ins and token refreshes leave it alone, while a real profile change, including a new avatar, still moves it; a password change or a two-factor toggle also moves it, because they rotate the key. Because the tag follows content, a save that leaves every field as it was keeps the same tag and replaying it is accepted. Identity's own `ConcurrencyStamp` check at save time is unchanged, so a non-profile write landing inside the single request between load and save still answers `412`. No migration and no client change: keep echoing the tag from the read that seeded the form. A profile form opened before the upgrade still holds an old tag, so its first save after the deploy answers `412` once, and the dashboard's usual re-read and re-save recovers from it. See [Identity](/docs/modules/identity/#profile-concurrency) and [#1425](https://github.com/fullstackhero/dotnet-starter-kit/pull/1425).
- **Authorization: `.RequirePermission(a, b)` now requires every listed permission (fix).** `RequiredPermissionAuthorizationHandler` checked only the first permission in the endpoint metadata, so a user holding `a` passed a gate that also demanded `b`. It now checks each one and succeeds only when the user holds all of them. A permission metadata entry with no permissions left (for example `.RequirePermission("")`) now denies the request; before, the handler threw `InvalidOperationException`. No kit endpoint lists more than one permission today, so nothing changes for the shipped API; your own multi-permission endpoints become stricter. See [Authorization](/docs/security/authorization/#applying-a-gate), [#1415](https://github.com/fullstackhero/dotnet-starter-kit/issues/1415) and [#1424](https://github.com/fullstackhero/dotnet-starter-kit/pull/1424).
- **Mailing & Docker Compose: e-mail works out of the box, and SMTP connection security is configurable (fix).** `SmtpMailService` always connected with STARTTLS, so any server that does not offer it was refused before a single envelope was sent, and the compose stack inherited the `smtp.ethereal.email` host with empty credentials: every confirmation, password-reset and welcome e-mail failed, and a user registered by an operator could not sign in until someone confirmed the address by hand. A new **`MailOptions:Smtp:Security`** setting (MailKit `SecureSocketOptions`, by name: `None`, `Auto`, `SslOnConnect`, `StartTls`, `StartTlsWhenAvailable`) chooses the mode; it defaults to `StartTls`, so existing configuration behaves exactly as before, and an unknown name fails the options binding instead of guessing. `deploy/docker/docker-compose.yml` now reads the SMTP target from `.env` (`FSH_SMTP_HOST`, `FSH_SMTP_PORT`, `FSH_SMTP_SECURITY`, `FSH_SMTP_USERNAME`, `FSH_SMTP_PASSWORD`), defaulting to a pinned [Mailpit](https://mailpit.axllent.org) catcher (`axllent/mailpit:v1.31.3`, `Security` set to `None`) that runs only under the `mail-catcher` compose profile, which `.env.example` enables with `COMPOSE_PROFILES=mail-catcher`. With that default, e-mail is caught, not delivered. SMTP stays on the compose network and the inbox UI is published on the host loopback only, at `http://localhost:8025` (`FSH_MAILPIT_PORT`), because it holds live reset and confirmation links. For real delivery, edit `.env` only: set `FSH_SMTP_*` to your provider, set `FSH_MAIL_FROM` to a sender it accepts (the compose default is `no-reply@fsh.local`, because `appsettings.Production.json` leaves `MailOptions:From` blank), and delete the `COMPOSE_PROFILES` line. See [Mailing](/docs/building-blocks/mailing/) and [#1409](https://github.com/fullstackhero/dotnet-starter-kit/pull/1409).
- **Docker Compose: no more `libgssapi_krb5.so.2` errors in the API and migrator logs (fix).** The chiseled .NET images do not ship the Kerberos library, and Npgsql tries GSS encryption by default (`GSS Encryption Mode=Prefer`), so the first connection logged `Cannot load library libgssapi_krb5.so.2` on every start. Both compose connection strings now append `GSS Encryption Mode=Disable`; keep it when you swap in your own Postgres unless the server uses Kerberos. See [Troubleshooting](/docs/getting-started/troubleshooting/) and [#1409](https://github.com/fullstackhero/dotnet-starter-kit/pull/1409).
Expand Down
11 changes: 6 additions & 5 deletions src/content/docs/modules/identity.mdx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
---
title: Identity module
lastUpdated: 2026-09-25
lastUpdated: 2026-10-02
description: JWT bearer + refresh tokens, ASP.NET Identity with roles + permissions, user groups, operator impersonation, two-factor TOTP, sessions, and password-policy enforcement.
sidebar:
label: Identity
Expand Down Expand Up @@ -149,15 +149,15 @@ endpoints.MapPost("/users", handler)

`PUT /profile` replaces the whole representation, so two overlapping saves used to silently overwrite each other: whoever wrote last won, and the other person's change was gone with no error anywhere. The endpoint now supports an optimistic precondition.

`GET /profile` returns the user's `ConcurrencyStamp` as a strong `ETag`. ASP.NET Identity already treats that column as an EF Core concurrency token and rotates it on every write, so no extra column and no migration were needed. Echo the tag back as `If-Match` on the `PUT` and the write is rejected with `412 Precondition Failed` when the stored profile has moved on:
`GET /profile` returns a strong `ETag` derived from the fields the `PUT` writes: an HMAC-SHA256 of the first name, last name, phone number and stored avatar URL, keyed with the user's `SecurityStamp`. The key never leaves the server, so a tag that ends up in a proxy or APM log does not let anyone confirm a guessed name and phone number. Nothing is stored for it, so no extra column and no migration were needed. Echo the tag back as `If-Match` on the `PUT` and the write is rejected with `412 Precondition Failed` when one of those fields has changed since the read:

```http
GET /api/v1/identity/profile
→ 200 OK
ETag: "6f9619ff-8b86-d011-b42d-00cf4fc964ff"
ETag: "9c1f0e4b7a2d38e5c6b14f90d2a7e3b85f6c0d19a4e2b7c83d5f61a09e4b2c7d"

PUT /api/v1/identity/profile
If-Match: "6f9619ff-8b86-d011-b42d-00cf4fc964ff"
If-Match: "9c1f0e4b7a2d38e5c6b14f90d2a7e3b85f6c0d19a4e2b7c83d5f61a09e4b2c7d"
→ 200 OK # nobody else wrote in between
→ 412 # someone did; nothing was written
```
Expand All @@ -172,7 +172,8 @@ Details worth knowing:

- **Take the tag from the read that populated the form**, not from a read issued inside the save. The lost update this guards against happens between the moment the user saw the values and the moment they save, so a tag fetched at save time is never stale and therefore never catches anything.

- **The token also rotates on writes the user does not think of as profile edits**, such as a password change, a new avatar, or a failed sign-in. Those show up as a `412` on an otherwise innocent save, which is why the message the user sees should read as "this changed, review and save again" rather than as an error.
- **Only a profile change moves the tag.** Sign-in attempts (a wrong password bumps `AccessFailedCount`, the next good sign-in resets it), and token refreshes write the user row but leave the tag alone, so nobody can turn an open form into a `412` by typing a wrong password for the account. A new avatar or a phone change made elsewhere does move it, and so do a password change and turning two-factor authentication on or off, because those rotate the `SecurityStamp` that keys the hash. Because the tag follows content, a save that leaves every field as it was keeps the same tag, and replaying it is accepted: nothing can be lost by rewriting identical values.
- **One narrow `412` without a profile change remains.** If a non-profile write, such as a failed sign-in, lands between the moment the `PUT` loads the user and the moment it saves, ASP.NET Identity's own `ConcurrencyStamp` check rejects the save, and that also answers `412`. The window is a single request wide, which is one more reason the message the user sees should read as "this changed, review and save again" rather than as an error.
- **CORS is already configured for it.** `ETag` is not a CORS-safelisted response header, so the framework's CORS policy exposes it explicitly and `if-match` ships in `CorsOptions.AllowedHeaders`. Without both, a browser on another origin cannot read the tag and silently stops sending the precondition. Keep them in place if you replace the policy with your own.

## Endpoints
Expand Down