From 17f77d95fc58b2ce72660f9f18b344953d71f6eb Mon Sep 17 00:00:00 2001 From: Amir Pournasserian Date: Sun, 18 Oct 2026 11:02:37 -0400 Subject: [PATCH 1/3] Add Input, Card and FormField components --- src/FluentCMS.Admin/Components/Ui/Card.razor | 9 +++ .../Components/Ui/CardContent.razor | 8 +++ .../Components/Ui/CardDescription.razor | 8 +++ .../Components/Ui/CardHeader.razor | 8 +++ .../Components/Ui/CardTitle.razor | 9 +++ .../Components/Ui/ClassNames.cs | 17 ++++++ .../Components/Ui/FormField.razor | 37 ++++++++++++ src/FluentCMS.Admin/Components/Ui/Input.razor | 16 +++++ tests/FluentCMS.Tests/Admin/CardTests.cs | 32 ++++++++++ tests/FluentCMS.Tests/Admin/FormFieldTests.cs | 60 +++++++++++++++++++ tests/FluentCMS.Tests/Admin/InputTests.cs | 45 ++++++++++++++ 11 files changed, 249 insertions(+) create mode 100644 src/FluentCMS.Admin/Components/Ui/Card.razor create mode 100644 src/FluentCMS.Admin/Components/Ui/CardContent.razor create mode 100644 src/FluentCMS.Admin/Components/Ui/CardDescription.razor create mode 100644 src/FluentCMS.Admin/Components/Ui/CardHeader.razor create mode 100644 src/FluentCMS.Admin/Components/Ui/CardTitle.razor create mode 100644 src/FluentCMS.Admin/Components/Ui/FormField.razor create mode 100644 src/FluentCMS.Admin/Components/Ui/Input.razor create mode 100644 tests/FluentCMS.Tests/Admin/CardTests.cs create mode 100644 tests/FluentCMS.Tests/Admin/FormFieldTests.cs create mode 100644 tests/FluentCMS.Tests/Admin/InputTests.cs diff --git a/src/FluentCMS.Admin/Components/Ui/Card.razor b/src/FluentCMS.Admin/Components/Ui/Card.razor new file mode 100644 index 000000000..a84d8954b --- /dev/null +++ b/src/FluentCMS.Admin/Components/Ui/Card.razor @@ -0,0 +1,9 @@ +
+ @ChildContent +
+ +@code { + [Parameter] public string? Class { get; set; } + [Parameter] public RenderFragment? ChildContent { get; set; } + [Parameter(CaptureUnmatchedValues = true)] public Dictionary? AdditionalAttributes { get; set; } +} diff --git a/src/FluentCMS.Admin/Components/Ui/CardContent.razor b/src/FluentCMS.Admin/Components/Ui/CardContent.razor new file mode 100644 index 000000000..e366ec704 --- /dev/null +++ b/src/FluentCMS.Admin/Components/Ui/CardContent.razor @@ -0,0 +1,8 @@ +
+ @ChildContent +
+ +@code { + [Parameter] public string? Class { get; set; } + [Parameter] public RenderFragment? ChildContent { get; set; } +} diff --git a/src/FluentCMS.Admin/Components/Ui/CardDescription.razor b/src/FluentCMS.Admin/Components/Ui/CardDescription.razor new file mode 100644 index 000000000..e8c2d25ee --- /dev/null +++ b/src/FluentCMS.Admin/Components/Ui/CardDescription.razor @@ -0,0 +1,8 @@ +
+ @ChildContent +
+ +@code { + [Parameter] public string? Class { get; set; } + [Parameter] public RenderFragment? ChildContent { get; set; } +} diff --git a/src/FluentCMS.Admin/Components/Ui/CardHeader.razor b/src/FluentCMS.Admin/Components/Ui/CardHeader.razor new file mode 100644 index 000000000..19af576d2 --- /dev/null +++ b/src/FluentCMS.Admin/Components/Ui/CardHeader.razor @@ -0,0 +1,8 @@ +
+ @ChildContent +
+ +@code { + [Parameter] public string? Class { get; set; } + [Parameter] public RenderFragment? ChildContent { get; set; } +} diff --git a/src/FluentCMS.Admin/Components/Ui/CardTitle.razor b/src/FluentCMS.Admin/Components/Ui/CardTitle.razor new file mode 100644 index 000000000..bbed53869 --- /dev/null +++ b/src/FluentCMS.Admin/Components/Ui/CardTitle.razor @@ -0,0 +1,9 @@ +@* A div as in shadcn; a page puts its heading inside, so the heading level stays the page's choice. *@ +
+ @ChildContent +
+ +@code { + [Parameter] public string? Class { get; set; } + [Parameter] public RenderFragment? ChildContent { get; set; } +} diff --git a/src/FluentCMS.Admin/Components/Ui/ClassNames.cs b/src/FluentCMS.Admin/Components/Ui/ClassNames.cs index a85340d2f..32d1e0c58 100644 --- a/src/FluentCMS.Admin/Components/Ui/ClassNames.cs +++ b/src/FluentCMS.Admin/Components/Ui/ClassNames.cs @@ -28,3 +28,20 @@ public sealed class CommandContext public string ListId => Id + "-list"; public string InputId => Id + "-input"; } + +public sealed class FormFieldContext +{ + public string Id { get; } = "field-" + Guid.NewGuid().ToString("N")[..8]; + public string DescriptionId => Id + "-description"; + public string ErrorId => Id + "-error"; + public bool HasDescription { get; set; } + public bool HasError { get; set; } + + public string? DescribedBy => (HasDescription, HasError) switch + { + (true, true) => DescriptionId + " " + ErrorId, + (true, false) => DescriptionId, + (false, true) => ErrorId, + _ => null, + }; +} diff --git a/src/FluentCMS.Admin/Components/Ui/FormField.razor b/src/FluentCMS.Admin/Components/Ui/FormField.razor new file mode 100644 index 000000000..af5e5f9b3 --- /dev/null +++ b/src/FluentCMS.Admin/Components/Ui/FormField.razor @@ -0,0 +1,37 @@ +@* One labelled control with an optional description and error, wired together for assistive technology like shadcn's FormItem. + Not a fixed cascading value: an error that appears after a submit has to re-render the control as well. *@ + +
+ + @ChildContent + @if (Description is not null) + { +

@Description

+ } + @if (Error is not null) + { +

@Error

+ } +
+
+ +@code { + private readonly FormFieldContext _context = new(); + + [Parameter, EditorRequired] public string Label { get; set; } = string.Empty; + [Parameter] public string? Description { get; set; } + [Parameter] public string? Error { get; set; } + [Parameter] public string? Class { get; set; } + [Parameter] public RenderFragment? ChildContent { get; set; } + + protected override void OnParametersSet() + { + _context.HasDescription = Description is not null; + _context.HasError = Error is not null; + } +} diff --git a/src/FluentCMS.Admin/Components/Ui/Input.razor b/src/FluentCMS.Admin/Components/Ui/Input.razor new file mode 100644 index 000000000..ca52a4972 --- /dev/null +++ b/src/FluentCMS.Admin/Components/Ui/Input.razor @@ -0,0 +1,16 @@ +@* Inside a FormField the input takes the field's id and is described by its description and error. *@ + + +@code { + [CascadingParameter] private FormFieldContext? Field { get; set; } + + [Parameter] public string Type { get; set; } = "text"; + [Parameter] public string? Class { get; set; } + [Parameter(CaptureUnmatchedValues = true)] public Dictionary? AdditionalAttributes { get; set; } +} diff --git a/tests/FluentCMS.Tests/Admin/CardTests.cs b/tests/FluentCMS.Tests/Admin/CardTests.cs new file mode 100644 index 000000000..b72533741 --- /dev/null +++ b/tests/FluentCMS.Tests/Admin/CardTests.cs @@ -0,0 +1,32 @@ +using Bunit; +using FluentCMS.Admin.Components.Ui; + +namespace FluentCMS.Tests.Admin; + +public sealed class CardTests +{ + [Fact] + public void Renders_the_shadcn_slots_in_order_with_the_page_heading_inside_the_title() + { + using var context = new AdminTestContext(); + + var cut = context.Render(card => card + .AddUnmatched("data-testid", "sign-in-card") + .AddChildContent(header => header + .AddChildContent(title => title.AddChildContent("

Sign in

")) + .AddChildContent(description => description.AddChildContent("Enter your email."))) + .AddChildContent(content => content.Add(c => c.Class, "grid gap-6").AddChildContent("
"))); + + var card = cut.Find("[data-slot=card]"); + var slots = card.QuerySelectorAll("[data-slot]").Select(element => element.GetAttribute("data-slot")); + + Assert.Equal("sign-in-card", card.GetAttribute("data-testid")); + Assert.Contains("rounded-xl", card.ClassList); + Assert.Contains("bg-card", card.ClassList); + Assert.Equal(["card-header", "card-title", "card-description", "card-content"], slots); + Assert.Equal("Sign in", cut.Find("[data-slot=card-title] > h1").TextContent); + Assert.Contains("text-muted-foreground", cut.Find("[data-slot=card-description]").ClassList); + Assert.Contains("grid", cut.Find("[data-slot=card-content]").ClassList); + Assert.Contains("px-6", cut.Find("[data-slot=card-content]").ClassList); + } +} diff --git a/tests/FluentCMS.Tests/Admin/FormFieldTests.cs b/tests/FluentCMS.Tests/Admin/FormFieldTests.cs new file mode 100644 index 000000000..ed5d7e526 --- /dev/null +++ b/tests/FluentCMS.Tests/Admin/FormFieldTests.cs @@ -0,0 +1,60 @@ +using Bunit; +using FluentCMS.Admin.Components.Ui; + +namespace FluentCMS.Tests.Admin; + +public sealed class FormFieldTests +{ + [Fact] + public void Label_names_the_input_and_the_description_describes_it() + { + using var context = new AdminTestContext(); + + var cut = context.Render(field => field + .Add(f => f.Label, "Name") + .Add(f => f.Description, "Shown in the audit log.") + .AddChildContent(input => input.AddUnmatched("name", "Form.DisplayName"))); + + var label = cut.Find("label"); + var input = cut.Find("input"); + + Assert.Equal("Name", label.TextContent.Trim()); + Assert.Equal(input.Id, label.GetAttribute("for")); + Assert.Equal(cut.Find("[data-slot=form-description]").Id, input.GetAttribute("aria-describedby")); + Assert.Equal("false", label.GetAttribute("data-error")); + Assert.False(input.HasAttribute("aria-invalid")); + Assert.Empty(cut.FindAll("[data-slot=form-message]")); + } + + [Fact] + public void An_error_that_appears_later_marks_the_input_invalid_and_describes_it() + { + using var context = new AdminTestContext(); + + var cut = context.Render(field => field + .Add(f => f.Label, "Password") + .Add(f => f.Description, "At least twelve characters.") + .AddChildContent(input => input.Add(i => i.Type, "password"))); + + cut.Render(field => field.Add(f => f.Error, "The passwords do not match.")); + + var input = cut.Find("input"); + var message = cut.Find("[data-slot=form-message]"); + + Assert.Equal("The passwords do not match.", message.TextContent); + Assert.Equal("true", input.GetAttribute("aria-invalid")); + Assert.Equal($"{cut.Find("[data-slot=form-description]").Id} {message.Id}", input.GetAttribute("aria-describedby")); + Assert.Equal("true", cut.Find("label").GetAttribute("data-error")); + } + + [Fact] + public void Two_fields_never_share_an_id() + { + using var context = new AdminTestContext(); + + var first = context.Render(field => field.Add(f => f.Label, "Email").AddChildContent()); + var second = context.Render(field => field.Add(f => f.Label, "Password").AddChildContent()); + + Assert.NotEqual(first.Find("input").Id, second.Find("input").Id); + } +} diff --git a/tests/FluentCMS.Tests/Admin/InputTests.cs b/tests/FluentCMS.Tests/Admin/InputTests.cs new file mode 100644 index 000000000..240c71db9 --- /dev/null +++ b/tests/FluentCMS.Tests/Admin/InputTests.cs @@ -0,0 +1,45 @@ +using Bunit; +using FluentCMS.Admin.Components.Ui; + +namespace FluentCMS.Tests.Admin; + +public sealed class InputTests +{ + [Fact] + public void Renders_a_native_text_input_with_the_shadcn_classes() + { + using var context = new AdminTestContext(); + + var input = context.Render(i => i.Add(x => x.Class, "max-w-xs")).Find("input"); + + Assert.Equal("text", input.GetAttribute("type")); + Assert.Equal("input", input.GetAttribute("data-slot")); + Assert.Contains("border-input", input.ClassList); + Assert.Contains("focus-visible:ring-[3px]", input.ClassList); + Assert.Contains("aria-invalid:border-destructive", input.ClassList); + Assert.Contains("max-w-xs", input.ClassList); + Assert.False(input.HasAttribute("id")); + Assert.False(input.HasAttribute("aria-describedby")); + } + + [Fact] + public void Passes_form_attributes_through_to_the_element() + { + using var context = new AdminTestContext(); + + var input = context.Render(i => i + .Add(x => x.Type, "email") + .AddUnmatched("name", "Form.Email") + .AddUnmatched("value", "owner@example.com") + .AddUnmatched("autocomplete", "username") + .AddUnmatched("required", true) + .AddUnmatched("id", "email")).Find("input"); + + Assert.Equal("email", input.GetAttribute("type")); + Assert.Equal("Form.Email", input.GetAttribute("name")); + Assert.Equal("owner@example.com", input.GetAttribute("value")); + Assert.Equal("username", input.GetAttribute("autocomplete")); + Assert.True(input.HasAttribute("required")); + Assert.Equal("email", input.Id); + } +} From ade953006c78374c1cc811e04aaf9ea34c0b31fc Mon Sep 17 00:00:00 2001 From: Amir Pournasserian Date: Sun, 18 Oct 2026 11:02:37 -0400 Subject: [PATCH 2/3] Add sign-in, first-run setup and web hardening --- .gitignore | 3 + CONTRIBUTING.md | 2 + .../AcceptsCredentialsAttribute.cs | 5 + src/FluentCMS.Admin/AdminOptions.cs | 2 +- src/FluentCMS.Admin/AdminRoutes.cs | 25 +++ .../Components/Layout/AccountLayout.razor | 9 + .../Components/Layout/MainLayout.razor | 11 + .../Components/Pages/NotFound.razor | 1 + .../Components/Pages/Setup.razor | 132 ++++++++++++ .../Components/Pages/SignIn.razor | 73 +++++++ .../Components/Pages/SignOut.razor | 29 +++ src/FluentCMS.Admin/Components/_Imports.razor | 3 + src/FluentCMS.Core/Identity/IUserSignIn.cs | 12 ++ src/FluentCMS.Host/FluentCmsHost.cs | 42 +++- .../Identity/CookieUserSignIn.cs | 67 ++++++ src/FluentCMS.Host/Security/KeyRingOptions.cs | 10 + .../Security/SecurityHeaders.cs | 29 +++ .../Security/SignInRateLimit.cs | 78 +++++++ .../FluentCmsClaimsPrincipalFactory.cs | 22 ++ ...frastructureServiceCollectionExtensions.cs | 3 +- tests/FluentCMS.IntegrationTests/AdminHost.cs | 147 +++++++++++++ tests/FluentCMS.IntegrationTests/HostTests.cs | 74 +++---- .../Identity/SignInTests.cs | 202 ++++++++++++++++++ .../WebHardeningTests.cs | 118 ++++++++++ .../FluentCMS.Tests/Admin/AdminRoutesTests.cs | 29 +++ 25 files changed, 1076 insertions(+), 52 deletions(-) create mode 100644 src/FluentCMS.Admin/AcceptsCredentialsAttribute.cs create mode 100644 src/FluentCMS.Admin/AdminRoutes.cs create mode 100644 src/FluentCMS.Admin/Components/Layout/AccountLayout.razor create mode 100644 src/FluentCMS.Admin/Components/Pages/Setup.razor create mode 100644 src/FluentCMS.Admin/Components/Pages/SignIn.razor create mode 100644 src/FluentCMS.Admin/Components/Pages/SignOut.razor create mode 100644 src/FluentCMS.Core/Identity/IUserSignIn.cs create mode 100644 src/FluentCMS.Host/Identity/CookieUserSignIn.cs create mode 100644 src/FluentCMS.Host/Security/KeyRingOptions.cs create mode 100644 src/FluentCMS.Host/Security/SecurityHeaders.cs create mode 100644 src/FluentCMS.Host/Security/SignInRateLimit.cs create mode 100644 src/FluentCMS.Infrastructure/Identity/FluentCmsClaimsPrincipalFactory.cs create mode 100644 tests/FluentCMS.IntegrationTests/AdminHost.cs create mode 100644 tests/FluentCMS.IntegrationTests/Identity/SignInTests.cs create mode 100644 tests/FluentCMS.IntegrationTests/WebHardeningTests.cs create mode 100644 tests/FluentCMS.Tests/Admin/AdminRoutesTests.cs diff --git a/.gitignore b/.gitignore index a0b9a240a..e41c565c8 100644 --- a/.gitignore +++ b/.gitignore @@ -44,3 +44,6 @@ appsettings.*.local.json # Operating system .DS_Store Thumbs.db + +# Data Protection keys of a local run +data-protection-keys/ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 037e19f42..8a709926d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -54,6 +54,8 @@ Tests run against SQLite in-process. To run the PostgreSQL tests as well, set `F To run the host, `dotnet run --project src/FluentCMS.Host` starts the web host on `http://localhost:5000` with a SQLite database in `fluentcms.db`, and `dotnet run --project src/FluentCMS.Host -- mcp` starts the stdio mode. Configuration comes from environment variables such as `Database__ConnectionString`, `Database__Provider` (`sqlite` or `postgresql`, inferred from the connection string when omitted) and `JobQueue__WorkerCount`, and from an optional `appsettings.json` next to the binary. +On a fresh database the admin asks for the first administrator at `/setup`; every other admin page needs a signed-in user. Data Protection keeps the keys that protect sign-in cookies, anti-forgery tokens and stored secrets in `DataProtection__KeysDirectory` (default `data-protection-keys` under the content root). Put it on storage that survives restarts and upgrades and that only the host's account can read; losing it signs everyone out. Sign-in and setup posts are limited per client address by `SignInRateLimit__PermitLimit` and `SignInRateLimit__Window`, ten a minute by default. + ## Database migrations Each provider has its own `DbContext` subclass and migrations folder (ADR-0005). After changing the model, add a migration for both; no database needs to be running, the design-time factories build the model on their own. diff --git a/src/FluentCMS.Admin/AcceptsCredentialsAttribute.cs b/src/FluentCMS.Admin/AcceptsCredentialsAttribute.cs new file mode 100644 index 000000000..eff9ed209 --- /dev/null +++ b/src/FluentCMS.Admin/AcceptsCredentialsAttribute.cs @@ -0,0 +1,5 @@ +namespace FluentCMS.Admin; + +// Marks a page whose form takes a password; the host rate limits posts to it (SEC-07). +[AttributeUsage(AttributeTargets.Class, Inherited = false)] +public sealed class AcceptsCredentialsAttribute : Attribute; diff --git a/src/FluentCMS.Admin/AdminOptions.cs b/src/FluentCMS.Admin/AdminOptions.cs index 1b54aad35..1998e11f8 100644 --- a/src/FluentCMS.Admin/AdminOptions.cs +++ b/src/FluentCMS.Admin/AdminOptions.cs @@ -2,6 +2,6 @@ namespace FluentCMS.Admin; public sealed class AdminOptions { - // The /ui-spike showcase is reachable only where the host enables it (Development until F-USR adds sign-in). + // The /ui-spike showcase is reachable only where the host enables it (Development), and like every admin page only when signed in. public bool ShowcaseEnabled { get; set; } } diff --git a/src/FluentCMS.Admin/AdminRoutes.cs b/src/FluentCMS.Admin/AdminRoutes.cs new file mode 100644 index 000000000..a6ffe0657 --- /dev/null +++ b/src/FluentCMS.Admin/AdminRoutes.cs @@ -0,0 +1,25 @@ +namespace FluentCMS.Admin; + +// The account routes the host needs for the cookie's redirects; each matches the @page directive of its page. +public static class AdminRoutes +{ + public const string Setup = "/setup"; + public const string SignIn = "/sign-in"; + public const string SignOut = "/sign-out"; + + // Only a path on this site is followed after sign-in, so a crafted link cannot send someone elsewhere. + public static bool IsLocal(string? url) + { + if (string.IsNullOrEmpty(url) || url[0] != '/') + { + return false; + } + + if (url.Length > 1 && (url[1] == '/' || url[1] == '\\')) + { + return false; + } + + return !url.Any(char.IsControl); + } +} diff --git a/src/FluentCMS.Admin/Components/Layout/AccountLayout.razor b/src/FluentCMS.Admin/Components/Layout/AccountLayout.razor new file mode 100644 index 000000000..fb59db312 --- /dev/null +++ b/src/FluentCMS.Admin/Components/Layout/AccountLayout.razor @@ -0,0 +1,9 @@ +@inherits LayoutComponentBase + +@* The setup, sign-in and sign-out pages: one centred card, no navigation. *@ +
+
+ FluentCMS + @Body +
+
diff --git a/src/FluentCMS.Admin/Components/Layout/MainLayout.razor b/src/FluentCMS.Admin/Components/Layout/MainLayout.razor index e7e57795f..695b071b1 100644 --- a/src/FluentCMS.Admin/Components/Layout/MainLayout.razor +++ b/src/FluentCMS.Admin/Components/Layout/MainLayout.razor @@ -1,10 +1,21 @@ @inherits LayoutComponentBase +@inject ICurrentInitiator Initiator @inject IStringLocalizer L
diff --git a/src/FluentCMS.Admin/Components/Pages/NotFound.razor b/src/FluentCMS.Admin/Components/Pages/NotFound.razor index 9d78cc145..6ee96559a 100644 --- a/src/FluentCMS.Admin/Components/Pages/NotFound.razor +++ b/src/FluentCMS.Admin/Components/Pages/NotFound.razor @@ -1,4 +1,5 @@ @page "/not-found" +@attribute [AllowAnonymous] @inject IStringLocalizer L @L["Not found"] diff --git a/src/FluentCMS.Admin/Components/Pages/Setup.razor b/src/FluentCMS.Admin/Components/Pages/Setup.razor new file mode 100644 index 000000000..f68486dfc --- /dev/null +++ b/src/FluentCMS.Admin/Components/Pages/Setup.razor @@ -0,0 +1,132 @@ +@page "/setup" +@layout AccountLayout +@attribute [AllowAnonymous] +@attribute [AcceptsCredentials] +@using FluentCMS.Core.Results +@inject IFirstRunSetup FirstRun +@inject IUserSignIn UserSignIn +@inject NavigationManager Navigation +@inject IStringLocalizer L + +@L["Set up FluentCMS"] + + + +

@L["Create the first administrator"]

+ @L["This account signs in to the admin and can add the rest of the team."] +
+ +
+ + @if (_formError is not null) + { + + } + + + + + + + + + + + + + + +
+
+ +@code { + private readonly Dictionary _errors = []; + private string? _formError; + + [SupplyParameterFromForm(FormName = "setup")] + private SetupForm Form { get; set; } = default!; + + protected override async Task OnInitializedAsync() + { + Form ??= new(); + + if (!await FirstRun.NeedsFirstAdministratorAsync()) + { + Navigation.NavigateTo("sign-in"); + } + } + + private async Task SubmitAsync() + { + Require(nameof(SetupForm.DisplayName), Form.DisplayName, L["Enter your name."]); + Require(nameof(SetupForm.Email), Form.Email, L["Enter your email address."]); + Require(nameof(SetupForm.Password), Form.Password, L["Enter a password."]); + if (Form.Password != Form.ConfirmPassword) + { + _errors[nameof(SetupForm.ConfirmPassword)] = L["The passwords do not match."]; + } + + if (_errors.Count > 0) + { + return; + } + + var email = Form.Email!.Trim(); + var created = await FirstRun.CreateFirstAdministratorAsync(new NewUser(email, Form.DisplayName!.Trim(), Form.Password!)); + if (!created.IsSuccess) + { + foreach (var error in created.Errors) + { + Report(error); + } + + return; + } + + var signedIn = await UserSignIn.SignInAsync(email, Form.Password!); + Navigation.NavigateTo(signedIn.IsSuccess ? "" : "sign-in"); + } + + private void Require(string field, string? value, string message) + { + if (string.IsNullOrWhiteSpace(value)) + { + _errors[field] = message; + } + } + + // Identity reports problems by code: the code is the resource key, and Identity's own message the text until a resource exists. + private void Report(Error error) + { + var localized = L[error.Code]; + var message = localized.ResourceNotFound ? error.Message : localized.Value; + var field = error.Code switch + { + "InvalidEmail" or "DuplicateEmail" or "InvalidUserName" or "DuplicateUserName" => nameof(SetupForm.Email), + _ when error.Code.StartsWith("Password", StringComparison.Ordinal) => nameof(SetupForm.Password), + _ => null, + }; + + if (field is null) + { + _formError = _formError is null ? message : _formError + " " + message; + } + else + { + _errors[field] = _errors.TryGetValue(field, out var existing) ? existing + " " + message : message; + } + } + + private string? ErrorFor(string field) => _errors.GetValueOrDefault(field); + + private sealed class SetupForm + { + public string? DisplayName { get; set; } + + public string? Email { get; set; } + + public string? Password { get; set; } + + public string? ConfirmPassword { get; set; } + } +} diff --git a/src/FluentCMS.Admin/Components/Pages/SignIn.razor b/src/FluentCMS.Admin/Components/Pages/SignIn.razor new file mode 100644 index 000000000..69232ebd3 --- /dev/null +++ b/src/FluentCMS.Admin/Components/Pages/SignIn.razor @@ -0,0 +1,73 @@ +@page "/sign-in" +@layout AccountLayout +@attribute [AllowAnonymous] +@attribute [AcceptsCredentials] +@inject IFirstRunSetup FirstRun +@inject IUserSignIn UserSignIn +@inject NavigationManager Navigation +@inject IStringLocalizer L + +@L["Sign in"] + + + +

@L["Sign in"]

+ @L["Enter your email and password to sign in to the admin."] +
+ +
+ + @if (_failed) + { + + } + + + + + + + + +
+
+ +@code { + private bool _failed; + + [SupplyParameterFromForm(FormName = "sign-in")] + private SignInForm Form { get; set; } = default!; + + [SupplyParameterFromQuery] + private string? ReturnUrl { get; set; } + + protected override async Task OnInitializedAsync() + { + Form ??= new(); + + // Nobody can sign in before the first administrator exists, so a fresh install continues at setup. + if (await FirstRun.NeedsFirstAdministratorAsync()) + { + Navigation.NavigateTo("setup"); + } + } + + private async Task SubmitAsync() + { + var result = await UserSignIn.SignInAsync(Form.Email?.Trim() ?? string.Empty, Form.Password ?? string.Empty); + if (!result.IsSuccess) + { + _failed = true; + return; + } + + Navigation.NavigateTo(AdminRoutes.IsLocal(ReturnUrl) ? ReturnUrl! : ""); + } + + private sealed class SignInForm + { + public string? Email { get; set; } + + public string? Password { get; set; } + } +} diff --git a/src/FluentCMS.Admin/Components/Pages/SignOut.razor b/src/FluentCMS.Admin/Components/Pages/SignOut.razor new file mode 100644 index 000000000..1dc5dc587 --- /dev/null +++ b/src/FluentCMS.Admin/Components/Pages/SignOut.razor @@ -0,0 +1,29 @@ +@page "/sign-out" +@layout AccountLayout +@inject IUserSignIn UserSignIn +@inject NavigationManager Navigation +@inject IStringLocalizer L + +@L["Sign out"] + +@* Signing out only happens on a post, so a link or an image on another site cannot end the session. *@ + + +

@L["Sign out"]

+ @L["Sign out of the admin on this browser."] +
+ +
+ + + +
+
+ +@code { + private async Task SubmitAsync() + { + await UserSignIn.SignOutAsync(); + Navigation.NavigateTo("sign-in"); + } +} diff --git a/src/FluentCMS.Admin/Components/_Imports.razor b/src/FluentCMS.Admin/Components/_Imports.razor index f6e799946..92cd33dbc 100644 --- a/src/FluentCMS.Admin/Components/_Imports.razor +++ b/src/FluentCMS.Admin/Components/_Imports.razor @@ -1,3 +1,5 @@ +@using Microsoft.AspNetCore.Authorization +@using Microsoft.AspNetCore.Components.Forms @using Microsoft.AspNetCore.Components.Routing @using Microsoft.AspNetCore.Components.Web @using static Microsoft.AspNetCore.Components.Web.RenderMode @@ -6,3 +8,4 @@ @using FluentCMS.Admin.Components @using FluentCMS.Admin.Components.Layout @using FluentCMS.Admin.Components.Ui +@using FluentCMS.Core.Identity diff --git a/src/FluentCMS.Core/Identity/IUserSignIn.cs b/src/FluentCMS.Core/Identity/IUserSignIn.cs new file mode 100644 index 000000000..29ad038c2 --- /dev/null +++ b/src/FluentCMS.Core/Identity/IUserSignIn.cs @@ -0,0 +1,12 @@ +using FluentCMS.Core.Results; + +namespace FluentCMS.Core.Identity; + +// Sign-in for people; agents authenticate with their own credentials and never through this (ADR-0009). +public interface IUserSignIn +{ + // Every failure carries the same error, so a caller cannot tell an unknown email from a wrong password or a disabled account. + Task SignInAsync(string email, string password, CancellationToken cancellationToken = default); + + Task SignOutAsync(CancellationToken cancellationToken = default); +} diff --git a/src/FluentCMS.Host/FluentCmsHost.cs b/src/FluentCMS.Host/FluentCmsHost.cs index 7fc1a7854..7abbd4b20 100644 --- a/src/FluentCMS.Host/FluentCmsHost.cs +++ b/src/FluentCMS.Host/FluentCmsHost.cs @@ -5,8 +5,12 @@ using FluentCMS.Core.Identity; using FluentCMS.Host.Identity; using FluentCMS.Host.Jobs; +using FluentCMS.Host.Security; using FluentCMS.Infrastructure; +using FluentCMS.Infrastructure.Persistence; using FluentCMS.Mcp; +using Microsoft.AspNetCore.DataProtection; +using Microsoft.AspNetCore.Identity; using Microsoft.Extensions.Logging.Console; namespace FluentCMS.Host; @@ -31,24 +35,50 @@ public static WebApplication BuildWebApplication(string[] args, Action(); + AddCookieSignIn(builder.Services); + builder.Services.AddSignInRateLimit(builder.Configuration); builder.Services.AddHealthChecks(); builder.Services.AddLocalization(); builder.Services.AddRazorComponents().AddInteractiveServerComponents(); - configure?.Invoke(builder); var app = builder.Build(); + app.UseSecurityHeaders(); app.UseStatusCodePagesWithReExecute("/not-found", createScopeForStatusCodePages: true); + app.UseRateLimiter(); + app.UseAuthentication(); + app.UseAuthorization(); app.UseAntiforgery(); MapStaticAssets(app); app.MapHealthChecks("/health"); - app.MapRazorComponents().AddInteractiveServerRenderMode(); + + // Every admin page needs a signed-in user unless it allows anonymous access itself (setup, sign-in, not found). + app.MapRazorComponents() + .AddInteractiveServerRenderMode() + .RequireAuthorization() + .RateLimitCredentialForms(); return app; } + private static void AddCookieSignIn(IServiceCollection services) + { + services.AddAuthentication(IdentityConstants.ApplicationScheme).AddIdentityCookies(); + services.ConfigureApplicationCookie(options => + { + options.LoginPath = AdminRoutes.SignIn; + options.LogoutPath = AdminRoutes.SignOut; + }); + new IdentityBuilder(typeof(FluentCmsUser), typeof(FluentCmsRole), services).AddSignInManager(); + services.AddAuthorization(); + services.AddScoped(); + } + // The manifest is named after the entry assembly, which is the test host under xunit; without it the files are served unfingerprinted. private static void MapStaticAssets(WebApplication app) { @@ -67,6 +97,7 @@ private static void MapStaticAssets(WebApplication app) public static IHost BuildMcpHost(string[] args, Stream? standardInput = null, Action? configure = null) { var builder = Microsoft.Extensions.Hosting.Host.CreateApplicationBuilder(args); + configure?.Invoke(builder); ConfigureLogging(builder.Logging, logToStandardError: true); AddFluentCms(builder.Services, builder.Configuration, builder.Environment); @@ -75,7 +106,6 @@ public static IHost BuildMcpHost(string[] args, Stream? standardInput = null, Ac : new StandardInputSource(() => standardInput)); builder.Services.AddHostedService(); builder.Services.AddScoped(); - configure?.Invoke(builder); return builder.Build(); } @@ -89,6 +119,12 @@ private static void AddFluentCms(IServiceCollection services, IConfiguration con services.AddFluentCmsApi(); services.AddFluentCmsAdmin(options => options.ShowcaseEnabled = environment.IsDevelopment()); + // Both modes share one key ring, so whatever the web host protects the stdio host can read. + var keyRing = configuration.GetSection(KeyRingOptions.SectionName).Get() ?? new KeyRingOptions(); + services.AddDataProtection() + .SetApplicationName("FluentCMS") + .PersistKeysToFileSystem(new DirectoryInfo(Path.Combine(environment.ContentRootPath, keyRing.KeysDirectory))); + services.Configure(configuration.GetSection(JobQueueOptions.SectionName)); services.AddHostedService(); } diff --git a/src/FluentCMS.Host/Identity/CookieUserSignIn.cs b/src/FluentCMS.Host/Identity/CookieUserSignIn.cs new file mode 100644 index 000000000..e59ec35ee --- /dev/null +++ b/src/FluentCMS.Host/Identity/CookieUserSignIn.cs @@ -0,0 +1,67 @@ +using System.Security.Claims; +using FluentCMS.Core.Audit; +using FluentCMS.Core.Identity; +using FluentCMS.Core.Persistence; +using FluentCMS.Core.Results; +using FluentCMS.Infrastructure.Persistence; +using Microsoft.AspNetCore.Identity; + +namespace FluentCMS.Host.Identity; + +// Signs people in with Identity's application cookie (ADR-0009). Each sign-in and sign-out is an audit event naming the user. +internal sealed class CookieUserSignIn( + SignInManager signInManager, + IUnitOfWork unitOfWork, + IAuditLog auditLog, + IHttpContextAccessor accessor) : IUserSignIn +{ + private const string SubjectType = "user"; + + private static readonly Result Failed = Result.Failure("sign_in_failed", "The email or password is incorrect, or the account is locked."); + + public async Task SignInAsync(string email, string password, CancellationToken cancellationToken = default) + { + var user = await signInManager.UserManager.FindByEmailAsync(email); + if (user is null) + { + // Hashing anyway makes an unknown email as slow as a wrong password, so the timing does not reveal which accounts exist. + signInManager.UserManager.PasswordHasher.HashPassword(new FluentCmsUser(), password); + return Failed; + } + + await using var scope = await unitOfWork.BeginAsync(cancellationToken); + + // A failure still commits, because the failed-attempt count it raised is what locks the account out. + var check = await signInManager.CheckPasswordSignInAsync(user, password, lockoutOnFailure: true); + if (!check.Succeeded) + { + await scope.CommitAsync(cancellationToken); + return Failed; + } + + // The password is verified, so the rest of this request acts as the user and the log names them as initiator. + var principal = await signInManager.CreateUserPrincipalAsync(user); + HttpContext.User = principal; + await auditLog.AppendAsync(new NewAuditEvent("user.signed_in") { SubjectType = SubjectType, SubjectId = user.Id }, cancellationToken); + await scope.CommitAsync(cancellationToken); + + // The cookie goes out only once the event has committed. + await signInManager.SignInWithClaimsAsync(user, isPersistent: false, [new Claim("amr", "pwd")]); + return Result.Success(); + } + + public async Task SignOutAsync(CancellationToken cancellationToken = default) + { + if (ClaimsInitiator.From(HttpContext.User) is UserInitiator user) + { + await using var scope = await unitOfWork.BeginAsync(cancellationToken); + await auditLog.AppendAsync(new NewAuditEvent("user.signed_out") { SubjectType = SubjectType, SubjectId = user.UserId }, cancellationToken); + await scope.CommitAsync(cancellationToken); + } + + await signInManager.SignOutAsync(); + } + + private HttpContext HttpContext => accessor.HttpContext + ?? throw new InvalidOperationException("Cookie sign-in runs inside an HTTP request."); +} diff --git a/src/FluentCMS.Host/Security/KeyRingOptions.cs b/src/FluentCMS.Host/Security/KeyRingOptions.cs new file mode 100644 index 000000000..d65ce684c --- /dev/null +++ b/src/FluentCMS.Host/Security/KeyRingOptions.cs @@ -0,0 +1,10 @@ +namespace FluentCMS.Host.Security; + +// Where Data Protection keeps the keys that protect cookies, anti-forgery tokens and, later, stored secrets (SEC-07). +public sealed class KeyRingOptions +{ + public const string SectionName = "DataProtection"; + + // Relative to the content root. It has to survive restarts and upgrades, and only the host's account should read it. + public string KeysDirectory { get; set; } = "data-protection-keys"; +} diff --git a/src/FluentCMS.Host/Security/SecurityHeaders.cs b/src/FluentCMS.Host/Security/SecurityHeaders.cs new file mode 100644 index 000000000..1c3e018de --- /dev/null +++ b/src/FluentCMS.Host/Security/SecurityHeaders.cs @@ -0,0 +1,29 @@ +namespace FluentCMS.Host.Security; + +internal static class SecurityHeaders +{ + // Everything from this origin and nothing inline: Blazor's script, the admin's module and the stylesheet are files (SEC-07). + // Blazor's default reconnect display injects a style element, which this blocks; it still works, unstyled, until the shell brings its own. + public const string ContentSecurityPolicy = + "default-src 'self'; base-uri 'self'; object-src 'none'; frame-ancestors 'none'; form-action 'self'; " + + "script-src 'self'; style-src 'self'; img-src 'self' data:; connect-src 'self'"; + + public static IApplicationBuilder UseSecurityHeaders(this IApplicationBuilder app) + { + return app.Use((context, next) => + { + // Set as the response starts, so status code pages and every endpoint get them, and the policy replaces + // the frame-ancestors-only header Blazor adds to interactive pages. + context.Response.OnStarting(() => + { + var headers = context.Response.Headers; + headers.ContentSecurityPolicy = ContentSecurityPolicy; + headers.XContentTypeOptions = "nosniff"; + headers["Referrer-Policy"] = "same-origin"; + return Task.CompletedTask; + }); + + return next(context); + }); + } +} diff --git a/src/FluentCMS.Host/Security/SignInRateLimit.cs b/src/FluentCMS.Host/Security/SignInRateLimit.cs new file mode 100644 index 000000000..a842e48bd --- /dev/null +++ b/src/FluentCMS.Host/Security/SignInRateLimit.cs @@ -0,0 +1,78 @@ +using System.Globalization; +using System.Threading.RateLimiting; +using FluentCMS.Admin; +using Microsoft.AspNetCore.RateLimiting; +using Microsoft.Extensions.Localization; +using Microsoft.Extensions.Options; + +namespace FluentCMS.Host.Security; + +public sealed class SignInRateLimitOptions +{ + public const string SectionName = "SignInRateLimit"; + + public int PermitLimit { get; set; } = 10; + + public TimeSpan Window { get; set; } = TimeSpan.FromMinutes(1); +} + +// Limits the posts to pages that take a password, per client address (SEC-07). Identity's lockout limits attempts per account on top. +internal static class SignInRateLimit +{ + private const string PolicyName = "sign-in"; + + public static IServiceCollection AddSignInRateLimit(this IServiceCollection services, IConfiguration configuration) + { + services.Configure(configuration.GetSection(SignInRateLimitOptions.SectionName)); + services.AddRateLimiter(options => + { + options.RejectionStatusCode = StatusCodes.Status429TooManyRequests; + options.AddPolicy(PolicyName, Partition); + options.OnRejected = RejectAsync; + }); + + return services; + } + + public static TBuilder RateLimitCredentialForms(this TBuilder builder) + where TBuilder : IEndpointConventionBuilder + { + builder.Add(endpoint => + { + if (endpoint.Metadata.OfType().Any()) + { + endpoint.Metadata.Add(new EnableRateLimitingAttribute(PolicyName)); + } + }); + + return builder; + } + + // Showing the form is never limited, only submitting it. + private static RateLimitPartition Partition(HttpContext context) + { + if (!HttpMethods.IsPost(context.Request.Method)) + { + return RateLimitPartition.GetNoLimiter("*"); + } + + var options = context.RequestServices.GetRequiredService>().Value; + return RateLimitPartition.GetFixedWindowLimiter( + context.Connection.RemoteIpAddress?.ToString() ?? "unknown", + _ => new FixedWindowRateLimiterOptions { PermitLimit = options.PermitLimit, Window = options.Window, QueueLimit = 0 }); + } + + // A body of its own, so the status code page does not answer a throttled post with "Not found". + private static async ValueTask RejectAsync(OnRejectedContext context, CancellationToken cancellationToken) + { + var response = context.HttpContext.Response; + if (context.Lease.TryGetMetadata(MetadataName.RetryAfter, out var retryAfter)) + { + response.Headers.RetryAfter = ((int)Math.Ceiling(retryAfter.TotalSeconds)).ToString(CultureInfo.InvariantCulture); + } + + var localizer = context.HttpContext.RequestServices.GetRequiredService>(); + response.ContentType = "text/plain; charset=utf-8"; + await response.WriteAsync(localizer["Too many attempts. Wait a moment and try again."], cancellationToken); + } +} diff --git a/src/FluentCMS.Infrastructure/Identity/FluentCmsClaimsPrincipalFactory.cs b/src/FluentCMS.Infrastructure/Identity/FluentCmsClaimsPrincipalFactory.cs new file mode 100644 index 000000000..34e8d13ac --- /dev/null +++ b/src/FluentCMS.Infrastructure/Identity/FluentCmsClaimsPrincipalFactory.cs @@ -0,0 +1,22 @@ +using System.Security.Claims; +using FluentCMS.Core.Identity; +using FluentCMS.Infrastructure.Persistence; +using Microsoft.AspNetCore.Identity; +using Microsoft.Extensions.Options; + +namespace FluentCMS.Infrastructure.Identity; + +// Adds the display name, so the initiator a signed-in user is recorded as carries their name rather than their email. +internal sealed class FluentCmsClaimsPrincipalFactory( + UserManager users, + RoleManager roles, + IOptions options) + : UserClaimsPrincipalFactory(users, roles, options) +{ + protected override async Task GenerateClaimsAsync(FluentCmsUser user) + { + var identity = await base.GenerateClaimsAsync(user); + identity.AddClaim(new Claim(FluentCmsClaimTypes.DisplayName, user.DisplayName)); + return identity; + } +} diff --git a/src/FluentCMS.Infrastructure/InfrastructureServiceCollectionExtensions.cs b/src/FluentCMS.Infrastructure/InfrastructureServiceCollectionExtensions.cs index 09e5b1fed..e699db3ad 100644 --- a/src/FluentCMS.Infrastructure/InfrastructureServiceCollectionExtensions.cs +++ b/src/FluentCMS.Infrastructure/InfrastructureServiceCollectionExtensions.cs @@ -55,7 +55,8 @@ private static void AddIdentity(IServiceCollection services) { services.AddIdentityCore(options => options.User.RequireUniqueEmail = true) .AddRoles() - .AddEntityFrameworkStores(); + .AddEntityFrameworkStores() + .AddClaimsPrincipalFactory(); services.AddScoped(); services.AddScoped(); diff --git a/tests/FluentCMS.IntegrationTests/AdminHost.cs b/tests/FluentCMS.IntegrationTests/AdminHost.cs new file mode 100644 index 000000000..60ddbe74c --- /dev/null +++ b/tests/FluentCMS.IntegrationTests/AdminHost.cs @@ -0,0 +1,147 @@ +using System.Net; +using System.Text.RegularExpressions; +using FluentCMS.Core.Identity; +using FluentCMS.Host; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; + +namespace FluentCMS.IntegrationTests; + +// What an install keeps between restarts: its database and its Data Protection key ring. +internal sealed class TemporaryInstall : IDisposable +{ + private readonly TemporarySqliteDatabase _database = new(); + + public string KeysDirectory { get; } = Path.Combine(Path.GetTempPath(), $"fluentcms-keys-{Guid.NewGuid():N}"); + + public Dictionary Configuration => new(_database.Configuration) + { + ["DataProtection:KeysDirectory"] = KeysDirectory, + }; + + public void Dispose() + { + _database.Dispose(); + + if (Directory.Exists(KeysDirectory)) + { + Directory.Delete(KeysDirectory, recursive: true); + } + } +} + +// The web host on a free port, with a client that keeps cookies like a browser and does not follow redirects. +internal sealed partial class AdminHost : IAsyncDisposable +{ + public const string AuthCookie = ".AspNetCore.Identity.Application"; + + public static readonly NewUser Owner = new("owner@example.com", "Owner", "Str0ng!Passw0rd"); + + private AdminHost(WebApplication app, CookieContainer cookies) + { + App = app; + Cookies = cookies; + Client = new HttpClient(new HttpClientHandler { AllowAutoRedirect = false, CookieContainer = cookies }) + { + BaseAddress = new Uri(app.Urls.Single()), + }; + } + + public WebApplication App { get; } + + public CookieContainer Cookies { get; } + + public HttpClient Client { get; } + + public bool IsSignedIn => Cookies.GetCookies(Client.BaseAddress!).Any(cookie => cookie.Name == AuthCookie); + + public static async Task StartAsync( + TemporaryInstall install, + string[]? args = null, + IDictionary? settings = null, + CookieContainer? cookies = null) + { + var app = FluentCmsHost.BuildWebApplication(args ?? [], builder => + { + builder.WebHost.UseUrls("http://127.0.0.1:0"); + builder.Configuration.AddInMemoryCollection(install.Configuration); + builder.Configuration.AddInMemoryCollection(settings ?? new Dictionary()); + }); + + await app.StartAsync(); + return new AdminHost(app, cookies ?? new CookieContainer()); + } + + public async Task CreateFirstAdministratorAsync(NewUser? user = null) + { + await using var scope = App.Services.CreateAsyncScope(); + var created = await scope.ServiceProvider.GetRequiredService().CreateFirstAdministratorAsync(user ?? Owner); + return created.Value; + } + + // Fetches the page with the form first, as a browser would, for the anti-forgery cookie and the token that goes with it. + public async Task SubmitAsync(string path, string handler, IReadOnlyDictionary fields, string? from = null) + { + var page = await Client.GetStringAsync(from ?? path); + var token = AntiforgeryToken().Match(page); + Assert.True(token.Success, $"{from ?? path} has no anti-forgery token."); + + var form = new Dictionary(fields) + { + ["__RequestVerificationToken"] = WebUtility.HtmlDecode(token.Groups[1].Value), + ["_handler"] = handler, + }; + + return await Client.PostAsync(path, new FormUrlEncodedContent(form)); + } + + public Task SignInAsync(NewUser user, string path = "/sign-in") + { + return SignInAsync(user.Email, user.Password, path); + } + + public Task SignInAsync(string email, string password, string path = "/sign-in") + { + return SubmitAsync(path, "sign-in", new Dictionary + { + ["Form.Email"] = email, + ["Form.Password"] = password, + }); + } + + public string? LocationOf(HttpResponseMessage response) + { + return response.Headers.Location is { } location + ? new Uri(Client.BaseAddress!, location).PathAndQuery + : null; + } + + // Follows redirects the way a browser would and returns the path it ends on. + public async Task<(string Path, HttpResponseMessage Response)> FollowAsync(string path) + { + for (var hops = 0; hops < 10; hops++) + { + var response = await Client.GetAsync(path); + if (LocationOf(response) is not { } next) + { + return (path, response); + } + + path = next; + } + + throw new InvalidOperationException($"Too many redirects from {path}."); + } + + public async ValueTask DisposeAsync() + { + Client.Dispose(); + await App.StopAsync(); + await App.DisposeAsync(); + } + + [GeneratedRegex("name=\"__RequestVerificationToken\" value=\"([^\"]+)\"")] + private static partial Regex AntiforgeryToken(); +} diff --git a/tests/FluentCMS.IntegrationTests/HostTests.cs b/tests/FluentCMS.IntegrationTests/HostTests.cs index b87b300a3..3a454a9d1 100644 --- a/tests/FluentCMS.IntegrationTests/HostTests.cs +++ b/tests/FluentCMS.IntegrationTests/HostTests.cs @@ -14,11 +14,11 @@ public sealed class HostTests [Fact] public async Task Web_host_answers_the_health_endpoint_and_runs_the_job_queue() { - using var database = new TemporarySqliteDatabase(); + using var install = new TemporaryInstall(); var app = FluentCmsHost.BuildWebApplication([], builder => { builder.WebHost.UseUrls("http://127.0.0.1:0"); - builder.Configuration.AddInMemoryCollection(database.Configuration); + builder.Configuration.AddInMemoryCollection(install.Configuration); }); await using (app) @@ -40,66 +40,46 @@ public async Task Web_host_answers_the_health_endpoint_and_runs_the_job_queue() [Fact] public async Task Web_host_serves_the_admin_shell_and_the_showcase_only_in_development() { - using var database = new TemporarySqliteDatabase(); - var app = FluentCmsHost.BuildWebApplication(["--environment", "Development"], builder => - { - builder.WebHost.UseUrls("http://127.0.0.1:0"); - builder.Configuration.AddInMemoryCollection(database.Configuration); - }); - - await using (app) - { - await app.StartAsync(); - using var client = new HttpClient { BaseAddress = new Uri(app.Urls.Single()) }; - - var home = await client.GetAsync("/"); - var homeHtml = await home.Content.ReadAsStringAsync(); - var rtl = await client.GetStringAsync("/?dir=rtl&theme=dark"); - var showcase = await client.GetAsync("/ui-spike"); - - Assert.Equal(HttpStatusCode.OK, home.StatusCode); - Assert.Contains(" - { - builder.WebHost.UseUrls("http://127.0.0.1:0"); - builder.Configuration.AddInMemoryCollection(database.Configuration); - }); - - await using (app) - { - await app.StartAsync(); - using var client = new HttpClient { BaseAddress = new Uri(app.Urls.Single()) }; + using var install = new TemporaryInstall(); + await using var host = await AdminHost.StartAsync(install); + await host.CreateFirstAdministratorAsync(); + await host.SignInAsync(AdminHost.Owner); - var showcase = await client.GetAsync("/ui-spike"); + var showcase = await host.Client.GetAsync("/ui-spike"); - Assert.Equal(HttpStatusCode.NotFound, showcase.StatusCode); - - await app.StopAsync(); - } + Assert.Equal(HttpStatusCode.NotFound, showcase.StatusCode); } [Fact] public async Task Mcp_host_has_no_web_server_and_stops_when_standard_input_closes() { - using var database = new TemporarySqliteDatabase(); + using var install = new TemporaryInstall(); using var host = FluentCmsHost.BuildMcpHost( [], standardInput: Stream.Null, - configure: builder => builder.Configuration.AddInMemoryCollection(database.Configuration)); + configure: builder => builder.Configuration.AddInMemoryCollection(install.Configuration)); Assert.Null(host.Services.GetService()); Assert.Contains(host.Services.GetServices(), service => service is JobQueueWorker); diff --git a/tests/FluentCMS.IntegrationTests/Identity/SignInTests.cs b/tests/FluentCMS.IntegrationTests/Identity/SignInTests.cs new file mode 100644 index 000000000..fc2f8decb --- /dev/null +++ b/tests/FluentCMS.IntegrationTests/Identity/SignInTests.cs @@ -0,0 +1,202 @@ +using System.Net; +using FluentCMS.Core.Identity; +using FluentCMS.Infrastructure.Audit; +using FluentCMS.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; + +namespace FluentCMS.IntegrationTests.Identity; + +public sealed class SignInTests +{ + private static readonly NewUser Editor = new("editor@example.com", "Editor", "An0ther!Passw0rd"); + + [Fact] + public async Task A_fresh_install_leads_every_admin_route_to_setup_until_the_first_administrator_exists() + { + using var install = new TemporaryInstall(); + await using var host = await AdminHost.StartAsync(install, ["--environment", "Development"]); + + foreach (var route in new[] { "/", "/ui-spike", "/sign-in", "/sign-out" }) + { + var (path, response) = await host.FollowAsync(route); + Assert.Equal("/setup", path); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } + + var created = await host.SubmitAsync("/setup", "setup", new Dictionary + { + ["Form.DisplayName"] = AdminHost.Owner.DisplayName, + ["Form.Email"] = AdminHost.Owner.Email, + ["Form.Password"] = AdminHost.Owner.Password, + ["Form.ConfirmPassword"] = AdminHost.Owner.Password, + }); + + Assert.Equal(HttpStatusCode.Redirect, created.StatusCode); + Assert.Equal("/", host.LocationOf(created)); + Assert.True(host.IsSignedIn); + + var home = await host.Client.GetAsync("/"); + Assert.Equal(HttpStatusCode.OK, home.StatusCode); + Assert.Contains(">Owner", await home.Content.ReadAsStringAsync()); + + var setupAgain = await host.Client.GetAsync("/setup"); + Assert.Equal("/sign-in", host.LocationOf(setupAgain)); + } + + [Fact] + public async Task Setup_reports_problems_next_to_the_fields_and_creates_nobody() + { + using var install = new TemporaryInstall(); + await using var host = await AdminHost.StartAsync(install); + + var mismatch = await host.SubmitAsync("/setup", "setup", new Dictionary + { + ["Form.DisplayName"] = "Owner", + ["Form.Email"] = "owner@example.com", + ["Form.Password"] = "Str0ng!Passw0rd", + ["Form.ConfirmPassword"] = "Different!Passw0rd", + }); + var weak = await host.SubmitAsync("/setup", "setup", new Dictionary + { + ["Form.DisplayName"] = "Owner", + ["Form.Email"] = "owner@example.com", + ["Form.Password"] = "short", + ["Form.ConfirmPassword"] = "short", + }); + + var mismatchHtml = await mismatch.Content.ReadAsStringAsync(); + Assert.Equal(HttpStatusCode.OK, mismatch.StatusCode); + Assert.Contains("The passwords do not match.", mismatchHtml); + Assert.Contains("aria-invalid=\"true\"", mismatchHtml); + Assert.Contains("value=\"owner@example.com\"", mismatchHtml); + + Assert.Equal(HttpStatusCode.OK, weak.StatusCode); + Assert.Contains("Passwords must be at least", await weak.Content.ReadAsStringAsync()); + Assert.False(host.IsSignedIn); + + await using var scope = host.App.Services.CreateAsyncScope(); + Assert.True(await scope.ServiceProvider.GetRequiredService().NeedsFirstAdministratorAsync()); + } + + [Fact] + public async Task An_anonymous_request_is_sent_to_sign_in_and_returns_to_the_route_it_asked_for() + { + using var install = new TemporaryInstall(); + await using var host = await AdminHost.StartAsync(install, ["--environment", "Development"]); + await host.CreateFirstAdministratorAsync(); + + var anonymous = await host.Client.GetAsync("/ui-spike"); + Assert.Equal(HttpStatusCode.Redirect, anonymous.StatusCode); + Assert.Equal("/sign-in?ReturnUrl=%2Fui-spike", host.LocationOf(anonymous)); + + var signedIn = await host.SignInAsync(AdminHost.Owner, host.LocationOf(anonymous)!); + + Assert.Equal(HttpStatusCode.Redirect, signedIn.StatusCode); + Assert.Equal("/ui-spike", host.LocationOf(signedIn)); + Assert.Equal(HttpStatusCode.OK, (await host.Client.GetAsync("/ui-spike")).StatusCode); + } + + [Theory] + [InlineData("https%3A%2F%2Fevil.example%2F")] + [InlineData("%2F%2Fevil.example")] + [InlineData("%2F%5Cevil.example")] + public async Task A_return_url_to_another_site_lands_on_the_admin_home(string returnUrl) + { + using var install = new TemporaryInstall(); + await using var host = await AdminHost.StartAsync(install); + await host.CreateFirstAdministratorAsync(); + + var signedIn = await host.SignInAsync(AdminHost.Owner, "/sign-in?ReturnUrl=" + returnUrl); + + Assert.Equal(HttpStatusCode.Redirect, signedIn.StatusCode); + Assert.Equal("/", host.LocationOf(signedIn)); + } + + [Fact] + public async Task A_wrong_password_an_unknown_email_and_a_disabled_account_fail_alike_and_issue_no_cookie() + { + using var install = new TemporaryInstall(); + await using var host = await AdminHost.StartAsync(install); + await host.CreateFirstAdministratorAsync(); + await using (var scope = host.App.Services.CreateAsyncScope()) + { + var directory = scope.ServiceProvider.GetRequiredService(); + var editor = await directory.CreateAsync(Editor); + await directory.DisableAsync(editor.Value); + } + + var attempts = new[] + { + await host.SignInAsync(AdminHost.Owner.Email, "Wr0ng!Passw0rd"), + await host.SignInAsync("nobody@example.com", AdminHost.Owner.Password), + await host.SignInAsync(Editor), + }; + + foreach (var attempt in attempts) + { + Assert.Equal(HttpStatusCode.OK, attempt.StatusCode); + Assert.Contains("data-testid=\"sign-in-error\"", await attempt.Content.ReadAsStringAsync()); + } + + Assert.False(host.IsSignedIn); + Assert.Equal(HttpStatusCode.Redirect, (await host.Client.GetAsync("/")).StatusCode); + } + + [Fact] + public async Task Repeated_wrong_passwords_lock_the_account_even_for_the_right_one() + { + using var install = new TemporaryInstall(); + await using var host = await AdminHost.StartAsync(install); + await host.CreateFirstAdministratorAsync(); + + // Identity's default lockout is five failed attempts. + for (var attempt = 0; attempt < 5; attempt++) + { + await host.SignInAsync(AdminHost.Owner.Email, "Wr0ng!Passw0rd"); + } + + var locked = await host.SignInAsync(AdminHost.Owner); + + Assert.Equal(HttpStatusCode.OK, locked.StatusCode); + Assert.Contains("data-testid=\"sign-in-error\"", await locked.Content.ReadAsStringAsync()); + Assert.False(host.IsSignedIn); + } + + [Fact] + public async Task Signing_in_and_out_is_audited_with_the_user_as_initiator() + { + using var install = new TemporaryInstall(); + await using var host = await AdminHost.StartAsync(install); + var ownerId = await host.CreateFirstAdministratorAsync(); + + var signedIn = await host.SignInAsync(AdminHost.Owner); + Assert.Equal("/", host.LocationOf(signedIn)); + + // The sign-out button in the main layout posts to the sign-out page's handler. + var home = await host.Client.GetStringAsync("/"); + Assert.Contains("action=\"sign-out\"", home); + var signedOut = await host.SubmitAsync("/sign-out", "sign-out", new Dictionary(), from: "/"); + + Assert.Equal(HttpStatusCode.Redirect, signedOut.StatusCode); + Assert.Equal("/sign-in", host.LocationOf(signedOut)); + Assert.False(host.IsSignedIn); + Assert.Equal(HttpStatusCode.Redirect, (await host.Client.GetAsync("/")).StatusCode); + + await using var scope = host.App.Services.CreateAsyncScope(); + var rows = (await scope.ServiceProvider.GetRequiredService().AuditEvents.ToListAsync()) + .OrderBy(row => row.Timestamp) + .ToList(); + + Assert.Equal(["user.signed_in", "user.signed_out"], rows.Select(row => row.Operation)); + Assert.All(rows, row => + { + Assert.Equal(InitiatorKinds.User, row.InitiatorKind); + Assert.Equal(ownerId, row.InitiatorId); + Assert.Equal(AdminHost.Owner.DisplayName, row.InitiatorName); + Assert.Null(row.AgentCredentialId); + Assert.Equal("user", row.SubjectType); + Assert.Equal(ownerId, row.SubjectId); + }); + } +} diff --git a/tests/FluentCMS.IntegrationTests/WebHardeningTests.cs b/tests/FluentCMS.IntegrationTests/WebHardeningTests.cs new file mode 100644 index 000000000..943fa22b1 --- /dev/null +++ b/tests/FluentCMS.IntegrationTests/WebHardeningTests.cs @@ -0,0 +1,118 @@ +using System.Net; +using System.Text.RegularExpressions; + +namespace FluentCMS.IntegrationTests; + +public sealed partial class WebHardeningTests +{ + [Fact] + public async Task A_form_post_without_a_valid_antiforgery_token_is_rejected() + { + using var install = new TemporaryInstall(); + await using var host = await AdminHost.StartAsync(install); + await host.CreateFirstAdministratorAsync(); + + var signIn = await host.Client.PostAsync("/sign-in", new FormUrlEncodedContent(new Dictionary + { + ["_handler"] = "sign-in", + ["Form.Email"] = AdminHost.Owner.Email, + ["Form.Password"] = AdminHost.Owner.Password, + })); + + Assert.Equal(HttpStatusCode.BadRequest, signIn.StatusCode); + Assert.False(host.IsSignedIn); + + await host.SignInAsync(AdminHost.Owner); + var signOut = await host.Client.PostAsync("/sign-out", new FormUrlEncodedContent(new Dictionary + { + ["_handler"] = "sign-out", + ["__RequestVerificationToken"] = "forged", + })); + + Assert.Equal(HttpStatusCode.BadRequest, signOut.StatusCode); + Assert.True(host.IsSignedIn); + } + + [Fact] + public async Task Sign_in_posts_beyond_the_limit_are_throttled_while_the_page_stays_reachable() + { + using var install = new TemporaryInstall(); + await using var host = await AdminHost.StartAsync(install, settings: new Dictionary + { + ["SignInRateLimit:PermitLimit"] = "3", + ["SignInRateLimit:Window"] = "00:01:00", + }); + await host.CreateFirstAdministratorAsync(); + + for (var attempt = 0; attempt < 3; attempt++) + { + Assert.Equal(HttpStatusCode.OK, (await host.SignInAsync(AdminHost.Owner.Email, "Wr0ng!Passw0rd")).StatusCode); + } + + var throttled = await host.SignInAsync(AdminHost.Owner); + + Assert.Equal(HttpStatusCode.TooManyRequests, throttled.StatusCode); + Assert.NotNull(throttled.Headers.RetryAfter); + Assert.Contains("Too many attempts", await throttled.Content.ReadAsStringAsync()); + Assert.False(host.IsSignedIn); + Assert.Equal(HttpStatusCode.OK, (await host.Client.GetAsync("/sign-in")).StatusCode); + } + + [Fact] + public async Task Every_response_carries_the_content_security_policy_and_the_sign_in_page_loads_its_assets_anonymously() + { + using var install = new TemporaryInstall(); + await using var host = await AdminHost.StartAsync(install); + await host.CreateFirstAdministratorAsync(); + + var signIn = await host.Client.GetAsync("/sign-in"); + var html = await signIn.Content.ReadAsStringAsync(); + var assets = AssetReference().Matches(html).Select(match => "/" + match.Groups[1].Value).ToList(); + var responses = new List { signIn, await host.Client.GetAsync("/health"), await host.Client.GetAsync("/no-such-page") }; + + Assert.Contains(assets, asset => asset.Contains("app.generated")); + Assert.Contains(assets, asset => asset.Contains("blazor.web")); + foreach (var asset in assets) + { + var response = await host.Client.GetAsync(asset); + Assert.True(response.StatusCode == HttpStatusCode.OK, $"{asset} answered {response.StatusCode}."); + responses.Add(response); + } + + Assert.Equal(HttpStatusCode.NotFound, responses[2].StatusCode); + Assert.All(responses, response => + { + var policy = Assert.Single(response.Headers.GetValues("Content-Security-Policy")); + Assert.Contains("default-src 'self'", policy); + Assert.Contains("script-src 'self'", policy); + Assert.Contains("frame-ancestors 'none'", policy); + Assert.Contains("object-src 'none'", policy); + Assert.Equal("nosniff", Assert.Single(response.Headers.GetValues("X-Content-Type-Options"))); + }); + } + + [Fact] + public async Task A_cookie_issued_before_a_restart_is_still_valid_after_it() + { + using var install = new TemporaryInstall(); + var cookies = new System.Net.CookieContainer(); + + await using (var before = await AdminHost.StartAsync(install, cookies: cookies)) + { + await before.CreateFirstAdministratorAsync(); + await before.SignInAsync(AdminHost.Owner); + Assert.True(before.IsSignedIn); + } + + Assert.NotEmpty(Directory.GetFiles(install.KeysDirectory, "key-*.xml")); + + await using var after = await AdminHost.StartAsync(install, cookies: cookies); + var home = await after.Client.GetAsync("/"); + + Assert.Equal(HttpStatusCode.OK, home.StatusCode); + Assert.Contains(">Owner", await home.Content.ReadAsStringAsync()); + } + + [GeneratedRegex("(?:href|src)=\"(_(?:content|framework)/[^\"]+)\"")] + private static partial Regex AssetReference(); +} diff --git a/tests/FluentCMS.Tests/Admin/AdminRoutesTests.cs b/tests/FluentCMS.Tests/Admin/AdminRoutesTests.cs new file mode 100644 index 000000000..45bb0f05a --- /dev/null +++ b/tests/FluentCMS.Tests/Admin/AdminRoutesTests.cs @@ -0,0 +1,29 @@ +using FluentCMS.Admin; + +namespace FluentCMS.Tests.Admin; + +public sealed class AdminRoutesTests +{ + [Theory] + [InlineData("/")] + [InlineData("/ui-spike")] + [InlineData("/entries?type=page&page=2")] + public void A_path_on_this_site_is_local(string url) + { + Assert.True(AdminRoutes.IsLocal(url)); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData("https://evil.example/")] + [InlineData("//evil.example")] + [InlineData("/\\evil.example")] + [InlineData("evil.example")] + [InlineData("javascript:alert(1)")] + [InlineData("/\r\nLocation: https://evil.example")] + public void Anything_that_can_leave_the_site_is_not(string? url) + { + Assert.False(AdminRoutes.IsLocal(url)); + } +} From a55c6527f1d4a68296b1da66e5928c2e405eeadb Mon Sep 17 00:00:00 2001 From: Amir Pournasserian Date: Sun, 18 Oct 2026 11:10:52 -0400 Subject: [PATCH 3/3] Serve the generated stylesheet after a clean build --- src/FluentCMS.Admin/Tailwind.targets | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/FluentCMS.Admin/Tailwind.targets b/src/FluentCMS.Admin/Tailwind.targets index 27acf0563..763ede70d 100644 --- a/src/FluentCMS.Admin/Tailwind.targets +++ b/src/FluentCMS.Admin/Tailwind.targets @@ -10,7 +10,8 @@ 4.3.3 $(MSBuildProjectDirectory)/Styles/app.css - $(MSBuildProjectDirectory)/wwwroot/css/app.generated.css + <_TailwindOutputRelativePath>wwwroot\css\app.generated.css + $(MSBuildProjectDirectory)/$(_TailwindOutputRelativePath) $(LocalAppData)/FluentCMS/tailwind $(HOME)/.cache/fluentcms/tailwind @@ -71,11 +72,12 @@ - + - - + +