diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/EndpointRouteBuilderExtensions.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/EndpointRouteBuilderExtensions.cs index 90f7a30..b223fec 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/EndpointRouteBuilderExtensions.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/EndpointRouteBuilderExtensions.cs @@ -20,6 +20,9 @@ public static class EndpointRouteBuilderExtensions public static IEndpointRouteBuilder MapAdminUI(this IEndpointRouteBuilder app) where TApp : IComponent { + // Razor class library static assets (_content/ZB.MOM.WW.OtOpcUa.AdminUI/**) are + // served via the Host's app.UseStaticFiles() middleware which must run BEFORE + // UseAuthentication() — see Program.cs. app.MapRazorComponents() .AddInteractiveServerRenderMode(); return app; diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs index ded12f3..06af6df 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs @@ -28,6 +28,11 @@ var hasDriver = roles.Contains("driver"); var builder = WebApplication.CreateBuilder(args); +// Razor class library static assets (_content//...) only auto-enable in +// the Development environment. Opt in explicitly so the AdminUI's CSS/JS works +// regardless of ASPNETCORE_ENVIRONMENT. +builder.WebHost.UseStaticWebAssets(); + // Per-role appsettings overlay: appsettings.{role}.json (single role) or appsettings.admin-driver.json // (both). Optional — base appsettings.json carries enough to boot if these don't exist. var roleSuffix = roles.Length == 0 ? null : string.Join('-', roles.OrderBy(r => r, StringComparer.Ordinal)); @@ -111,6 +116,9 @@ if (hasAdmin) // Auth + AdminUI surface only mounted on admin-role nodes. Driver-only nodes have no UI. builder.Services.AddOtOpcUaAuth(builder.Configuration); builder.Services.AddAdminUI(); + // Flow AuthenticationState through cascading parameters so works + // inside interactive components (NavSidebar's session block). + builder.Services.AddCascadingAuthenticationState(); builder.Services.AddSignalR(); builder.Services.AddOtOpcUaAdminClients(); } @@ -121,6 +129,12 @@ builder.Services.AddOtOpcUaObservability(); var app = builder.Build(); app.UseSerilogRequestLogging(); +// Razor class library static assets (_content//...) are served via endpoint +// routing, NOT the UseStaticFiles middleware — so we MUST mark the static-asset +// endpoints AllowAnonymous, otherwise the AddOtOpcUaAuth fallback RequireAuthenticatedUser +// policy 401s every CSS/JS request and the login page renders unstyled. +app.MapStaticAssets().AllowAnonymous(); + if (hasAdmin) { app.UseAuthentication(); diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Security/Endpoints/AuthEndpoints.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Security/Endpoints/AuthEndpoints.cs index 74a5470..094f81d 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Security/Endpoints/AuthEndpoints.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Security/Endpoints/AuthEndpoints.cs @@ -1,4 +1,5 @@ using System.Security.Claims; +using System.Text.Json; using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authentication.Cookies; using Microsoft.AspNetCore.Authorization; @@ -12,13 +13,20 @@ namespace ZB.MOM.WW.OtOpcUa.Security.Endpoints; public static class AuthEndpoints { + /// JSON body schema for API-side login callers (kept stable for tests). public sealed record LoginRequest(string Username, string Password); public sealed record TokenResponse(string Token); public static IEndpointRouteBuilder MapOtOpcUaAuth(this IEndpointRouteBuilder app) { - app.MapPost("/auth/login", (Delegate)LoginAsync).AllowAnonymous(); + // The login endpoint serves two callers with different ergonomics: + // - Browser form POST (application/x-www-form-urlencoded) → redirect dance + // - API JSON POST (application/json) → 204 / 401 / 503 status codes + // DisableAntiforgery: the login form is the entry point — anonymous by definition, + // no prior session, so XSRF doesn't apply. AllowAnonymous: override the + // AddOtOpcUaAuth fallback policy that would otherwise 401 the request. + app.MapPost("/auth/login", (Delegate)LoginAsync).AllowAnonymous().DisableAntiforgery(); app.MapGet("/auth/ping", (Delegate)Ping).AllowAnonymous(); app.MapPost("/auth/token", (Delegate)IssueToken).RequireAuthorization(); app.MapPost("/auth/logout", (Delegate)LogoutAsync).RequireAuthorization(); @@ -26,15 +34,35 @@ public static class AuthEndpoints } private static async Task LoginAsync( - LoginRequest request, HttpContext http, ILdapAuthService ldap, CancellationToken ct) { + var isForm = http.Request.HasFormContentType; + string username, password, returnUrl; + + if (isForm) + { + var form = await http.Request.ReadFormAsync(ct); + username = form["username"].ToString(); + password = form["password"].ToString(); + returnUrl = form["returnUrl"].ToString(); + } + else + { + var body = await JsonSerializer.DeserializeAsync( + http.Request.Body, + new JsonSerializerOptions(JsonSerializerDefaults.Web), + ct); + username = body?.Username ?? string.Empty; + password = body?.Password ?? string.Empty; + returnUrl = string.Empty; + } + LdapAuthResult result; try { - result = await ldap.AuthenticateAsync(request.Username, request.Password, ct); + result = await ldap.AuthenticateAsync(username, password, ct); } catch (Exception) { @@ -42,13 +70,20 @@ public static class AuthEndpoints } if (!result.Success) - return Results.Unauthorized(); + { + if (!isForm) return Results.Unauthorized(); + + var qs = $"?error={Uri.EscapeDataString(result.Error ?? "Invalid credentials")}"; + if (!string.IsNullOrWhiteSpace(returnUrl)) + qs += $"&returnUrl={Uri.EscapeDataString(returnUrl)}"; + return Results.Redirect("/login" + qs); + } var claims = new List { - new(ClaimTypes.NameIdentifier, result.Username ?? request.Username), - new(JwtTokenService.UsernameClaimType, result.Username ?? request.Username), - new(JwtTokenService.DisplayNameClaimType, result.DisplayName ?? request.Username), + new(ClaimTypes.NameIdentifier, result.Username ?? username), + new(JwtTokenService.UsernameClaimType, result.Username ?? username), + new(JwtTokenService.DisplayNameClaimType, result.DisplayName ?? username), }; foreach (var role in result.Roles) claims.Add(new Claim(ClaimTypes.Role, role)); @@ -57,7 +92,9 @@ public static class AuthEndpoints var principal = new ClaimsPrincipal(identity); await http.SignInAsync(CookieAuthenticationDefaults.AuthenticationScheme, principal); - return Results.NoContent(); + + if (!isForm) return Results.NoContent(); + return Results.Redirect(string.IsNullOrWhiteSpace(returnUrl) ? "/" : returnUrl); } private static IResult Ping(HttpContext http) => diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Security/Ldap/LdapAuthService.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Security/Ldap/LdapAuthService.cs index 55191eb..9e4c2f7 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Security/Ldap/LdapAuthService.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Security/Ldap/LdapAuthService.cs @@ -22,6 +22,12 @@ public sealed class LdapAuthService(IOptions options, ILoggerDev-only escape hatch — must be false in production. public bool AllowInsecureLdap { get; set; } + /// + /// Dev-only stub: when true, bypasses the real LDAP + /// bind and accepts any non-empty username/password, returning a single FleetAdmin role + /// so the operator can navigate the full Admin UI. MUST be false in production. + /// + public bool DevStubMode { get; set; } + public string SearchBase { get; set; } = "dc=lmxopcua,dc=local"; /// diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Security/ServiceCollectionExtensions.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Security/ServiceCollectionExtensions.cs index 4dbc1e9..bee04e3 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Security/ServiceCollectionExtensions.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Security/ServiceCollectionExtensions.cs @@ -43,7 +43,11 @@ public static class ServiceCollectionExtensions services.AddOptions().Bind(configuration.GetSection(LdapOptions.SectionName)); services.AddSingleton(); - services.AddScoped(); + // Singleton — LdapAuthService is stateless (creates an LdapConnection per call) and + // must be consumable by the Singleton LdapOpcUaUserAuthenticator on driver-role nodes. + // The driver-branch in Host/Program.cs registers the same way; consistent lifetime + // across both paths keeps ValidateScopes-on-Build clean. + services.AddSingleton(); services.AddDataProtection() .PersistKeysToDbContext()