feat(security): cookie session idle-timeout + LDAP-free role-mapping refresh (#15, M2.19)
Spike outcome: the shared ILdapAuthService (ZB.MOM.WW.Auth.Abstractions, an external
NuGet package) exposes ONLY AuthenticateAsync(username, password, ct) — no passwordless
service-account group-search. A live LDAP group re-query for an active session therefore
requires a new lib method and is OUT OF SCOPE (cannot modify the external package).
Implemented the always-achievable layers (cookie-only; no embedded JWT for cookie principals):
- /auth/login now stores the user's raw LDAP groups (one zb:group claim each) plus a
zb:lastrolerefresh anchor (login time, UTC), seeding the LastActivity idle anchor too.
- SessionClaimBuilder: single shared DRY claim-builder used by BOTH /auth/login AND the
refresh path, so the two claim shapes cannot drift (canonical identity/role/scope claims
with nameType/roleType pinned, plus the M2.19 group + refresh-anchor additions).
- CookieSessionValidator (TimeProvider-injected, unit-testable) + a thin
CookieAuthenticationEvents.OnValidatePrincipal adapter:
* idle-timeout: a session past IdleTimeoutMinutes (default 30) is RejectPrincipal+SignOut;
consistent with the cookie ExpireTimeSpan+SlidingExpiration window (same value).
* role refresh WITHOUT LDAP: when older than RoleRefreshThresholdMinutes (new option,
default 15) the DB-backed RoleMapper re-runs on the STORED groups, claims are rebuilt
via the shared builder, the anchor advances, principal is replaced + cookie renewed.
Revoked DB mappings drop the user's roles mid-session.
* fail-soft: any refresh error KEEPS the existing principal (no sign-out, never throws)
— mirrors the documented "LDAP failure: active sessions continue with current roles".
- Documented residual limitation in Component-Security.md: central role-mapping/scope
changes apply within ~15 min without LDAP; live directory group-membership changes are
picked up only at next login (needs a passwordless group-search on the external
ZB.MOM.WW.Auth.Ldap lib — tracked follow-up).
Tests (Security.Tests, all green): CookieSessionValidatorTests + SessionClaimBuilderParityTests
— idle reject/keep, LDAP-free remap-from-stored-groups, revoked-roles loss, sub-threshold
no-refresh, refresh-throws-keeps-session, and login/refresh claim-parity.
This commit is contained in:
@@ -1,5 +1,8 @@
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.AspNetCore.Authentication.Cookies;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using ZB.MOM.WW.Auth.Abstractions.Roles;
|
||||
@@ -51,6 +54,14 @@ public static class ServiceCollectionExtensions
|
||||
services.AddScoped<JwtTokenService>();
|
||||
services.AddScoped<RoleMapper>();
|
||||
|
||||
// M2.19 (#15): the cookie OnValidatePrincipal core. Scoped to match the
|
||||
// IGroupRoleMapper<string> it depends on (which depends on the Scoped
|
||||
// ISecurityRepository). The clock is injected (TimeProvider) so the idle/refresh
|
||||
// thresholds can be exercised deterministically in tests; the production default
|
||||
// is the wall clock. TryAddSingleton keeps the Host free to register its own.
|
||||
services.TryAddSingleton(TimeProvider.System);
|
||||
services.AddScoped<CookieSessionValidator>();
|
||||
|
||||
// Audit Actor wiring (Phase 3): the user-facing inbound API audit path
|
||||
// sources AuditEvent.Actor from the authenticated principal via this
|
||||
// seam. HttpAuditActorAccessor reads IHttpContextAccessor.HttpContext?.User
|
||||
@@ -94,6 +105,16 @@ public static class ServiceCollectionExtensions
|
||||
// environments sharing a hostname can be given distinct names. HttpOnly /
|
||||
// SameSite / SecurePolicy / SlidingExpiration / ExpireTimeSpan are likewise
|
||||
// applied there via ZbCookieDefaults.Apply.
|
||||
|
||||
// M2.19 (#15): OnValidatePrincipal enforces the idle timeout and refreshes
|
||||
// the role/scope claims from the session's STORED LDAP groups (DB-backed
|
||||
// RoleMapper, NO LDAP) so central role-mapping changes take effect
|
||||
// mid-session. The lambda is a THIN adapter: it resolves the request-scoped
|
||||
// CookieSessionValidator (which holds all the testable idle/refresh logic)
|
||||
// and translates its decision into the cookie context calls. It NEVER
|
||||
// throws — CookieSessionValidator.ValidateAsync swallows refresh faults and
|
||||
// keeps the session (mirrors "LDAP failure: active sessions continue").
|
||||
options.Events.OnValidatePrincipal = OnValidatePrincipalAsync;
|
||||
});
|
||||
|
||||
// CentralUI-005: configure the cookie session as a sliding window so the
|
||||
@@ -152,6 +173,53 @@ public static class ServiceCollectionExtensions
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// M2.19 (#15): 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.Keep"/> → no-op (no refresh due, or a swallowed refresh fault).</item>
|
||||
/// </list>
|
||||
/// All logic lives in <see cref="CookieSessionValidator.ValidateAsync"/>, which never
|
||||
/// throws, so this adapter cannot bubble an exception out into the request pipeline.
|
||||
/// </summary>
|
||||
/// <param name="context">The cookie validation context supplied by the middleware.</param>
|
||||
/// <returns>A task that completes when the decision has been applied.</returns>
|
||||
internal static async Task OnValidatePrincipalAsync(CookieValidatePrincipalContext context)
|
||||
{
|
||||
var validator = context.HttpContext.RequestServices.GetRequiredService<CookieSessionValidator>();
|
||||
|
||||
var result = await validator
|
||||
.ValidateAsync(context.Principal, context.HttpContext.RequestAborted)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
switch (result.Action)
|
||||
{
|
||||
case SessionValidationAction.Reject:
|
||||
// Idle-timeout: drop the principal AND clear the cookie so the next
|
||||
// request is treated as anonymous and redirected to /login.
|
||||
context.RejectPrincipal();
|
||||
await context.HttpContext
|
||||
.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme)
|
||||
.ConfigureAwait(false);
|
||||
break;
|
||||
|
||||
case SessionValidationAction.Replace when result.Principal is not null:
|
||||
// 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;
|
||||
break;
|
||||
|
||||
case SessionValidationAction.Keep:
|
||||
default:
|
||||
// Leave the principal untouched.
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Registers security-related Akka actors (placeholder for future actor registrations).
|
||||
/// </summary>
|
||||
|
||||
Reference in New Issue
Block a user