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:
@@ -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
|
||||
{
|
||||
/// <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 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<IResult> 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<LoginRequest>(
|
||||
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<Claim>
|
||||
{
|
||||
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) =>
|
||||
|
||||
Reference in New Issue
Block a user