Files
ScadaBridge/src/ZB.MOM.WW.ScadaBridge.Security/ServiceCollectionExtensions.cs
T

155 lines
9.2 KiB
C#

using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.Extensions.DependencyInjection;
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>
public static IServiceCollection AddSecurity(this IServiceCollection services)
{
// 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>();
// 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>();
// 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. The non-
// SecurityOptions-coupled settings (paths, cookie name) are set here; the
// hardened cookie defaults that depend on SecurityOptions (idle timeout,
// HTTPS policy) are applied via the SecurityOptions-bound PostConfigure
// below through ZbCookieDefaults.Apply.
services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
.AddCookie(options =>
{
options.LoginPath = "/login";
options.LogoutPath = "/auth/logout";
// The cookie NAME is app-owned and not set by ZbCookieDefaults.Apply
// (so co-hosted ZB apps do not clobber each other's session). Keep
// ScadaBridge's existing name so live sessions survive this change.
options.Cookie.Name = "ZB.MOM.WW.ScadaBridge.Auth";
// HttpOnly / SameSite / SecurePolicy / SlidingExpiration /
// ExpireTimeSpan are all set by ZbCookieDefaults.Apply in the
// SecurityOptions-bound PostConfigure below.
});
// 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.
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));
// 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>
/// Registers security-related Akka actors (placeholder for future actor registrations).
/// </summary>
/// <param name="services">The service collection to register into.</param>
public static IServiceCollection AddSecurityActors(this IServiceCollection services)
{
// Phase 0: placeholder for Akka actor registration
return services;
}
}