fddc69545f
- Add SecurityOptionsValidator (IValidateOptions<SecurityOptions>) enforcing RoleRefreshThresholdMinutes < IdleTimeoutMinutes; registered with ValidateOnStart in AddSecurity — startup FAILS if threshold >= idle, so the invariant cannot be silently misconfigured away. - Update SecurityOptions XML-docs: class-level summary distinguishes JWT Bearer path (JwtSigningKey/JwtExpiryMinutes) from Blazor cookie session path (IdleTimeoutMinutes/ RoleRefreshThresholdMinutes); both time fields document the ~45-min effective idle window and the new cross-field constraint. - Remove dead jwtService variable from /auth/login lambda in AuthEndpoints.cs (resolved but never used since login moved to SessionClaimBuilder). - Extract ApplyValidationResultAsync helper from OnValidatePrincipalAsync (pure decision-application step); add 3 adapter tests covering Reject → RejectPrincipal + SignOutAsync; Replace → ReplacePrincipal + ShouldRenew; Keep → no-op. - Fix inaccurate TryRefreshAsync comment (dropped "OR last-activity needs advancing" — the code only returns non-null when roleRefreshDue). - Add InternalsVisibleTo for Security.Tests in Security.csproj. - Add IsRoleRefreshDue tests: missing claim → due; unparsable claim → due; plus integration test covering the full ValidateAsync path for a principal missing zb:lastrolerefresh (triggers refresh + re-stamps anchor rather than keeping stale principal forever). - Add SecurityOptionsValidatorConfigGuardTests: default succeeds; equal fails; greater fails; boundary (idle-1) succeeds; wiring confirmed via AddSecurity container.
143 lines
7.6 KiB
C#
143 lines
7.6 KiB
C#
namespace ZB.MOM.WW.ScadaBridge.Security;
|
|
|
|
/// <summary>
|
|
/// Non-LDAP security configuration for the ScadaBridge Central UI.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// <b>JWT Bearer path (<c>/auth/token</c>)</b>: <see cref="JwtSigningKey"/> and
|
|
/// <see cref="JwtExpiryMinutes"/> govern the short-lived Bearer token issued to
|
|
/// the CLI / Inbound API. They have no effect on the Blazor cookie session.
|
|
/// </para>
|
|
/// <para>
|
|
/// <b>Blazor cookie session</b>: <see cref="IdleTimeoutMinutes"/> and
|
|
/// <see cref="RoleRefreshThresholdMinutes"/> govern the cookie-only session used by
|
|
/// the Blazor Server UI. There is no embedded JWT in this path — the cookie is
|
|
/// HttpOnly/Secure and managed entirely by ASP.NET Core cookie authentication.
|
|
/// </para>
|
|
/// <para>
|
|
/// Task 1.2/1.4 cutover: the LDAP connection settings that used to live here as
|
|
/// flat <c>Ldap*</c> keys (server, port, transport, search base, service account,
|
|
/// attributes, timeout) moved into a nested <c>ScadaBridge:Security:Ldap</c>
|
|
/// sub-section bound to the shared <c>ZB.MOM.WW.Auth.Abstractions.Ldap.LdapOptions</c>
|
|
/// and registered via <c>AddZbLdapAuth</c>. This is a BREAKING config-key change —
|
|
/// see CHANGELOG. The non-LDAP fields below are unchanged and still bound from
|
|
/// <c>ScadaBridge:Security</c>.
|
|
/// </para>
|
|
/// </remarks>
|
|
public class SecurityOptions
|
|
{
|
|
/// <summary>
|
|
/// Symmetric HMAC-SHA256 signing key for cookie-embedded JWTs. Must be at least
|
|
/// 32 bytes (256 bits) — validated at <see cref="JwtTokenService"/> construction.
|
|
/// </summary>
|
|
public string JwtSigningKey { get; set; } = string.Empty;
|
|
|
|
/// <summary>
|
|
/// Minimum signing-key length in bytes required for HMAC-SHA256 (256 bits).
|
|
/// </summary>
|
|
public const int MinJwtSigningKeyBytes = 32;
|
|
/// <summary>Cookie-embedded JWT lifetime in minutes before it must be refreshed.</summary>
|
|
public int JwtExpiryMinutes { get; set; } = 15;
|
|
/// <summary>
|
|
/// Session idle timeout in minutes for the Blazor cookie session; sessions inactive
|
|
/// beyond this are expired and the user is redirected to <c>/login</c>. Default: <b>30</b>.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Because <see cref="RoleRefreshThresholdMinutes"/> is the only operation that advances
|
|
/// the <c>LastActivity</c> anchor, the effective maximum idle window before a session is
|
|
/// guaranteed to be rejected is approximately
|
|
/// <c>IdleTimeoutMinutes + RoleRefreshThresholdMinutes</c> (~45 minutes with defaults).
|
|
/// This is intentional and mirrors the cookie middleware's own <c>SlidingExpiration</c>
|
|
/// fuzziness. Must be strictly greater than <see cref="RoleRefreshThresholdMinutes"/>
|
|
/// (enforced at startup by <see cref="SecurityOptionsValidator"/>).
|
|
/// </remarks>
|
|
public int IdleTimeoutMinutes { get; set; } = 30;
|
|
|
|
/// <summary>
|
|
/// Minutes before token expiry to trigger refresh.
|
|
/// </summary>
|
|
public int JwtRefreshThresholdMinutes { get; set; } = 5;
|
|
|
|
/// <summary>
|
|
/// M2.19 (#15): how long a cookie session's role-mapping projection may be stale
|
|
/// before <c>OnValidatePrincipal</c> re-runs the DB-backed <c>RoleMapper</c> on the
|
|
/// session's stored LDAP group claims and rebuilds the role/scope claims. Default:
|
|
/// <b>15 minutes</b>, matching the documented sliding-refresh cadence.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// This is a purely central (database) refresh — it picks up LDAP-group→role mapping
|
|
/// changes and scope-rule changes WITHOUT contacting LDAP, so revoked roles take effect
|
|
/// within this window. It does NOT pick up live LDAP group-membership changes (the
|
|
/// shared LDAP library exposes no passwordless group-search; that remains a
|
|
/// next-login refresh — see Component-Security.md).
|
|
/// <para>
|
|
/// Because a role-refresh is also the only operation that advances the
|
|
/// <c>LastActivity</c> anchor, the effective maximum idle window is approximately
|
|
/// <c><see cref="IdleTimeoutMinutes"/> + RoleRefreshThresholdMinutes</c> (~45 minutes
|
|
/// with defaults). Must be strictly less than <see cref="IdleTimeoutMinutes"/>
|
|
/// (enforced at startup by <see cref="SecurityOptionsValidator"/>).
|
|
/// </para>
|
|
/// </remarks>
|
|
public int RoleRefreshThresholdMinutes { get; set; } = 15;
|
|
|
|
/// <summary>
|
|
/// When true (default) the authentication cookie is always marked
|
|
/// <c>Secure</c> (sent only over HTTPS) — the correct production setting,
|
|
/// since the cookie carries the embedded JWT bearer credential. Set false
|
|
/// for an HTTP-only deployment such as the local Docker dev cluster: the
|
|
/// cookie then uses <c>SameAsRequest</c>, so it is still <c>Secure</c> on
|
|
/// any HTTPS request but is usable over plain HTTP.
|
|
/// </summary>
|
|
public bool RequireHttpsCookie { get; set; } = true;
|
|
|
|
/// <summary>The canonical default authentication-cookie name (<see cref="CookieName"/>).</summary>
|
|
public const string DefaultCookieName = "ZB.MOM.WW.ScadaBridge.Auth";
|
|
|
|
/// <summary>
|
|
/// Authentication cookie name. Defaults to <see cref="DefaultCookieName"/>. Override it
|
|
/// (<c>ScadaBridge:Security:CookieName</c>) to give a distinct name to a deployment that
|
|
/// shares a hostname with another ScadaBridge environment — browser cookies are scoped by
|
|
/// host+path but NOT by port, so two clusters on the same host (e.g. two local Docker
|
|
/// stacks on <c>localhost:9000</c> and <c>localhost:9100</c>) would otherwise clobber each
|
|
/// other's session under a shared cookie name. A blank/whitespace value falls back to
|
|
/// <see cref="DefaultCookieName"/>. Changing this invalidates existing sessions on next deploy.
|
|
/// </summary>
|
|
public string CookieName { get; set; } = DefaultCookieName;
|
|
}
|
|
|
|
/// <summary>
|
|
/// M2.19 (#15): startup validator for <see cref="SecurityOptions"/>. Fails fast at boot
|
|
/// on any configuration that would defeat idle-timeout enforcement.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Registered with <c>ValidateOnStart()</c> by
|
|
/// <see cref="ServiceCollectionExtensions.AddSecurity"/> so a misconfigured appsettings
|
|
/// section is caught at application startup rather than silently misapplied at runtime.
|
|
/// </remarks>
|
|
public sealed class SecurityOptionsValidator : Microsoft.Extensions.Options.IValidateOptions<SecurityOptions>
|
|
{
|
|
/// <inheritdoc/>
|
|
public Microsoft.Extensions.Options.ValidateOptionsResult Validate(string? name, SecurityOptions options)
|
|
{
|
|
// SECURITY: RoleRefreshThresholdMinutes must be strictly less than IdleTimeoutMinutes.
|
|
// The role-refresh cycle is the ONLY operation that advances the LastActivity anchor,
|
|
// so a single un-refreshed cycle must not be able to exhaust the entire idle window.
|
|
// If threshold >= idle, a user who triggers exactly one refresh at t=0 would have
|
|
// their anchor advanced to t=threshold while the idle check only fires at t>idle —
|
|
// meaning t=threshold >= t=idle is already past (or at) the expiry, defeating enforcement.
|
|
if (options.RoleRefreshThresholdMinutes >= options.IdleTimeoutMinutes)
|
|
{
|
|
return Microsoft.Extensions.Options.ValidateOptionsResult.Fail(
|
|
$"{nameof(SecurityOptions.RoleRefreshThresholdMinutes)} " +
|
|
$"({options.RoleRefreshThresholdMinutes}) must be strictly less than " +
|
|
$"{nameof(SecurityOptions.IdleTimeoutMinutes)} " +
|
|
$"({options.IdleTimeoutMinutes}). " +
|
|
$"A single refresh cycle must not equal or exceed the idle window or idle " +
|
|
$"enforcement is defeated.");
|
|
}
|
|
|
|
return Microsoft.Extensions.Options.ValidateOptionsResult.Success;
|
|
}
|
|
}
|