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:
@@ -0,0 +1,116 @@
|
||||
using System.Security.Claims;
|
||||
using Microsoft.AspNetCore.Authentication.Cookies;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.Security;
|
||||
|
||||
/// <summary>
|
||||
/// M2.19 (#15): the single, shared source of truth for the FULL set of claims that
|
||||
/// back an interactive cookie session. BOTH the <c>/auth/login</c> endpoint and the
|
||||
/// <c>OnValidatePrincipal</c> mid-session role-refresh path build their principal
|
||||
/// through <see cref="Build"/>, so the two can never drift — the spec requires the
|
||||
/// refresh to "rebuild claims identically to /auth/login".
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The claim shape is exactly what the login endpoint historically minted, plus the
|
||||
/// two M2.19 additions:
|
||||
/// <list type="bullet">
|
||||
/// <item><see cref="ClaimTypes.Name"/> — resolves <c>Identity.Name</c>.</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>
|
||||
/// <item><see cref="JwtTokenService.SiteIdClaimType"/> — one per permitted site,
|
||||
/// ONLY when the mapping is not system-wide (deny-by-omission preserved).</item>
|
||||
/// <item><see cref="JwtTokenService.GroupClaimType"/> — one per raw LDAP group
|
||||
/// (M2.19): the durable input the mid-session RoleMapper re-run consumes.</item>
|
||||
/// <item><see cref="JwtTokenService.LastRoleRefreshClaimType"/> — the role-mapping
|
||||
/// refresh anchor (M2.19), ISO-8601 round-trippable.</item>
|
||||
/// <item><see cref="JwtTokenService.LastActivityClaimType"/> — the idle-timeout
|
||||
/// anchor; seeded to the refresh timestamp at login so idle-timeout can be
|
||||
/// enforced consistently from the very first request.</item>
|
||||
/// </list>
|
||||
/// The <see cref="ClaimsIdentity"/> is built with <c>nameType = ClaimTypes.Name</c>
|
||||
/// and <c>roleType = RoleClaimType</c> so <c>Identity.Name</c> / <c>IsInRole</c> /
|
||||
/// <c>[Authorize(Roles=…)]</c> resolve against exactly the canonical types minted here.
|
||||
/// </remarks>
|
||||
public static class SessionClaimBuilder
|
||||
{
|
||||
/// <summary>
|
||||
/// Builds the full cookie-session <see cref="ClaimsPrincipal"/> from the resolved
|
||||
/// identity, the raw LDAP groups, the DB-backed role mapping, and the refresh
|
||||
/// timestamp. Used identically by <c>/auth/login</c> and the
|
||||
/// <c>OnValidatePrincipal</c> refresh path so the two cannot diverge.
|
||||
/// </summary>
|
||||
/// <param name="username">The canonical authenticated username (becomes <see cref="ClaimTypes.Name"/> + <see cref="JwtTokenService.UsernameClaimType"/>).</param>
|
||||
/// <param name="displayName">The human-readable display name.</param>
|
||||
/// <param name="groups">The user's raw LDAP groups, stored one per <see cref="JwtTokenService.GroupClaimType"/> claim.</param>
|
||||
/// <param name="mapping">The DB-backed role mapping (roles + permitted sites + system-wide flag).</param>
|
||||
/// <param name="refreshTimestamp">The role-mapping refresh anchor; also seeds the last-activity anchor.</param>
|
||||
/// <param name="authenticationType">The authentication type stamped on the identity (defaults to the cookie scheme).</param>
|
||||
/// <returns>A fully populated cookie <see cref="ClaimsPrincipal"/>.</returns>
|
||||
public static ClaimsPrincipal Build(
|
||||
string username,
|
||||
string displayName,
|
||||
IReadOnlyList<string> groups,
|
||||
RoleMappingResult mapping,
|
||||
DateTimeOffset refreshTimestamp,
|
||||
string authenticationType = CookieAuthenticationDefaults.AuthenticationScheme)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(username);
|
||||
ArgumentNullException.ThrowIfNull(displayName);
|
||||
ArgumentNullException.ThrowIfNull(groups);
|
||||
ArgumentNullException.ThrowIfNull(mapping);
|
||||
|
||||
var refreshStamp = refreshTimestamp.ToString("o");
|
||||
|
||||
var claims = new List<Claim>
|
||||
{
|
||||
new(ClaimTypes.Name, username),
|
||||
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.
|
||||
new(JwtTokenService.LastRoleRefreshClaimType, refreshStamp),
|
||||
new(JwtTokenService.LastActivityClaimType, refreshStamp),
|
||||
};
|
||||
|
||||
foreach (var role in mapping.Roles)
|
||||
{
|
||||
claims.Add(new Claim(JwtTokenService.RoleClaimType, role));
|
||||
}
|
||||
|
||||
// Deny-by-omission: only stamp SiteId claims for a non-system-wide mapping.
|
||||
if (!mapping.IsSystemWideDeployment)
|
||||
{
|
||||
foreach (var siteId in mapping.PermittedSiteIds)
|
||||
{
|
||||
claims.Add(new Claim(JwtTokenService.SiteIdClaimType, siteId));
|
||||
}
|
||||
}
|
||||
|
||||
// Store the raw LDAP groups so the mid-session refresh can re-run the
|
||||
// DB-backed RoleMapper without any LDAP round-trip.
|
||||
foreach (var group in groups)
|
||||
{
|
||||
claims.Add(new Claim(JwtTokenService.GroupClaimType, group));
|
||||
}
|
||||
|
||||
var identity = new ClaimsIdentity(
|
||||
claims,
|
||||
authenticationType: authenticationType,
|
||||
nameType: ClaimTypes.Name,
|
||||
roleType: JwtTokenService.RoleClaimType);
|
||||
|
||||
return new ClaimsPrincipal(identity);
|
||||
}
|
||||
|
||||
/// <summary>Reads the stored LDAP group claims (<see cref="JwtTokenService.GroupClaimType"/>) off a principal.</summary>
|
||||
/// <param name="principal">The cookie principal to read from.</param>
|
||||
/// <returns>The stored LDAP group names; empty if none were stored.</returns>
|
||||
public static IReadOnlyList<string> ReadGroups(ClaimsPrincipal principal)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(principal);
|
||||
return principal.FindAll(JwtTokenService.GroupClaimType).Select(c => c.Value).ToList();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user