9cff87fe85
Remove project bookkeeping citations from shipped code comments across the solution: hyphenated task IDs (WP-14, StoreAndForward-025), milestone/task/ issue refs (M3, Task 4, Audit Log #23, #21), Bundle X task-bundle labels, and C/D/K/S/T phase labels. Comment text only — no code logic, string/log literals, or XML-doc structure changed. Genuine descriptions are preserved (only the citation is stripped), and technical lookalikes are retained (UTF-8, SHA-256, T00:00:00, M365, UTC-5, pre-C4/pre-C5 schema versions). Flagged by the new CommentChecker TaskReferenceInComment / TrackingReferenceInComment checks plus targeted grep passes; full solution builds clean, append-only guard tests pass.
117 lines
5.9 KiB
C#
117 lines
5.9 KiB
C#
using System.Security.Claims;
|
|
using Microsoft.AspNetCore.Authentication.Cookies;
|
|
|
|
namespace ZB.MOM.WW.ScadaBridge.Security;
|
|
|
|
/// <summary>
|
|
/// 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 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
|
|
/// — the durable input the mid-session RoleMapper re-run consumes.</item>
|
|
/// <item><see cref="JwtTokenService.LastRoleRefreshClaimType"/> — the role-mapping
|
|
/// refresh anchor, 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();
|
|
}
|
|
}
|