using System.Security.Claims; using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authentication.Cookies; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Routing; using Microsoft.Extensions.DependencyInjection; using ZB.MOM.WW.Auth.Abstractions.Ldap; using ZB.MOM.WW.Auth.Abstractions.Roles; using ZB.MOM.WW.ScadaBridge.Security; namespace ZB.MOM.WW.ScadaBridge.CentralUI.Auth; /// /// Minimal API endpoints for login/logout. These run outside Blazor Server (standard HTTP POST). /// On success, signs in via ASP.NET Core cookie authentication and redirects to dashboard. /// public static class AuthEndpoints { /// Registers the /auth/login, /auth/logout, and /auth/ping endpoints on the given route builder. /// The route builder to add the endpoints to. public static IEndpointRouteBuilder MapAuthEndpoints(this IEndpointRouteBuilder endpoints) { endpoints.MapPost("/auth/login", async (HttpContext context) => { var form = await context.Request.ReadFormAsync(); var username = form["username"].ToString(); var password = form["password"].ToString(); if (string.IsNullOrWhiteSpace(username) || string.IsNullOrWhiteSpace(password)) { context.Response.Redirect("/login?error=Username+and+password+are+required."); return; } var ldapAuth = context.RequestServices.GetRequiredService(); var jwtService = context.RequestServices.GetRequiredService(); var roleMapper = context.RequestServices.GetRequiredService>(); var authResult = await ldapAuth.AuthenticateAsync(username, password, context.RequestAborted); if (!authResult.Succeeded) { var errorMsg = Uri.EscapeDataString(LdapAuthFailureMessages.ToMessage(authResult.Failure)); context.Response.Redirect($"/login?error={errorMsg}"); return; } // Map LDAP groups to roles via the shared IGroupRoleMapper seam // (Task 1.1 ScadaBridgeGroupRoleMapper, wrapping the DB-backed RoleMapper). // The full RoleMappingResult — including PermittedSiteIds and the // system-wide flag — is carried in the mapping's opaque Scope so the // site-scope→SiteId claims below are built exactly as before. var roleMapping = await roleMapper.MapAsync(authResult.Groups, context.RequestAborted); // The ScadaBridge mapper carries the full RoleMappingResult in the seam's // opaque Scope (see ScadaBridgeGroupRoleMapper). Guard the unwrap (review I4): // a future/alternate IGroupRoleMapper could leave Scope null or set a // different type. Rather than throw InvalidCastException mid-login, fall back to // the most restrictive interpretation — not a system-wide deployment and no // permitted sites — so no SiteId claims are stamped (deny-by-omission). The real // ScadaBridge mapper always supplies a RoleMappingResult, so behaviour is unchanged. var scope = roleMapping.Scope is RoleMappingResult mapped ? mapped : new RoleMappingResult(roleMapping.Roles, [], IsSystemWideDeployment: false); // Build claims from LDAP auth + role mapping. // CentralUI-005: no fixed "expires_at" absolute-cap claim is stamped // — session expiry is owned by the cookie middleware's sliding window // (ZB.MOM.WW.ScadaBridge.Security AddCookie: ExpireTimeSpan = idle timeout, // SlidingExpiration = true). A frozen absolute claim would contradict // the documented sliding-refresh policy. var displayName = string.IsNullOrEmpty(authResult.DisplayName) ? username : authResult.DisplayName; var resolvedUsername = string.IsNullOrEmpty(authResult.Username) ? username : authResult.Username; var claims = new List { new(ClaimTypes.Name, resolvedUsername), new(JwtTokenService.DisplayNameClaimType, displayName), new(JwtTokenService.UsernameClaimType, resolvedUsername), }; foreach (var role in roleMapping.Roles) { claims.Add(new Claim(JwtTokenService.RoleClaimType, role)); } if (!scope.IsSystemWideDeployment) { foreach (var siteId in scope.PermittedSiteIds) { claims.Add(new Claim(JwtTokenService.SiteIdClaimType, siteId)); } } var identity = new ClaimsIdentity(claims, CookieAuthenticationDefaults.AuthenticationScheme); var principal = new ClaimsPrincipal(identity); await context.SignInAsync( CookieAuthenticationDefaults.AuthenticationScheme, principal, BuildSignInProperties()); context.Response.Redirect("/"); }).DisableAntiforgery(); endpoints.MapPost("/auth/token", async (HttpContext context) => { var form = await context.Request.ReadFormAsync(); var username = form["username"].ToString(); var password = form["password"].ToString(); if (string.IsNullOrWhiteSpace(username) || string.IsNullOrWhiteSpace(password)) { return Results.Json(new { error = "Username and password are required." }, statusCode: 400); } var ldapAuth = context.RequestServices.GetRequiredService(); var jwtService = context.RequestServices.GetRequiredService(); var roleMapper = context.RequestServices.GetRequiredService>(); var authResult = await ldapAuth.AuthenticateAsync(username, password, context.RequestAborted); if (!authResult.Succeeded) { return Results.Json( new { error = LdapAuthFailureMessages.ToMessage(authResult.Failure) }, statusCode: 401); } var roleMapping = await roleMapper.MapAsync(authResult.Groups, context.RequestAborted); // Guard the opaque-Scope unwrap (review I4); see the matching note on // /auth/login. Fall back to no site-scope rather than throwing if a future // mapper leaves Scope null or sets a different type. var scope = roleMapping.Scope is RoleMappingResult mapped ? mapped : new RoleMappingResult(roleMapping.Roles, [], IsSystemWideDeployment: false); var displayName = string.IsNullOrEmpty(authResult.DisplayName) ? username : authResult.DisplayName; var resolvedUsername = string.IsNullOrEmpty(authResult.Username) ? username : authResult.Username; var token = jwtService.GenerateToken( displayName, resolvedUsername, roleMapping.Roles, scope.IsSystemWideDeployment ? null : scope.PermittedSiteIds); return Results.Json(new { access_token = token, token_type = "Bearer", username = resolvedUsername, display_name = displayName, roles = roleMapping.Roles, }); }).DisableAntiforgery(); // Logout is a state-changing authenticated action (CentralUI-017): it // keeps antiforgery validation enabled so it cannot be triggered // cross-site. The NavMenu sign-out form includes the antiforgery token // (rendered by the component). There is deliberately // no GET /logout route — a state-changing GET is itself a CSRF vector // (an would forcibly log a user out). endpoints.MapPost("/auth/logout", async (HttpContext context) => { await context.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme); context.Response.Redirect("/login"); }); // CentralUI-020: liveness probe for the client-side idle-logout check. // The Blazor circuit's CookieAuthenticationStateProvider serves a frozen // constructor-time principal (CentralUI-004), so a circuit can never // observe a server-side cookie expiry by polling the auth state. // SessionExpiry instead polls this endpoint via fetch(): being a normal // HTTP request, the cookie middleware re-validates (and slides) the // cookie on every hit. It deliberately does NOT use RequireAuthorization // — that would make the middleware answer a lapsed request with a 302 to // /login, which fetch() follows transparently and reads as a 200 login // page. Allowing anonymous access and returning 200/401 ourselves gives // the client an unambiguous expiry signal. endpoints.MapGet("/auth/ping", HandlePing); return endpoints; } /// /// Handler for GET /auth/ping. Returns 200 while the caller's /// cookie session is still valid and 401 once it has lapsed /// server-side. See CentralUI-020. /// /// The current HTTP context used to check authentication state and write the response. public static Task HandlePing(HttpContext context) { context.Response.StatusCode = context.User.Identity?.IsAuthenticated == true ? StatusCodes.Status200OK : StatusCodes.Status401Unauthorized; return Task.CompletedTask; } /// /// Builds the for the login sign-in. /// CentralUI-005: deliberately does not set . /// Session expiry is owned by the cookie authentication middleware's sliding /// window (configured in ZB.MOM.WW.ScadaBridge.Security's AddCookie: /// ExpireTimeSpan = the idle timeout, SlidingExpiration = true). /// Setting a fixed ExpiresUtc here would re-impose a hard absolute cap /// that overrides the sliding window and contradicts the documented /// "sliding refresh, 30-minute idle timeout" policy. /// is true so the cookie survives a browser restart within the idle window; /// is left unset (null) /// so the middleware is free to slide the expiry on activity. /// public static AuthenticationProperties BuildSignInProperties() => new() { IsPersistent = true }; }