Resolve all high-severity architecture review findings
This commit is contained in:
@@ -40,7 +40,7 @@ public enum SessionValidationAction
|
||||
/// <summary>The session is idle-timed-out; reject + sign out.</summary>
|
||||
Reject,
|
||||
|
||||
/// <summary>The role mapping was refreshed; replace the principal and renew the cookie.</summary>
|
||||
/// <summary>The role mapping was refreshed; replace the principal. The HTTP adapter decides whether renewal is appropriate.</summary>
|
||||
Replace,
|
||||
}
|
||||
|
||||
@@ -56,9 +56,8 @@ public enum SessionValidationAction
|
||||
/// computed from the <see cref="JwtTokenService.LastActivityClaimType"/> anchor. This is
|
||||
/// the explicit, deterministic counterpart to the cookie middleware's
|
||||
/// <c>ExpireTimeSpan</c> + <c>SlidingExpiration</c> window — both use the SAME idle
|
||||
/// timeout value, so the explicit check never contradicts the cookie window. A
|
||||
/// not-timed-out session has its last-activity anchor advanced to "now" (genuine
|
||||
/// request = activity), mirroring the sliding renew.
|
||||
/// timeout value. Passive validation preserves the activity anchor. Explicit
|
||||
/// validated browser activity is also visible to live circuits through the session tracker.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Role refresh</b> (default <see cref="SecurityOptions.RoleRefreshThresholdMinutes"/>
|
||||
@@ -69,10 +68,9 @@ public enum SessionValidationAction
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Failure policy</b>: a refresh error (e.g. the mapper throws because the DB is
|
||||
/// unreachable) NEVER signs the user out and NEVER throws out of validation — it returns
|
||||
/// <see cref="SessionValidationResult.Keep"/>, mirroring the documented "LDAP failure:
|
||||
/// active sessions continue with current roles" stance. Only the explicit idle-timeout
|
||||
/// path rejects.
|
||||
/// unreachable) preserves current grants during ordinary cookie validation.
|
||||
/// Authoritative circuit mutation checks request fail-closed validation and propagate
|
||||
/// lookup errors, preventing a mutation from using grants that could not be refreshed.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class CookieSessionValidator
|
||||
@@ -81,6 +79,7 @@ public sealed class CookieSessionValidator
|
||||
private readonly SecurityOptions _options;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly ILogger<CookieSessionValidator> _logger;
|
||||
private readonly SessionActivityTracker? _activity;
|
||||
|
||||
/// <summary>Initializes the validator.</summary>
|
||||
/// <param name="roleMapper">The DB-backed group→role mapping seam (no LDAP) used for the mid-session refresh.</param>
|
||||
@@ -91,23 +90,24 @@ public sealed class CookieSessionValidator
|
||||
IGroupRoleMapper<string> roleMapper,
|
||||
IOptions<SecurityOptions> options,
|
||||
TimeProvider timeProvider,
|
||||
ILogger<CookieSessionValidator> logger)
|
||||
ILogger<CookieSessionValidator> logger, SessionActivityTracker? activity = null)
|
||||
{
|
||||
_roleMapper = roleMapper ?? throw new ArgumentNullException(nameof(roleMapper));
|
||||
_options = (options ?? throw new ArgumentNullException(nameof(options))).Value;
|
||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_activity = activity;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Evaluates a cookie principal: enforces the idle timeout, then refreshes the
|
||||
/// role/scope claims from the stored LDAP groups when the role-refresh interval has
|
||||
/// elapsed. Never throws.
|
||||
/// elapsed or a caller requires a fresh mapping. Lookup errors propagate when failClosed is set.
|
||||
/// </summary>
|
||||
/// <param name="principal">The current cookie principal under validation.</param>
|
||||
/// <param name="ct">Cancellation token (the request-aborted token in the pipeline).</param>
|
||||
/// <returns>The action the caller must take and any replacement principal.</returns>
|
||||
public async Task<SessionValidationResult> ValidateAsync(ClaimsPrincipal? principal, CancellationToken ct = default)
|
||||
public async Task<SessionValidationResult> ValidateAsync(ClaimsPrincipal? principal, CancellationToken ct = default, bool recordActivity = true, bool forceRefresh = false, bool failClosed = false)
|
||||
{
|
||||
// An unauthenticated / null principal is left to the rest of the pipeline.
|
||||
if (principal?.Identity is not { IsAuthenticated: true })
|
||||
@@ -129,16 +129,16 @@ public sealed class CookieSessionValidator
|
||||
return SessionValidationResult.Reject;
|
||||
}
|
||||
|
||||
// 2) Role-mapping refresh — best-effort. Any failure keeps the existing session.
|
||||
// 2) Ordinary refresh keeps existing grants on failure; authoritative checks propagate it.
|
||||
try
|
||||
{
|
||||
var refreshed = await TryRefreshAsync(principal, now, ct).ConfigureAwait(false);
|
||||
var refreshed = await TryRefreshAsync(principal, now, ct, recordActivity, forceRefresh).ConfigureAwait(false);
|
||||
if (refreshed is not null)
|
||||
{
|
||||
return SessionValidationResult.Replace(refreshed);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
catch (Exception ex) when (!failClosed)
|
||||
{
|
||||
// SECURITY: never broaden access and never sign the user out on a transient
|
||||
// refresh fault — keep the existing principal (current roles) and swallow.
|
||||
@@ -168,16 +168,17 @@ public sealed class CookieSessionValidator
|
||||
return true;
|
||||
}
|
||||
|
||||
lastActivity = _activity?.LastActivity(principal) ?? lastActivity;
|
||||
return (now - lastActivity).TotalMinutes > _options.IdleTimeoutMinutes;
|
||||
}
|
||||
|
||||
// Returns a rebuilt principal when the role-refresh interval has elapsed; null when
|
||||
// nothing changed. The principal is rebuilt via SessionClaimBuilder so its shape is
|
||||
// identical to /auth/login.
|
||||
private async Task<ClaimsPrincipal?> TryRefreshAsync(ClaimsPrincipal principal, DateTimeOffset now, CancellationToken ct)
|
||||
private async Task<ClaimsPrincipal?> TryRefreshAsync(ClaimsPrincipal principal, DateTimeOffset now, CancellationToken ct, bool recordActivity, bool forceRefresh)
|
||||
{
|
||||
var roleRefreshDue = IsRoleRefreshDue(principal, now);
|
||||
if (!roleRefreshDue)
|
||||
if (!roleRefreshDue && !forceRefresh)
|
||||
{
|
||||
// No mapping refresh due. We deliberately do NOT mint a new principal just to
|
||||
// advance LastActivity: the cookie middleware's SlidingExpiration already
|
||||
@@ -207,7 +208,20 @@ public sealed class CookieSessionValidator
|
||||
|
||||
// Rebuild identically to /auth/login, advancing BOTH anchors: the role-refresh
|
||||
// anchor (we just refreshed) and the idle anchor (this is a genuine request).
|
||||
return SessionClaimBuilder.Build(username, displayName, groups, scope, now);
|
||||
var rebuilt = SessionClaimBuilder.Build(username, displayName, groups, scope, now);
|
||||
var identity = (ClaimsIdentity)rebuilt.Identity!;
|
||||
var sessionId = principal.FindFirst(SessionActivityTracker.SessionIdClaim);
|
||||
identity.RemoveClaim(identity.FindFirst(SessionActivityTracker.SessionIdClaim)!);
|
||||
if (sessionId is not null) identity.AddClaim(new Claim(sessionId.Type, sessionId.Value));
|
||||
if (!recordActivity)
|
||||
{
|
||||
identity.RemoveClaim(identity.FindFirst(JwtTokenService.LastActivityClaimType)!);
|
||||
var lastActivity = _activity?.LastActivity(principal)?.ToString("o")
|
||||
?? principal.FindFirst(JwtTokenService.LastActivityClaimType)!.Value;
|
||||
identity.AddClaim(new Claim(JwtTokenService.LastActivityClaimType, lastActivity));
|
||||
}
|
||||
else _activity?.Record(rebuilt, now);
|
||||
return rebuilt;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -68,6 +68,7 @@ public static class ServiceCollectionExtensions
|
||||
// is the wall clock. TryAddSingleton keeps the Host free to register its own.
|
||||
services.TryAddSingleton(TimeProvider.System);
|
||||
services.AddScoped<CookieSessionValidator>();
|
||||
services.AddSingleton<SessionActivityTracker>();
|
||||
|
||||
// Audit Actor wiring (Phase 3): the user-facing inbound API audit path
|
||||
// sources AuditEvent.Actor from the authenticated principal via this
|
||||
@@ -157,6 +158,11 @@ public static class ServiceCollectionExtensions
|
||||
// throws — CookieSessionValidator.ValidateAsync swallows refresh faults and
|
||||
// keeps the session (mirrors "LDAP failure: active sessions continue").
|
||||
options.Events.OnValidatePrincipal = OnValidatePrincipalAsync;
|
||||
options.Events.OnCheckSlidingExpiration = context =>
|
||||
{
|
||||
if (IsPassiveSessionRequest(context.HttpContext)) context.ShouldRenew = false;
|
||||
return Task.CompletedTask;
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@@ -218,13 +224,19 @@ public static class ServiceCollectionExtensions
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>Requests that cannot by themselves establish user activity.</summary>
|
||||
internal static bool IsPassiveSessionRequest(HttpContext context)
|
||||
=> context.Request.Path.StartsWithSegments("/auth/ping")
|
||||
|| context.Request.Path.StartsWithSegments("/auth/activity")
|
||||
|| context.Request.Path.StartsWithSegments("/_blazor");
|
||||
|
||||
/// <summary>
|
||||
/// The thin <see cref="CookieAuthenticationEvents.OnValidatePrincipal"/>
|
||||
/// adapter. It resolves the request-scoped <see cref="CookieSessionValidator"/>,
|
||||
/// asks it for a decision, and applies it to the cookie context:
|
||||
/// <list type="bullet">
|
||||
/// <item><see cref="SessionValidationAction.Reject"/> → <see cref="CookieValidatePrincipalContext.RejectPrincipal"/> + sign out (idle-timeout — the only sign-out path).</item>
|
||||
/// <item><see cref="SessionValidationAction.Replace"/> → <see cref="CookieValidatePrincipalContext.ReplacePrincipal"/> + <c>ShouldRenew = true</c> (role mapping refreshed).</item>
|
||||
/// <item><see cref="SessionValidationAction.Replace"/> → <see cref="CookieValidatePrincipalContext.ReplacePrincipal"/> + renewal only for non-passive requests (role mapping refreshed).</item>
|
||||
/// <item><see cref="SessionValidationAction.Keep"/> → no-op (no refresh due, or a swallowed refresh fault).</item>
|
||||
/// </list>
|
||||
/// All logic lives in <see cref="CookieSessionValidator.ValidateAsync"/>, which never
|
||||
@@ -237,7 +249,7 @@ public static class ServiceCollectionExtensions
|
||||
var validator = context.HttpContext.RequestServices.GetRequiredService<CookieSessionValidator>();
|
||||
|
||||
var result = await validator
|
||||
.ValidateAsync(context.Principal, context.HttpContext.RequestAborted)
|
||||
.ValidateAsync(context.Principal, context.HttpContext.RequestAborted, recordActivity: !IsPassiveSessionRequest(context.HttpContext))
|
||||
.ConfigureAwait(false);
|
||||
|
||||
await ApplyValidationResultAsync(context, result).ConfigureAwait(false);
|
||||
@@ -272,7 +284,7 @@ public static class ServiceCollectionExtensions
|
||||
// Role mapping refreshed from stored groups — swap in the rebuilt
|
||||
// principal and re-issue the cookie so the new claims persist.
|
||||
context.ReplacePrincipal(result.Principal);
|
||||
context.ShouldRenew = true;
|
||||
context.ShouldRenew = !IsPassiveSessionRequest(context.HttpContext);
|
||||
break;
|
||||
|
||||
case SessionValidationAction.Keep:
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
using System.Security.Claims;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.Security;
|
||||
|
||||
/// <summary>
|
||||
/// Bounded, process-local activity shared by an HTTP cookie and its existing Blazor
|
||||
/// circuit. Only authenticated, validated interaction requests advance this clock.
|
||||
/// The protected cookie remains the durable anchor on restart or node change.
|
||||
/// </summary>
|
||||
public sealed class SessionActivityTracker
|
||||
{
|
||||
/// <summary>Opaque per-login identifier carried in the protected cookie.</summary>
|
||||
public const string SessionIdClaim = "zb:sessionid";
|
||||
private const int Capacity = 10_000;
|
||||
private readonly object _sync = new();
|
||||
private readonly Dictionary<string, DateTimeOffset> _activity = new(StringComparer.Ordinal);
|
||||
|
||||
/// <summary>Returns the newer of the protected cookie anchor and this node's observed activity.</summary>
|
||||
public DateTimeOffset? LastActivity(ClaimsPrincipal principal)
|
||||
{
|
||||
if (!DateTimeOffset.TryParse(principal.FindFirst(JwtTokenService.LastActivityClaimType)?.Value, out var stamp))
|
||||
return null;
|
||||
var id = principal.FindFirst(SessionIdClaim)?.Value;
|
||||
lock (_sync)
|
||||
return id is not null && _activity.TryGetValue(id, out var seen) && seen > stamp ? seen : stamp;
|
||||
}
|
||||
|
||||
/// <summary>Records a validated interaction without changing another login's activity.</summary>
|
||||
public void Record(ClaimsPrincipal principal, DateTimeOffset now)
|
||||
{
|
||||
var id = principal.FindFirst(SessionIdClaim)?.Value;
|
||||
if (id is null || principal.Identity?.IsAuthenticated != true) return;
|
||||
lock (_sync)
|
||||
{
|
||||
if (!_activity.ContainsKey(id) && _activity.Count >= Capacity)
|
||||
_activity.Remove(_activity.MinBy(p => p.Value).Key);
|
||||
if (!_activity.TryGetValue(id, out var previous) || now > previous)
|
||||
_activity[id] = now;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -11,10 +11,11 @@ namespace ZB.MOM.WW.ScadaBridge.Security;
|
||||
/// refresh to "rebuild claims identically to /auth/login".
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The claim shape is exactly what the login endpoint historically minted, plus the
|
||||
/// two additions:
|
||||
/// The login and refresh paths share the following identity and session claims:
|
||||
/// <list type="bullet">
|
||||
/// <item><see cref="ClaimTypes.NameIdentifier"/> — stable antiforgery identity.</item>
|
||||
/// <item><see cref="ClaimTypes.Name"/> — resolves <c>Identity.Name</c>.</item>
|
||||
/// <item><see cref="SessionActivityTracker.SessionIdClaim"/> — isolates activity to this login.</item>
|
||||
/// <item><see cref="JwtTokenService.DisplayNameClaimType"/> — human display name.</item>
|
||||
/// <item><see cref="JwtTokenService.UsernameClaimType"/> — canonical username.</item>
|
||||
/// <item><see cref="JwtTokenService.RoleClaimType"/> — one per mapped role.</item>
|
||||
@@ -64,13 +65,15 @@ public static class SessionClaimBuilder
|
||||
|
||||
var claims = new List<Claim>
|
||||
{
|
||||
// Antiforgery binds tokens to this stable identity, not mutable role/activity claims.
|
||||
new(ClaimTypes.NameIdentifier, username),
|
||||
new(ClaimTypes.Name, username),
|
||||
new(SessionActivityTracker.SessionIdClaim, Guid.NewGuid().ToString("N")),
|
||||
new(JwtTokenService.DisplayNameClaimType, displayName),
|
||||
new(JwtTokenService.UsernameClaimType, username),
|
||||
// Role-refresh anchor AND idle anchor are seeded from the same instant at
|
||||
// build time. They then diverge: OnValidatePrincipal advances LastActivity
|
||||
// on every request but only advances LastRoleRefresh when it actually
|
||||
// re-runs the mapping.
|
||||
// build time. Passive requests preserve LastActivity; accepted activity
|
||||
// advances it. LastRoleRefresh advances when the mapping is re-run.
|
||||
new(JwtTokenService.LastRoleRefreshClaimType, refreshStamp),
|
||||
new(JwtTokenService.LastActivityClaimType, refreshStamp),
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user