fix(admin): resolve High code-review findings (Admin-003, Admin-004, Admin-005)
Admin-003 — SignalR hubs were anonymously reachable: an unauthenticated client could open /hubs/fleet, /hubs/alerts and /hubs/script-log and stream fleet state, alert detail text and server script-log contents. Added [Authorize] to FleetStatusHub, AlertHub and ScriptLogHub, and chained .RequireAuthorization() onto all three MapHub() calls as a belt-and-braces backstop. Admin-004 — appsettings.json committed live-looking secrets (the `sa` ConfigDb password and the LDAP ServiceAccountPassword) in plaintext. Replaced both with empty placeholders sourced from user-secrets (dev) or the ConnectionStrings__ConfigDb / Authentication__Ldap__ServiceAccountPassword environment variables (prod); added a UserSecretsId to the Admin csproj and a fail-fast guard in Program.cs when ConfigDb is empty/missing. Admin-005 — Login.razor performed SignInAsync from an interactive Blazor circuit, where the original HTTP response has long completed so the auth cookie was not emitted. Rewrote it as a static-rendered plain HTML form (data-enhance="false") posting to a new AuthEndpoints.MapAuthEndpoints() minimal-API handler (/auth/login, /auth/logout) that does the LDAP bind, grant resolution, cookie SignInAsync and redirect while the endpoint still owns the response. Includes an open-redirect guard on returnUrl. Added xUnit + Shouldly regression tests: AuthEndpointsTests (login cookie issuance, failed-bind redirect, open-redirect rejection, logout, anonymous hub negotiate rejection) and AppSettingsSecretHygieneTests (no committed secrets). All 26 auth-related tests pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,101 @@
|
||||
using System.Security.Claims;
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.AspNetCore.Authentication.Cookies;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Admin.Security;
|
||||
|
||||
/// <summary>
|
||||
/// Minimal-API authentication endpoints. Admin-005: the login is a static-rendered HTML
|
||||
/// form (<c>Login.razor</c>, <c>data-enhance="false"</c>) POSTing here, so the LDAP bind,
|
||||
/// grant resolution, <see cref="AuthenticationHttpContextExtensions.SignInAsync(HttpContext,
|
||||
/// string?, ClaimsPrincipal)"/> cookie write and the redirect all happen while the endpoint
|
||||
/// still owns an unstarted HTTP response. Performing <c>SignInAsync</c> from an interactive
|
||||
/// Blazor circuit (the previous implementation) could not emit the auth cookie because the
|
||||
/// original HTTP response had already completed.
|
||||
/// </summary>
|
||||
public static class AuthEndpoints
|
||||
{
|
||||
/// <summary>Maps <c>POST /auth/login</c> and <c>POST /auth/logout</c>.</summary>
|
||||
public static IEndpointRouteBuilder MapAuthEndpoints(this IEndpointRouteBuilder endpoints)
|
||||
{
|
||||
// Anonymous: the login POST is the only way in, so the fallback authorization policy
|
||||
// (Admin-001) must not gate it. DisableAntiforgery — the static form posts with
|
||||
// data-enhance="false" and renders no token; the cookie scheme + LDAP bind are the
|
||||
// gate here. (Admin-006 covers emitting a token for a hardened build.)
|
||||
endpoints.MapPost("/auth/login", (Delegate)LoginAsync)
|
||||
.AllowAnonymous()
|
||||
.DisableAntiforgery();
|
||||
|
||||
endpoints.MapPost("/auth/logout", (Delegate)LogoutAsync)
|
||||
.DisableAntiforgery();
|
||||
|
||||
return endpoints;
|
||||
}
|
||||
|
||||
private static async Task<IResult> LoginAsync(
|
||||
HttpContext ctx,
|
||||
[FromForm] string? username,
|
||||
[FromForm] string? password,
|
||||
[FromForm] string? returnUrl,
|
||||
ILdapAuthService ldapAuth,
|
||||
IAdminRoleGrantResolver grantResolver,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(username) || string.IsNullOrWhiteSpace(password))
|
||||
return RedirectToLogin("Username and password are required", returnUrl);
|
||||
|
||||
var result = await ldapAuth.AuthenticateAsync(username, password, ct);
|
||||
if (!result.Success)
|
||||
return RedirectToLogin(result.Error ?? "Sign-in failed", returnUrl);
|
||||
|
||||
// Grants come from the static bootstrap dictionary + DB-backed role grants;
|
||||
// result.Roles (static-only) is intentionally not consulted here.
|
||||
var grants = await grantResolver.ResolveAsync(result.Groups, ct);
|
||||
if (grants.IsEmpty)
|
||||
return RedirectToLogin(
|
||||
"Sign-in succeeded but no Admin roles mapped for your LDAP groups. Contact your administrator.",
|
||||
returnUrl);
|
||||
|
||||
var claims = new List<Claim>
|
||||
{
|
||||
new(ClaimTypes.Name, result.DisplayName ?? result.Username ?? username),
|
||||
new(ClaimTypes.NameIdentifier, username),
|
||||
};
|
||||
foreach (var role in grants.FleetRoles)
|
||||
claims.Add(new Claim(ClaimTypes.Role, role));
|
||||
foreach (var clusterGrant in grants.ClusterRoles)
|
||||
claims.Add(new Claim(ClusterRoleClaims.ClaimType,
|
||||
ClusterRoleClaims.Encode(clusterGrant.ClusterId, clusterGrant.Role)));
|
||||
foreach (var group in result.Groups)
|
||||
claims.Add(new Claim("ldap_group", group));
|
||||
|
||||
var identity = new ClaimsIdentity(claims, CookieAuthenticationDefaults.AuthenticationScheme);
|
||||
await ctx.SignInAsync(CookieAuthenticationDefaults.AuthenticationScheme,
|
||||
new ClaimsPrincipal(identity));
|
||||
|
||||
return Results.Redirect(SafeReturnUrl(returnUrl));
|
||||
}
|
||||
|
||||
private static async Task<IResult> LogoutAsync(HttpContext ctx)
|
||||
{
|
||||
await ctx.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);
|
||||
return Results.Redirect("/login");
|
||||
}
|
||||
|
||||
private static IResult RedirectToLogin(string error, string? returnUrl)
|
||||
{
|
||||
var target = $"/login?error={Uri.EscapeDataString(error)}";
|
||||
if (!string.IsNullOrWhiteSpace(returnUrl) && IsLocalUrl(returnUrl))
|
||||
target += $"&returnUrl={Uri.EscapeDataString(returnUrl)}";
|
||||
return Results.Redirect(target);
|
||||
}
|
||||
|
||||
/// <summary>Open-redirect guard — only same-site relative paths are honoured.</summary>
|
||||
private static string SafeReturnUrl(string? returnUrl) =>
|
||||
!string.IsNullOrWhiteSpace(returnUrl) && IsLocalUrl(returnUrl) ? returnUrl : "/";
|
||||
|
||||
private static bool IsLocalUrl(string url) =>
|
||||
url.StartsWith('/') && !url.StartsWith("//") && !url.StartsWith("/\\");
|
||||
}
|
||||
Reference in New Issue
Block a user