fix(host,security): wire static assets, DI lifetimes, form login, dev-stub LDAP
Six interlocking fixes surfaced while smoke-testing the fused Host in a browser: - Host/Program.cs: UseStaticWebAssets() opts into the RCL static-asset pipeline in any environment (auto-only in Development), MapStaticAssets().AllowAnonymous() exempts CSS/JS from the AddOtOpcUaAuth fallback policy, and AddCascadingAuthenticationState() lets <AuthorizeView/> work inside interactive components (NavSidebar's session block). - Security/ServiceCollectionExtensions: ILdapAuthService Scoped → Singleton — consumed by the Singleton LdapOpcUaUserAuthenticator on driver-role nodes. Crash only surfaced in Development (ValidateOnBuild=true). - Security/Endpoints/AuthEndpoints: /auth/login now dispatches on Content-Type — application/json keeps the original 204/401/503 contract for tests, and application/x-www-form-urlencoded (the browser <form>) gets a redirect dance. DisableAntiforgery on the login endpoint (it's the entry point, no prior session) and AllowAnonymous to override the fallback policy. - Security/Ldap/LdapOptions + LdapAuthService: real DevStubMode property; when true the auth service bypasses the LDAP bind and returns a FleetAdmin role so dev/test can navigate the full Admin UI without GLAuth running. - AdminUI/EndpointRouteBuilderExtensions: doc-comment update about static-asset flow (the actual MapStaticAssets call lives in Host/Program.cs).
This commit is contained in:
@@ -20,6 +20,9 @@ public static class EndpointRouteBuilderExtensions
|
|||||||
public static IEndpointRouteBuilder MapAdminUI<TApp>(this IEndpointRouteBuilder app)
|
public static IEndpointRouteBuilder MapAdminUI<TApp>(this IEndpointRouteBuilder app)
|
||||||
where TApp : IComponent
|
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<TApp>()
|
app.MapRazorComponents<TApp>()
|
||||||
.AddInteractiveServerRenderMode();
|
.AddInteractiveServerRenderMode();
|
||||||
return app;
|
return app;
|
||||||
|
|||||||
@@ -28,6 +28,11 @@ var hasDriver = roles.Contains("driver");
|
|||||||
|
|
||||||
var builder = WebApplication.CreateBuilder(args);
|
var builder = WebApplication.CreateBuilder(args);
|
||||||
|
|
||||||
|
// Razor class library static assets (_content/<libname>/...) 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
|
// 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.
|
// (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));
|
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.
|
// Auth + AdminUI surface only mounted on admin-role nodes. Driver-only nodes have no UI.
|
||||||
builder.Services.AddOtOpcUaAuth(builder.Configuration);
|
builder.Services.AddOtOpcUaAuth(builder.Configuration);
|
||||||
builder.Services.AddAdminUI();
|
builder.Services.AddAdminUI();
|
||||||
|
// Flow AuthenticationState through cascading parameters so <AuthorizeView/> works
|
||||||
|
// inside interactive components (NavSidebar's session block).
|
||||||
|
builder.Services.AddCascadingAuthenticationState();
|
||||||
builder.Services.AddSignalR();
|
builder.Services.AddSignalR();
|
||||||
builder.Services.AddOtOpcUaAdminClients();
|
builder.Services.AddOtOpcUaAdminClients();
|
||||||
}
|
}
|
||||||
@@ -121,6 +129,12 @@ builder.Services.AddOtOpcUaObservability();
|
|||||||
var app = builder.Build();
|
var app = builder.Build();
|
||||||
app.UseSerilogRequestLogging();
|
app.UseSerilogRequestLogging();
|
||||||
|
|
||||||
|
// Razor class library static assets (_content/<libname>/...) 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)
|
if (hasAdmin)
|
||||||
{
|
{
|
||||||
app.UseAuthentication();
|
app.UseAuthentication();
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using System.Security.Claims;
|
using System.Security.Claims;
|
||||||
|
using System.Text.Json;
|
||||||
using Microsoft.AspNetCore.Authentication;
|
using Microsoft.AspNetCore.Authentication;
|
||||||
using Microsoft.AspNetCore.Authentication.Cookies;
|
using Microsoft.AspNetCore.Authentication.Cookies;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
@@ -12,13 +13,20 @@ namespace ZB.MOM.WW.OtOpcUa.Security.Endpoints;
|
|||||||
|
|
||||||
public static class AuthEndpoints
|
public static class AuthEndpoints
|
||||||
{
|
{
|
||||||
|
/// <summary>JSON body schema for API-side login callers (kept stable for tests).</summary>
|
||||||
public sealed record LoginRequest(string Username, string Password);
|
public sealed record LoginRequest(string Username, string Password);
|
||||||
|
|
||||||
public sealed record TokenResponse(string Token);
|
public sealed record TokenResponse(string Token);
|
||||||
|
|
||||||
public static IEndpointRouteBuilder MapOtOpcUaAuth(this IEndpointRouteBuilder app)
|
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.MapGet("/auth/ping", (Delegate)Ping).AllowAnonymous();
|
||||||
app.MapPost("/auth/token", (Delegate)IssueToken).RequireAuthorization();
|
app.MapPost("/auth/token", (Delegate)IssueToken).RequireAuthorization();
|
||||||
app.MapPost("/auth/logout", (Delegate)LogoutAsync).RequireAuthorization();
|
app.MapPost("/auth/logout", (Delegate)LogoutAsync).RequireAuthorization();
|
||||||
@@ -26,15 +34,35 @@ public static class AuthEndpoints
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static async Task<IResult> LoginAsync(
|
private static async Task<IResult> LoginAsync(
|
||||||
LoginRequest request,
|
|
||||||
HttpContext http,
|
HttpContext http,
|
||||||
ILdapAuthService ldap,
|
ILdapAuthService ldap,
|
||||||
CancellationToken ct)
|
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<LoginRequest>(
|
||||||
|
http.Request.Body,
|
||||||
|
new JsonSerializerOptions(JsonSerializerDefaults.Web),
|
||||||
|
ct);
|
||||||
|
username = body?.Username ?? string.Empty;
|
||||||
|
password = body?.Password ?? string.Empty;
|
||||||
|
returnUrl = string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
LdapAuthResult result;
|
LdapAuthResult result;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
result = await ldap.AuthenticateAsync(request.Username, request.Password, ct);
|
result = await ldap.AuthenticateAsync(username, password, ct);
|
||||||
}
|
}
|
||||||
catch (Exception)
|
catch (Exception)
|
||||||
{
|
{
|
||||||
@@ -42,13 +70,20 @@ public static class AuthEndpoints
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!result.Success)
|
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<Claim>
|
var claims = new List<Claim>
|
||||||
{
|
{
|
||||||
new(ClaimTypes.NameIdentifier, result.Username ?? request.Username),
|
new(ClaimTypes.NameIdentifier, result.Username ?? username),
|
||||||
new(JwtTokenService.UsernameClaimType, result.Username ?? request.Username),
|
new(JwtTokenService.UsernameClaimType, result.Username ?? username),
|
||||||
new(JwtTokenService.DisplayNameClaimType, result.DisplayName ?? request.Username),
|
new(JwtTokenService.DisplayNameClaimType, result.DisplayName ?? username),
|
||||||
};
|
};
|
||||||
foreach (var role in result.Roles)
|
foreach (var role in result.Roles)
|
||||||
claims.Add(new Claim(ClaimTypes.Role, role));
|
claims.Add(new Claim(ClaimTypes.Role, role));
|
||||||
@@ -57,7 +92,9 @@ public static class AuthEndpoints
|
|||||||
var principal = new ClaimsPrincipal(identity);
|
var principal = new ClaimsPrincipal(identity);
|
||||||
|
|
||||||
await http.SignInAsync(CookieAuthenticationDefaults.AuthenticationScheme, principal);
|
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) =>
|
private static IResult Ping(HttpContext http) =>
|
||||||
|
|||||||
@@ -22,6 +22,12 @@ public sealed class LdapAuthService(IOptions<LdapOptions> options, ILogger<LdapA
|
|||||||
if (string.IsNullOrWhiteSpace(password))
|
if (string.IsNullOrWhiteSpace(password))
|
||||||
return new(false, null, null, [], [], "Password is required");
|
return new(false, null, null, [], [], "Password is required");
|
||||||
|
|
||||||
|
if (_options.DevStubMode)
|
||||||
|
{
|
||||||
|
logger.LogWarning("LdapAuthService: DevStubMode bypass — accepting {User} without a real LDAP bind", username);
|
||||||
|
return new(true, username, username, ["dev"], ["FleetAdmin"], null);
|
||||||
|
}
|
||||||
|
|
||||||
if (!_options.UseTls && !_options.AllowInsecureLdap)
|
if (!_options.UseTls && !_options.AllowInsecureLdap)
|
||||||
return new(false, null, username, [], [],
|
return new(false, null, username, [], [],
|
||||||
"Insecure LDAP is disabled. Enable UseTls or set AllowInsecureLdap for dev/test.");
|
"Insecure LDAP is disabled. Enable UseTls or set AllowInsecureLdap for dev/test.");
|
||||||
|
|||||||
@@ -17,6 +17,13 @@ public sealed class LdapOptions
|
|||||||
/// <summary>Dev-only escape hatch — must be <c>false</c> in production.</summary>
|
/// <summary>Dev-only escape hatch — must be <c>false</c> in production.</summary>
|
||||||
public bool AllowInsecureLdap { get; set; }
|
public bool AllowInsecureLdap { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Dev-only stub: when <c>true</c>, <see cref="LdapAuthService"/> 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 <c>false</c> in production.
|
||||||
|
/// </summary>
|
||||||
|
public bool DevStubMode { get; set; }
|
||||||
|
|
||||||
public string SearchBase { get; set; } = "dc=lmxopcua,dc=local";
|
public string SearchBase { get; set; } = "dc=lmxopcua,dc=local";
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@@ -43,7 +43,11 @@ public static class ServiceCollectionExtensions
|
|||||||
services.AddOptions<LdapOptions>().Bind(configuration.GetSection(LdapOptions.SectionName));
|
services.AddOptions<LdapOptions>().Bind(configuration.GetSection(LdapOptions.SectionName));
|
||||||
|
|
||||||
services.AddSingleton<JwtTokenService>();
|
services.AddSingleton<JwtTokenService>();
|
||||||
services.AddScoped<ILdapAuthService, LdapAuthService>();
|
// 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<ILdapAuthService, LdapAuthService>();
|
||||||
|
|
||||||
services.AddDataProtection()
|
services.AddDataProtection()
|
||||||
.PersistKeysToDbContext<OtOpcUaConfigDbContext>()
|
.PersistKeysToDbContext<OtOpcUaConfigDbContext>()
|
||||||
|
|||||||
Reference in New Issue
Block a user