fix(security): finish the bearer-token design and cache role mappings (review 2026-10-21 05/U1, 05/P1)
- ManagementAuthenticator accepts Authorization: Bearer <jwt> alongside Basic on /management, the audit REST endpoints and the debug-stream hub. Tokens are validated locally (HS256 only, alg=none rejected, issuer/audience, lifetime with 30 s skew); roles and site scope inside a valid token are authoritative for its lifetime, so a bearer call costs no LDAP bind and no role re-map. - /auth/token returns expires_in + management_bearer:true, carries username, display name, roles and permitted sites, and answers 404 under DisableLogin. - CLI exchanges credentials once per process (in-memory CliTokenStore), sends Bearer, re-exchanges once on 401 then falls back to Basic; older/login-disabled servers are Basic-only; bad credentials are not retried with Basic. Debug stream uses the token via AccessTokenProvider. SCADABRIDGE_AUTH_MODE=basic opts out. - StartupValidator fails a Central boot on a JwtSigningKey under 32 UTF-8 bytes; fixed the false 'fail-fast at construction' comment. Removed the dead RefreshToken/RecordActivity/ShouldRefresh/IsIdleTimedOut members and the inert JwtRefreshThresholdMinutes; JwtExpiryMinutes now documented as the bearer lifetime (validated > 0). - RoleMapper reads through a process-wide RoleMappingCache (RoleMappingCacheSeconds, default 30 s): one refresh loads the table plus every Deployer mapping's scope rules, Invalidate() drops it (generation-guarded against in-flight refreshes). - Component-Security.md / Component-CLI.md / CLI README describe the real model (cookie claims ticket, bearer for CLI) instead of a cookie-embedded JWT.
This commit is contained in:
@@ -6,9 +6,11 @@ using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Options;
|
||||
using ZB.MOM.WW.Auth.Abstractions.Ldap;
|
||||
using ZB.MOM.WW.Auth.Abstractions.Roles;
|
||||
using ZB.MOM.WW.ScadaBridge.Security;
|
||||
using ZB.MOM.WW.ScadaBridge.Security.Auth;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Auth;
|
||||
|
||||
@@ -18,7 +20,7 @@ namespace ZB.MOM.WW.ScadaBridge.CentralUI.Auth;
|
||||
/// </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>
|
||||
/// <summary>Registers the <c>/auth/login</c>, <c>/auth/token</c>, <c>/auth/logout</c>, <c>/auth/ping</c> and <c>/auth/activity</c> endpoints on the given route builder.</summary>
|
||||
/// <param name="endpoints">The route builder to add the endpoints to.</param>
|
||||
/// <returns>The same <paramref name="endpoints"/> instance, for call chaining.</returns>
|
||||
public static IEndpointRouteBuilder MapAuthEndpoints(this IEndpointRouteBuilder endpoints)
|
||||
@@ -118,70 +120,9 @@ public static class AuthEndpoints
|
||||
context.Response.Redirect("/");
|
||||
});
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
// Login throttle: refuse a bind while this username+IP key is locked out
|
||||
// (fixed-window password-spray guard, arch-review P1/UA5).
|
||||
var throttle = context.RequestServices.GetRequiredService<LoginThrottle>();
|
||||
var remoteIp = context.Connection.RemoteIpAddress?.ToString();
|
||||
if (throttle.IsLockedOut(username, remoteIp!))
|
||||
{
|
||||
return Results.Json(
|
||||
new { error = "Too many failed authentication attempts. Try again later.", code = "AUTH_THROTTLED" },
|
||||
statusCode: 429);
|
||||
}
|
||||
|
||||
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)
|
||||
{
|
||||
throttle.RecordFailure(username, remoteIp!);
|
||||
return Results.Json(
|
||||
new { error = LdapAuthFailureMessages.ToMessage(authResult.Failure) },
|
||||
statusCode: 401);
|
||||
}
|
||||
|
||||
throttle.RecordSuccess(username, remoteIp!);
|
||||
|
||||
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();
|
||||
// Credential → bearer-token exchange for the CLI (review 2026-10-21 05/U1, 05/P1).
|
||||
// Antiforgery is disabled: this is a non-cookie, credential-in-body endpoint.
|
||||
endpoints.MapPost("/auth/token", (Delegate)HandleTokenAsync).DisableAntiforgery();
|
||||
|
||||
// Logout is a state-changing authenticated action: it
|
||||
// keeps antiforgery validation enabled so it cannot be triggered
|
||||
@@ -212,6 +153,107 @@ public static class AuthEndpoints
|
||||
return endpoints;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handler for <c>POST /auth/token</c>: binds the form-posted <c>username</c>/<c>password</c>
|
||||
/// against LDAP (login-throttled), maps the groups to roles and site scope, and returns a
|
||||
/// short-lived HS256 bearer JWT (<see cref="SecurityOptions.JwtExpiryMinutes"/>) that
|
||||
/// <c>ManagementAuthenticator</c> accepts on <c>POST /management</c>, the audit REST
|
||||
/// endpoints and the debug-stream hub. The token carries the username, display name, roles
|
||||
/// and permitted site ids — everything those surfaces need, so a bearer request costs no
|
||||
/// LDAP bind. The response's <c>management_bearer: true</c> marks a server whose management
|
||||
/// surfaces accept the token; a client that does not see it (an older server, whose tokens
|
||||
/// nothing accepted) falls back to HTTP Basic.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Under the dev/test <c>DisableLogin</c> flag the exchange answers <c>404</c>: the
|
||||
/// management surfaces already authenticate every request as the dev user, so the CLI falls
|
||||
/// back to Basic, and no credential-free token is ever minted that another deployment
|
||||
/// sharing the signing key might honour.
|
||||
/// </remarks>
|
||||
/// <param name="context">The current HTTP context.</param>
|
||||
/// <returns>The token JSON on success; 400/401/404/429 otherwise.</returns>
|
||||
public static async Task<IResult> HandleTokenAsync(HttpContext context)
|
||||
{
|
||||
var disableLogin = context.RequestServices.GetService<IOptions<AuthDisableLoginOptions>>()?.Value;
|
||||
if (disableLogin?.DisableLogin == true)
|
||||
{
|
||||
return Results.Json(
|
||||
new { error = "Token exchange is unavailable while login is disabled.", code = "TOKEN_EXCHANGE_DISABLED" },
|
||||
statusCode: StatusCodes.Status404NotFound);
|
||||
}
|
||||
|
||||
if (!context.Request.HasFormContentType)
|
||||
{
|
||||
return Results.Json(new { error = "Username and password are required." }, statusCode: 400);
|
||||
}
|
||||
|
||||
var form = await context.Request.ReadFormAsync(context.RequestAborted);
|
||||
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);
|
||||
}
|
||||
|
||||
// Login throttle: refuse a bind while this username+IP key is locked out
|
||||
// (fixed-window password-spray guard, arch-review P1/UA5).
|
||||
var throttle = context.RequestServices.GetRequiredService<LoginThrottle>();
|
||||
var remoteIp = context.Connection.RemoteIpAddress?.ToString();
|
||||
if (throttle.IsLockedOut(username, remoteIp!))
|
||||
{
|
||||
return Results.Json(
|
||||
new { error = "Too many failed authentication attempts. Try again later.", code = "AUTH_THROTTLED" },
|
||||
statusCode: 429);
|
||||
}
|
||||
|
||||
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)
|
||||
{
|
||||
throttle.RecordFailure(username, remoteIp!);
|
||||
return Results.Json(
|
||||
new { error = LdapAuthFailureMessages.ToMessage(authResult.Failure), code = "AUTH_FAILED" },
|
||||
statusCode: 401);
|
||||
}
|
||||
|
||||
throttle.RecordSuccess(username, remoteIp!);
|
||||
|
||||
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;
|
||||
|
||||
// Site ids are stamped exactly as ManagementAuthenticator's Basic path projects them:
|
||||
// none for a system-wide grant, the permitted set otherwise.
|
||||
var token = jwtService.GenerateToken(
|
||||
displayName,
|
||||
resolvedUsername,
|
||||
roleMapping.Roles,
|
||||
scope.IsSystemWideDeployment ? null : scope.PermittedSiteIds);
|
||||
|
||||
return Results.Json(new
|
||||
{
|
||||
access_token = token,
|
||||
token_type = "Bearer",
|
||||
expires_in = (int)jwtService.TokenLifetime.TotalSeconds,
|
||||
management_bearer = true,
|
||||
username = resolvedUsername,
|
||||
display_name = displayName,
|
||||
roles = roleMapping.Roles,
|
||||
});
|
||||
}
|
||||
|
||||
/// <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
|
||||
|
||||
Reference in New Issue
Block a user