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. *@
+
+ @L["This account signs in to the admin and can add the rest of the team."]
+
+
+
+
+
+
+@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."]
+
+
+
+
+
+
+@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/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/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.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 @@
-
+
-
-
+
+
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));
+ }
+}
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);
+ }
+}