Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,8 @@ To run the host, `dotnet run --project src/FluentCMS.Host` starts the web host o

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.

People can also sign in through one OpenID Connect provider (Keycloak, Authentik, Microsoft Entra, Google and similar). Set `Authentication__OpenIdConnect__Authority`, `Authentication__OpenIdConnect__ClientId` and `Authentication__OpenIdConnect__ClientSecret`, optionally `Authentication__OpenIdConnect__DisplayName` for the button, and register `https://<host>/signin-oidc` as the redirect URI at the provider. Without an authority and client id the option does not appear. Users are not created from the provider: the first sign-in links the provider's account to the user with the same email, and only if the provider marks the email as verified. Later sign-ins match on the provider's issuer and subject. A provider that never sends `email_verified`, such as Microsoft Entra, needs `Authentication__OpenIdConnect__AssumeVerifiedEmail=true`; set it only if that provider issues verified addresses alone.

## 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.
Expand Down
1 change: 1 addition & 0 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
</PropertyGroup>

<ItemGroup Label="Runtime">
<PackageVersion Include="Microsoft.AspNetCore.Authentication.OpenIdConnect" Version="10.0.13" />
<PackageVersion Include="Microsoft.AspNetCore.Components.Web" Version="10.0.13" />
<PackageVersion Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="10.0.13" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.13" />
Expand Down
4 changes: 4 additions & 0 deletions src/FluentCMS.Admin/AdminRoutes.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@ public static class AdminRoutes
public const string Setup = "/setup";
public const string SignIn = "/sign-in";
public const string SignOut = "/sign-out";
public const string ExternalSignIn = "/sign-in/external";

// Not a page: the host's endpoint that sends the browser to the external provider.
public const string ExternalSignInStart = "/sign-in/external/start";

// 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)
Expand Down
33 changes: 33 additions & 0 deletions src/FluentCMS.Admin/Components/Pages/ExternalSignIn.razor
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
@page "/sign-in/external"
@layout AccountLayout
@attribute [AllowAnonymous]
@inject IUserSignIn UserSignIn
@inject NavigationManager Navigation
@inject IStringLocalizer<ExternalSignIn> L

@* Where the browser returns from the external provider. A success moves on at once; only a refusal renders. *@
<PageTitle>@L["Sign-in did not work"]</PageTitle>

<Card data-testid="external-sign-in-error">
<CardHeader>
<CardTitle><h1>@L["Sign-in did not work"]</h1></CardTitle>
<CardDescription>@L["Your account at the identity provider needs a verified email that belongs to an active FluentCMS user. Ask an administrator to add you, then try again."]</CardDescription>
</CardHeader>
<CardContent>
<a href="sign-in" class="@ButtonVariants.Classes("outline", className: "w-full")">@L["Back to sign in"]</a>
</CardContent>
</Card>

@code {
[SupplyParameterFromQuery]
private string? ReturnUrl { get; set; }

protected override async Task OnInitializedAsync()
{
var result = await UserSignIn.SignInExternalAsync();
if (result.IsSuccess)
{
Navigation.NavigateTo(AdminRoutes.IsLocal(ReturnUrl) ? ReturnUrl! : "");
}
}
}
21 changes: 20 additions & 1 deletion src/FluentCMS.Admin/Components/Pages/SignIn.razor
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
<CardTitle><h1>@L["Sign in"]</h1></CardTitle>
<CardDescription>@L["Enter your email and password to sign in to the admin."]</CardDescription>
</CardHeader>
<CardContent>
<CardContent Class="grid gap-6">
<form method="post" @onsubmit="SubmitAsync" @formname="sign-in" class="grid gap-6">
<AntiforgeryToken />
@if (_failed)
Expand All @@ -29,11 +29,28 @@
</FormField>
<Button Type="submit" Class="w-full">@L["Sign in"]</Button>
</form>
@if (_providers.Count > 0)
{
<div class="relative text-center text-sm after:absolute after:inset-0 after:top-1/2 after:z-0 after:flex after:items-center after:border-t after:border-border">
<span class="relative z-10 bg-card px-2 text-muted-foreground">@L["or"]</span>
</div>
@foreach (var provider in _providers)
{
@* A plain form: the host's endpoint starts the redirect to the provider, which a page cannot do. *@
<form method="post" action="sign-in/external/start" class="grid">
<AntiforgeryToken />
<input type="hidden" name="provider" value="@provider.Scheme" />
<input type="hidden" name="returnUrl" value="@(AdminRoutes.IsLocal(ReturnUrl) ? ReturnUrl : null)" />
<Button Type="submit" Variant="outline" Class="w-full">@L["Sign in with {0}", provider.DisplayName]</Button>
</form>
}
}
</CardContent>
</Card>

@code {
private bool _failed;
private IReadOnlyList<ExternalProvider> _providers = [];

[SupplyParameterFromForm(FormName = "sign-in")]
private SignInForm Form { get; set; } = default!;
Expand All @@ -50,6 +67,8 @@
{
Navigation.NavigateTo("setup");
}

_providers = await UserSignIn.GetExternalProvidersAsync();
}

private async Task SubmitAsync()
Expand Down
9 changes: 9 additions & 0 deletions src/FluentCMS.Core/Identity/IUserSignIn.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,13 @@ public interface IUserSignIn
Task<Result> SignInAsync(string email, string password, CancellationToken cancellationToken = default);

Task SignOutAsync(CancellationToken cancellationToken = default);

// The external identity providers the owner configured; empty when there are none (P10).
Task<IReadOnlyList<ExternalProvider>> GetExternalProvidersAsync(CancellationToken cancellationToken = default);

// Completes the sign-in an external provider has just vouched for. There is no self-registration in M1:
// an identity that matches no user, by an earlier link or by a verified email, fails and nothing is linked.
Task<Result> SignInExternalAsync(CancellationToken cancellationToken = default);
}

public sealed record ExternalProvider(string Scheme, string DisplayName);
4 changes: 4 additions & 0 deletions src/FluentCMS.Host/FluentCMS.Host.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -15,4 +15,8 @@
<ProjectReference Include="..\FluentCMS.Mcp\FluentCMS.Mcp.csproj" />
</ItemGroup>

<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Authentication.OpenIdConnect" />
</ItemGroup>

</Project>
35 changes: 31 additions & 4 deletions src/FluentCMS.Host/FluentCmsHost.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
using FluentCMS.Infrastructure.Persistence;
using FluentCMS.Mcp;
using Microsoft.AspNetCore.DataProtection;
using Microsoft.AspNetCore.Diagnostics;
using Microsoft.AspNetCore.Identity;
using Microsoft.Extensions.Logging.Console;

Expand Down Expand Up @@ -42,21 +43,23 @@ public static WebApplication BuildWebApplication(string[] args, Action<WebApplic
AddFluentCms(builder.Services, builder.Configuration, builder.Environment);
builder.Services.AddHttpContextAccessor();
builder.Services.AddScoped<ICurrentInitiator, HttpContextInitiator>();
AddCookieSignIn(builder.Services);
var provider = AddCookieSignIn(builder.Services, builder.Configuration, builder.Environment);
builder.Services.AddSignInRateLimit(builder.Configuration);
builder.Services.AddHealthChecks();
builder.Services.AddLocalization();
builder.Services.AddRazorComponents().AddInteractiveServerComponents();

var app = builder.Build();
app.UseSecurityHeaders();
app.UseSecurityHeaders(provider.IsConfigured ? [provider.AuthorityOrigin] : []);
app.UseStatusCodePagesWithReExecute("/not-found", createScopeForStatusCodePages: true);
KeepStatusCodePagesToPageLoads(app);
app.UseRateLimiter();
app.UseAuthentication();
app.UseAuthorization();
app.UseAntiforgery();
MapStaticAssets(app);
app.MapHealthChecks("/health");
app.MapExternalSignIn();

// Every admin page needs a signed-in user unless it allows anonymous access itself (setup, sign-in, not found).
app.MapRazorComponents<App>()
Expand All @@ -66,9 +69,15 @@ public static WebApplication BuildWebApplication(string[] args, Action<WebApplic
return app;
}

private static void AddCookieSignIn(IServiceCollection services)
private static OpenIdConnectProviderOptions AddCookieSignIn(IServiceCollection services, IConfiguration configuration, IHostEnvironment environment)
{
services.AddAuthentication(IdentityConstants.ApplicationScheme).AddIdentityCookies();
var providerSection = configuration.GetSection(OpenIdConnectProviderOptions.SectionName);
var provider = providerSection.Get<OpenIdConnectProviderOptions>() ?? new OpenIdConnectProviderOptions();
services.Configure<OpenIdConnectProviderOptions>(providerSection);

var authentication = services.AddAuthentication(IdentityConstants.ApplicationScheme);
authentication.AddIdentityCookies();
authentication.AddOpenIdConnectProvider(provider, environment);
services.ConfigureApplicationCookie(options =>
{
options.LoginPath = AdminRoutes.SignIn;
Expand All @@ -77,6 +86,24 @@ private static void AddCookieSignIn(IServiceCollection services)
new IdentityBuilder(typeof(FluentCmsUser), typeof(FluentCmsRole), services).AddSignInManager();
services.AddAuthorization();
services.AddScoped<IUserSignIn, CookieUserSignIn>();
return provider;
}

// The not-found page is for page loads. Re-running a rejected post against it would check the anti-forgery token
// a second time, which throws once the first check has failed, and would call the rejected form "not found".
private static void KeepStatusCodePagesToPageLoads(WebApplication app)
{
app.Use((context, next) =>
{
if (!HttpMethods.IsGet(context.Request.Method)
&& !HttpMethods.IsHead(context.Request.Method)
&& context.Features.Get<IStatusCodePagesFeature>() is { } statusCodePages)
{
statusCodePages.Enabled = false;
}

return next(context);
});
}

// The manifest is named after the entry assembly, which is the test host under xunit; without it the files are served unfingerprinted.
Expand Down
71 changes: 70 additions & 1 deletion src/FluentCMS.Host/Identity/CookieUserSignIn.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@
using FluentCMS.Core.Persistence;
using FluentCMS.Core.Results;
using FluentCMS.Infrastructure.Persistence;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Identity;
using Microsoft.Extensions.Options;

namespace FluentCMS.Host.Identity;

Expand All @@ -13,12 +15,17 @@ internal sealed class CookieUserSignIn(
SignInManager<FluentCmsUser> signInManager,
IUnitOfWork unitOfWork,
IAuditLog auditLog,
IHttpContextAccessor accessor) : IUserSignIn
IHttpContextAccessor accessor,
IOptions<OpenIdConnectProviderOptions> providerOptions) : 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.");

private static readonly Result ExternalFailed = Result.Failure(
"external_sign_in_failed",
"The identity provider's account matches no active user with the same verified email.");

public async Task<Result> SignInAsync(string email, string password, CancellationToken cancellationToken = default)
{
var user = await signInManager.UserManager.FindByEmailAsync(email);
Expand Down Expand Up @@ -62,6 +69,68 @@ public async Task SignOutAsync(CancellationToken cancellationToken = default)
await signInManager.SignOutAsync();
}

public async Task<IReadOnlyList<ExternalProvider>> GetExternalProvidersAsync(CancellationToken cancellationToken = default)
{
var schemes = await signInManager.GetExternalAuthenticationSchemesAsync();
return schemes.Select(scheme => new ExternalProvider(scheme.Name, scheme.DisplayName ?? scheme.Name)).ToList();
}

public async Task<Result> SignInExternalAsync(CancellationToken cancellationToken = default)
{
var info = await signInManager.GetExternalLoginInfoAsync();

// The external cookie has done its job whatever happens next.
await HttpContext.SignOutAsync(IdentityConstants.ExternalScheme);

if (info?.Principal.FindFirst(ClaimTypes.NameIdentifier) is not { } subject)
{
return ExternalFailed;
}

// A subject is unique only at the issuer that assigned it, so the link records both.
var login = new UserLoginInfo(subject.Issuer, subject.Value, info.ProviderDisplayName);
var users = signInManager.UserManager;
var user = await users.FindByLoginAsync(login.LoginProvider, login.ProviderKey);
var isNewLink = user is null;
if (user is null && VerifiedEmail(info.Principal) is { } email)
{
user = await users.FindByEmailAsync(email);
}

if (user is null || await users.IsLockedOutAsync(user) || !await signInManager.CanSignInAsync(user))
{
return ExternalFailed;
}

await using var scope = await unitOfWork.BeginAsync(cancellationToken);
if (isNewLink && !(await users.AddLoginAsync(user, login)).Succeeded)
{
return ExternalFailed;
}

HttpContext.User = await signInManager.CreateUserPrincipalAsync(user);
if (isNewLink)
{
await auditLog.AppendAsync(new NewAuditEvent("user.login_linked") { SubjectType = SubjectType, SubjectId = user.Id }, cancellationToken);
}

await auditLog.AppendAsync(new NewAuditEvent("user.signed_in") { SubjectType = SubjectType, SubjectId = user.Id }, cancellationToken);
await scope.CommitAsync(cancellationToken);

await signInManager.SignInWithClaimsAsync(user, isPersistent: false, [new Claim(ClaimTypes.AuthenticationMethod, info.LoginProvider)]);
return Result.Success();
}

// Linking by email is only as safe as the provider's word that the address belongs to the person signing in.
private string? VerifiedEmail(ClaimsPrincipal principal)
{
var email = principal.FindFirstValue(ClaimTypes.Email);
var verified = providerOptions.Value.AssumeVerifiedEmail
|| string.Equals(principal.FindFirstValue("email_verified"), "true", StringComparison.OrdinalIgnoreCase);

return verified && !string.IsNullOrWhiteSpace(email) ? email : null;
}

private HttpContext HttpContext => accessor.HttpContext
?? throw new InvalidOperationException("Cookie sign-in runs inside an HTTP request.");
}
65 changes: 65 additions & 0 deletions src/FluentCMS.Host/Identity/ExternalSignIn.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
using FluentCMS.Admin;
using FluentCMS.Infrastructure.Persistence;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.OpenIdConnect;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.IdentityModel.Protocols.OpenIdConnect;

namespace FluentCMS.Host.Identity;

internal static class ExternalSignIn
{
public static void AddOpenIdConnectProvider(this AuthenticationBuilder authentication, OpenIdConnectProviderOptions provider, IHostEnvironment environment)
{
if (!provider.IsConfigured)
{
return;
}

// Caught here rather than by the handler, which would fail every request once the host is running.
var isDevelopment = environment.IsDevelopment();
if (!Uri.TryCreate(provider.Authority, UriKind.Absolute, out var authority)
|| (authority.Scheme != Uri.UriSchemeHttps && !(isDevelopment && authority.Scheme == Uri.UriSchemeHttp)))
{
throw new InvalidOperationException(
$"{OpenIdConnectProviderOptions.SectionName}:Authority must be an absolute HTTPS URL; plain HTTP is accepted only in Development.");
}

// The provider's answer lands in Identity's external cookie; the admin page then signs the matching user in.
authentication.AddOpenIdConnect(OpenIdConnectProviderOptions.Scheme, provider.DisplayName, options =>
{
options.SignInScheme = IdentityConstants.ExternalScheme;
options.RequireHttpsMetadata = !isDevelopment;
options.Authority = provider.Authority;
options.ClientId = provider.ClientId;
options.ClientSecret = provider.ClientSecret;
options.ResponseType = OpenIdConnectResponseType.Code;
options.ResponseMode = OpenIdConnectResponseMode.FormPost;
options.UsePkce = true;
options.Scope.Add("email");
});
}

// The sign-in page's "Sign in with ..." form posts here; the form binding makes the anti-forgery token required.
public static void MapExternalSignIn(this IEndpointRouteBuilder endpoints)
{
endpoints.MapPost(AdminRoutes.ExternalSignInStart, async (
[FromForm] string provider,
[FromForm] string? returnUrl,
SignInManager<FluentCmsUser> signInManager) =>
{
var schemes = await signInManager.GetExternalAuthenticationSchemesAsync();
if (!schemes.Any(scheme => scheme.Name == provider))
{
return Results.NotFound();
}

var callback = AdminRoutes.IsLocal(returnUrl)
? $"{AdminRoutes.ExternalSignIn}?ReturnUrl={Uri.EscapeDataString(returnUrl!)}"
: AdminRoutes.ExternalSignIn;
var properties = signInManager.ConfigureExternalAuthenticationProperties(provider, callback);
return Results.Challenge(properties, [provider]);
});
}
}
25 changes: 25 additions & 0 deletions src/FluentCMS.Host/Identity/OpenIdConnectProviderOptions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
namespace FluentCMS.Host.Identity;

// The one external identity provider an owner can configure (USR-01). Off until the authority and client id are set (P10).
public sealed class OpenIdConnectProviderOptions
{
public const string SectionName = "Authentication:OpenIdConnect";
public const string Scheme = "oidc";

public string? Authority { get; set; }

public string? ClientId { get; set; }

// From the environment or a secret store, never from a file in the repository.
public string? ClientSecret { get; set; }

public string DisplayName { get; set; } = "single sign-on";

// For providers that never send email_verified (Microsoft Entra). Only safe when the provider issues verified addresses alone,
// because the first sign-in links to the user with the same email.
public bool AssumeVerifiedEmail { get; set; }

public bool IsConfigured => !string.IsNullOrWhiteSpace(Authority) && !string.IsNullOrWhiteSpace(ClientId);

public string AuthorityOrigin => new Uri(Authority!).GetLeftPart(UriPartial.Authority);
}
Loading
Loading