This commit is contained in:
2026-06-02 15:46:13 +02:00
25 changed files with 951 additions and 215 deletions
+61 -16
View File
@@ -2,6 +2,8 @@ using System.Security.Claims;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Authentication.OpenIdConnect;
using Microsoft.AspNetCore.Antiforgery;
using Microsoft.AspNetCore.Identity;
using Microsoft.Extensions.Options;
using Microsoft.IdentityModel.Protocols.OpenIdConnect;
using Serilog;
@@ -120,6 +122,7 @@ builder.Services.AddSingleton(new UserRepository(Path.Combine(dataFolder, "users
builder.Services.AddSingleton(new AuditRepository(Path.Combine(dataFolder, "audit.jsonl")));
// ── Auth infrastructure ───────────────────────────────────────────────────────
builder.Services.AddSingleton<IPasswordHasher<AppUser>, PasswordHasher<AppUser>>();
builder.Services.AddSingleton<IUserService, UserService>();
builder.Services.AddSingleton<IOAuthFlowCache, OAuthFlowCache>();
builder.Services.AddSingleton<IEntraDeviceCodeFlow, EntraDeviceCodeFlow>();
@@ -187,20 +190,32 @@ app.UseAuthorization();
app.UseAntiforgery();
// ── Login / Logout endpoints ──────────────────────────────────────────────────
if (app.Environment.IsDevelopment())
var isDev = app.Environment.IsDevelopment();
// Combined login page. Dev: local form + "Quick sign in as Dev Admin" (no OIDC scheme registered).
// Prod: local form + "Sign in with Microsoft".
app.MapGet("/account/login", (HttpContext ctx, IAntiforgery antiforgery, string? returnUrl, bool? error) =>
{
app.MapGet("/account/login", async (HttpContext ctx, string? returnUrl, IUserService userService) =>
var html = LoginPageRenderer.Build(
ctx, antiforgery, returnUrl, error == true,
showEntra: !isDev,
showDevButton: isDev);
return Results.Content(html, "text/html");
});
if (isDev)
{
// Dev shortcut: provision + sign in the hardcoded Dev Admin (first run = Admin).
app.MapGet("/account/login/dev", async (HttpContext ctx, string? returnUrl, IUserService userService) =>
{
const string devEmail = "dev@local.test";
const string devName = "Dev Admin";
// Provision the dev user in users.json (first run = Admin)
var provisionPrincipal = new ClaimsPrincipal(new ClaimsIdentity(
new[] { new Claim("preferred_username", devEmail), new Claim("name", devName) },
CookieAuthenticationDefaults.AuthenticationScheme));
var user = await userService.ProvisionAsync(provisionPrincipal);
// Sign in with full claims including app_role for HTTP endpoints
var principal = new ClaimsPrincipal(new ClaimsIdentity(
new Claim[] {
new("preferred_username", devEmail),
@@ -212,16 +227,11 @@ if (app.Environment.IsDevelopment())
await ctx.SignInAsync(CookieAuthenticationDefaults.AuthenticationScheme, principal);
ctx.Response.Redirect(string.IsNullOrEmpty(returnUrl) ? "/" : returnUrl);
});
app.MapGet("/account/logout", async (HttpContext ctx) =>
{
await ctx.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);
ctx.Response.Redirect("/");
});
}
else
{
app.MapGet("/account/login", async (HttpContext ctx, string? returnUrl) =>
// Microsoft / Entra OIDC challenge (the "Sign in with Microsoft" button).
app.MapGet("/account/login/entra", async (HttpContext ctx, string? returnUrl) =>
{
var props = new AuthenticationProperties
{
@@ -229,14 +239,49 @@ else
};
await ctx.ChallengeAsync(OpenIdConnectDefaults.AuthenticationScheme, props);
});
}
app.MapGet("/account/logout", async (HttpContext ctx) =>
{
await ctx.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);
// Local password sign-in — available in every environment.
app.MapPost("/account/local-login", async (HttpContext ctx, IAntiforgery antiforgery, IUserService userService) =>
{
try { await antiforgery.ValidateRequestAsync(ctx); }
catch (AntiforgeryValidationException) { return Results.BadRequest(); }
var form = await ctx.Request.ReadFormAsync();
var email = form["email"].ToString();
var password = form["password"].ToString();
var returnUrl = form["returnUrl"].ToString();
var safeReturn = string.IsNullOrEmpty(returnUrl) || !returnUrl.StartsWith('/') ? "/" : returnUrl;
var user = await userService.ValidateLocalCredentialsAsync(email, password);
if (user is null)
return Results.Redirect($"/account/login?error=true&returnUrl={Uri.EscapeDataString(safeReturn)}");
var principal = new ClaimsPrincipal(new ClaimsIdentity(
new Claim[]
{
new("preferred_username", user.Email),
new("name", user.DisplayName),
new("app_role", user.Role.ToString()),
new("auth_provider", nameof(AuthProvider.Local)),
},
CookieAuthenticationDefaults.AuthenticationScheme));
await ctx.SignInAsync(CookieAuthenticationDefaults.AuthenticationScheme, principal);
return Results.Redirect(safeReturn);
});
app.MapGet("/account/logout", async (HttpContext ctx) =>
{
// Local/dev accounts only hold the cookie; Entra accounts also have an OIDC session to end.
var isLocal = ctx.User.HasClaim("auth_provider", nameof(AuthProvider.Local));
await ctx.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);
if (!isDev && !isLocal && ctx.User.Identity?.IsAuthenticated == true)
await ctx.SignOutAsync(OpenIdConnectDefaults.AuthenticationScheme,
new AuthenticationProperties { RedirectUri = "/" });
});
}
else
ctx.Response.Redirect("/");
});
// ── OAuth2 connect endpoints ──────────────────────────────────────────────────
app.MapOAuthEndpoints();