Skip to content

Add sign-in, first-run setup and web hardening - #2450

Merged
pournasserian merged 3 commits into
v2from
2447-admin-sign-in
Oct 18, 2026
Merged

pournasserian merged 3 commits into
v2from
2447-admin-sign-in

Conversation

@pournasserian

@pournasserian pournasserian commented Oct 18, 2026 •

Copy link
Copy Markdown
Member

Closes #2447

Refs #2434

What

The first admin screens of F-USR (#2428), with the SEC-07 hardening from the first page.

  • Core: IUserSignIn (sign in with email and password, sign out). Every failure is the same sign_in_failed error, so the page cannot tell an unknown email from a wrong password or a disabled account.
  • Host: Identity's application cookie and SignInManager behind CookieUserSignIn. Failed attempts commit so Identity's lockout counts them; an unknown email still hashes the password so timing does not reveal accounts. Sign-in and sign-out append user.signed_in and user.signed_out inside a unit of work with the user as initiator, and the cookie is issued only after the event commits. Every Razor component endpoint requires a signed-in user; setup, sign-in and not found opt out with [AllowAnonymous].
  • Infrastructure: a claims principal factory that adds the display name claim, so the log shows the name, not the email.
  • Admin: static server-rendered /setup, /sign-in and /sign-out on a centred account layout; the main layout shows the signed-in user and a sign-out button that posts. A fresh install has nobody who can sign in, so every route ends at /setup; afterwards /setup leads to /sign-in. Only a local return URL is followed. Strings go through IStringLocalizer; Identity's error codes are the resource keys, with Identity's message as the fallback text.
  • Hardening: anti-forgery on every form (the existing middleware, now with forms behind it); a fixed-window rate limit per client address on posts to pages marked [AcceptsCredentials] (setup and sign-in), ten a minute by default, answered with 429, Retry-After and a plain message instead of the not-found page; Content-Security-Policy (default-src 'self', no inline script or style, frame-ancestors 'none', object-src 'none'), X-Content-Type-Options and Referrer-Policy on every response; Data Protection with a fixed application name and keys persisted to DataProtection:KeysDirectory, shared by both host modes.
  • Components (refs Admin shell and component set #2434): Input, Card (header, title, description, content) and FormField (label, description and error wired to the control through for, aria-describedby and aria-invalid), from the initial set in ADR-0011, with bUnit tests. Logical utilities only. /ui-spike now also requires sign-in.
  • Host tests: BuildWebApplication and BuildMcpHost now run the configure callback before the composition. Before, the settings tests added arrived after the database was registered, so every host test used fluentcms.db in the test directory; the first-run tests need a fresh database each.
  • Tailwind target: after a clean build (CI, a fresh clone) the generated stylesheet was not a static web asset, so the admin was served without CSS. The target added it to Content by absolute path, and the SDK only takes Content matching wwwroot/**; incremental builds worked only because the file already existed when the project was evaluated. It is now added by its path relative to the project. The new asset test caught this on CI.

Why

#2440 left sign-in, the pages and the web hardening for after the admin shell. Choices made here:

  • Data Protection keys on disk, not in the database. No migration while F-SCH owns the model this round, and the keys stay apart from the database whose secrets they will protect. The directory is part of what an install keeps, like the media folder (OPS-01); CONTRIBUTING says it must persist and be private.
  • Password reset moved to its own slice. M1 has no delivery channel (D25: no email before M2), and a development-only sender would ship a reset that does not work in production. That slice chooses between a one-time link an administrator issues from user management and a host command for the owner.
  • No encrypt-a-secret API yet. Nothing stores a secret; the first slice that does adds it on this key ring.
  • Strict style-src 'self'. Blazor's default reconnect display injects a <style> element, which the policy blocks: the overlay still appears and disappears, unstyled. The shell's reconnect handling in Admin shell and component set #2434 should bring its own markup rather than loosen the policy.

No new NuGet dependency: authentication, rate limiting and Data Protection come from the ASP.NET Core shared framework the host already uses, and [AllowAnonymous] reaches Admin through Microsoft.AspNetCore.Components.

Left for later, outside this issue:

  • Behind a reverse proxy the rate limit sees the proxy's address until forwarded headers are configured (OPS).
  • HttpContextInitiator reads the request's user; an interactive page that writes through a circuit will need an initiator from the circuit's authentication state.
  • Disabling a user does not yet end their open sessions (security stamp), which belongs with user management.

Tests

Integration, through the running host: a fresh install sends /, /ui-spike, /sign-in and /sign-out to /setup, setup signs the administrator in and lands on home, and /setup then leads to sign-in; setup errors appear next to their fields and create nobody; an anonymous request returns to its route after sign-in and three kinds of external return URL land on home; a wrong password, an unknown email and a disabled account fail alike with no cookie; five wrong passwords lock the account; sign-in and sign-out from the layout are audited with the user as initiator; posts without a valid anti-forgery token get 400; the fourth sign-in post over a limit of three gets 429 while the page stays reachable; the CSP and nosniff are on pages, assets, health and 404s, and the sign-in page's stylesheet and scripts load anonymously; a cookie issued before a restart is valid after it. Unit: return URL checks. bUnit: Input, Card and FormField, including an error that appears after the first render.

Checked by hand in the browser: setup, sign-in, sign-out and /ui-spike in RTL with no CSP violations except the reconnect overlay's style described above.

Checklist

  • Linked to an issue a maintainer has confirmed
  • Targets v2 (or dev for a v1 security fix)
  • Tests or docs updated where behaviour changed
  • No tool-generated co-author trailers or footers

@pournasserian pournasserian added this to the M1 Governance core milestone Oct 18, 2026
@pournasserian pournasserian added type:feature New capability v2 FluentCMS v2 (v2 branch) area:users Users and sign-in (USR) labels Oct 18, 2026
@pournasserian
pournasserian merged commit 24aec09 into v2 Oct 18, 2026
2 checks passed
@pournasserian
pournasserian deleted the 2447-admin-sign-in branch October 18, 2026 15:25
pournasserian added a commit that referenced this pull request Oct 18, 2026
Closes #2454

## What

`SignInTests.cs` imports `FluentCMS.Core.Audit` for `InitiatorKinds`
instead of `FluentCMS.Infrastructure.Audit`, which it used for nothing
else.

## Why

#2449 moved `InitiatorKinds` to `FluentCMS.Core.Audit`, and #2450 was
written against the old namespace. Each passed CI alone, but together
they break the build on `v2` and in every pull request into it, such as
#2453. Build with warnings as errors and all unit and integration tests
pass with the fix.

## Checklist

- [x] Linked to an issue a maintainer has confirmed
- [x] Targets `v2`
- [x] Tests or docs updated where behaviour changed
- [x] No tool-generated co-author trailers or footers
pournasserian added a commit that referenced this pull request Oct 18, 2026
Closes #2457

## What

The external identity option from USR-01: one OpenID Connect provider
the owner configures. It is off until configured, so nothing external is
needed (P10).

- **Host**: an `oidc` scheme from `Authentication:OpenIdConnect`
(`Authority`, `ClientId`, `ClientSecret`, `DisplayName`,
`AssumeVerifiedEmail`), registered only when the authority and client id
are set. It uses the authorization code flow with PKCE, the response
posted back as a form, and the `openid profile email` scopes, and signs
into Identity's external cookie. `POST /sign-in/external/start` starts
the challenge; its form binding makes the anti-forgery token required.
An authority that is not HTTPS fails at startup, with plain HTTP allowed
only in Development. Otherwise the handler would throw on every request.
- **Core**: `IUserSignIn` gains `GetExternalProvidersAsync` and
`SignInExternalAsync`.
- **Linking** (in `CookieUserSignIn`): a login is the provider's issuer
plus subject, because a subject is unique only at its issuer. The first
time, it links to the user with the same email, and only if
`email_verified` is true or the owner set `AssumeVerifiedEmail` (for
Entra, which never sends it). An identity with no matching user, an
unverified email or a disabled account is refused and nothing is linked.
Linking appends `user.login_linked` and the sign-in appends
`user.signed_in`, both with the user as initiator, in one unit of work,
before the cookie is issued.
- **Admin**: "Sign in with <name>" under an "or" divider on `/sign-in`
when a provider is configured. `/sign-in/external` completes the sign-in
and moves on to the return URL, or explains the refusal and links back
to sign-in. Only existing components are used.
- **CSP**: `form-action` now also allows the provider's origin, because
browsers check it on the redirect that leaves the start post. Nothing
else in the policy changed.
- **Status code pages**: the not-found page now handles only GET and
HEAD. A rejected post that had no body was being re-run against
`/not-found`, whose second anti-forgery check throws once the first has
failed (500 instead of 400). This was latent since #2450 and surfaced
with the first minimal-API form.
- `CONTRIBUTING.md`: how to configure the provider and register the
redirect URI.

## Why

USR-01 asks for at least one external identity option, and ADR-0009
settles it as a generic OpenID Connect provider. Choices made here:

- **No self-registration**: M1 has no user creation outside the
first-run setup and user management, so an unknown identity is refused
rather than given an account.
- **Issuer plus subject, not the scheme name**: Identity's default
stores the scheme name as the login provider. If an owner switched
providers under the same scheme, a reused subject could then sign
someone in as another user.
- **Verified email only by default**: linking by an address the provider
hasn't verified would let anyone who can set that address at the
provider take over the account.

New dependency: `Microsoft.AspNetCore.Authentication.OpenIdConnect`
10.0.13, Microsoft's handler at the version of the other ASP.NET Core
packages. It brings `Microsoft.IdentityModel.Protocols.OpenIdConnect`
and `Microsoft.IdentityModel.JsonWebTokens` with it. No migration:
Identity's `UserLogins` table holds the links.

## Tests

Integration, through the host, against `TestOpenIdProvider`, a small
in-process provider in the test project (discovery, keys, authorize with
form_post, token with PKCE and `at_hash`, signed with a test RSA key).
It needs no external service and no test package.

- Without a provider: no button, `form-action 'self'` only, and the
start endpoint answers 404.
- A verified matching email signs in, returns to `/ui-spike` and is
audited as `user.login_linked` then `user.signed_in` with the user as
initiator.
- After sign-out, the same subject with a changed email signs in again
with no second link, and the stored login is the issuer and subject.
- An unknown email, an unverified email, a missing `email_verified` and
a disabled account are each refused, with no cookie, no link and no
audit event.
- `AssumeVerifiedEmail` lets a provider without `email_verified`
through.
- The start post without a token gets 400.
- An HTTP authority outside Development fails at startup.

Checked by hand in the browser: the button under the divider, and the
refusal page in RTL.

## Checklist

- [x] Linked to an issue a maintainer has confirmed
- [x] Targets `v2` (or `dev` for a v1 security fix)
- [x] Tests or docs updated where behaviour changed
- [x] No tool-generated co-author trailers or footers
pournasserian added a commit that referenced this pull request Oct 18, 2026
Closes #2458

Refs #2434

## What

The third slice of F-AUD (#2424): the first screen on the log, behind
the sign-in from #2450.

- **Core**: `IAuditExporter.ExportAsync` now commits an `audit.exported`
event in its own unit of work before it writes anything. The event names
the current initiator and, when the selection is one item's history,
that item as its subject. An invalid query or format records nothing.
The query runs only when the export starts writing, so a selection that
reaches the present contains its own export event. `AuditErrorCodes`
replaces the string literals in `AuditQuery` and the exporter.
- **Admin**: `/audit`, server-rendered without interactivity like the
account pages, with an "Audit log" link in the main layout.
- The export form takes from and to as UTC dates (both days included,
either optional), an optional item type and id, and Download JSON or
Download CSV.
- The history lookup lists an item's events oldest first. Each event
shows its operation, a User or Agent badge, the initiator's name, the
time, and the agent credential, model, prompt hash, sources, change set,
previous version and event id when they are set. It shows at most 200
events and points to the export for the rest.
- `AuditExportForm` maps the posted fields to an `AuditQuery` and a
format and reports every problem at once, including unreadable dates and
a malformed id.
- Strings go through `IStringLocalizer`, and the layout uses logical
utilities only.
- **Host**: `POST /audit/export` maps the form, calls the exporter and
streams the file with `Content-Disposition: attachment` and
`Cache-Control: no-store`. The file name is `audit-log` or
`audit-history-<id>`, so nothing typed into the form reaches the header.
It requires a signed-in user, and form binding applies the anti-forgery
check. A rejected form goes back to `/audit` with its values and error
codes in the query, and the page puts each message next to its field.
`GET /audit/export` redirects to the page, for someone whose session ran
out, who signs in again and comes back with a GET.
- **Component** (refs #2434): Badge, with the shadcn variants and bUnit
tests. The rest of the page uses Input, FormField, Button and Card
unchanged.
- **CONTRIBUTING.md**: the recorded export and the download endpoint.

## Why

- **An export records itself.** AUD-01 covers every operation, and an
export is where data leaves the system. Recording it in Core means any
later exposure, an API or an MCP tool, is recorded too without doing
anything. Viewing an item's history on screen is a read and is not
recorded. The event has no field for the window or the format, so they
are not recorded. Adding them needs a details field on the audit event,
which is a model change for a later slice.
- **One HTTP endpoint despite ADR-0006's "no admin HTTP API".** A Razor
page cannot answer with a file. The endpoint is not an API: it has no
contract of its own, only maps the page's form onto the same Core
service, and is protected like every page form. It lives in Host because
Admin references only Core and the components package.
- **The history is a list, not a table.** An event has up to fourteen
fields, too wide for a table row. ADR-0011 puts tables on QuickGrid, a
new package, which this screen does not need; the first tabular list,
such as the browsable log in ADM-01, can bring it.
- **A forged token gets a 400, not a 500.** The endpoint's 400 has no
body, so the not-found page used to re-run as a POST and the
anti-forgery check then threw on the same rejected form. #2459 since
stopped status code pages for anything but GET and HEAD across the host,
so the endpoint needs nothing of its own; the forged-token test covers
it.

No new NuGet dependency and no migration.

## Tests

Unit: form mapping (UTC days with both ends included, empty fields
select the full log, a one-day window, an item's history, every problem
reported at once, unreadable dates per field, half an item, the return
query keeps values and lists each code once). Exporter: the export event
is committed before the first byte in both formats; an item's export
names the item; an invalid query or format records nothing and opens no
unit of work. bUnit: Badge default and variants.

Integration through the running host: anonymous `GET /audit` and `POST
/audit/export` go to sign-in and record nothing. A signed-in JSON
download of an item's history and a CSV download of a date range return
the selected events, each export's own event included, as attachments
with the right content type, file name, `no-store` and the CSP; each
records one `audit.exported` event naming the user. A rejected export
redirects to the page with both messages next to their fields, the
values kept, and nothing recorded. A forged anti-forgery token gets 400
and records nothing. The history lookup shows an item's events oldest
first with every field, encodes a source containing a script tag, leaves
out other items' events, and handles an unknown and a malformed id. The
layout links to the page. The integration tests from #2449 now also
check that an item's export contains its own record and that an empty
selection is still recorded.

Checked by hand in the browser: the page in light, dark and RTL, a
rejected export coming back with its errors, the history of the
signed-in user, and no CSP violations.

## Checklist

- [x] Linked to an issue a maintainer has confirmed
- [x] Targets `v2` (or `dev` for a v1 security fix)
- [x] Tests or docs updated where behaviour changed
- [x] No tool-generated co-author trailers or footers
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:users Users and sign-in (USR) type:feature New capability v2 FluentCMS v2 (v2 branch)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Admin sign-in: first-run administrator, email and password sign-in and sign-out, web hardening

1 participant