Files
ScadaBridge/src/ZB.MOM.WW.ScadaBridge.CentralUI/Auth/AuthEndpoints.cs
T

216 lines
11 KiB
C#

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;
/// <summary>
/// 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.
/// </summary>
public static class AuthEndpoints
{
/// <summary>Registers the <c>/auth/login</c>, <c>/auth/logout</c>, and <c>/auth/ping</c> endpoints on the given route builder.</summary>
/// <param name="endpoints">The route builder to add the endpoints to.</param>
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<ILdapAuthService>();
var jwtService = context.RequestServices.GetRequiredService<JwtTokenService>();
var roleMapper = context.RequestServices.GetRequiredService<IGroupRoleMapper<string>>();
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<string> 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<string> 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<Claim>
{
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<ILdapAuthService>();
var jwtService = context.RequestServices.GetRequiredService<JwtTokenService>();
var roleMapper = context.RequestServices.GetRequiredService<IGroupRoleMapper<string>>();
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 <AntiforgeryToken /> component). There is deliberately
// no GET /logout route — a state-changing GET is itself a CSRF vector
// (an <img src="/logout"> 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;
}
/// <summary>
/// Handler for <c>GET /auth/ping</c>. Returns <c>200</c> while the caller's
/// cookie session is still valid and <c>401</c> once it has lapsed
/// server-side. See CentralUI-020.
/// </summary>
/// <param name="context">The current HTTP context used to check authentication state and write the response.</param>
public static Task HandlePing(HttpContext context)
{
context.Response.StatusCode = context.User.Identity?.IsAuthenticated == true
? StatusCodes.Status200OK
: StatusCodes.Status401Unauthorized;
return Task.CompletedTask;
}
/// <summary>
/// Builds the <see cref="AuthenticationProperties"/> for the login sign-in.
/// CentralUI-005: deliberately does <b>not</b> set <see cref="AuthenticationProperties.ExpiresUtc"/>.
/// Session expiry is owned by the cookie authentication middleware's sliding
/// window (configured in <c>ZB.MOM.WW.ScadaBridge.Security</c>'s <c>AddCookie</c>:
/// <c>ExpireTimeSpan</c> = the idle timeout, <c>SlidingExpiration = true</c>).
/// Setting a fixed <c>ExpiresUtc</c> here would re-impose a hard absolute cap
/// that overrides the sliding window and contradicts the documented
/// "sliding refresh, 30-minute idle timeout" policy. <see cref="AuthenticationProperties.IsPersistent"/>
/// is true so the cookie survives a browser restart within the idle window;
/// <see cref="AuthenticationProperties.AllowRefresh"/> is left unset (null)
/// so the middleware is free to slide the expiry on activity.
/// </summary>
public static AuthenticationProperties BuildSignInProperties() => new()
{
IsPersistent = true
};
}