eabf270d71
Resolve all 622 issues flagged by the enhanced CommentChecker: add missing <returns> tags (incl. the standard phrasing on non-generic Task methods), add missing <summary> tags, and replace misused/redundant <inheritdoc/> on members that override or implement nothing with real documentation. Documentation-only — no behavior change; solution builds clean.
279 lines
14 KiB
C#
279 lines
14 KiB
C#
using System.IdentityModel.Tokens.Jwt;
|
|
using System.Security.Claims;
|
|
using System.Text;
|
|
using Microsoft.Extensions.Logging;
|
|
using Microsoft.Extensions.Options;
|
|
using Microsoft.IdentityModel.Tokens;
|
|
using ZB.MOM.WW.Auth.AspNetCore;
|
|
|
|
namespace ZB.MOM.WW.ScadaBridge.Security;
|
|
|
|
public class JwtTokenService
|
|
{
|
|
private readonly SecurityOptions _options;
|
|
private readonly ILogger<JwtTokenService> _logger;
|
|
|
|
// Task 1.5 (full canonical claims): these constants are the single source of
|
|
// truth that every mint site, every authorization policy, and the token
|
|
// validation parameters reference. Redefining them as aliases of the shared
|
|
// ZbClaimTypes vocabulary migrates the whole module centrally:
|
|
// - Role → the framework URI (ClaimTypes.Role) so [Authorize(Roles=…)],
|
|
// IsInRole, AND RequireClaim(RoleClaimType,…) all resolve.
|
|
// - SiteId → ZbClaimTypes.ScopeId ("zb:scopeid") — the canonical scope claim.
|
|
// - DisplayName/Username → the canonical "zb:" strings.
|
|
// LastActivity has no canonical equivalent (it is a ScadaBridge-internal
|
|
// idle-timeout anchor), so it keeps its existing literal.
|
|
public const string DisplayNameClaimType = ZbClaimTypes.DisplayName;
|
|
public const string UsernameClaimType = ZbClaimTypes.Username;
|
|
public const string RoleClaimType = ZbClaimTypes.Role;
|
|
public const string SiteIdClaimType = ZbClaimTypes.ScopeId;
|
|
public const string LastActivityClaimType = "LastActivity";
|
|
|
|
/// <summary>
|
|
/// Fixed issuer bound into every token and required on validation. Binding
|
|
/// issuer/audience is defence-in-depth: even though the HMAC key is shared only
|
|
/// between the two central nodes, accidental reuse of the same secret for an
|
|
/// unrelated internal token would otherwise be silently exploitable.
|
|
/// </summary>
|
|
public const string TokenIssuer = "scadabridge-central";
|
|
|
|
/// <summary>Fixed audience bound into every token and required on validation.</summary>
|
|
public const string TokenAudience = "scadabridge-central";
|
|
|
|
/// <summary>Initializes the service and validates the JWT signing key meets the minimum length requirement.</summary>
|
|
/// <param name="options">Security options containing the JWT signing key, expiry, and timeout settings.</param>
|
|
/// <param name="logger">Logger instance.</param>
|
|
public JwtTokenService(IOptions<SecurityOptions> options, ILogger<JwtTokenService> logger)
|
|
{
|
|
_options = options?.Value ?? throw new ArgumentNullException(nameof(options));
|
|
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
|
|
|
// Fail fast: a missing or short signing key produces trivially forgeable tokens.
|
|
// HMAC-SHA256 requires a key of at least 256 bits (32 bytes).
|
|
var keyByteLength = string.IsNullOrEmpty(_options.JwtSigningKey)
|
|
? 0
|
|
: Encoding.UTF8.GetByteCount(_options.JwtSigningKey);
|
|
if (keyByteLength < SecurityOptions.MinJwtSigningKeyBytes)
|
|
{
|
|
throw new InvalidOperationException(
|
|
$"SecurityOptions.JwtSigningKey must be at least {SecurityOptions.MinJwtSigningKeyBytes} bytes " +
|
|
$"(256 bits) for HMAC-SHA256; the configured key is {keyByteLength} byte(s). " +
|
|
"Configure a strong signing key before starting the service.");
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Issues a fresh JWT. <paramref name="lastActivity"/> sets the idle-timeout
|
|
/// anchor; when omitted (a brand-new login) it defaults to now. On a token
|
|
/// refresh the caller MUST pass the existing anchor forward so the idle window
|
|
/// continues to be measured from the user's last genuine activity rather than
|
|
/// from token issuance time.
|
|
/// </summary>
|
|
/// <param name="displayName">Human-readable display name embedded as a claim.</param>
|
|
/// <param name="username">Authenticated username embedded as a claim.</param>
|
|
/// <param name="roles">Role names to embed as <see cref="RoleClaimType"/> claims.</param>
|
|
/// <param name="permittedSiteIds">Site identifiers the user may deploy to; null for system-wide access.</param>
|
|
/// <param name="lastActivity">Idle-timeout anchor; defaults to now when null (fresh login).</param>
|
|
/// <returns>The signed JWT string, ready to embed in a cookie or bearer header.</returns>
|
|
public string GenerateToken(
|
|
string displayName,
|
|
string username,
|
|
IReadOnlyList<string> roles,
|
|
IReadOnlyList<string>? permittedSiteIds,
|
|
DateTimeOffset? lastActivity = null)
|
|
{
|
|
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_options.JwtSigningKey));
|
|
var credentials = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);
|
|
|
|
var claims = new List<Claim>
|
|
{
|
|
new(DisplayNameClaimType, displayName),
|
|
new(UsernameClaimType, username),
|
|
new(LastActivityClaimType, (lastActivity ?? DateTimeOffset.UtcNow).ToString("o"))
|
|
};
|
|
|
|
foreach (var role in roles)
|
|
{
|
|
claims.Add(new Claim(RoleClaimType, role));
|
|
}
|
|
|
|
if (permittedSiteIds != null)
|
|
{
|
|
foreach (var siteId in permittedSiteIds)
|
|
{
|
|
claims.Add(new Claim(SiteIdClaimType, siteId));
|
|
}
|
|
}
|
|
|
|
var token = new JwtSecurityToken(
|
|
issuer: TokenIssuer,
|
|
audience: TokenAudience,
|
|
claims: claims,
|
|
expires: DateTime.UtcNow.AddMinutes(_options.JwtExpiryMinutes),
|
|
signingCredentials: credentials);
|
|
|
|
// MapOutboundClaims=false: write the claim TYPE strings into the JWT
|
|
// verbatim (e.g. the ClaimTypes.Role URI for RoleClaimType) rather than
|
|
// letting JwtSecurityTokenHandler.DefaultOutboundClaimTypeMap rewrite the
|
|
// framework URI to a short JWT name ("role"). Paired with
|
|
// MapInboundClaims=false on validation (see ValidateToken), this makes the
|
|
// claim type the policy checks — RequireClaim(RoleClaimType, …) — byte-for-byte
|
|
// the same string both in the token and after it is read back, with no
|
|
// mapping round-trip surprises. The "zb:" scope/display/username claims are
|
|
// not in any map and were unaffected either way; pinning both directions
|
|
// makes the role/site migration to canonical types deterministic.
|
|
var handler = new JwtSecurityTokenHandler { MapInboundClaims = false };
|
|
handler.OutboundClaimTypeMap.Clear();
|
|
return handler.WriteToken(token);
|
|
}
|
|
|
|
/// <summary>Validates a JWT string and returns the decoded <see cref="ClaimsPrincipal"/>, or null if the token is invalid or expired.</summary>
|
|
/// <param name="token">The JWT string to validate.</param>
|
|
/// <returns>The decoded principal, or <c>null</c> if validation fails.</returns>
|
|
public ClaimsPrincipal? ValidateToken(string token)
|
|
{
|
|
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_options.JwtSigningKey));
|
|
var validationParameters = new TokenValidationParameters
|
|
{
|
|
ValidateIssuer = true,
|
|
ValidIssuer = TokenIssuer,
|
|
ValidateAudience = true,
|
|
ValidAudience = TokenAudience,
|
|
ValidateLifetime = true,
|
|
ValidateIssuerSigningKey = true,
|
|
IssuerSigningKey = key,
|
|
ClockSkew = TimeSpan.Zero,
|
|
|
|
// The token was minted carrying the canonical claim TYPE strings
|
|
// verbatim (RoleClaimType = ClaimTypes.Role URI, SiteIdClaimType =
|
|
// ZbClaimTypes.ScopeId, etc.). Pin the same role/name claim types here
|
|
// so the validated principal's Identity.Name and IsInRole resolve, and
|
|
// so RequireClaim(RoleClaimType, …) sees exactly the type that is in the
|
|
// token.
|
|
RoleClaimType = RoleClaimType,
|
|
NameClaimType = ZbClaimTypes.Name
|
|
};
|
|
|
|
try
|
|
{
|
|
// MapInboundClaims=false: do NOT let JwtSecurityTokenHandler rewrite
|
|
// inbound claim types via DefaultInboundClaimTypeMap. The token already
|
|
// holds the canonical type strings (see GenerateToken's
|
|
// MapOutboundClaims=false), so reading them back unmapped yields exactly
|
|
// the strings every policy + claim helper expects.
|
|
var handler = new JwtSecurityTokenHandler { MapInboundClaims = false };
|
|
var principal = handler.ValidateToken(token, validationParameters, out _);
|
|
return principal;
|
|
}
|
|
catch (Exception ex) when (ex is SecurityTokenException or ArgumentException)
|
|
{
|
|
_logger.LogDebug(ex, "Token validation failed");
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/// <summary>Returns true if the token carried by <paramref name="principal"/> is within the sliding-refresh threshold and should be reissued.</summary>
|
|
/// <param name="principal">The current authenticated principal.</param>
|
|
/// <returns><c>true</c> when the remaining token lifetime falls below <see cref="SecurityOptions.JwtRefreshThresholdMinutes"/>.</returns>
|
|
public bool ShouldRefresh(ClaimsPrincipal principal)
|
|
{
|
|
var expClaim = principal.FindFirst("exp");
|
|
if (expClaim == null || !long.TryParse(expClaim.Value, out var expUnix))
|
|
return false;
|
|
|
|
var expiry = DateTimeOffset.FromUnixTimeSeconds(expUnix);
|
|
var remaining = expiry - DateTimeOffset.UtcNow;
|
|
|
|
return remaining.TotalMinutes < _options.JwtRefreshThresholdMinutes;
|
|
}
|
|
|
|
/// <summary>Returns true if the session has exceeded the idle timeout based on the <see cref="LastActivityClaimType"/> claim.</summary>
|
|
/// <param name="principal">The current authenticated principal.</param>
|
|
/// <returns><c>true</c> when the elapsed time since last activity exceeds <see cref="SecurityOptions.IdleTimeoutMinutes"/>.</returns>
|
|
public bool IsIdleTimedOut(ClaimsPrincipal principal)
|
|
{
|
|
var lastActivityClaim = principal.FindFirst(LastActivityClaimType);
|
|
if (lastActivityClaim == null || !DateTimeOffset.TryParse(lastActivityClaim.Value, out var lastActivity))
|
|
return true;
|
|
|
|
return (DateTimeOffset.UtcNow - lastActivity).TotalMinutes > _options.IdleTimeoutMinutes;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Issues a fresh token (new expiry, re-queried roles) while <b>preserving</b> the
|
|
/// existing <see cref="LastActivityClaimType"/> anchor. A refresh is itself
|
|
/// triggered by a request, but it must not be treated as user activity — the
|
|
/// idle window must keep being measured from the user's last genuine interaction,
|
|
/// otherwise the documented 30-minute idle timeout could never fire for a client
|
|
/// that polls in the background. Call <see cref="RecordActivity"/> to advance the
|
|
/// anchor when handling a genuine user request.
|
|
/// <para>
|
|
/// A principal that is already past the idle timeout cannot be refreshed: this
|
|
/// method returns <c>null</c> (the same "cannot refresh" signal it uses for missing
|
|
/// claims). Enforcing the idle check here — rather than relying on the caller to
|
|
/// invoke <see cref="IsIdleTimedOut"/> first — guarantees the documented 30-minute
|
|
/// idle policy holds regardless of caller discipline; otherwise an idle-expired
|
|
/// session could be kept alive indefinitely by background refreshes (Security-014).
|
|
/// </para>
|
|
/// </summary>
|
|
/// <param name="currentPrincipal">The current authenticated principal whose claims carry identity and the last-activity anchor.</param>
|
|
/// <param name="currentRoles">Re-queried role list to embed in the new token.</param>
|
|
/// <param name="permittedSiteIds">Site identifiers the user may deploy to; null for system-wide access.</param>
|
|
/// <returns>A new token string, or <c>null</c> if the session is idle-expired or claims are missing.</returns>
|
|
public string? RefreshToken(ClaimsPrincipal currentPrincipal, IReadOnlyList<string> currentRoles, IReadOnlyList<string>? permittedSiteIds)
|
|
{
|
|
var displayName = currentPrincipal.FindFirst(DisplayNameClaimType)?.Value;
|
|
var username = currentPrincipal.FindFirst(UsernameClaimType)?.Value;
|
|
|
|
if (displayName == null || username == null)
|
|
{
|
|
_logger.LogWarning("Cannot refresh token: missing DisplayName or Username claims");
|
|
return null;
|
|
}
|
|
|
|
// An idle-expired session must not be renewed — the user must re-login.
|
|
if (IsIdleTimedOut(currentPrincipal))
|
|
{
|
|
_logger.LogInformation(
|
|
"Cannot refresh token for {Username}: session is past the idle timeout", username);
|
|
return null;
|
|
}
|
|
|
|
return GenerateToken(displayName, username, currentRoles, permittedSiteIds,
|
|
ReadLastActivity(currentPrincipal));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Issues a fresh token whose <see cref="LastActivityClaimType"/> anchor is
|
|
/// advanced to now. This is the explicit "user did something" path — distinct
|
|
/// from <see cref="RefreshToken"/> — to be called by the request pipeline when
|
|
/// handling a genuine user interaction.
|
|
/// </summary>
|
|
/// <param name="currentPrincipal">The current authenticated principal.</param>
|
|
/// <param name="currentRoles">Re-queried role list to embed in the new token.</param>
|
|
/// <param name="permittedSiteIds">Site identifiers the user may deploy to; null for system-wide access.</param>
|
|
/// <returns>A new token string with an updated last-activity anchor, or <c>null</c> if claims are missing.</returns>
|
|
public string? RecordActivity(ClaimsPrincipal currentPrincipal, IReadOnlyList<string> currentRoles, IReadOnlyList<string>? permittedSiteIds)
|
|
{
|
|
var displayName = currentPrincipal.FindFirst(DisplayNameClaimType)?.Value;
|
|
var username = currentPrincipal.FindFirst(UsernameClaimType)?.Value;
|
|
|
|
if (displayName == null || username == null)
|
|
{
|
|
_logger.LogWarning("Cannot record activity: missing DisplayName or Username claims");
|
|
return null;
|
|
}
|
|
|
|
return GenerateToken(displayName, username, currentRoles, permittedSiteIds,
|
|
DateTimeOffset.UtcNow);
|
|
}
|
|
|
|
private static DateTimeOffset? ReadLastActivity(ClaimsPrincipal principal)
|
|
{
|
|
var claim = principal.FindFirst(LastActivityClaimType);
|
|
return claim != null && DateTimeOffset.TryParse(claim.Value, out var value)
|
|
? value
|
|
: null;
|
|
}
|
|
}
|