Files
lmxopcua/src/Server/ZB.MOM.WW.OtOpcUa.Security/Endpoints/AuthEndpoints.cs
T
Joseph Doherty 3ddb6876db fix(security): first-party JwtBearer with HS256 pinned; /auth/token mints for a cookie session only
The hand-rolled bearer handler is replaced by AddJwtBearer (Microsoft.AspNetCore.Authentication.JwtBearer
10.0.7, pinned on the same servicing line as the other AspNetCore packages). Validation parameters come
from JwtTokenService.BuildValidationParameters, which now pins ValidAlgorithms = HS256 for both the
bearer scheme and TryValidate; MapInboundClaims is off so the minted Role/zb:username keys resolve.
/auth/token requires the cookie scheme, so a bearer can no longer mint a fresh token for itself.

Refs #733
Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
2026-10-22 13:35:54 -04:00

224 lines
11 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 Microsoft.Extensions.Options;
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>
/// The <c>/auth/token</c> policy: an authenticated principal from the COOKIE scheme. Naming the scheme
/// makes the authorization middleware authenticate that scheme alone, so an <c>Authorization: Bearer</c>
/// header is never consulted here (with login disabled the auto-login handler sits under the same
/// scheme name, so the dev rig is unchanged).
/// </summary>
internal static readonly AuthorizationPolicy CookieSessionOnly =
new AuthorizationPolicyBuilder(CookieAuthenticationDefaults.AuthenticationScheme)
.RequireAuthenticatedUser()
.Build();
/// <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();
// Mapped only when a signing key is configured (JwtOptions.IsConfigured): an unconfigured
// endpoint used to answer HTTP 500 from the JwtTokenService constructor on every call.
if (app.ServiceProvider.GetRequiredService<IOptions<JwtOptions>>().Value.IsConfigured)
// COOKIE scheme only (fix round 1 / I2): the default scheme also accepts a bearer, and a bearer
// that could mint a fresh token would renew itself forever — outliving its own expiry and the
// user's LDAP membership. A token is minted only for an interactive (cookie) session.
app.MapPost("/auth/token", (Delegate)IssueToken).RequireAuthorization(CookieSessionOnly);
else
app.ServiceProvider.GetService<ILoggerFactory>()?
.CreateLogger("ZB.MOM.WW.OtOpcUa.Security.AuthEndpoints")
.LogInformation("Security:Jwt:SigningKey is not configured — /auth/token is not mapped and bearer tokens are not accepted");
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 ex) when (ex is not OperationCanceledException)
{
// R03:2.7 — a directory outage used to leave NOTHING in the server log from this path; the
// operator had to infer every 503 from the directory side.
http.RequestServices.GetService<ILoggerFactory>()?
.CreateLogger("ZB.MOM.WW.OtOpcUa.Security.AuthEndpoints")
.LogWarning(ex, "LDAP authentication failed for {User}; answering 503", username);
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();
// Validate returnUrl is a relative (local) URL before redirecting.
// An attacker can supply an absolute URL (e.g. https://evil.com) via a crafted link;
// LoginCard echoes the value verbatim into the hidden form field. Fall back to the
// app root rather than following the external URL (open-redirect prevention).
var destination = !string.IsNullOrWhiteSpace(returnUrl) && IsLocalUrl(returnUrl) ? returnUrl : "/";
return Results.Redirect(destination);
}
/// <summary>
/// Returns <see langword="true"/> when <paramref name="url"/> is a relative (local) URL
/// safe to redirect to after login. Rejects absolute URLs (with a scheme, e.g.
/// <c>https://evil.com</c>), protocol-relative URLs (<c>//evil.com</c>), and
/// anything that is not a well-formed relative URI — preventing open-redirect attacks
/// via a crafted <c>returnUrl</c> query parameter.
/// </summary>
/// <param name="url">The candidate redirect target.</param>
private static bool IsLocalUrl(string url) =>
Uri.IsWellFormedUriString(url, UriKind.Relative) && !url.StartsWith("//");
/// <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");
}
}