Fix Central launch profile: auth middleware, cookie auth, antiforgery, static files

- Add UseAuthentication/UseAuthorization/UseAntiforgery/UseStaticFiles middleware
- Register ASP.NET Core cookie authentication scheme in AddSecurity()
- Update auth endpoints to use SignInAsync/SignOutAsync (proper cookie auth)
- Add [AllowAnonymous] to login page
- Create wwwroot for static file serving
- Regenerate clean EF migration after model changes

Verified with launch profile "ScadaLink Central":
- Host starts, connects to SQL Server, applies EF migrations
- Akka.NET cluster forms (remoting on 8081, node joins self as leader)
- /health/ready returns Healthy (DB + Akka checks)
- LDAP auth works (admin/password via GLAuth → 302 + auth cookie set)
- Login page renders (HTTP 200)
- Unauthenticated requests redirect to /login
This commit is contained in:
Joseph Doherty
2026-03-17 03:43:11 -04:00
parent 121983fd66
commit 0b10747bd2
6 changed files with 1061 additions and 24 deletions

View File

@@ -1,3 +1,6 @@
using System.Security.Claims;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing; using Microsoft.AspNetCore.Routing;
@@ -8,7 +11,7 @@ namespace ScadaLink.CentralUI.Auth;
/// <summary> /// <summary>
/// Minimal API endpoints for login/logout. These run outside Blazor Server (standard HTTP POST). /// Minimal API endpoints for login/logout. These run outside Blazor Server (standard HTTP POST).
/// On success, sets an HTTP-only cookie containing the JWT, then redirects to dashboard. /// On success, signs in via ASP.NET Core cookie authentication and redirects to dashboard.
/// </summary> /// </summary>
public static class AuthEndpoints public static class AuthEndpoints
{ {
@@ -41,37 +44,47 @@ public static class AuthEndpoints
// Map LDAP groups to roles // Map LDAP groups to roles
var roleMappingResult = await roleMapper.MapGroupsToRolesAsync(authResult.Groups ?? []); var roleMappingResult = await roleMapper.MapGroupsToRolesAsync(authResult.Groups ?? []);
var token = jwtService.GenerateToken( // Build claims from LDAP auth + role mapping
authResult.DisplayName ?? username, var claims = new List<Claim>
authResult.Username ?? username, {
roleMappingResult.Roles, new(ClaimTypes.Name, authResult.Username ?? username),
roleMappingResult.IsSystemWideDeployment ? null : roleMappingResult.PermittedSiteIds); new(JwtTokenService.DisplayNameClaimType, authResult.DisplayName ?? username),
new(JwtTokenService.UsernameClaimType, authResult.Username ?? username),
};
// Set HTTP-only cookie with the JWT foreach (var role in roleMappingResult.Roles)
context.Response.Cookies.Append( {
CookieAuthenticationStateProvider.AuthCookieName, claims.Add(new Claim(JwtTokenService.RoleClaimType, role));
token, }
new CookieOptions
if (!roleMappingResult.IsSystemWideDeployment)
{
foreach (var siteId in roleMappingResult.PermittedSiteIds)
{ {
HttpOnly = true, claims.Add(new Claim(JwtTokenService.SiteIdClaimType, siteId));
Secure = context.Request.IsHttps, }
SameSite = SameSiteMode.Strict, }
Path = "/",
// Cookie expiry matches JWT idle timeout (30 min default) var identity = new ClaimsIdentity(claims, CookieAuthenticationDefaults.AuthenticationScheme);
MaxAge = TimeSpan.FromMinutes(30) var principal = new ClaimsPrincipal(identity);
await context.SignInAsync(
CookieAuthenticationDefaults.AuthenticationScheme,
principal,
new AuthenticationProperties
{
IsPersistent = true,
ExpiresUtc = DateTimeOffset.UtcNow.AddMinutes(30)
}); });
context.Response.Redirect("/"); context.Response.Redirect("/");
}); }).DisableAntiforgery();
endpoints.MapPost("/auth/logout", (HttpContext context) => endpoints.MapPost("/auth/logout", async (HttpContext context) =>
{ {
context.Response.Cookies.Delete(CookieAuthenticationStateProvider.AuthCookieName, new CookieOptions await context.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);
{
Path = "/"
});
context.Response.Redirect("/login"); context.Response.Redirect("/login");
}); }).DisableAntiforgery();
return endpoints; return endpoints;
} }

View File

@@ -1,4 +1,6 @@
@page "/login" @page "/login"
@using Microsoft.AspNetCore.Authorization
@attribute [AllowAnonymous]
<div class="container" style="max-width: 400px; margin-top: 10vh;"> <div class="container" style="max-width: 400px; margin-top: 10vh;">
<div class="card shadow-sm"> <div class="card shadow-sm">

View File

@@ -108,6 +108,12 @@ try
} }
} }
// Middleware pipeline
app.UseStaticFiles();
app.UseAuthentication();
app.UseAuthorization();
app.UseAntiforgery();
// WP-12: Map readiness endpoint — returns 503 until all checks pass, 200 when ready // WP-12: Map readiness endpoint — returns 503 until all checks pass, 200 when ready
app.MapHealthChecks("/health/ready", new HealthCheckOptions app.MapHealthChecks("/health/ready", new HealthCheckOptions
{ {

File diff suppressed because it is too large Load Diff

View File

View File

@@ -1,3 +1,4 @@
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
namespace ScadaLink.Security; namespace ScadaLink.Security;
@@ -9,6 +10,18 @@ public static class ServiceCollectionExtensions
services.AddScoped<LdapAuthService>(); services.AddScoped<LdapAuthService>();
services.AddScoped<JwtTokenService>(); services.AddScoped<JwtTokenService>();
services.AddScoped<RoleMapper>(); services.AddScoped<RoleMapper>();
// Register ASP.NET Core authentication with cookie scheme
services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
.AddCookie(options =>
{
options.LoginPath = "/login";
options.LogoutPath = "/auth/logout";
options.Cookie.Name = "ScadaLink.Auth";
options.Cookie.HttpOnly = true;
options.Cookie.SameSite = Microsoft.AspNetCore.Http.SameSiteMode.Strict;
});
services.AddScadaLinkAuthorization(); services.AddScadaLinkAuthorization();
return services; return services;