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>
|
||||
|
||||
Reference in New Issue
Block a user