using System.Text; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; using ZB.MOM.WW.Auth.Abstractions.Ldap; using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management; using ZB.MOM.WW.ScadaBridge.Security; using ZB.MOM.WW.ScadaBridge.Security.Auth; namespace ZB.MOM.WW.ScadaBridge.ManagementService; /// /// Shared HTTP-Basic → LDAP authentication for the management/CLI surfaces /// (, , and the debug-stream hub). /// Centralising the flow here means the dev/test DisableLogin bypass is applied /// identically on every CLI-facing surface. /// /// /// /// ScadaBridge:Security:Auth:DisableLogin swaps the cookie authentication /// scheme for AutoLoginAuthenticationHandler, which lets the interactive browser UI /// in without credentials. The CLI does not use cookies — it authenticates the /// POST /management (and audit REST) surfaces with HTTP Basic, which run their own /// manual Basic → LDAP check. That check is independent of the cookie scheme, so without the /// bypass below the CLI is locked out of a login-disabled deployment whenever LDAP is not in /// use (the bind fails and every call returns 401 AUTH_FAILED). /// /// /// The bypass mirrors AutoLoginAuthenticationHandler exactly: the configured dev user /// (default multi-role) with ALL roles, system-wide (empty /// ). The startup AUTH DISABLED warning /// (emitted by AddSecurity) already announces this posture. Dev/test ONLY. /// /// public static class ManagementAuthenticator { /// Outcome of : exactly one of the two fields is set. /// The authenticated principal on success; otherwise . /// The 401 to return on failure; otherwise . public readonly record struct AuthOutcome(AuthenticatedUser? User, IResult? Failure); /// /// When ScadaBridge:Security:Auth:DisableLogin is true, returns the synthesized dev /// principal (configured user, ALL roles, system-wide) that mirrors the cookie-scheme /// AutoLoginAuthenticationHandler — so the Basic-Auth CLI surfaces are reachable in a /// login-disabled (e.g. no-LDAP) deployment. Returns when the flag is /// off, in which case the caller must fall back to real Basic → LDAP authentication. /// Dev/test ONLY. /// /// The current HTTP request context (used to resolve the options). /// The dev principal when login is disabled; otherwise . public static AuthenticatedUser? TryDisableLoginUser(HttpContext context) { var opts = context.RequestServices.GetService>()?.Value; if (opts is null || !opts.DisableLogin) return null; var user = string.IsNullOrWhiteSpace(opts.User) ? "multi-role" : opts.User; return new AuthenticatedUser(user, user, Roles.All, Array.Empty()); } /// /// Authenticates a management/CLI request. Honours the dev/test DisableLogin bypass /// first (); otherwise decodes HTTP Basic Auth, binds /// against LDAP, and resolves roles. Returns a populated on /// success, or an carrying the 401 response on any failure. /// /// The HTTP request to authenticate. /// An carrying either the user or the 401 failure result. public static async Task AuthenticateAsync(HttpContext context) { // Dev/test bypass: when login is disabled, the CLI surface auto-authenticates as the // configured dev user — no credential check — mirroring the interactive cookie handler. var bypass = TryDisableLoginUser(context); if (bypass is not null) return new AuthOutcome(bypass, null); // 1. Decode Basic Auth var authHeader = context.Request.Headers.Authorization.ToString(); if (string.IsNullOrEmpty(authHeader) || !authHeader.StartsWith("Basic ", StringComparison.OrdinalIgnoreCase)) { return new AuthOutcome(null, Results.Json( new { error = "Authorization header required (Basic scheme).", code = "AUTH_FAILED" }, statusCode: 401)); } string username, password; try { var decoded = Encoding.UTF8.GetString(Convert.FromBase64String(authHeader["Basic ".Length..])); var colon = decoded.IndexOf(':'); if (colon < 0) throw new FormatException(); username = decoded[..colon]; password = decoded[(colon + 1)..]; } catch { return new AuthOutcome(null, Results.Json( new { error = "Malformed Basic Auth header.", code = "AUTH_FAILED" }, statusCode: 401)); } if (string.IsNullOrWhiteSpace(username) || string.IsNullOrWhiteSpace(password)) { return new AuthOutcome(null, Results.Json( new { error = "Username and password are required.", code = "AUTH_FAILED" }, statusCode: 401)); } // 2. LDAP authentication var ldapAuth = context.RequestServices.GetRequiredService(); var authResult = await ldapAuth.AuthenticateAsync(username, password, context.RequestAborted); if (!authResult.Succeeded) { return new AuthOutcome(null, Results.Json( new { error = LdapAuthFailureMessages.ToMessage(authResult.Failure), code = "AUTH_FAILED" }, statusCode: 401)); } // 3. Role resolution var roleMapper = context.RequestServices.GetRequiredService(); var mappingResult = await roleMapper.MapGroupsToRolesAsync(authResult.Groups, context.RequestAborted); var permittedSiteIds = mappingResult.IsSystemWideDeployment ? Array.Empty() : mappingResult.PermittedSiteIds.ToArray(); var resolvedUser = new AuthenticatedUser( authResult.Username, authResult.DisplayName, mappingResult.Roles.ToArray(), permittedSiteIds); return new AuthOutcome(resolvedUser, null); } }