diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Security/AssemblyInfo.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Security/AssemblyInfo.cs new file mode 100644 index 0000000..afc932a --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Security/AssemblyInfo.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("ZB.MOM.WW.OtOpcUa.Security.Tests")] diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Security/CookieOptions.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Security/CookieOptions.cs new file mode 100644 index 0000000..8afb320 --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Security/CookieOptions.cs @@ -0,0 +1,11 @@ +namespace ZB.MOM.WW.OtOpcUa.Security; + +public sealed class OtOpcUaCookieOptions +{ + public const string SectionName = "Security:Cookie"; + + public string Name { get; set; } = "OtOpcUa.Auth"; + + /// Idle sliding window, in minutes (default 30). + public int ExpiryMinutes { get; set; } = 30; +} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Security/ServiceCollectionExtensions.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Security/ServiceCollectionExtensions.cs new file mode 100644 index 0000000..657c794 --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Security/ServiceCollectionExtensions.cs @@ -0,0 +1,73 @@ +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.Options; +using ZB.MOM.WW.OtOpcUa.Configuration; +using ZB.MOM.WW.OtOpcUa.Security.Jwt; +using ZB.MOM.WW.OtOpcUa.Security.Ldap; + +namespace ZB.MOM.WW.OtOpcUa.Security; + +public static class ServiceCollectionExtensions +{ + /// + /// 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. + /// + public static IServiceCollection AddOtOpcUaAuth(this IServiceCollection services, IConfiguration configuration) + { + services.AddOptions().Bind(configuration.GetSection(JwtOptions.SectionName)); + services.AddOptions().Bind(configuration.GetSection(OtOpcUaCookieOptions.SectionName)); + services.AddOptions().Bind(configuration.GetSection(LdapOptions.SectionName)); + + services.AddSingleton(); + services.AddScoped(); + + services.AddDataProtection() + .PersistKeysToDbContext() + .SetApplicationName("OtOpcUa"); + + services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme) + .AddCookie(o => + { + o.Cookie.Name = "OtOpcUa.Auth"; + 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, o => + { + using var scope = services.BuildServiceProvider().CreateScope(); + var jwt = scope.ServiceProvider.GetRequiredService(); + o.TokenValidationParameters = jwt.BuildValidationParameters(); + }); + + services.AddAuthorization(o => + { + o.FallbackPolicy = new Microsoft.AspNetCore.Authorization.AuthorizationPolicyBuilder( + CookieAuthenticationDefaults.AuthenticationScheme, + JwtBearerDefaults.AuthenticationScheme) + .RequireAuthenticatedUser() + .Build(); + }); + + return services; + } +}