83856b7c27
Add ZB.MOM.WW.Auth.AspNetCore package ref to Security project (version 0.1.1
from central PM). Alias JwtTokenService.UsernameClaimType and DisplayNameClaimType
to ZbClaimTypes.Username ("zb:username") and ZbClaimTypes.DisplayName ("zb:displayname")
so every mint/read site inherits the canonical spelling. AuthEndpoints login path now
emits ZbClaimTypes.Name (= ClaimTypes.Name, populates Identity.Name) instead of
ClaimTypes.NameIdentifier (no other read site used it), and references ZbClaimTypes.Role
(= ClaimTypes.Role) for role claims so [Authorize(Roles=...)] continues to resolve.
Cookie hardening now flows through ZbCookieDefaults.Apply (sets HttpOnly, SameSite=Strict,
SlidingExpiration, SecurePolicy, ExpireTimeSpan) followed by opts.Cookie.Name = v.Name to
preserve the OtOpcUa-specific "ZB.MOM.WW.OtOpcUa.Auth" cookie name. Two new tests added
to AuthEndpointsIntegrationTests assert canonical ZbClaimTypes on the cookie principal and
canonical zb: keys in the JWT payload; all 35 security tests green.
181 lines
8.4 KiB
C#
181 lines
8.4 KiB
C#
using System.Security.Claims;
|
|
using System.Text.Json;
|
|
using Microsoft.AspNetCore.Authentication;
|
|
using Microsoft.AspNetCore.Authentication.Cookies;
|
|
using Microsoft.AspNetCore.Authorization;
|
|
using Microsoft.AspNetCore.Builder;
|
|
using Microsoft.AspNetCore.Http;
|
|
using Microsoft.AspNetCore.Routing;
|
|
using Microsoft.Extensions.DependencyInjection;
|
|
using Microsoft.Extensions.Logging;
|
|
using ZB.MOM.WW.Auth.AspNetCore;
|
|
using ZB.MOM.WW.Auth.Abstractions.Roles;
|
|
using ZB.MOM.WW.OtOpcUa.Security.Jwt;
|
|
using ZB.MOM.WW.OtOpcUa.Security.Ldap;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Security.Endpoints;
|
|
|
|
public static class AuthEndpoints
|
|
{
|
|
/// <summary>JSON body schema for API-side login callers (kept stable for tests).</summary>
|
|
public sealed record LoginRequest(string Username, string Password);
|
|
|
|
/// <summary>Response for a token issue request.</summary>
|
|
public sealed record TokenResponse(string Token);
|
|
|
|
/// <summary>Maps OtOpcUa authentication endpoints to the application route builder.</summary>
|
|
/// <param name="app">The endpoint route builder.</param>
|
|
/// <returns>The endpoint route builder for chaining.</returns>
|
|
public static IEndpointRouteBuilder MapOtOpcUaAuth(this IEndpointRouteBuilder app)
|
|
{
|
|
// The login endpoint serves two callers with different ergonomics:
|
|
// - Browser form POST (application/x-www-form-urlencoded) → redirect dance
|
|
// - API JSON POST (application/json) → 204 / 401 / 503 status codes
|
|
// DisableAntiforgery: the login form is the entry point — anonymous by definition,
|
|
// no prior session, so XSRF doesn't apply. AllowAnonymous: override the
|
|
// AddOtOpcUaAuth fallback policy that would otherwise 401 the request.
|
|
app.MapPost("/auth/login", (Delegate)LoginAsync).AllowAnonymous().DisableAntiforgery();
|
|
app.MapGet("/auth/ping", (Delegate)Ping).AllowAnonymous();
|
|
app.MapPost("/auth/token", (Delegate)IssueToken).RequireAuthorization();
|
|
app.MapPost("/auth/logout", (Delegate)LogoutAsync).RequireAuthorization();
|
|
return app;
|
|
}
|
|
|
|
private static async Task<IResult> LoginAsync(
|
|
HttpContext http,
|
|
ILdapAuthService ldap,
|
|
IGroupRoleMapper<string> roleMapper,
|
|
CancellationToken ct)
|
|
{
|
|
var isForm = http.Request.HasFormContentType;
|
|
string username, password, returnUrl;
|
|
|
|
if (isForm)
|
|
{
|
|
var form = await http.Request.ReadFormAsync(ct);
|
|
username = form["username"].ToString();
|
|
password = form["password"].ToString();
|
|
returnUrl = form["returnUrl"].ToString();
|
|
}
|
|
else
|
|
{
|
|
var body = await JsonSerializer.DeserializeAsync<LoginRequest>(
|
|
http.Request.Body,
|
|
new JsonSerializerOptions(JsonSerializerDefaults.Web),
|
|
ct);
|
|
username = body?.Username ?? string.Empty;
|
|
password = body?.Password ?? string.Empty;
|
|
returnUrl = string.Empty;
|
|
}
|
|
|
|
LdapAuthResult result;
|
|
try
|
|
{
|
|
result = await ldap.AuthenticateAsync(username, password, ct);
|
|
}
|
|
catch (Exception)
|
|
{
|
|
return Results.StatusCode(StatusCodes.Status503ServiceUnavailable);
|
|
}
|
|
|
|
if (!result.Success)
|
|
{
|
|
if (!isForm) return Results.Unauthorized();
|
|
|
|
var qs = $"?error={Uri.EscapeDataString(result.Error ?? "Invalid credentials")}";
|
|
if (!string.IsNullOrWhiteSpace(returnUrl))
|
|
qs += $"&returnUrl={Uri.EscapeDataString(returnUrl)}";
|
|
return Results.Redirect("/login" + qs);
|
|
}
|
|
|
|
// Role resolution now lives behind the shared IGroupRoleMapper<string> seam
|
|
// (OtOpcUaGroupRoleMapper): it applies the appsettings GroupToRole baseline AND merges
|
|
// system-wide DB grants from the user's LDAP groups. result.Roles is empty on the real
|
|
// LDAP path (the library returns groups, not roles); it is only pre-populated on the
|
|
// DevStub success path (FleetAdmin) — union that pre-resolved set in so the dev grant
|
|
// survives the move to the mapper.
|
|
IReadOnlyList<string> roles = result.Roles;
|
|
try
|
|
{
|
|
var mapping = await roleMapper.MapAsync(result.Groups, ct);
|
|
roles = Union(result.Roles, mapping.Roles);
|
|
}
|
|
catch (Exception ex) when (ex is not OperationCanceledException)
|
|
{
|
|
// A DB hiccup (or any mapper fault) must never block sign-in — fall back to the
|
|
// pre-resolved baseline roles (empty on the real path, FleetAdmin under DevStub).
|
|
// This is intentionally FAIL-CLOSED on the real LDAP path: result.Roles is empty there
|
|
// (the library returns groups, never roles — the mapper is the sole role source), so a
|
|
// mapper fault signs the user in AUTHENTICATED but with ZERO role claims. They can prove
|
|
// identity but are denied every role-gated action until the mapper recovers — strictly
|
|
// safer than failing open with a stale/guessed role set. (See AuthEndpoints test
|
|
// Login_when_role_mapper_throws_signs_in_with_no_role_claims.)
|
|
http.RequestServices.GetService<ILoggerFactory>()?
|
|
.CreateLogger("ZB.MOM.WW.OtOpcUa.Security.AuthEndpoints")
|
|
.LogWarning(ex, "Role-map lookup failed for {User}; using pre-resolved baseline roles", username);
|
|
}
|
|
|
|
var claims = new List<Claim>
|
|
{
|
|
// ZbClaimTypes.Name = ClaimTypes.Name — populates Identity.Name canonically.
|
|
new(ZbClaimTypes.Name, result.Username ?? username),
|
|
new(ZbClaimTypes.Username, result.Username ?? username),
|
|
new(ZbClaimTypes.DisplayName, result.DisplayName ?? username),
|
|
};
|
|
foreach (var role in roles)
|
|
// ZbClaimTypes.Role = ClaimTypes.Role — framework [Authorize(Roles=...)] + IsInRole work.
|
|
claims.Add(new Claim(ZbClaimTypes.Role, role));
|
|
|
|
var identity = new ClaimsIdentity(claims, CookieAuthenticationDefaults.AuthenticationScheme);
|
|
var principal = new ClaimsPrincipal(identity);
|
|
|
|
await http.SignInAsync(CookieAuthenticationDefaults.AuthenticationScheme, principal);
|
|
|
|
if (!isForm) return Results.NoContent();
|
|
return Results.Redirect(string.IsNullOrWhiteSpace(returnUrl) ? "/" : returnUrl);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Case-insensitive set-union of two role lists, preserving the de-duplication semantics the
|
|
/// legacy <c>RoleMapper.Merge</c> applied. Used to fold any pre-resolved roles (the DevStub
|
|
/// FleetAdmin grant) into the mapper-resolved set.
|
|
/// </summary>
|
|
/// <param name="first">The first role set (pre-resolved baseline).</param>
|
|
/// <param name="second">The second role set (mapper output).</param>
|
|
private static IReadOnlyList<string> Union(IReadOnlyList<string> first, IReadOnlyList<string> second)
|
|
{
|
|
var roles = new HashSet<string>(first, StringComparer.OrdinalIgnoreCase);
|
|
foreach (var role in second)
|
|
roles.Add(role);
|
|
return [.. roles];
|
|
}
|
|
|
|
private static IResult Ping(HttpContext http) =>
|
|
http.User.Identity?.IsAuthenticated == true ? Results.Ok() : Results.Unauthorized();
|
|
|
|
private static IResult IssueToken(HttpContext http, JwtTokenService jwt)
|
|
{
|
|
var user = http.User;
|
|
var username = user.FindFirst(JwtTokenService.UsernameClaimType)?.Value
|
|
?? user.Identity?.Name
|
|
?? string.Empty;
|
|
var displayName = user.FindFirst(JwtTokenService.DisplayNameClaimType)?.Value ?? username;
|
|
var roles = user.FindAll(ZbClaimTypes.Role).Select(c => c.Value).ToArray();
|
|
|
|
return Results.Ok(new TokenResponse(jwt.Issue(displayName, username, roles)));
|
|
}
|
|
|
|
private static async Task<IResult> LogoutAsync(HttpContext http)
|
|
{
|
|
await http.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);
|
|
|
|
// Browser form POST → redirect to /login so the user lands somewhere visible.
|
|
// API callers that prefer the status-only contract should hit the endpoint with
|
|
// Accept: application/json and we'll hand them a 204 instead.
|
|
var wantsJson = http.Request.Headers.Accept.Any(v =>
|
|
v?.Contains("application/json", StringComparison.OrdinalIgnoreCase) == true);
|
|
if (wantsJson) return Results.NoContent();
|
|
return Results.Redirect("/login");
|
|
}
|
|
}
|