290 lines
18 KiB
C#
290 lines
18 KiB
C#
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;
|
|
using ZB.MOM.WW.Auth.AspNetCore;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
|
|
|
|
namespace ZB.MOM.WW.ScadaBridge.Security;
|
|
|
|
public static class ServiceCollectionExtensions
|
|
{
|
|
/// <summary>
|
|
/// The configuration section bound to the shared LDAP <c>LdapOptions</c>. Nested
|
|
/// under the existing <c>ScadaBridge:Security</c> section as a <c>Ldap</c> sub-section
|
|
/// (Task 1.4 config rename) so the non-LDAP <see cref="SecurityOptions"/> fields stay
|
|
/// where they are while the LDAP connection settings bind to the shared library.
|
|
/// The Host composition root references this constant to register the shared LDAP
|
|
/// auth (<c>AddZbLdapAuth</c>) before calling <see cref="AddSecurity"/>.
|
|
/// </summary>
|
|
public const string LdapSectionPath = "ScadaBridge:Security:Ldap";
|
|
|
|
/// <summary>
|
|
/// Registers the JWT token service, role mapper, cookie authentication, and
|
|
/// authorization policies. This is a component library and therefore takes no
|
|
/// <c>IConfiguration</c> (Options pattern only).
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// LDAP authentication (shared <c>ZB.MOM.WW.Auth.Ldap</c> via <c>AddZbLdapAuth</c>) is
|
|
/// registered by the Host composition root — which calls <c>AddZbLdapAuth</c> with the
|
|
/// nested <see cref="LdapSectionPath"/> <em>before</em> this method — because that
|
|
/// registration is config-coupled (it binds <c>LdapOptions</c> from
|
|
/// <c>IConfiguration</c>) and component libraries must not accept <c>IConfiguration</c>.
|
|
/// </remarks>
|
|
/// <param name="services">The service collection to register into.</param>
|
|
/// <param name="disableLogin">
|
|
/// Dev/test flag (bound from <see cref="Auth.AuthDisableLoginOptions"/> by the Host
|
|
/// composition root). When true the cookie handler is replaced — under the same cookie
|
|
/// scheme name — by an always-succeeding <see cref="Auth.AutoLoginAuthenticationHandler"/>
|
|
/// that authenticates every request as the configured dev user with ALL roles, and a loud
|
|
/// startup warning is emitted. Default false. Never enable in production.
|
|
/// </param>
|
|
/// <returns>The same <paramref name="services"/> instance for chaining.</returns>
|
|
public static IServiceCollection AddSecurity(this IServiceCollection services, bool disableLogin = false)
|
|
{
|
|
// Task 1.2 cutover: ScadaBridge's bespoke LdapAuthService was replaced by the
|
|
// shared ZB.MOM.WW.Auth.Ldap implementation (ScadaBridge was the donor for its
|
|
// hardened bind-then-search / escaping / fail-closed semantics, so this was a
|
|
// behaviour-equivalent re-point). That registration — AddZbLdapAuth, which binds
|
|
// LdapOptions from the nested Ldap sub-section, registers IValidateOptions<LdapOptions>
|
|
// with ValidateOnStart (so a misconfigured directory fails fast at boot —
|
|
// superseding the old SecurityOptionsValidator LDAP checks), and registers
|
|
// ILdapAuthService as a stateless singleton — now lives at the Host composition
|
|
// root (which calls AddZbLdapAuth(configuration, LdapSectionPath) immediately
|
|
// before AddSecurity()), because it is config-coupled and this is a component
|
|
// library.
|
|
|
|
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
|
|
// (canonical username claim, Identity.Name fallback) and returns null when
|
|
// there is no authenticated interactive user — so the caller keeps its
|
|
// existing actor/fallback (API-key name, "system"). Registered as a
|
|
// singleton (it is stateless and only dereferences the ambient request);
|
|
// AddHttpContextAccessor is idempotent (TryAdd-based) so calling it here
|
|
// is safe even though the Host's AddCentralUI also registers it.
|
|
services.AddHttpContextAccessor();
|
|
services.AddSingleton<IAuditActorAccessor, HttpAuditActorAccessor>();
|
|
|
|
// Auth-adoption Task 1.1: register the shared IGroupRoleMapper<string>
|
|
// seam additively, wrapping RoleMapper to reuse its DB-backed mapping +
|
|
// site-scope union semantics. Scoped to match RoleMapper's lifetime (it
|
|
// depends on the Scoped ISecurityRepository). The existing RoleMapper
|
|
// registration and its call sites are left untouched — login is rewired
|
|
// to consume this seam in a later task.
|
|
services.AddScoped<IGroupRoleMapper<string>, ScadaBridgeGroupRoleMapper>();
|
|
|
|
// M2.19 (#15): fail-fast config guard — RoleRefreshThresholdMinutes must be strictly
|
|
// less than IdleTimeoutMinutes. If they are equal or inverted, a single un-refreshed
|
|
// cycle can exhaust the entire idle window and idle enforcement is silently defeated.
|
|
// SecurityOptionsValidator is registered with ValidateOnStart so a misconfigured
|
|
// appsettings section fails at boot with a clear message rather than behaving subtly
|
|
// incorrectly at runtime. Config-binding stays with the Host (component library must
|
|
// not take IConfiguration), so we only register the validator + ValidateOnStart here.
|
|
services.AddOptions<SecurityOptions>().ValidateOnStart();
|
|
services.AddSingleton<IValidateOptions<SecurityOptions>, SecurityOptionsValidator>();
|
|
|
|
// Note: the old SecurityOptionsValidator (which fail-fast-validated LdapServer +
|
|
// LdapSearchBase) is gone — those keys moved into the shared LdapOptions, whose
|
|
// LdapOptionsValidator (registered with ValidateOnStart by AddZbLdapAuth above)
|
|
// now enforces Server + SearchBase + ServiceAccountDn + transport at startup. The
|
|
// JWT signing key continues to fail-fast at JwtTokenService construction.
|
|
|
|
// Register ASP.NET Core authentication with cookie scheme. Only the static
|
|
// SecurityOptions-independent settings (login/logout paths) are set here; the
|
|
// cookie NAME and the hardened defaults that depend on SecurityOptions (idle
|
|
// timeout, HTTPS policy) are applied via the SecurityOptions-bound PostConfigure
|
|
// below (cookie name through SecurityOptions.CookieName, the rest through
|
|
// ZbCookieDefaults.Apply).
|
|
var authBuilder = services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme);
|
|
if (disableLogin)
|
|
{
|
|
// DEV/TEST ONLY: replace the cookie handler with an always-succeeding handler registered
|
|
// UNDER the cookie scheme name, so every authorization policy (which names this scheme)
|
|
// authenticates through it with all roles — zero policy changes. No cookie is written;
|
|
// OnValidatePrincipal (idle/refresh) does not apply in this mode. See AuthDisableLoginOptions.
|
|
authBuilder.AddScheme<AuthenticationSchemeOptions, Auth.AutoLoginAuthenticationHandler>(
|
|
CookieAuthenticationDefaults.AuthenticationScheme, _ => { });
|
|
|
|
services.AddOptions<Auth.AuthDisableLoginOptions>()
|
|
.PostConfigure<ILoggerFactory>((opts, lf) =>
|
|
lf.CreateLogger("ZB.MOM.WW.ScadaBridge.Security").LogWarning(
|
|
"AUTH DISABLED (ScadaBridge:Security:Auth:DisableLogin=true) — every request is " +
|
|
"authenticated as '{User}' with FULL permissions ({Roles}) across ALL sites. This " +
|
|
"is a SCADA control surface; dev/test ONLY — never enable in production.",
|
|
opts.User, string.Join(", ", Roles.All)));
|
|
}
|
|
else
|
|
{
|
|
authBuilder.AddCookie(options =>
|
|
{
|
|
options.LoginPath = "/login";
|
|
options.LogoutPath = "/auth/logout";
|
|
// Cookie.Name is app-owned (not set by ZbCookieDefaults.Apply, so co-hosted
|
|
// ZB apps do not clobber each other) AND now config-driven — it is set from
|
|
// SecurityOptions.CookieName in the PostConfigure below so two ScadaBridge
|
|
// 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
|
|
// code matches the documented policy ("sliding refresh, 30-minute idle
|
|
// timeout"). ASP.NET cookie auth exposes a single ExpireTimeSpan plus a
|
|
// SlidingExpiration flag — it cannot natively model a 15-minute sliding
|
|
// token AND a separate 30-minute absolute idle cap. The faithful
|
|
// interpretation: the cookie window IS the idle timeout
|
|
// (SecurityOptions.IdleTimeoutMinutes, default 30) and SlidingExpiration
|
|
// renews it on activity (the middleware re-issues the cookie once past
|
|
// the halfway mark of the window). An active user is therefore kept
|
|
// signed in; an idle user is signed out after the idle timeout. The
|
|
// 15-minute JwtExpiryMinutes governs the lifetime of the embedded JWT
|
|
// itself (see JwtTokenService) — a separate layer from the cookie
|
|
// session window. Bound here via PostConfigure so SecurityOptions
|
|
// (configured by the Host after AddSecurity) is honoured.
|
|
//
|
|
// Task 1.5: the cookie hardening (HttpOnly=true, SameSite=Strict,
|
|
// SecurePolicy, SlidingExpiration=true, ExpireTimeSpan=idle) now comes from
|
|
// the shared ZbCookieDefaults.Apply, with requireHttps + idleTimeout driven
|
|
// by SecurityOptions so behaviour (30-min sliding idle window, HTTPS-only
|
|
// unless explicitly opted out) is preserved.
|
|
// Harmless/unused when disableLogin is true: the cookie handler is not registered,
|
|
// so this CookieAuthenticationOptions PostConfigure has no scheme to configure.
|
|
services.AddOptions<CookieAuthenticationOptions>(CookieAuthenticationDefaults.AuthenticationScheme)
|
|
.Configure<IOptions<SecurityOptions>, ILoggerFactory>((cookieOptions, securityOptions, loggerFactory) =>
|
|
{
|
|
ZbCookieDefaults.Apply(
|
|
cookieOptions,
|
|
requireHttps: securityOptions.Value.RequireHttpsCookie,
|
|
idleTimeout: TimeSpan.FromMinutes(securityOptions.Value.IdleTimeoutMinutes));
|
|
|
|
// App-owned, config-driven cookie name (ScadaBridge:Security:CookieName).
|
|
// ZbCookieDefaults.Apply intentionally leaves the name untouched. A
|
|
// blank/whitespace value falls back to the canonical default so a
|
|
// misconfiguration cannot produce an unnamed cookie.
|
|
var cookieName = securityOptions.Value.CookieName;
|
|
cookieOptions.Cookie.Name = string.IsNullOrWhiteSpace(cookieName)
|
|
? SecurityOptions.DefaultCookieName
|
|
: cookieName;
|
|
|
|
// Security-021: when the operator opts out of HTTPS-only cookies,
|
|
// log a Warning so an HTTP-only deployment is at least audible in
|
|
// the startup log. The cookie carries the embedded JWT bearer
|
|
// credential — over plain HTTP that travels in cleartext on every
|
|
// request. The default is true; this branch fires only on an
|
|
// explicit opt-out (typically the dev Docker cluster). Apply sets
|
|
// SecurePolicy=SameAsRequest in that case.
|
|
if (!securityOptions.Value.RequireHttpsCookie)
|
|
{
|
|
loggerFactory.CreateLogger("ZB.MOM.WW.ScadaBridge.Security").LogWarning(
|
|
"SecurityOptions:RequireHttpsCookie is DISABLED — auth cookie SecurePolicy is SameAsRequest. The cookie-embedded JWT will be transmitted in cleartext over plain HTTP. This setting is intended for local dev only — set SecurityOptions:RequireHttpsCookie=true in production.");
|
|
}
|
|
});
|
|
|
|
services.AddScadaBridgeAuthorization();
|
|
|
|
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);
|
|
|
|
await ApplyValidationResultAsync(context, result).ConfigureAwait(false);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Applies a <see cref="SessionValidationResult"/> to a
|
|
/// <see cref="CookieValidatePrincipalContext"/>: the pure decision-application
|
|
/// step extracted from <see cref="OnValidatePrincipalAsync"/> so it can be
|
|
/// exercised in unit tests without a live DI container resolving
|
|
/// <see cref="CookieSessionValidator"/>.
|
|
/// </summary>
|
|
/// <param name="context">The cookie validation context to mutate.</param>
|
|
/// <param name="result">The decision produced by <see cref="CookieSessionValidator.ValidateAsync"/>.</param>
|
|
/// <returns>A task that completes when the result has been applied.</returns>
|
|
internal static async Task ApplyValidationResultAsync(
|
|
CookieValidatePrincipalContext context,
|
|
SessionValidationResult result)
|
|
{
|
|
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>
|
|
/// <param name="services">The service collection to register into.</param>
|
|
/// <returns>The same <paramref name="services"/> instance for chaining.</returns>
|
|
public static IServiceCollection AddSecurityActors(this IServiceCollection services)
|
|
{
|
|
// Phase 0: placeholder for Akka actor registration
|
|
return services;
|
|
}
|
|
}
|