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 ScadaLink.Security; namespace ScadaLink.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 { 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); if (!authResult.Success) { var errorMsg = Uri.EscapeDataString(authResult.ErrorMessage ?? "Authentication failed."); context.Response.Redirect($"/login?error={errorMsg}"); return; } // Map LDAP groups to roles var roleMappingResult = await roleMapper.MapGroupsToRolesAsync(authResult.Groups ?? []); // 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 // (ScadaLink.Security AddCookie: ExpireTimeSpan = idle timeout, // SlidingExpiration = true). A frozen absolute claim would contradict // the documented sliding-refresh policy. var claims = new List { new(ClaimTypes.Name, authResult.Username ?? username), new(JwtTokenService.DisplayNameClaimType, authResult.DisplayName ?? username), new(JwtTokenService.UsernameClaimType, authResult.Username ?? username), }; foreach (var role in roleMappingResult.Roles) { claims.Add(new Claim(JwtTokenService.RoleClaimType, role)); } if (!roleMappingResult.IsSystemWideDeployment) { foreach (var siteId in roleMappingResult.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); if (!authResult.Success) { return Results.Json( new { error = authResult.ErrorMessage ?? "Authentication failed." }, statusCode: 401); } var roleMappingResult = await roleMapper.MapGroupsToRolesAsync(authResult.Groups ?? []); var token = jwtService.GenerateToken( authResult.DisplayName ?? username, authResult.Username ?? username, roleMappingResult.Roles, roleMappingResult.IsSystemWideDeployment ? null : roleMappingResult.PermittedSiteIds); return Results.Json(new { access_token = token, token_type = "Bearer", username = authResult.Username ?? username, display_name = authResult.DisplayName ?? username, roles = roleMappingResult.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"); }); return endpoints; } /// /// 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 ScadaLink.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 }; }