diff --git a/src/ZB.MOM.WW.ScadaBridge.ManagementService/AuditEndpoints.cs b/src/ZB.MOM.WW.ScadaBridge.ManagementService/AuditEndpoints.cs index acbffcc0..a66481f8 100644 --- a/src/ZB.MOM.WW.ScadaBridge.ManagementService/AuditEndpoints.cs +++ b/src/ZB.MOM.WW.ScadaBridge.ManagementService/AuditEndpoints.cs @@ -329,63 +329,16 @@ public static class AuditEndpoints private readonly record struct AuthOutcome(AuthenticatedUser? User, IResult? Failure); /// - /// Decodes HTTP Basic Auth, binds against LDAP, and resolves roles — the same - /// flow uses. Returns a populated - /// on success, or an - /// carrying the 401 response on any failure. + /// Authenticates the audit-REST request via the shared + /// — the dev/test + /// DisableLogin bypass first, otherwise HTTP Basic → LDAP → roles, the same flow + /// uses. Returns a populated + /// on success, or an carrying the 401 response on any failure. /// private static async Task AuthenticateAsync(HttpContext context) { - 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)); - } - - 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)); - } - - var roleMapper = context.RequestServices.GetRequiredService(); - var mappingResult = await roleMapper.MapGroupsToRolesAsync(authResult.Groups, context.RequestAborted); - - var permittedSiteIds = mappingResult.IsSystemWideDeployment - ? Array.Empty() - : mappingResult.PermittedSiteIds.ToArray(); - - var user = new AuthenticatedUser( - authResult.Username, - authResult.DisplayName, - mappingResult.Roles.ToArray(), - permittedSiteIds); - - return new AuthOutcome(user, null); + var outcome = await ManagementAuthenticator.AuthenticateAsync(context); + return new AuthOutcome(outcome.User, outcome.Failure); } private static bool HasAnyRole(AuthenticatedUser user, string[] allowed) => diff --git a/src/ZB.MOM.WW.ScadaBridge.ManagementService/DebugStreamHub.cs b/src/ZB.MOM.WW.ScadaBridge.ManagementService/DebugStreamHub.cs index fb2ac1de..2581e9f8 100644 --- a/src/ZB.MOM.WW.ScadaBridge.ManagementService/DebugStreamHub.cs +++ b/src/ZB.MOM.WW.ScadaBridge.ManagementService/DebugStreamHub.cs @@ -75,6 +75,21 @@ public class DebugStreamHub : Hub return; } + // Dev/test: when login is disabled, auto-authenticate the debug stream as the configured + // dev user (ALL roles incl. Deployer, system-wide) — mirroring the cookie/management + // bypass so the CLI debug stream is usable in a login-disabled (e.g. no-LDAP) deployment. + // See ManagementAuthenticator. + var bypassUser = ManagementAuthenticator.TryDisableLoginUser(httpContext); + if (bypassUser is not null) + { + Context.Items[RolesKey] = bypassUser.Roles; + Context.Items[PermittedSiteIdsKey] = bypassUser.PermittedSiteIds; + _logger.LogInformation( + "DebugStreamHub connection established for {Username} (login disabled)", bypassUser.Username); + await base.OnConnectedAsync(); + return; + } + // Extract Basic Auth credentials var authHeader = httpContext.Request.Headers.Authorization.ToString(); if (string.IsNullOrEmpty(authHeader) || !authHeader.StartsWith("Basic ", StringComparison.OrdinalIgnoreCase)) diff --git a/src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementAuthenticator.cs b/src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementAuthenticator.cs new file mode 100644 index 00000000..aa277300 --- /dev/null +++ b/src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementAuthenticator.cs @@ -0,0 +1,132 @@ +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); + } +} diff --git a/src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementEndpoints.cs b/src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementEndpoints.cs index 7d9d9095..4883f033 100644 --- a/src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementEndpoints.cs +++ b/src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementEndpoints.cs @@ -61,55 +61,17 @@ public static class ManagementEndpoints maxBodyFeature.MaxRequestBodySize = MaxManagementRequestBodyBytes; } - // 1. Decode Basic Auth - var authHeader = context.Request.Headers.Authorization.ToString(); - if (string.IsNullOrEmpty(authHeader) || !authHeader.StartsWith("Basic ", StringComparison.OrdinalIgnoreCase)) + // 1-3. Authenticate: dev/test DisableLogin bypass → else HTTP Basic → LDAP → roles. + // Centralised in ManagementAuthenticator so every CLI surface honours DisableLogin + // identically — the cookie auto-login (AutoLoginAuthenticationHandler) only covers the + // interactive UI, not this Basic-Auth surface. + var auth = await ManagementAuthenticator.AuthenticateAsync(context); + if (auth.Failure is not null) { - return Results.Json(new { error = "Authorization header required (Basic scheme).", code = "AUTH_FAILED" }, statusCode: 401); + return auth.Failure; } - 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 Results.Json(new { error = "Malformed Basic Auth header.", code = "AUTH_FAILED" }, statusCode: 401); - } - - if (string.IsNullOrWhiteSpace(username) || string.IsNullOrWhiteSpace(password)) - { - return 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 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 authenticatedUser = new AuthenticatedUser( - authResult.Username, - authResult.DisplayName, - mappingResult.Roles.ToArray(), - permittedSiteIds); + var authenticatedUser = auth.User!; // 4. Parse command from request body string body; diff --git a/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/ManagementAuthenticatorTests.cs b/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/ManagementAuthenticatorTests.cs new file mode 100644 index 00000000..a19212e9 --- /dev/null +++ b/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/ManagementAuthenticatorTests.cs @@ -0,0 +1,96 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using ZB.MOM.WW.ScadaBridge.ManagementService; +using ZB.MOM.WW.ScadaBridge.Security; +using ZB.MOM.WW.ScadaBridge.Security.Auth; + +namespace ZB.MOM.WW.ScadaBridge.ManagementService.Tests; + +/// +/// Tests for the dev/test DisableLogin bypass on the management/CLI auth surface +/// (). The bypass lets the HTTP-Basic CLI surfaces +/// authenticate without LDAP when login is disabled — mirroring the interactive cookie +/// handler — so a login-disabled deployment is not locked out of the CLI. +/// +public class ManagementAuthenticatorTests +{ + private static HttpContext ContextWith(AuthDisableLoginOptions options) + { + var services = new ServiceCollection(); + services.AddSingleton>(Options.Create(options)); + return new DefaultHttpContext { RequestServices = services.BuildServiceProvider() }; + } + + [Fact] + public void TryDisableLoginUser_WhenDisabled_ReturnsNull() + { + var context = ContextWith(new AuthDisableLoginOptions { DisableLogin = false }); + + Assert.Null(ManagementAuthenticator.TryDisableLoginUser(context)); + } + + [Fact] + public void TryDisableLoginUser_WhenNoOptionsRegistered_ReturnsNull() + { + var context = new DefaultHttpContext + { + RequestServices = new ServiceCollection().BuildServiceProvider(), + }; + + Assert.Null(ManagementAuthenticator.TryDisableLoginUser(context)); + } + + [Fact] + public void TryDisableLoginUser_WhenEnabled_ReturnsDevUserWithAllRolesSystemWide() + { + var context = ContextWith(new AuthDisableLoginOptions { DisableLogin = true, User = "ci-bot" }); + + var user = ManagementAuthenticator.TryDisableLoginUser(context); + + Assert.NotNull(user); + Assert.Equal("ci-bot", user!.Username); + Assert.Equal("ci-bot", user.DisplayName); + Assert.Equal(Roles.All, user.Roles); + Assert.Empty(user.PermittedSiteIds); // empty == system-wide, mirrors AutoLoginAuthenticationHandler + } + + [Fact] + public void TryDisableLoginUser_WhenEnabledWithBlankUser_FallsBackToMultiRole() + { + var context = ContextWith(new AuthDisableLoginOptions { DisableLogin = true, User = " " }); + + var user = ManagementAuthenticator.TryDisableLoginUser(context); + + Assert.NotNull(user); + Assert.Equal("multi-role", user!.Username); + } + + [Fact] + public async Task AuthenticateAsync_WhenDisabled_BypassesBasicAuthAndReturnsDevUser() + { + // No Authorization header and no ILdapAuthService registered: the bypass must short-circuit + // before either is consulted. + var context = ContextWith(new AuthDisableLoginOptions { DisableLogin = true }); + + var outcome = await ManagementAuthenticator.AuthenticateAsync(context); + + Assert.Null(outcome.Failure); + Assert.NotNull(outcome.User); + Assert.Equal("multi-role", outcome.User!.Username); + Assert.Equal(Roles.All, outcome.User.Roles); + } + + [Fact] + public async Task AuthenticateAsync_WhenEnabledWithNoHeader_ReturnsUnauthorized() + { + // Login NOT disabled and no Basic header: the real auth path rejects with a 401 result + // before any LDAP lookup. + var context = ContextWith(new AuthDisableLoginOptions { DisableLogin = false }); + + var outcome = await ManagementAuthenticator.AuthenticateAsync(context); + + Assert.Null(outcome.User); + Assert.NotNull(outcome.Failure); + } +}