refactor(security): drop JwtBearer parallel scheme, externalize cookie config
Single Cookie auth scheme; framework default challenge restores 302 → /login for browsers + 401 for AJAX. OtOpcUaCookieOptions now flows through to CookieAuthenticationOptions via PostConfigure (fixes a latent bug where the options class was bound but ignored). Cookie name moves to ZB.MOM.WW.OtOpcUa.Auth; existing sessions get a one-time forced sign-out.
This commit is contained in:
@@ -1,9 +1,9 @@
|
||||
using Microsoft.AspNetCore.Authentication.Cookies;
|
||||
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||
using Microsoft.AspNetCore.DataProtection;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration;
|
||||
using ZB.MOM.WW.OtOpcUa.Security.Jwt;
|
||||
@@ -12,35 +12,20 @@ using ZB.MOM.WW.OtOpcUa.Security.Ldap;
|
||||
namespace ZB.MOM.WW.OtOpcUa.Security;
|
||||
|
||||
/// <summary>
|
||||
/// Resolves <see cref="JwtTokenService"/> from the real DI container at runtime so the bearer
|
||||
/// pipeline's <see cref="Microsoft.IdentityModel.Tokens.TokenValidationParameters"/> stay in
|
||||
/// lock-step with <see cref="JwtTokenService.BuildValidationParameters"/>. Replaces the prior
|
||||
/// <c>services.BuildServiceProvider()</c> antipattern (ASP0000) that built a captive provider
|
||||
/// from inside <c>.AddJwtBearer</c>.
|
||||
/// DI registration for OtOpcUa auth. Single Cookie scheme (the JWT lives inside the
|
||||
/// cookie as its credential payload); no JwtBearer parallel scheme. Matches ScadaBridge
|
||||
/// structurally — see <c>docs/plans/2026-05-29-auth-alignment-design.md</c>.
|
||||
/// </summary>
|
||||
internal sealed class ConfigureJwtBearerFromTokenService(JwtTokenService tokenService)
|
||||
: IPostConfigureOptions<JwtBearerOptions>
|
||||
{
|
||||
/// <summary>Configures JWT bearer options from the token service.</summary>
|
||||
/// <param name="name">The options name.</param>
|
||||
/// <param name="options">The JWT bearer options to configure.</param>
|
||||
public void PostConfigure(string? name, JwtBearerOptions options)
|
||||
{
|
||||
if (name != JwtBearerDefaults.AuthenticationScheme) return;
|
||||
options.TokenValidationParameters = tokenService.BuildValidationParameters();
|
||||
}
|
||||
}
|
||||
|
||||
public static class ServiceCollectionExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Wires cookie+JWT hybrid authentication. Cookies are the primary scheme for browser-facing
|
||||
/// Blazor + Razor flows; JWT bearer is layered in for external API consumers (OPC UA client
|
||||
/// tools, scripts). DataProtection keys persist to the shared ConfigDb so cookies survive
|
||||
/// failover between nodes.
|
||||
/// Wires cookie authentication, DataProtection key persistence to ConfigDb,
|
||||
/// LDAP services, and the LDAP-backed JwtTokenService. Browser flows redirect to
|
||||
/// <c>/login</c>; AJAX/JSON callers receive 401 (handled by the framework's default
|
||||
/// challenge heuristic).
|
||||
/// </summary>
|
||||
/// <param name="services">The service collection.</param>
|
||||
/// <param name="configuration">The application configuration.</param>
|
||||
/// <param name="configuration">The application configuration root.</param>
|
||||
public static IServiceCollection AddOtOpcUaAuth(this IServiceCollection services, IConfiguration configuration)
|
||||
{
|
||||
services.AddOptions<JwtOptions>().Bind(configuration.GetSection(JwtOptions.SectionName));
|
||||
@@ -50,8 +35,6 @@ public static class ServiceCollectionExtensions
|
||||
services.AddSingleton<JwtTokenService>();
|
||||
// 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()
|
||||
@@ -61,32 +44,42 @@ public static class ServiceCollectionExtensions
|
||||
services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
|
||||
.AddCookie(o =>
|
||||
{
|
||||
o.Cookie.Name = "OtOpcUa.Auth";
|
||||
// Static fields only — Name / ExpireTimeSpan / SecurePolicy / SlidingExpiration
|
||||
// are bound from OtOpcUaCookieOptions in the PostConfigure block below.
|
||||
o.LoginPath = "/login";
|
||||
o.LogoutPath = "/auth/logout";
|
||||
o.Cookie.HttpOnly = true;
|
||||
o.Cookie.SameSite = SameSiteMode.Strict;
|
||||
o.Cookie.SecurePolicy = CookieSecurePolicy.SameAsRequest;
|
||||
o.SlidingExpiration = true;
|
||||
o.ExpireTimeSpan = TimeSpan.FromMinutes(30);
|
||||
o.Events.OnRedirectToLogin = ctx =>
|
||||
{
|
||||
ctx.Response.StatusCode = StatusCodes.Status401Unauthorized;
|
||||
return Task.CompletedTask;
|
||||
};
|
||||
o.Events.OnRedirectToAccessDenied = ctx =>
|
||||
{
|
||||
ctx.Response.StatusCode = StatusCodes.Status403Forbidden;
|
||||
return Task.CompletedTask;
|
||||
};
|
||||
})
|
||||
.AddJwtBearer(JwtBearerDefaults.AuthenticationScheme, _ => { /* parameters set by IPostConfigureOptions below */ });
|
||||
// No OnRedirectToLogin / OnRedirectToAccessDenied overrides — let the framework's
|
||||
// built-in IsAjaxRequest heuristic do its thing (302 for browsers, 401 for AJAX).
|
||||
});
|
||||
|
||||
services.AddSingleton<IPostConfigureOptions<JwtBearerOptions>, ConfigureJwtBearerFromTokenService>();
|
||||
// Externalised cookie config — mirrors ScadaBridge's PostConfigure pattern. Fixes a
|
||||
// pre-existing latent bug where OtOpcUaCookieOptions was bound but ignored.
|
||||
services.AddOptions<CookieAuthenticationOptions>(CookieAuthenticationDefaults.AuthenticationScheme)
|
||||
.Configure<IOptions<OtOpcUaCookieOptions>, ILoggerFactory>((cookieOpts, ourOpts, lf) =>
|
||||
{
|
||||
var v = ourOpts.Value;
|
||||
cookieOpts.Cookie.Name = v.Name;
|
||||
cookieOpts.ExpireTimeSpan = TimeSpan.FromMinutes(v.ExpiryMinutes);
|
||||
cookieOpts.SlidingExpiration = true;
|
||||
cookieOpts.Cookie.SecurePolicy = v.RequireHttpsCookie
|
||||
? CookieSecurePolicy.Always
|
||||
: CookieSecurePolicy.SameAsRequest;
|
||||
|
||||
if (!v.RequireHttpsCookie)
|
||||
{
|
||||
lf.CreateLogger("ZB.MOM.WW.OtOpcUa.Security").LogWarning(
|
||||
"Security:Cookie:RequireHttpsCookie is DISABLED — auth cookie SecurePolicy is " +
|
||||
"SameAsRequest. The cookie-embedded JWT will travel in cleartext over plain HTTP. " +
|
||||
"Intended for local dev only — set Security:Cookie:RequireHttpsCookie=true in production.");
|
||||
}
|
||||
});
|
||||
|
||||
services.AddAuthorization(o =>
|
||||
{
|
||||
o.FallbackPolicy = new Microsoft.AspNetCore.Authorization.AuthorizationPolicyBuilder(
|
||||
CookieAuthenticationDefaults.AuthenticationScheme,
|
||||
JwtBearerDefaults.AuthenticationScheme)
|
||||
CookieAuthenticationDefaults.AuthenticationScheme)
|
||||
.RequireAuthenticatedUser()
|
||||
.Build();
|
||||
|
||||
|
||||
@@ -90,7 +90,7 @@ public sealed class AuthEndpointsIntegrationTests : IAsyncLifetime
|
||||
new AuthEndpoints.LoginRequest("alice", "valid-password"), Ct);
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.NoContent);
|
||||
response.Headers.GetValues("Set-Cookie").ShouldContain(c => c.StartsWith("OtOpcUa.Auth="));
|
||||
response.Headers.GetValues("Set-Cookie").ShouldContain(c => c.StartsWith("ZB.MOM.WW.OtOpcUa.Auth="));
|
||||
}
|
||||
|
||||
/// <summary>Tests that login with invalid credentials returns 401.</summary>
|
||||
|
||||
Reference in New Issue
Block a user