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 { /// /// The configuration section bound to the shared LDAP LdapOptions. Nested /// under the existing ScadaBridge:Security section as a Ldap sub-section /// (Task 1.4 config rename) so the non-LDAP 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 (AddZbLdapAuth) before calling . /// public const string LdapSectionPath = "ScadaBridge:Security:Ldap"; /// /// Registers the JWT token service, role mapper, cookie authentication, and /// authorization policies. This is a component library and therefore takes no /// IConfiguration (Options pattern only). /// /// /// LDAP authentication (shared ZB.MOM.WW.Auth.Ldap via AddZbLdapAuth) is /// registered by the Host composition root — which calls AddZbLdapAuth with the /// nested before this method — because that /// registration is config-coupled (it binds LdapOptions from /// IConfiguration) and component libraries must not accept IConfiguration. /// /// The service collection to register into. /// /// Dev/test flag (bound from by the Host /// composition root). When true the cookie handler is replaced — under the same cookie /// scheme name — by an always-succeeding /// 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. /// /// The same instance for chaining. 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 // 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(); services.AddScoped(); // M2.19 (#15): the cookie OnValidatePrincipal core. Scoped to match the // IGroupRoleMapper 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(); // 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(); // Auth-adoption Task 1.1: register the shared IGroupRoleMapper // 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, 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().ValidateOnStart(); services.AddSingleton, 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( CookieAuthenticationDefaults.AuthenticationScheme, _ => { }); services.AddOptions() .PostConfigure((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(CookieAuthenticationDefaults.AuthenticationScheme) .Configure, 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; } /// /// M2.19 (#15): the thin /// adapter. It resolves the request-scoped , /// asks it for a decision, and applies it to the cookie context: /// /// + sign out (idle-timeout — the only sign-out path). /// + ShouldRenew = true (role mapping refreshed). /// → no-op (no refresh due, or a swallowed refresh fault). /// /// All logic lives in , which never /// throws, so this adapter cannot bubble an exception out into the request pipeline. /// /// The cookie validation context supplied by the middleware. /// A task that completes when the decision has been applied. internal static async Task OnValidatePrincipalAsync(CookieValidatePrincipalContext context) { var validator = context.HttpContext.RequestServices.GetRequiredService(); var result = await validator .ValidateAsync(context.Principal, context.HttpContext.RequestAborted) .ConfigureAwait(false); await ApplyValidationResultAsync(context, result).ConfigureAwait(false); } /// /// Applies a to a /// : the pure decision-application /// step extracted from so it can be /// exercised in unit tests without a live DI container resolving /// . /// /// The cookie validation context to mutate. /// The decision produced by . /// A task that completes when the result has been applied. 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; } } /// /// Registers security-related Akka actors (placeholder for future actor registrations). /// /// The service collection to register into. /// The same instance for chaining. public static IServiceCollection AddSecurityActors(this IServiceCollection services) { // Phase 0: placeholder for Akka actor registration return services; } }