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:
Joseph Doherty
2026-05-22 06:27:11 -04:00
parent abbf49141c
commit 3de688f8d6
11 changed files with 447 additions and 108 deletions

View File

@@ -2,38 +2,40 @@
@* The login page must stay anonymously reachable — otherwise the fallback
authorization policy (Admin-001) would lock operators out of the only way in. *@
@attribute [Microsoft.AspNetCore.Authorization.AllowAnonymous]
@using System.Security.Claims
@using Microsoft.AspNetCore.Authentication
@using Microsoft.AspNetCore.Authentication.Cookies
@using ZB.MOM.WW.OtOpcUa.Admin.Security
@inject IHttpContextAccessor Http
@inject ILdapAuthService LdapAuth
@inject IAdminRoleGrantResolver GrantResolver
@inject NavigationManager Nav
@* Admin-005: this page is static server-rendered (no @rendermode). It is a plain HTML
form that POSTs to the /auth/login minimal-API endpoint with data-enhance="false", so
the LDAP bind, cookie SignInAsync and redirect all run while the endpoint still owns
an unstarted HTTP response. SignInAsync must NOT be called from an interactive Blazor
circuit — by then the original HTTP response has long completed. *@
<div class="login-wrap rise" style="animation-delay:.02s">
<section class="panel">
<div class="panel-head">OtOpcUa Admin &mdash; sign in</div>
<div style="padding:1.1rem 1.1rem 1.25rem">
<EditForm Model="_input" OnValidSubmit="SignInAsync" FormName="login">
<form method="post" action="/auth/login" data-enhance="false">
@if (ReturnUrl is not null)
{
<input type="hidden" name="returnUrl" value="@ReturnUrl"/>
}
<div class="mb-3">
<label class="form-label">Username</label>
<InputText @bind-Value="_input.Username" class="form-control form-control-sm" autocomplete="username"/>
<label class="form-label" for="username">Username</label>
<input id="username" name="username" type="text"
class="form-control form-control-sm" autocomplete="username"/>
</div>
<div class="mb-3">
<label class="form-label">Password</label>
<InputText type="password" @bind-Value="_input.Password" class="form-control form-control-sm" autocomplete="current-password"/>
<label class="form-label" for="password">Password</label>
<input id="password" name="password" type="password"
class="form-control form-control-sm" autocomplete="current-password"/>
</div>
@if (_error is not null)
@if (!string.IsNullOrWhiteSpace(Error))
{
<div class="panel notice" style="margin-bottom:.85rem">@_error</div>
<div class="panel notice" style="margin-bottom:.85rem">@Error</div>
}
<button class="btn btn-primary w-100" type="submit" disabled="@_busy">
@(_busy ? "Signing in…" : "Sign in")
</button>
</EditForm>
<button class="btn btn-primary w-100" type="submit">Sign in</button>
</form>
<div style="margin-top:1rem;padding-top:.85rem;border-top:1px solid var(--rule);
font-size:.78rem;color:var(--ink-faint)">
@@ -45,73 +47,11 @@
</div>
@code {
private sealed class Input
{
public string Username { get; set; } = string.Empty;
public string Password { get; set; } = string.Empty;
}
/// <summary>Error message surfaced by the /auth/login endpoint after a failed bind.</summary>
[SupplyParameterFromQuery]
private string? Error { get; set; }
// Static-SSR form post: the model must be [SupplyParameterFromForm] or the
// submitted username/password never bind back onto _input. The property
// cannot carry an initializer (BL0008) — seed it in OnInitialized instead.
[SupplyParameterFromForm]
private Input _input { get; set; } = default!;
private string? _error;
private bool _busy;
protected override void OnInitialized() => _input ??= new();
private async Task SignInAsync()
{
_error = null;
_busy = true;
try
{
if (string.IsNullOrWhiteSpace(_input.Username) || string.IsNullOrWhiteSpace(_input.Password))
{
_error = "Username and password are required";
return;
}
var result = await LdapAuth.AuthenticateAsync(_input.Username, _input.Password, CancellationToken.None);
if (!result.Success)
{
_error = result.Error ?? "Sign-in failed";
return;
}
// Resolve grants 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, CancellationToken.None);
if (grants.IsEmpty)
{
_error = "Sign-in succeeded but no Admin roles mapped for your LDAP groups. Contact your administrator.";
return;
}
var ctx = Http.HttpContext
?? throw new InvalidOperationException("HttpContext unavailable at sign-in");
var claims = new List<Claim>
{
new(ClaimTypes.Name, result.DisplayName ?? result.Username ?? _input.Username),
new(ClaimTypes.NameIdentifier, _input.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));
ctx.Response.Redirect("/");
}
finally { _busy = false; }
}
/// <summary>Original protected URL the operator was bounced from; round-tripped to the endpoint.</summary>
[SupplyParameterFromQuery]
private string? ReturnUrl { get; set; }
}

View File

@@ -1,3 +1,4 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.SignalR;
namespace ZB.MOM.WW.OtOpcUa.Admin.Hubs;
@@ -7,6 +8,11 @@ namespace ZB.MOM.WW.OtOpcUa.Admin.Hubs;
/// anomalies) to subscribed admin clients. Alerts don't auto-clear — the operator acks them
/// from the UI via <see cref="AcknowledgeAsync"/>.
/// </summary>
/// <remarks>
/// <see cref="AuthorizeAttribute"/> gates the hub so failure-detail alert text is not pushed
/// to anonymous connections (Admin-003).
/// </remarks>
[Authorize]
public sealed class AlertHub : Hub
{
public const string AllAlertsGroup = "__alerts__";

View File

@@ -1,3 +1,4 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.SignalR;
namespace ZB.MOM.WW.OtOpcUa.Admin.Hubs;
@@ -8,6 +9,12 @@ namespace ZB.MOM.WW.OtOpcUa.Admin.Hubs;
/// scope notifications; the server sends <c>NodeStateChanged</c> messages whenever the poller
/// observes a delta.
/// </summary>
/// <remarks>
/// <see cref="AuthorizeAttribute"/> gates the hub: an unauthenticated client cannot open the
/// connection, so the fleet generation/role/resilience stream is not an anonymous
/// information-disclosure channel (Admin-003).
/// </remarks>
[Authorize]
public sealed class FleetStatusHub : Hub
{
public Task SubscribeCluster(string clusterId)

View File

@@ -1,5 +1,6 @@
using System.Runtime.CompilerServices;
using System.Text.RegularExpressions;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.SignalR;
namespace ZB.MOM.WW.OtOpcUa.Admin.Hubs;
@@ -26,7 +27,12 @@ namespace ZB.MOM.WW.OtOpcUa.Admin.Hubs;
/// first, then new lines are emitted as they are appended. The stream is cancelled when
/// the client disconnects.
/// </para>
/// <para>
/// <see cref="AuthorizeAttribute"/> gates the hub: only an authenticated operator can
/// open the connection and tail the server <c>scripts-*.log</c> contents (Admin-003).
/// </para>
/// </remarks>
[Authorize]
public sealed class ScriptLogHub(IConfiguration configuration, ILogger<ScriptLogHub> logger) : Hub
{
/// <summary>Number of existing lines to replay from the end of the file before live-tailing.</summary>

View File

@@ -1,4 +1,3 @@
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.EntityFrameworkCore;
using OpenTelemetry.Metrics;
@@ -42,9 +41,20 @@ builder.Services.AddAuthorizationBuilder()
builder.Services.AddCascadingAuthenticationState();
builder.Services.AddDbContext<OtOpcUaConfigDbContext>(opt =>
opt.UseSqlServer(builder.Configuration.GetConnectionString("ConfigDb")
?? throw new InvalidOperationException("ConnectionStrings:ConfigDb not configured")));
// Admin-004: the connection string is a secret — it is NOT committed to appsettings.json.
// Supply it via user-secrets (dev) or the ConnectionStrings__ConfigDb environment variable
// (prod). An empty/missing value is treated as "not configured" so a misconfigured deploy
// fails fast with a clear message rather than a downstream SqlClient parse error.
builder.Services.AddDbContext<OtOpcUaConfigDbContext>((sp, opt) =>
{
var connectionString = sp.GetRequiredService<IConfiguration>().GetConnectionString("ConfigDb");
if (string.IsNullOrWhiteSpace(connectionString))
throw new InvalidOperationException(
"ConnectionStrings:ConfigDb is not configured. Set it via user-secrets " +
"(dotnet user-secrets set \"ConnectionStrings:ConfigDb\" \"...\") or the " +
"ConnectionStrings__ConfigDb environment variable.");
opt.UseSqlServer(connectionString);
});
builder.Services.AddScoped<ClusterService>();
builder.Services.AddScoped<GenerationService>();
@@ -136,15 +146,17 @@ app.UseAuthentication();
app.UseAuthorization();
app.UseAntiforgery();
app.MapPost("/auth/logout", async (HttpContext ctx) =>
{
await ctx.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);
ctx.Response.Redirect("/");
});
// Admin-005: login + logout are minimal-API endpoints. The login does the LDAP bind,
// grant resolution, cookie SignInAsync and redirect while the HTTP response is still
// owned by the endpoint — a static-rendered Login.razor form posts here.
app.MapAuthEndpoints();
app.MapHub<FleetStatusHub>("/hubs/fleet");
app.MapHub<AlertHub>("/hubs/alerts");
app.MapHub<ScriptLogHub>("/hubs/script-log");
// Admin-003: every SignalR hub requires an authenticated caller. The [Authorize] attribute
// on each Hub class is the primary gate; .RequireAuthorization() on the endpoint is the
// belt-and-braces backstop so a hub added without the attribute still cannot ship anonymous.
app.MapHub<FleetStatusHub>("/hubs/fleet").RequireAuthorization();
app.MapHub<AlertHub>("/hubs/alerts").RequireAuthorization();
app.MapHub<ScriptLogHub>("/hubs/script-log").RequireAuthorization();
if (metricsEnabled)
{

View File

@@ -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("/\\");
}

View File

@@ -9,6 +9,9 @@
<NoWarn>$(NoWarn);CS1591</NoWarn>
<RootNamespace>ZB.MOM.WW.OtOpcUa.Admin</RootNamespace>
<AssemblyName>OtOpcUa.Admin</AssemblyName>
<!-- Admin-004: dev secrets (ConfigDb connection string, LDAP service-account password)
live in user-secrets, not in the committed appsettings.json. -->
<UserSecretsId>zb-mom-ww-otopcua-admin</UserSecretsId>
</PropertyGroup>
<ItemGroup>

View File

@@ -1,6 +1,7 @@
{
"_secrets": "Admin-004: no secrets are committed here. Supply the ConfigDb connection string and the LDAP service-account password via user-secrets (dev) or environment variables / a secret store (prod). Env-var keys: ConnectionStrings__ConfigDb and Authentication__Ldap__ServiceAccountPassword. The connection string defaults to Encrypt=True (TLS); use a least-privilege SQL login, not 'sa'.",
"ConnectionStrings": {
"ConfigDb": "Server=10.100.0.35,14330;Database=OtOpcUaConfig;User Id=sa;Password=OtOpcUaDev_2026!;TrustServerCertificate=True;Encrypt=False;"
"ConfigDb": ""
},
"Authentication": {
"Ldap": {
@@ -11,7 +12,7 @@
"AllowInsecureLdap": true,
"SearchBase": "dc=lmxopcua,dc=local",
"ServiceAccountDn": "cn=serviceaccount,dc=lmxopcua,dc=local",
"ServiceAccountPassword": "serviceaccount123",
"ServiceAccountPassword": "",
"DisplayNameAttribute": "cn",
"GroupAttribute": "memberOf",
"GroupToRole": {