| | | 1 | | using System.Globalization; |
| | | 2 | | using System.Net; |
| | | 3 | | using System.Net.Sockets; |
| | | 4 | | using ArturRios.Data.PostgreSql; |
| | | 5 | | using ArturRios.Data.Relational.Core.DependencyInjection; |
| | | 6 | | using ArturRios.Heimdall.Command.Auditing; |
| | | 7 | | using ArturRios.Heimdall.Command.Handlers; |
| | | 8 | | using ArturRios.Heimdall.Command.Input; |
| | | 9 | | using ArturRios.Heimdall.Command.Input.Validation; |
| | | 10 | | using ArturRios.Heimdall.Command.Output; |
| | | 11 | | using ArturRios.Heimdall.Command.Services; |
| | | 12 | | using ArturRios.Heimdall.Domain.Enums; |
| | | 13 | | using ArturRios.Heimdall.Domain.Persistence; |
| | | 14 | | using ArturRios.Heimdall.Data.Configuration; |
| | | 15 | | using ArturRios.Heimdall.Data.Persistence; |
| | | 16 | | using ArturRios.Heimdall.Data.Seeding; |
| | | 17 | | using ArturRios.Heimdall.Query.Handlers; |
| | | 18 | | using ArturRios.Heimdall.Query.HealthChecks; |
| | | 19 | | using ArturRios.Heimdall.Query.Input; |
| | | 20 | | using ArturRios.Heimdall.Query.Input.Validation; |
| | | 21 | | using ArturRios.Heimdall.Query.Output; |
| | | 22 | | using ArturRios.Heimdall.Shared.Messages; |
| | | 23 | | using ArturRios.Heimdall.Shared.Retention; |
| | | 24 | | using ArturRios.Heimdall.Shared.Security; |
| | | 25 | | using ArturRios.Heimdall.Shared.Services; |
| | | 26 | | using ArturRios.Heimdall.WebApi.Binding; |
| | | 27 | | using ArturRios.Heimdall.WebApi.Documentation; |
| | | 28 | | using ArturRios.Heimdall.WebApi.Email; |
| | | 29 | | using ArturRios.Heimdall.WebApi.Metrics; |
| | | 30 | | using ArturRios.Heimdall.WebApi.Monitoring; |
| | | 31 | | using ArturRios.Heimdall.WebApi.Retention; |
| | | 32 | | using ArturRios.Heimdall.WebApi.Security; |
| | | 33 | | using ArturRios.Jwt; |
| | | 34 | | using ArturRios.Output; |
| | | 35 | | using ArturRios.Messaging.Email; |
| | | 36 | | using ArturRios.Mediator.Command; |
| | | 37 | | using ArturRios.Mediator.Query; |
| | | 38 | | using ArturRios.Mediator.Query.Interfaces; |
| | | 39 | | using ArturRios.Util.WebApi.Configuration; |
| | | 40 | | using Microsoft.AspNetCore.DataProtection; |
| | | 41 | | using Microsoft.AspNetCore.Diagnostics; |
| | | 42 | | using FluentValidation; |
| | | 43 | | using Microsoft.AspNetCore.HttpOverrides; |
| | | 44 | | using ArturRios.Util.WebApi.Security.Authentication; |
| | | 45 | | using ArturRios.Util.WebApi.Security.Enums; |
| | | 46 | | using ArturRios.Util.WebApi.Security.Extensions; |
| | | 47 | | using ArturRios.Util.WebApi.Security.Interfaces; |
| | | 48 | | using Microsoft.AspNetCore.RateLimiting; |
| | | 49 | | using Microsoft.AspNetCore.Rewrite; |
| | | 50 | | using Microsoft.Net.Http.Headers; |
| | | 51 | | using Serilog; |
| | | 52 | | using Serilog.Events; |
| | | 53 | | using Serilog.Formatting.Json; |
| | | 54 | | using System.Threading.RateLimiting; |
| | | 55 | | |
| | | 56 | | namespace ArturRios.Heimdall.WebApi; |
| | | 57 | | |
| | | 58 | | /// <summary> |
| | | 59 | | /// Builds the API on Util.WebApi's standard sequence: configuration, controllers, the |
| | | 60 | | /// invalid-model-state envelope and Swagger, then <see cref="ConfigureServices" />, then the |
| | | 61 | | /// standard pipeline — forwarded headers, tracing, <c>ExceptionMiddleware</c>, Swagger, CORS and |
| | | 62 | | /// <c>AuthenticationMiddleware</c>, in that order — and the controllers. |
| | | 63 | | /// </summary> |
| | | 64 | | /// <remarks> |
| | | 65 | | /// What the standard pipeline has no place for is added around it rather than inside it. The |
| | | 66 | | /// metrics branch, forwarded headers, the developer exception page and HTTPS redirection go ahead |
| | | 67 | | /// of it, through <see cref="EdgePipeline" />; rate limiting goes after it, in |
| | | 68 | | /// <see cref="CreateApplication" />. |
| | | 69 | | /// Call <see cref="CreateApplication" />, not <c>Build</c>, to get a complete API. |
| | | 70 | | /// </remarks> |
| | | 71 | | public class Startup : WebApiStartup |
| | | 72 | | { |
| | | 73 | | /// <summary> |
| | | 74 | | /// Set to <c>true</c> to refuse start-up when the database connection does not require TLS, |
| | | 75 | | /// rather than warning. Opt-in: see <see cref="WarnIfDatabaseConnectionIsNotEncrypted" />. |
| | | 76 | | /// </summary> |
| | | 77 | | private const string RequireDatabaseTlsVariable = "HEIMDALL_DATA_REQUIRE_TLS"; |
| | | 78 | | |
| | | 79 | | private const string LogDirectoryEnvironmentVariable = "HEIMDALL_LOG_DIRECTORY"; |
| | | 80 | | private const string DefaultLogDirectory = "logs"; |
| | | 81 | | |
| | | 82 | | private const string TokenAudienceEnvironmentVariable = "HEIMDALL_AUTH_TOKEN_AUDIENCE"; |
| | | 83 | | private const string TokenExpirationEnvironmentVariable = "HEIMDALL_AUTH_TOKEN_EXPIRATION_IN_SECONDS"; |
| | | 84 | | private const string TokenIssuerEnvironmentVariable = "HEIMDALL_AUTH_TOKEN_ISSUER"; |
| | | 85 | | private const string TokenSecretEnvironmentVariable = TokenSigningSecrets.SecretVariable; |
| | | 86 | | private const string PreviousTokenSecretEnvironmentVariable = TokenSigningSecrets.PreviousSecretVariable; |
| | | 87 | | private const double DefaultTokenExpirationInSeconds = 3600; |
| | | 88 | | |
| | | 89 | | private const string CorsAllowedOriginsEnvironmentVariable = "HEIMDALL_CORS_ALLOWED_ORIGINS"; |
| | | 90 | | private const string CorsPolicyName = "HeimdallFrontEnds"; |
| | | 91 | | |
| | | 92 | | /// <summary> |
| | | 93 | | /// Rate-limiting policy name applied via <c>[EnableRateLimiting(AuthEndpointRateLimitPolicy)]</c> |
| | | 94 | | /// to <c>AuthController</c>'s anonymous, credential-checking endpoints. Public so the |
| | | 95 | | /// controller and this configuration stay in sync without duplicating the literal. |
| | | 96 | | /// </summary> |
| | | 97 | | public const string AuthEndpointRateLimitPolicy = "AuthAnonymous"; |
| | | 98 | | |
| | | 99 | | /// <summary> |
| | | 100 | | /// Rate-limiting policy name for the endpoints that create a person with a password — each of |
| | | 101 | | /// which derives an Argon2id hash — partitioned by the acting person rather than by address |
| | | 102 | | /// (see <see cref="AddAccountCreationRateLimiting" />). |
| | | 103 | | /// </summary> |
| | | 104 | | public const string AccountCreationRateLimitPolicy = "AccountCreation"; |
| | | 105 | | |
| | | 106 | | /// <summary>Requests per minute one actor may make to the account-creation endpoints.</summary> |
| | | 107 | | public const int AccountCreationPermitLimit = 30; |
| | | 108 | | |
| | | 109 | | /// <summary> |
| | | 110 | | /// Length, in seconds, of the <c>AuthAnonymous</c> fixed window. One value for the limiter's |
| | | 111 | | /// <c>Window</c> and for the <c>Retry-After</c> a rejection falls back to, so the two cannot |
| | | 112 | | /// drift apart. |
| | | 113 | | /// </summary> |
| | | 114 | | private const int AuthEndpointRateLimitWindowSeconds = 60; |
| | | 115 | | |
| | | 116 | | /// <summary> |
| | | 117 | | /// The IPv6 prefix one rate-limit partition covers (see <see cref="RateLimitPartitionKey" />). |
| | | 118 | | /// A whole number of bytes. |
| | | 119 | | /// </summary> |
| | | 120 | | private const int RateLimitIPv6PrefixLength = 56; |
| | | 121 | | |
| | | 122 | | /// <summary> |
| | | 123 | | /// Read in <see cref="AddMetrics" />, after <c>LoadConfiguration</c> has put the .env file's |
| | | 124 | | /// values in the environment, and used again by <see cref="EdgePipeline" /> to mount the scrape |
| | | 125 | | /// endpoint — one reading for both, so the exporter and its endpoint cannot disagree. |
| | | 126 | | /// </summary> |
| | 899 | 127 | | private MetricsOptions _metrics = MetricsOptions.Disabled; |
| | | 128 | | |
| | | 129 | | /// <summary> |
| | | 130 | | /// Decided once in <see cref="AddDependencies" /> (NFR-28) and read by every place that turns a |
| | | 131 | | /// sensitive diagnostic on — EF Core's options, <see cref="EdgePipeline" />'s developer exception |
| | | 132 | | /// page and, for the page the host adds by itself in Development, |
| | | 133 | | /// <see cref="SuppressedDeveloperPageFilter" /> — so they can never disagree. Everything off until |
| | | 134 | | /// it is decided. |
| | | 135 | | /// </summary> |
| | 899 | 136 | | private SensitiveDiagnostics _diagnostics = SensitiveDiagnostics.None; |
| | | 137 | | |
| | 899 | 138 | | public Startup(string[] args) : base(args, ConfigureStandardSequence) |
| | | 139 | | { |
| | | 140 | | // Before anything else, so every later step has somewhere to log — which is why |
| | | 141 | | // ConfigureLogging reads its settings straight from the environment (see its remarks). |
| | 899 | 142 | | ConfigureLogging(); |
| | | 143 | | |
| | 899 | 144 | | Builder.Host.UseSerilog(); |
| | | 145 | | |
| | 899 | 146 | | Log.Information("Hello world!"); |
| | 899 | 147 | | Log.Information("Building web api on {EnvironmentEnvironmentName} environment", Builder.Environment.EnvironmentN |
| | 899 | 148 | | } |
| | | 149 | | |
| | | 150 | | /// <summary> |
| | | 151 | | /// Tunes the parts of the standard sequence Heimdall does not take as they come. |
| | | 152 | | /// </summary> |
| | | 153 | | /// <remarks> |
| | | 154 | | /// <para> |
| | | 155 | | /// Swagger is described by <see cref="SwaggerConfiguration" /> alone, the same method |
| | | 156 | | /// tools/ArturRios.Heimdall.OpenApiGen applies to produce docs/openapi/heimdall.json, so the |
| | | 157 | | /// published page and the running API's are one document. Util.WebApi's own JWT scheme is |
| | | 158 | | /// left off for that reason: <see cref="SwaggerConfiguration" /> defines the "Bearer" scheme |
| | | 159 | | /// and the document-wide requirement itself, and the library's would collide with it — its |
| | | 160 | | /// <c>AddSecurityDefinition</c> throws on a duplicate key, and it runs after this callback. |
| | | 161 | | /// </para> |
| | | 162 | | /// <para> |
| | | 163 | | /// This does not expose the document in production: Util.WebApi registers Swagger only in |
| | | 164 | | /// the environments it allows, and in Production it registers nothing at all. |
| | | 165 | | /// </para> |
| | | 166 | | /// <para> |
| | | 167 | | /// The client IP address goes in every request's log entry. That is the library's default, |
| | | 168 | | /// set here all the same because it is a decision about personal data, not a technicality: |
| | | 169 | | /// the Privacy Notice (1.2) and the Data Retention Schedule declare it, and turning it off |
| | | 170 | | /// would make them say more than the API does. |
| | | 171 | | /// </para> |
| | | 172 | | /// <para> |
| | | 173 | | /// The same address is tagged on the request's activity as <c>client.address</c>, also the |
| | | 174 | | /// library's default and also set here, for the same reason. Nothing exports traces, so |
| | | 175 | | /// today the tag lives and dies with the activity in memory; adding a trace exporter would |
| | | 176 | | /// carry the address out of the process, and that is a change to the Privacy Notice and the |
| | | 177 | | /// Data Retention Schedule before it is a change here. |
| | | 178 | | /// </para> |
| | | 179 | | /// </remarks> |
| | | 180 | | private static void ConfigureStandardSequence(WebApiStartupOptions options) |
| | | 181 | | { |
| | 899 | 182 | | options.Swagger.ConfigureGenerator = SwaggerConfiguration.Configure; |
| | 899 | 183 | | options.TraceActivity.LogClientIp = true; |
| | 899 | 184 | | options.TraceActivity.TagClientAddress = true; |
| | 899 | 185 | | } |
| | | 186 | | |
| | | 187 | | /// <summary> |
| | | 188 | | /// Builds the API and completes it: <c>Build</c>'s standard sequence, then rate limiting, then |
| | | 189 | | /// the database seed — so nothing is served before the reference data exists. |
| | | 190 | | /// </summary> |
| | | 191 | | public WebApplication CreateApplication() |
| | | 192 | | { |
| | 899 | 193 | | var app = Build(); |
| | | 194 | | |
| | 899 | 195 | | Log.Information("App built successfully"); |
| | | 196 | | |
| | | 197 | | // After the standard pipeline, because it has no slot for it. Rate limiting still sees the |
| | | 198 | | // endpoint — WebApplication routes ahead of every middleware it is given — and still runs |
| | | 199 | | // before MVC: the endpoint itself is always the last step, however late this is added. Most |
| | | 200 | | // rate-limited endpoints are anonymous, so AuthenticationMiddleware passes them through to |
| | | 201 | | // this point rather than spending anything on them first; the few authenticated ones that |
| | | 202 | | // check a password or a second factor pay only for reading the bearer token. |
| | 899 | 203 | | app.UseRateLimiter(); |
| | | 204 | | |
| | 899 | 205 | | Log.Information("App configured successfully"); |
| | | 206 | | |
| | 899 | 207 | | SeedDatabase(app); |
| | | 208 | | |
| | 899 | 209 | | Log.Information("Services started successfully"); |
| | 899 | 210 | | Log.Information("Ready to run!"); |
| | | 211 | | |
| | 899 | 212 | | return app; |
| | | 213 | | } |
| | | 214 | | |
| | | 215 | | protected override void ConfigureServices(WebApplicationBuilder builder) |
| | | 216 | | { |
| | 899 | 217 | | Log.Information("Configuration loaded successfully"); |
| | | 218 | | |
| | 899 | 219 | | ConfigureWebApi(); |
| | | 220 | | |
| | 899 | 221 | | Log.Information("Web api configured successfully"); |
| | | 222 | | |
| | 899 | 223 | | AddDependencies(); |
| | | 224 | | |
| | 899 | 225 | | Log.Information("Dependencies added successfully"); |
| | | 226 | | |
| | 899 | 227 | | AddMetrics(); |
| | | 228 | | |
| | 899 | 229 | | builder.Services.AddSingleton<IStartupFilter>(new EdgePipeline(_metrics, _diagnostics)); |
| | | 230 | | |
| | 899 | 231 | | ConfigureSecurity(); |
| | 899 | 232 | | ConfigureCors(); |
| | 899 | 233 | | ConfigureForwardedHeaders(); |
| | | 234 | | |
| | 899 | 235 | | Log.Information("Security configured successfully"); |
| | 899 | 236 | | } |
| | | 237 | | |
| | | 238 | | private void AddDependencies() |
| | | 239 | | { |
| | | 240 | | // EF diagnostics expose parameter and column values — password hashes, salts, e-mails — so |
| | | 241 | | // they stay off in production, and off wherever the instance serves a public host unless the |
| | | 242 | | // operator keeps them on (NFR-28). |
| | 899 | 243 | | DecideSensitiveDiagnostics(); |
| | | 244 | | |
| | 899 | 245 | | Builder.Services.AddSingleton(_diagnostics); |
| | | 246 | | |
| | | 247 | | // The developer exception page the host adds in Development answers to the same decision: |
| | | 248 | | // where the environment asks for the page but it is off, a failure that reaches it gets the |
| | | 249 | | // API's own 500 envelope. Elsewhere there is no page, so there is nothing to filter. |
| | 899 | 250 | | if (AsksForDeveloperExceptionPage(Builder.Environment) && !_diagnostics.DeveloperExceptionPage) |
| | | 251 | | { |
| | 1 | 252 | | Builder.Services.AddSingleton<IDeveloperPageExceptionFilter, SuppressedDeveloperPageFilter>(); |
| | | 253 | | } |
| | | 254 | | |
| | 899 | 255 | | Builder.Services.AddSingleton(new DbContextDiagnosticsOptions |
| | 899 | 256 | | { |
| | 899 | 257 | | SensitiveDataLogging = _diagnostics.DataLogging, |
| | 899 | 258 | | DetailedErrors = _diagnostics.DataLogging |
| | 899 | 259 | | }); |
| | | 260 | | |
| | 899 | 261 | | Builder.Services.AddPostgreSqlProvider(); |
| | 899 | 262 | | Builder.Services.AddDataConfigFromEnvironment<AppDbContext>("HEIMDALL_DATA"); |
| | | 263 | | |
| | 899 | 264 | | WarnIfDatabaseConnectionIsNotEncrypted(); |
| | | 265 | | |
| | | 266 | | // Spending single-use credentials and charging bounded budgets as one conditional UPDATE |
| | | 267 | | // each, so concurrent requests cannot both spend one code or lose each other's counts. |
| | 899 | 268 | | Builder.Services.AddScoped<IAtomicWrites, AtomicWrites>(); |
| | | 269 | | // UC-53's two-live-credentials limit, held under a row lock so concurrent issues cannot pass it. |
| | 899 | 270 | | Builder.Services.AddScoped<IApplicationCredentialSlots, ApplicationCredentialSlots>(); |
| | | 271 | | // NFR-21 (#195): the tombstones a hard delete writes first, idempotently, as one upsert. |
| | 899 | 272 | | Builder.Services.AddScoped<IErasedSubjects, ErasedSubjects>(); |
| | | 273 | | // FR-SR-14: the per-address budget of registration-attempt notices, charged as one upsert. |
| | 899 | 274 | | Builder.Services.AddScoped<IAttemptNoticeBudgets, AttemptNoticeBudgets>(); |
| | | 275 | | |
| | 899 | 276 | | Builder.Services.AddScoped<CommandMediator>(); |
| | 899 | 277 | | Builder.Services.AddHttpContextAccessor(); |
| | 899 | 278 | | Builder.Services.AddScoped<IActorAccessor, HttpContextActorAccessor>(); |
| | 899 | 279 | | Builder.Services.AddScoped<IRetryAfterAdvisor, HttpResponseRetryAfterAdvisor>(); |
| | 899 | 280 | | Builder.Services.AddScoped<IAuditLogWriter, AuditLogWriter>(); |
| | 899 | 281 | | Builder.Services.AddScoped<IValidator<CreateScopeCommand>, CreateScopeCommandValidator>(); |
| | 899 | 282 | | Builder.Services.AddAuditedCommandHandler<CreateScopeCommand, CreateScopeCommandOutput, CreateScopeCommandHandle |
| | 899 | 283 | | Builder.Services.AddScoped<IValidator<UpdateScopeCommand>, UpdateScopeCommandValidator>(); |
| | 899 | 284 | | Builder.Services.AddAuditedCommandHandler<UpdateScopeCommand, UpdateScopeCommandOutput, UpdateScopeCommandHandle |
| | 899 | 285 | | Builder.Services.AddAuditedCommandHandler<DeleteScopeCommand, DeleteScopeCommandOutput, DeleteScopeCommandHandle |
| | 899 | 286 | | Builder.Services.AddAuditedCommandHandler<HardDeleteScopeCommand, HardDeleteScopeCommandOutput, HardDeleteScopeC |
| | | 287 | | // UC-24 does have a validator despite carrying a single field: Enabled is nullable so an |
| | | 288 | | // omitted value is refused (NFR-10) rather than binding to false and disabling the setting. |
| | 899 | 289 | | Builder.Services.AddScoped<IValidator<SetGoogleSignInCommand>, SetGoogleSignInCommandValidator>(); |
| | 899 | 290 | | Builder.Services.AddAuditedCommandHandler<SetGoogleSignInCommand, SetGoogleSignInCommandOutput, SetGoogleSignInC |
| | 899 | 291 | | Builder.Services.AddScoped<IValidator<SetSelfRegistrationCommand>, SetSelfRegistrationCommandValidator>(); |
| | 899 | 292 | | Builder.Services.AddAuditedCommandHandler<SetSelfRegistrationCommand, SetSelfRegistrationCommandOutput, SetSelfR |
| | 899 | 293 | | Builder.Services.AddScoped<IValidator<SetScopeEmailLinksCommand>, SetScopeEmailLinksCommandValidator>(); |
| | 899 | 294 | | Builder.Services.AddAuditedCommandHandler<SetScopeEmailLinksCommand, SetScopeEmailLinksCommandOutput, SetScopeEm |
| | | 295 | | // UC-50 (FR-SR-12): no validator — both identifiers are route values already constrained to |
| | | 296 | | // GUIDs. Audited like every write, with the registration as target. |
| | 899 | 297 | | Builder.Services.AddAuditedCommandHandler<DiscardPendingRegistrationCommand, DiscardPendingRegistrationCommandOu |
| | 899 | 298 | | Builder.Services.AddScoped<IValidator<CreateAdminCommand>, CreateAdminCommandValidator>(); |
| | 899 | 299 | | Builder.Services.AddAuditedCommandHandler<CreateAdminCommand, CreatePersonCommandOutput, CreateAdminCommandHandl |
| | 899 | 300 | | Builder.Services.AddScoped<IValidator<CreateUserCommand>, CreateUserCommandValidator>(); |
| | 899 | 301 | | Builder.Services.AddAuditedCommandHandler<CreateUserCommand, CreatePersonCommandOutput, CreateUserCommandHandler |
| | 899 | 302 | | Builder.Services.AddScoped<IValidator<CreateScopeOwnerCommand>, CreateScopeOwnerCommandValidator>(); |
| | 899 | 303 | | Builder.Services.AddAuditedCommandHandler<CreateScopeOwnerCommand, CreatePersonCommandOutput, CreateScopeOwnerCo |
| | | 304 | | // No validator: UC-21's request carries no body — both identifiers are route values already |
| | | 305 | | // constrained to GUIDs — so there is nothing left for NFR-10 to validate. |
| | 899 | 306 | | Builder.Services.AddAuditedCommandHandler<AddScopeOwnerCommand, AddScopeOwnerCommandOutput, AddScopeOwnerCommand |
| | | 307 | | // Likewise no validator for UC-22: both identifiers are route values. |
| | 899 | 308 | | Builder.Services.AddAuditedCommandHandler<RemoveScopeOwnerCommand, RemoveScopeOwnerCommandOutput, RemoveScopeOwn |
| | | 309 | | // Likewise no validator for UC-23: both identifiers are route values. |
| | 899 | 310 | | Builder.Services.AddAuditedCommandHandler<PromoteScopeUserCommand, PromoteScopeUserCommandOutput, PromoteScopeUs |
| | 899 | 311 | | Builder.Services.AddScoped<IValidator<UpdatePersonCommand>, UpdatePersonCommandValidator>(); |
| | 899 | 312 | | Builder.Services.AddAuditedCommandHandler<UpdatePersonCommand, UpdatePersonCommandOutput, UpdatePersonCommandHan |
| | 899 | 313 | | Builder.Services.AddAuditedCommandHandler<DeletePersonCommand, DeletePersonCommandOutput, DeletePersonCommandHan |
| | 899 | 314 | | Builder.Services.AddAuditedCommandHandler<HardDeletePersonCommand, HardDeletePersonCommandOutput, HardDeletePers |
| | 899 | 315 | | Builder.Services.AddScoped<IValidator<LoginCommand>, LoginCommandValidator>(); |
| | 899 | 316 | | Builder.Services.AddAuditedCommandHandler<LoginCommand, LoginCommandOutput, LoginCommandHandler>(); |
| | 899 | 317 | | Builder.Services.AddScoped<IValidator<PasswordRecoveryCommand>, PasswordRecoveryCommandValidator>(); |
| | 899 | 318 | | Builder.Services.AddAuditedCommandHandler<PasswordRecoveryCommand, PasswordRecoveryCommandOutput, PasswordRecove |
| | 899 | 319 | | Builder.Services.AddScoped<IValidator<RegisterCommand>, RegisterCommandValidator>(); |
| | 899 | 320 | | Builder.Services.AddAuditedCommandHandler<RegisterCommand, RegisterCommandOutput, RegisterCommandHandler>(); |
| | 899 | 321 | | Builder.Services.AddScoped<IValidator<ResetPasswordCommand>, ResetPasswordCommandValidator>(); |
| | 899 | 322 | | Builder.Services.AddAuditedCommandHandler<ResetPasswordCommand, ResetPasswordCommandOutput, ResetPasswordCommand |
| | 899 | 323 | | Builder.Services.AddScoped<IValidator<VerifyEmailCommand>, VerifyEmailCommandValidator>(); |
| | 899 | 324 | | Builder.Services.AddAuditedCommandHandler<VerifyEmailCommand, VerifyEmailCommandOutput, VerifyEmailCommandHandle |
| | 899 | 325 | | Builder.Services.AddScoped<IValidator<RequestErasureCommand>, RequestErasureCommandValidator>(); |
| | 899 | 326 | | Builder.Services |
| | 899 | 327 | | .AddAuditedCommandHandler<RequestErasureCommand, RequestErasureCommandOutput, |
| | 899 | 328 | | RequestErasureCommandHandler>(); |
| | 899 | 329 | | Builder.Services |
| | 899 | 330 | | .AddAuditedCommandHandler<ExportMyDataCommand, DataExportCommandOutput, |
| | 899 | 331 | | ExportMyDataCommandHandler>(); |
| | 899 | 332 | | Builder.Services |
| | 899 | 333 | | .AddAuditedCommandHandler<RestrictProcessingCommand, RestrictProcessingCommandOutput, |
| | 899 | 334 | | RestrictProcessingCommandHandler>(); |
| | 899 | 335 | | Builder.Services |
| | 899 | 336 | | .AddAuditedCommandHandler<LiftProcessingRestrictionCommand, |
| | 899 | 337 | | LiftProcessingRestrictionCommandOutput, LiftProcessingRestrictionCommandHandler>(); |
| | 899 | 338 | | Builder.Services |
| | 899 | 339 | | .AddAuditedCommandHandler<ReapplyErasuresCommand, ReapplyErasuresCommandOutput, |
| | 899 | 340 | | ReapplyErasuresCommandHandler>(); |
| | | 341 | | // No validator: UC-15's request carries no caller-supplied input at all — the person comes |
| | | 342 | | // from the bearer token — so there is nothing for NFR-10 to validate. |
| | 899 | 343 | | Builder.Services.AddAuditedCommandHandler<ResendVerificationEmailCommand, ResendVerificationEmailCommandOutput, |
| | | 344 | | // Likewise no validator for UC-25, for a different reason: the use case defines no 400 flow |
| | | 345 | | // and needs none, since an absent ID token fails verification (AF-25a, 401) and an empty |
| | | 346 | | // scope identifier matches no scope (AF-25b, 403). |
| | 899 | 347 | | Builder.Services.AddAuditedCommandHandler<GoogleSignInCommand, GoogleSignInCommandOutput, GoogleSignInCommandHan |
| | | 348 | | // No validator for UC-26 either, and for UC-15's reason: the sign-out request carries no |
| | | 349 | | // caller-supplied input — the Google User comes from the bearer token. |
| | 899 | 350 | | Builder.Services.AddAuditedCommandHandler<GoogleSignOutCommand, GoogleSignOutCommandOutput, GoogleSignOutCommand |
| | | 351 | | // UC-28 needs no validator either: both fields are typed route parameters, so there is no |
| | | 352 | | // caller-supplied input NFR-10 could reject that the route would not have refused first. |
| | 899 | 353 | | Builder.Services.AddAuditedCommandHandler<DeleteGoogleUserCommand, DeleteGoogleUserCommandOutput, DeleteGoogleUs |
| | 899 | 354 | | Builder.Services.AddAuditedCommandHandler<HardDeleteGoogleUserCommand, HardDeleteGoogleUserCommandOutput, HardDe |
| | 899 | 355 | | Builder.Services.AddScoped<IValidator<CreateApplicationCommand>, CreateApplicationCommandValidator>(); |
| | 899 | 356 | | Builder.Services.AddAuditedCommandHandler<CreateApplicationCommand, CreateApplicationCommandOutput, CreateApplic |
| | 899 | 357 | | Builder.Services.AddScoped<IValidator<UpdateApplicationCommand>, UpdateApplicationCommandValidator>(); |
| | 899 | 358 | | Builder.Services.AddAuditedCommandHandler<UpdateApplicationCommand, UpdateApplicationCommandOutput, UpdateApplic |
| | 899 | 359 | | Builder.Services.AddAuditedCommandHandler<DeleteApplicationCommand, DeleteApplicationCommandOutput, DeleteApplic |
| | 899 | 360 | | Builder.Services.AddAuditedCommandHandler<HardDeleteApplicationCommand, HardDeleteApplicationCommandOutput, Hard |
| | 899 | 361 | | Builder.Services.AddScoped<IValidator<IssueApplicationCredentialCommand>, IssueApplicationCredentialCommandValid |
| | 899 | 362 | | Builder.Services.AddAuditedCommandHandler<IssueApplicationCredentialCommand, IssueApplicationCredentialCommandOu |
| | 899 | 363 | | Builder.Services.AddAuditedCommandHandler<RevokeApplicationCredentialCommand, RevokeApplicationCredentialCommand |
| | 899 | 364 | | Builder.Services.AddScoped<IValidator<IssueApplicationTokenCommand>, IssueApplicationTokenCommandValidator>(); |
| | 899 | 365 | | Builder.Services.AddAuditedCommandHandler<IssueApplicationTokenCommand, IssueApplicationTokenCommandOutput, Issu |
| | 899 | 366 | | Builder.Services.AddScoped<IValidator<CreateScopePermissionCommand>, CreateScopePermissionCommandValidator>(); |
| | 899 | 367 | | Builder.Services.AddAuditedCommandHandler<CreateScopePermissionCommand, CreateScopePermissionCommandOutput, Crea |
| | 899 | 368 | | Builder.Services.AddScoped<IValidator<UpdateScopePermissionCommand>, UpdateScopePermissionCommandValidator>(); |
| | 899 | 369 | | Builder.Services.AddAuditedCommandHandler<UpdateScopePermissionCommand, UpdateScopePermissionCommandOutput, Upda |
| | 899 | 370 | | Builder.Services.AddAuditedCommandHandler<DeleteScopePermissionCommand, DeleteScopePermissionCommandOutput, Dele |
| | 899 | 371 | | Builder.Services.AddAuditedCommandHandler<HardDeleteScopePermissionCommand, HardDeleteScopePermissionCommandOutp |
| | | 372 | | |
| | 899 | 373 | | AddDataRetention(); |
| | | 374 | | |
| | 899 | 375 | | Builder.Services.AddScoped<QueryMediator>(); |
| | 899 | 376 | | Builder.Services |
| | 899 | 377 | | .AddScoped<IQueryHandlerAsync<GetScopeByIdQuery, ScopeOutput>, GetScopeByIdQueryHandler>(); |
| | 899 | 378 | | Builder.Services |
| | 899 | 379 | | .AddScoped<IPaginatedQueryHandlerAsync<ListScopesQuery, ScopeOutput>, ListScopesQueryHandler>(); |
| | 899 | 380 | | Builder.Services |
| | 899 | 381 | | .AddScoped<IQueryHandlerAsync<GetPersonByIdQuery, PersonOutput>, GetPersonByIdQueryHandler>(); |
| | 899 | 382 | | Builder.Services |
| | 899 | 383 | | .AddScoped<IPaginatedQueryHandlerAsync<ListScopePersonsQuery, PersonOutput>, ListScopePersonsQueryHandler>() |
| | 899 | 384 | | Builder.Services |
| | 899 | 385 | | .AddScoped<IPaginatedQueryHandlerAsync<ListScopeOwnersQuery, PersonOutput>, ListScopeOwnersQueryHandler>(); |
| | 899 | 386 | | Builder.Services |
| | 899 | 387 | | .AddScoped<IPaginatedQueryHandlerAsync<ListScopeAdminsQuery, PersonSummaryOutput>, |
| | 899 | 388 | | ListScopeAdminsQueryHandler>(); |
| | 899 | 389 | | Builder.Services |
| | 899 | 390 | | .AddScoped<IPaginatedQueryHandlerAsync<ListPendingRegistrationsQuery, PendingRegistrationOutput>, |
| | 899 | 391 | | ListPendingRegistrationsQueryHandler>(); |
| | 899 | 392 | | Builder.Services |
| | 899 | 393 | | .AddScoped<IPaginatedQueryHandlerAsync<ListSystemAdminsQuery, PersonSummaryOutput>, |
| | 899 | 394 | | ListSystemAdminsQueryHandler>(); |
| | 899 | 395 | | Builder.Services |
| | 899 | 396 | | .AddScoped<IQueryHandlerAsync<GetApplicationByIdQuery, ApplicationOutput>, |
| | 899 | 397 | | GetApplicationByIdQueryHandler>(); |
| | 899 | 398 | | Builder.Services |
| | 899 | 399 | | .AddScoped<IQueryHandlerAsync<CheckApplicationTokenQuery, ApplicationTokenCheckOutput>, |
| | 899 | 400 | | CheckApplicationTokenQueryHandler>(); |
| | 899 | 401 | | Builder.Services |
| | 899 | 402 | | .AddScoped<IPaginatedQueryHandlerAsync<ListScopeApplicationsQuery, ApplicationOutput>, |
| | 899 | 403 | | ListScopeApplicationsQueryHandler>(); |
| | 899 | 404 | | Builder.Services |
| | 899 | 405 | | .AddScoped<IQueryHandlerAsync<ListApplicationCredentialsQuery, ApplicationCredentialListOutput>, |
| | 899 | 406 | | ListApplicationCredentialsQueryHandler>(); |
| | 899 | 407 | | Builder.Services |
| | 899 | 408 | | .AddScoped<IQueryHandlerAsync<GetScopePermissionByIdQuery, ScopePermissionOutput>, |
| | 899 | 409 | | GetScopePermissionByIdQueryHandler>(); |
| | 899 | 410 | | Builder.Services |
| | 899 | 411 | | .AddScoped<IPaginatedQueryHandlerAsync<ListScopePermissionsQuery, ScopePermissionOutput>, |
| | 899 | 412 | | ListScopePermissionsQueryHandler>(); |
| | 899 | 413 | | Builder.Services |
| | 899 | 414 | | .AddScoped<IQueryHandlerAsync<GetGoogleUserByIdQuery, GoogleUserOutput>, |
| | 899 | 415 | | GetGoogleUserByIdQueryHandler>(); |
| | 899 | 416 | | Builder.Services |
| | 899 | 417 | | .AddScoped<IPaginatedQueryHandlerAsync<ListScopeGoogleUsersQuery, GoogleUserOutput>, |
| | 899 | 418 | | ListScopeGoogleUsersQueryHandler>(); |
| | 899 | 419 | | Builder.Services |
| | 899 | 420 | | .AddScoped<IPaginatedQueryHandlerAsync<ListErasureRequestsQuery, ErasureRequestOutput>, |
| | 899 | 421 | | ListErasureRequestsQueryHandler>(); |
| | 899 | 422 | | Builder.Services |
| | 899 | 423 | | .AddScoped<IQueryHandlerAsync<DetectSecuritySignalsQuery, SecuritySignalsOutput>, |
| | 899 | 424 | | DetectSecuritySignalsQueryHandler>(); |
| | 899 | 425 | | Builder.Services |
| | 899 | 426 | | .AddScoped<IValidator<ListErasureRequestsQuery>, ListErasureRequestsQueryValidator>(); |
| | | 427 | | |
| | | 428 | | // UC-48 (FR-AT-01/02): the audit trail, System Admins only. |
| | 899 | 429 | | Builder.Services |
| | 899 | 430 | | .AddScoped<IPaginatedQueryHandlerAsync<ListAuditLogQuery, AuditLogEntryOutput>, |
| | 899 | 431 | | ListAuditLogQueryHandler>(); |
| | 899 | 432 | | Builder.Services.AddScoped<IValidator<ListAuditLogQuery>, ListAuditLogQueryValidator>(); |
| | 899 | 433 | | Builder.Services |
| | 899 | 434 | | .AddScoped<IQueryHandlerAsync<GetTwoFactorStatusQuery, TwoFactorStatusOutput>, |
| | 899 | 435 | | GetTwoFactorStatusQueryHandler>(); |
| | | 436 | | |
| | | 437 | | // NFR-10: pagination/filter validation for every paginated list query. |
| | 899 | 438 | | Builder.Services.AddScoped<IValidator<ListScopesQuery>, ListScopesQueryValidator>(); |
| | 899 | 439 | | Builder.Services.AddScoped<IValidator<ListScopePersonsQuery>, ListScopePersonsQueryValidator>(); |
| | 899 | 440 | | Builder.Services.AddScoped<IValidator<ListScopeOwnersQuery>, ListScopeOwnersQueryValidator>(); |
| | 899 | 441 | | Builder.Services.AddScoped<IValidator<ListScopeAdminsQuery>, ListScopeAdminsQueryValidator>(); |
| | 899 | 442 | | Builder.Services |
| | 899 | 443 | | .AddScoped<IValidator<ListPendingRegistrationsQuery>, ListPendingRegistrationsQueryValidator>(); |
| | 899 | 444 | | Builder.Services.AddScoped<IValidator<ListSystemAdminsQuery>, ListSystemAdminsQueryValidator>(); |
| | 899 | 445 | | Builder.Services |
| | 899 | 446 | | .AddScoped<IValidator<ListScopeApplicationsQuery>, ListScopeApplicationsQueryValidator>(); |
| | 899 | 447 | | Builder.Services |
| | 899 | 448 | | .AddScoped<IValidator<ListScopePermissionsQuery>, ListScopePermissionsQueryValidator>(); |
| | 899 | 449 | | Builder.Services |
| | 899 | 450 | | .AddScoped<IValidator<ListScopeGoogleUsersQuery>, ListScopeGoogleUsersQueryValidator>(); |
| | | 451 | | |
| | | 452 | | // Health checks (UC-30). Each IServiceHealthCheck is one verified dependency; the detailed |
| | | 453 | | // handler resolves them all as IEnumerable, so new checks are added by registering another. |
| | 899 | 454 | | Builder.Services.AddScoped<IServiceHealthCheck, DatabaseHealthCheck>(); |
| | 899 | 455 | | Builder.Services |
| | 899 | 456 | | .AddScoped<IQueryHandlerAsync<DetailedHealthQuery, HealthCheckOutput>, GetDetailedHealthQueryHandler>(); |
| | | 457 | | |
| | 899 | 458 | | Builder.Services.AddSingleton(EmailVerificationOptions.FromEnvironment()); |
| | 899 | 459 | | Builder.Services.AddScoped<IEmailVerificationService, EmailVerificationService>(); |
| | 899 | 460 | | Builder.Services.AddSingleton(PasswordResetOptions.FromEnvironment()); |
| | 899 | 461 | | Builder.Services.AddScoped<IPasswordResetService, PasswordResetService>(); |
| | | 462 | | // UC-51/UC-52 (FR-EV-07, FR-PR-06): the per-person cooldown on administrator-initiated email, |
| | | 463 | | // read once at start-up like the token lifetimes it is compared against. |
| | 899 | 464 | | var adminEmail = AdminEmailOptions.FromEnvironment(); |
| | 899 | 465 | | WarnOnFallback(AdminEmailOptions.CooldownVariable, adminEmail.RejectedCooldown, |
| | 899 | 466 | | AdminEmailOptions.DefaultCooldownSeconds); |
| | 899 | 467 | | Builder.Services.AddSingleton(adminEmail); |
| | | 468 | | // UC-51: no validator — the only input is the route's person id, already constrained to a GUID. |
| | 899 | 469 | | Builder.Services.AddAuditedCommandHandler<SendPersonVerificationEmailCommand, SendPersonVerificationEmailCommand |
| | | 470 | | // UC-52: no validator either — the route's person id is the whole of the input. |
| | 899 | 471 | | Builder.Services.AddAuditedCommandHandler<SendPersonPasswordResetCommand, SendPersonPasswordResetCommandOutput, |
| | | 472 | | // UC-12 runs in two halves (AF-12a): the handler queues, the dispatcher does the work after |
| | | 473 | | // the caller has been answered, so no address takes longer to answer than another. See |
| | | 474 | | // PasswordRecoveryQueue for why this is an in-memory queue rather than a floor or an outbox. |
| | 899 | 475 | | Builder.Services.AddSingleton<PasswordRecoveryQueue>(); |
| | 899 | 476 | | Builder.Services.AddSingleton<IPasswordRecoveryQueue>( |
| | 915 | 477 | | provider => provider.GetRequiredService<PasswordRecoveryQueue>()); |
| | 899 | 478 | | Builder.Services.AddScoped<PasswordRecoveryProcessor>(); |
| | 899 | 479 | | Builder.Services.AddHostedService<PasswordRecoveryDispatcher>(); |
| | | 480 | | // UC-47 runs in the same two halves as UC-12 (AF-47b): the handler queues, the dispatcher |
| | | 481 | | // stores the pending registration or tells the holder of the attempt after the caller has been |
| | | 482 | | // answered. Following the mailed link (UC-14) then reaches the confirmer, which creates the User. |
| | 899 | 483 | | Builder.Services.AddSingleton<RegistrationQueue>(); |
| | 899 | 484 | | Builder.Services.AddSingleton<IRegistrationQueue>( |
| | 910 | 485 | | provider => provider.GetRequiredService<RegistrationQueue>()); |
| | 899 | 486 | | Builder.Services.AddScoped<RegistrationProcessor>(); |
| | 899 | 487 | | Builder.Services.AddScoped<IPendingRegistrationConfirmer, PendingRegistrationConfirmer>(); |
| | 899 | 488 | | Builder.Services.AddHostedService<RegistrationDispatcher>(); |
| | 899 | 489 | | AddEmailSenders(); |
| | 899 | 490 | | AddGoogleSignIn(); |
| | | 491 | | |
| | | 492 | | // UC-36 (FR-2F-02): the TOTP secret is encrypted at rest with ASP.NET Core's Data |
| | | 493 | | // Protection API before it is persisted. |
| | | 494 | | // |
| | | 495 | | // The key ring goes in the database, and the application name is fixed, because the default |
| | | 496 | | // is neither durable nor shared: keys land in a directory on the local filesystem that the |
| | | 497 | | // image does not persist and a second instance does not see. Either way the effect is the |
| | | 498 | | // same and it is silent — every stored TOTP secret becomes undecryptable, ITotpCodeVerifier |
| | | 499 | | // catches the CryptographicException and reports it as a wrong code, and every caller whose |
| | | 500 | | // second factor is an authenticator app is locked out of it with no indication why. That |
| | | 501 | | // survives a redeploy today only because a single container keeps its filesystem between |
| | | 502 | | // restarts; it would not survive being recreated, and it never survives a second replica |
| | | 503 | | // (NFR-06). |
| | 899 | 504 | | Builder.Services.AddDataProtection() |
| | 899 | 505 | | .PersistKeysToDbContext<AppDbContext>() |
| | 899 | 506 | | .SetApplicationName("Heimdall"); |
| | 899 | 507 | | Builder.Services.AddScoped<ITotpSecretProtector, TotpSecretProtector>(); |
| | | 508 | | // Shared by UC-37's confirmation and ITwoFactorFactorVerifier (UC-38/39/40): the TOTP secret, |
| | | 509 | | // the clock-drift window, and the single-use rule that keeps an app code from being replayed |
| | | 510 | | // live in one place rather than a copy per call site. |
| | 899 | 511 | | Builder.Services.AddScoped<ITotpCodeVerifier, TotpCodeVerifier>(); |
| | 899 | 512 | | Builder.Services.AddScoped<IValidator<EnableTwoFactorAuthCommand>, EnableTwoFactorAuthCommandValidator>(); |
| | 899 | 513 | | Builder.Services.AddAuditedCommandHandler<EnableTwoFactorAuthCommand, EnableTwoFactorAuthCommandOutput, EnableTw |
| | | 514 | | |
| | | 515 | | // UC-37 (FR-2F-04/05): no validator — which code(s) are required depends on the pending row's |
| | | 516 | | // AppEnabled/EmailEnabled, a database read the handler alone can make. |
| | 899 | 517 | | Builder.Services.AddAuditedCommandHandler<ConfirmTwoFactorAuthCommand, ConfirmTwoFactorAuthCommandOutput, Confir |
| | | 518 | | |
| | | 519 | | // UC-38 (FR-2F-06…FR-2F-09): no validator either — same reason as UC-37, plus the challenge |
| | | 520 | | // token itself is validated inside the handler, the same "opaque token as a body value" |
| | | 521 | | // shape UC-13's ResetPasswordCommand uses. |
| | 899 | 522 | | Builder.Services.AddAuditedCommandHandler<VerifyTwoFactorAuthCommand, VerifyTwoFactorAuthCommandOutput, VerifyTw |
| | | 523 | | |
| | | 524 | | // UC-46 (FR-2F-16): no validator — the challenge token is the whole of the input and is |
| | | 525 | | // validated inside the handler, the same shape UC-38 uses. |
| | 899 | 526 | | Builder.Services.AddAuditedCommandHandler<ResendTwoFactorChallengeCodeCommand, ResendTwoFactorChallengeCodeComma |
| | | 527 | | |
| | | 528 | | // Shared by UC-11's AF-11g, UC-36's Email method and UC-46's reissue: retire-then-issue is |
| | | 529 | | // one step in one place, so the three cannot drift and leave two live codes for one |
| | | 530 | | // configuration — the retirement is what makes a code single-use in practice. |
| | 899 | 531 | | Builder.Services.AddScoped<ITwoFactorEmailCodeIssuer, TwoFactorEmailCodeIssuer>(); |
| | | 532 | | |
| | | 533 | | // Shared by VerifyTwoFactorAuthCommandHandler (UC-38), DisableTwoFactorAuthCommandHandler |
| | | 534 | | // (UC-39), and RegenerateRecoveryCodesCommandHandler (UC-40) — the "code against TOTP, or |
| | | 535 | | // against the current email code, or against an unused recovery code" comparison lives in |
| | | 536 | | // exactly one place. |
| | 899 | 537 | | Builder.Services.AddScoped<ITwoFactorFactorVerifier, TwoFactorFactorVerifier>(); |
| | | 538 | | |
| | | 539 | | // UC-39 (FR-2F-11): no validator — which shape of second factor (code vs. recoveryCode) is |
| | | 540 | | // valid depends on a database read (the pending row's AppEnabled/EmailEnabled), the same |
| | | 541 | | // reason UC-37/UC-38 have none. |
| | 899 | 542 | | Builder.Services.AddAuditedCommandHandler<DisableTwoFactorAuthCommand, DisableTwoFactorAuthCommandOutput, Disabl |
| | | 543 | | |
| | | 544 | | // UC-40 (FR-2F-12): no validator either, same reason as UC-39 — reuses ITwoFactorFactorVerifier |
| | | 545 | | // (registered above) rather than reimplementing the second-factor check. |
| | 899 | 546 | | Builder.Services.AddAuditedCommandHandler<RegenerateRecoveryCodesCommand, RegenerateRecoveryCodesCommandOutput, |
| | | 547 | | |
| | 899 | 548 | | Builder.Services.AddScoped<IScopeOwnershipChecker, ScopeOwnershipChecker>(); |
| | | 549 | | |
| | | 550 | | // UC-11 issues tokens through the same claims mapper the middleware validates them with, |
| | | 551 | | // registered by AddTokenAuthentication in ConfigureSecurity. |
| | 899 | 552 | | Builder.Services.AddScoped<IAuthTokenIssuer, JwtAuthTokenIssuer>(); |
| | | 553 | | // Shared by LoginCommandHandler (UC-11) and VerifyTwoFactorAuthCommandHandler (UC-38) so the |
| | | 554 | | // scope-eligibility rules and the final token-issuing call live in one place. |
| | 899 | 555 | | Builder.Services.AddScoped<PersonAuthTokenService>(); |
| | | 556 | | // UC-38's challenge token (FR-2F-07…FR-2F-10, NFR-17): one class both issues and validates |
| | | 557 | | // it, registered once and exposed through both of its interfaces. |
| | 899 | 558 | | Builder.Services.AddScoped<JwtTwoFactorChallengeTokenIssuer>(); |
| | 899 | 559 | | Builder.Services.AddScoped<ITwoFactorChallengeTokenIssuer>( |
| | 1004 | 560 | | provider => provider.GetRequiredService<JwtTwoFactorChallengeTokenIssuer>()); |
| | 899 | 561 | | Builder.Services.AddScoped<ITwoFactorChallengeTokenValidator>( |
| | 989 | 562 | | provider => provider.GetRequiredService<JwtTwoFactorChallengeTokenIssuer>()); |
| | 899 | 563 | | Builder.Services.AddSingleton(MasterUserOptions.FromEnvironment()); |
| | 899 | 564 | | Builder.Services.AddScoped<DatabaseSeeder>(); |
| | 899 | 565 | | } |
| | | 566 | | |
| | | 567 | | /// <summary> |
| | | 568 | | /// Whether the environment asks for the developer exception page: Development and Local. What |
| | | 569 | | /// is served is <see cref="_diagnostics" />, which may turn it off. |
| | | 570 | | /// </summary> |
| | | 571 | | /// <remarks> |
| | | 572 | | /// Local is included alongside Development because it is what a developer machine now runs: |
| | | 573 | | /// the configuration loader resolves Environments/.env.<environment>, so the launch |
| | | 574 | | /// profiles name Local to reach .env.local rather than relying on the loader's fallback. |
| | | 575 | | /// Testing <c>IsDevelopment()</c> alone would have silently cost the developer exception page |
| | | 576 | | /// in the one environment that exists to have it. |
| | | 577 | | /// </remarks> |
| | | 578 | | private static bool AsksForDeveloperExceptionPage(IWebHostEnvironment environment) => |
| | 1798 | 579 | | environment.IsDevelopment() || environment.IsEnvironment("Local"); |
| | | 580 | | |
| | | 581 | | /// <summary> |
| | | 582 | | /// NFR-28: settles <see cref="_diagnostics" />. On an instance that serves a public host the |
| | | 583 | | /// sensitive diagnostics are turned off, unless <c>HEIMDALL_ALLOW_SENSITIVE_DIAGNOSTICS</c> is |
| | | 584 | | /// <c>true</c> (see <see cref="SensitiveDiagnosticsGuard" />). Either way it warns, once. |
| | | 585 | | /// </summary> |
| | | 586 | | private void DecideSensitiveDiagnostics() |
| | | 587 | | { |
| | 899 | 588 | | _diagnostics = SensitiveDiagnosticsGuard.EvaluateFromEnvironment( |
| | 899 | 589 | | !Builder.Environment.IsProduction(), |
| | 899 | 590 | | AsksForDeveloperExceptionPage(Builder.Environment)); |
| | | 591 | | |
| | 899 | 592 | | switch (_diagnostics.Decision) |
| | | 593 | | { |
| | | 594 | | case SensitiveDiagnosticsDecision.Suppressed: |
| | 1 | 595 | | Log.Warning( |
| | 1 | 596 | | "This instance serves a public host ({Hosts}), so EF Core sensitive data logging, " + |
| | 1 | 597 | | "detailed errors and the developer exception page are off in the {Environment} " + |
| | 1 | 598 | | "environment. Set {Variable}=true to keep them on where the instance holds made-up " + |
| | 1 | 599 | | "people only", |
| | 1 | 600 | | string.Join(", ", _diagnostics.PublicHosts), Builder.Environment.EnvironmentName, |
| | 1 | 601 | | SensitiveDiagnosticsGuard.AllowVariable); |
| | 1 | 602 | | break; |
| | | 603 | | |
| | | 604 | | case SensitiveDiagnosticsDecision.Allowed: |
| | 1 | 605 | | Log.Warning( |
| | 1 | 606 | | "Sensitive diagnostics are on in the {Environment} environment and this instance serves " + |
| | 1 | 607 | | "a public host ({Hosts}); {Variable} is true, which states that it holds made-up people only", |
| | 1 | 608 | | Builder.Environment.EnvironmentName, string.Join(", ", _diagnostics.PublicHosts), |
| | 1 | 609 | | SensitiveDiagnosticsGuard.AllowVariable); |
| | | 610 | | break; |
| | | 611 | | } |
| | 898 | 612 | | } |
| | | 613 | | |
| | | 614 | | /// <summary> |
| | | 615 | | /// The middlewares that must run ahead of the standard pipeline. A startup filter is how they |
| | | 616 | | /// get there: ASP.NET Core wraps the application's whole pipeline in it, so what it adds runs |
| | | 617 | | /// before anything <c>Build</c> adds. |
| | | 618 | | /// </summary> |
| | 899 | 619 | | private sealed class EdgePipeline(MetricsOptions metrics, SensitiveDiagnostics diagnostics) : IStartupFilter |
| | | 620 | | { |
| | 899 | 621 | | public Action<IApplicationBuilder> Configure(Action<IApplicationBuilder> next) => app => |
| | 899 | 622 | | { |
| | 899 | 623 | | // First, ahead of everything else — the standard pipeline's ExceptionMiddleware and |
| | 899 | 624 | | // AuthenticationMiddleware included. The scrape endpoint is a terminal branch taken only on |
| | 899 | 625 | | // the metrics port, so Prometheus is never asked for a token, never rate limited, never |
| | 899 | 626 | | // subject to CORS, and the endpoint never reaches the OpenAPI document. Every request on |
| | 899 | 627 | | // the public port falls straight through, /metrics included. |
| | 899 | 628 | | app.UseHeimdallMetrics(metrics); |
| | 899 | 629 | | |
| | 899 | 630 | | // The shared decision, never the environment directly: on a public host without the |
| | 899 | 631 | | // override it is off in Development too (NFR-28). This call is what Local needs; in |
| | 899 | 632 | | // Development the host adds its own page further in, which SuppressedDeveloperPageFilter |
| | 899 | 633 | | // holds to the same decision. |
| | 899 | 634 | | if (diagnostics.DeveloperExceptionPage) |
| | 899 | 635 | | { |
| | 898 | 636 | | app.UseDeveloperExceptionPage(); |
| | 899 | 637 | | } |
| | 899 | 638 | | |
| | 899 | 639 | | // Ahead of HTTPS redirection, which decides on the scheme: behind Traefik every request |
| | 899 | 640 | | // arrives as http, and only X-Forwarded-Proto says the caller used https. The standard |
| | 899 | 641 | | // pipeline runs the same middleware again first thing; by then the trusted hop has been |
| | 899 | 642 | | // consumed and the connection's address is no longer a proxy's, so it changes nothing. |
| | 899 | 643 | | app.UseForwardedHeaders(); |
| | 899 | 644 | | |
| | 899 | 645 | | app.UseHttpsRedirection(); |
| | 899 | 646 | | |
| | 899 | 647 | | // The health check moved under api/ so the web UI can reach it through its own host; the |
| | 899 | 648 | | // container probe and yggdrasil's status catalog still call the root address. Rewritten |
| | 899 | 649 | | // here, ahead of routing and authentication, so the alias runs the same endpoint with the |
| | 899 | 650 | | // same authorization, and stays out of the OpenAPI document. |
| | 899 | 651 | | app.UseRewriter(new RewriteOptions() |
| | 899 | 652 | | .AddRewrite("(?i)^healthcheck(/detailed)?/?$", "api/healthcheck$1", skipRemainingRules: true)); |
| | 899 | 653 | | |
| | 899 | 654 | | next(app); |
| | 1798 | 655 | | }; |
| | | 656 | | } |
| | | 657 | | |
| | | 658 | | /// <summary> |
| | | 659 | | /// Registers the OpenTelemetry metrics pipeline and its Prometheus exporter, on the port |
| | | 660 | | /// <c>HEIMDALL_METRICS_PORT</c> names (9464 by default, <c>0</c> to switch it off). |
| | | 661 | | /// </summary> |
| | | 662 | | /// <remarks> |
| | | 663 | | /// The scrape endpoint is recognised by the port the connection arrived on, never by the |
| | | 664 | | /// <c>Host</c> header — Traefik forwards the client's own, so it is attacker-controlled. See |
| | | 665 | | /// <see cref="MetricsOptions" /> for why the port is the one thing a caller cannot choose, and |
| | | 666 | | /// the Dockerfile for how the container is made to listen on it without publishing it. |
| | | 667 | | /// </remarks> |
| | | 668 | | private void AddMetrics() |
| | | 669 | | { |
| | 899 | 670 | | _metrics = MetricsOptions.FromEnvironment(); |
| | | 671 | | |
| | 899 | 672 | | if (_metrics.InvalidValue is not null) |
| | | 673 | | { |
| | 0 | 674 | | Log.Warning( |
| | 0 | 675 | | "Ignoring unusable {Variable} value {Value}; serving metrics on the default port {Port}", |
| | 0 | 676 | | MetricsOptions.PortVariable, _metrics.InvalidValue, _metrics.Port); |
| | | 677 | | } |
| | | 678 | | |
| | 899 | 679 | | if (!_metrics.Enabled) |
| | | 680 | | { |
| | 0 | 681 | | Log.Warning( |
| | 0 | 682 | | "Metrics are switched off by {Variable}; nothing will be collected or served for Prometheus", |
| | 0 | 683 | | MetricsOptions.PortVariable); |
| | | 684 | | |
| | 0 | 685 | | return; |
| | | 686 | | } |
| | | 687 | | |
| | 899 | 688 | | Builder.Services.AddHeimdallMetrics(_metrics); |
| | | 689 | | |
| | 899 | 690 | | Log.Information( |
| | 899 | 691 | | "Serving Prometheus metrics at {Path} on port {Port} only", |
| | 899 | 692 | | MetricsOptions.ScrapePath, _metrics.Port); |
| | 899 | 693 | | } |
| | | 694 | | |
| | | 695 | | /// <summary> |
| | | 696 | | /// Restricts cross-origin access to the front ends named by |
| | | 697 | | /// <c>HEIMDALL_CORS_ALLOWED_ORIGINS</c> (comma-separated, scheme and host as the browser sends |
| | | 698 | | /// them, e.g. <c>https://app.example.com</c>). With the variable unset, no cross-origin |
| | | 699 | | /// request is allowed. |
| | | 700 | | /// </summary> |
| | | 701 | | /// <remarks> |
| | | 702 | | /// <para> |
| | | 703 | | /// This used to be <c>AllowAnyOrigin</c> in every environment. The same-origin policy is |
| | | 704 | | /// the control that stops a page on an unrelated origin from reading an authenticated |
| | | 705 | | /// response, and switching it off system-wide is a poor trade for an identity API: any |
| | | 706 | | /// site the caller visits could then read <c>/api/persons/{id}</c> with a token it |
| | | 707 | | /// scraped, and drive the anonymous endpoints from every visitor's browser at once. |
| | | 708 | | /// </para> |
| | | 709 | | /// <para> |
| | | 710 | | /// The policy is registered here and applied by the standard pipeline, which names it in |
| | | 711 | | /// <c>UseCors</c> ahead of <c>AuthenticationMiddleware</c>, so a preflight request is |
| | | 712 | | /// answered without a token. With no origins configured no policy is named, and the |
| | | 713 | | /// pipeline adds no CORS middleware at all. |
| | | 714 | | /// </para> |
| | | 715 | | /// <para> |
| | | 716 | | /// Refusing by default rather than falling back to the wildcard is deliberate, and it is |
| | | 717 | | /// the <c>UnconfiguredGoogleIdTokenVerifier</c> judgement rather than the e-mail sender's: |
| | | 718 | | /// a missing CORS entry costs a browser-based front end its access until an operator adds |
| | | 719 | | /// one, which is visible and quickly fixed, while defaulting to "any origin" would leave a |
| | | 720 | | /// deployment wide open with nothing to indicate it. Server-to-server callers are |
| | | 721 | | /// unaffected — CORS is a browser rule and non-browser clients never send an Origin. |
| | | 722 | | /// </para> |
| | | 723 | | /// </remarks> |
| | | 724 | | private void ConfigureCors() |
| | | 725 | | { |
| | 899 | 726 | | var origins = (Environment.GetEnvironmentVariable(CorsAllowedOriginsEnvironmentVariable) ?? string.Empty) |
| | 899 | 727 | | .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); |
| | | 728 | | |
| | 899 | 729 | | if (origins.Length == 0) |
| | | 730 | | { |
| | 898 | 731 | | Log.Warning( |
| | 898 | 732 | | "No cross-origin front end is configured ({Variable}); every cross-origin request " + |
| | 898 | 733 | | "will be refused by the browser's same-origin policy", |
| | 898 | 734 | | CorsAllowedOriginsEnvironmentVariable); |
| | | 735 | | |
| | 898 | 736 | | return; |
| | | 737 | | } |
| | | 738 | | |
| | 1 | 739 | | Log.Information("Allowing cross-origin requests from {Origins}", origins); |
| | | 740 | | |
| | | 741 | | // Credentials are allowed because the front end sends the bearer token UC-11 issued. That is |
| | | 742 | | // also why the origin list has to be explicit: AllowAnyOrigin and AllowCredentials are |
| | | 743 | | // mutually exclusive by specification, precisely to stop this combination from existing. |
| | | 744 | | // Retry-After is exposed because it is not a safelisted response header: without it a front |
| | | 745 | | // end on another origin sees a 429 but cannot read how long to wait. |
| | 3 | 746 | | Builder.Services.AddCors(cors => cors.AddPolicy(CorsPolicyName, policy => policy |
| | 3 | 747 | | .WithOrigins(origins) |
| | 3 | 748 | | .AllowAnyMethod() |
| | 3 | 749 | | .AllowAnyHeader() |
| | 3 | 750 | | .AllowCredentials() |
| | 3 | 751 | | .WithExposedHeaders(HeaderNames.RetryAfter))); |
| | | 752 | | |
| | 1 | 753 | | Options.CorsPolicy = CorsPolicyName; |
| | 1 | 754 | | } |
| | | 755 | | |
| | | 756 | | /// <summary> |
| | | 757 | | /// Puts <see cref="PersonTokenValidator" /> in the place of the <see cref="JwtTokenValidator" /> |
| | | 758 | | /// that <c>AddTokenAuthentication</c> registers as an <see cref="ITokenValidator" />, keeping the |
| | | 759 | | /// library's validator as the inner one it wraps. |
| | | 760 | | /// </summary> |
| | | 761 | | /// <remarks> |
| | | 762 | | /// TB-5: the library's validator checks the signature and lifetime but not the issuer or the |
| | | 763 | | /// audience, and the signing key is not Heimdall's alone. PersonTokenValidator wraps it and |
| | | 764 | | /// takes its place, so a token reaches the middleware's user only if Heimdall issued it, for |
| | | 765 | | /// one of the person audiences. When the library registers its validator in some other way, |
| | | 766 | | /// start-up stops and says why, rather than running with the library's validator alone. |
| | | 767 | | /// </remarks> |
| | | 768 | | /// <exception cref="InvalidOperationException"> |
| | | 769 | | /// The services hold no <see cref="ITokenValidator" /> registered as <see cref="JwtTokenValidator" />. |
| | | 770 | | /// </exception> |
| | | 771 | | public static void ReplaceLibraryTokenValidator(IServiceCollection services) |
| | | 772 | | { |
| | 901 | 773 | | var libraryValidator = services.SingleOrDefault(descriptor => |
| | 456701 | 774 | | descriptor.ServiceType == typeof(ITokenValidator) && |
| | 456701 | 775 | | descriptor.ImplementationType == typeof(JwtTokenValidator)); |
| | | 776 | | |
| | 901 | 777 | | if (libraryValidator is null) |
| | | 778 | | { |
| | 1 | 779 | | throw new InvalidOperationException( |
| | 1 | 780 | | "ArturRios.Util.WebApi no longer registers JwtTokenValidator as an ITokenValidator; " + |
| | 1 | 781 | | "PersonTokenValidator cannot replace it. Refusing to start."); |
| | | 782 | | } |
| | | 783 | | |
| | 900 | 784 | | services.Remove(libraryValidator); |
| | 900 | 785 | | services.AddSingleton<JwtTokenValidator>(); |
| | 900 | 786 | | services.AddSingleton<ITokenValidator, PersonTokenValidator>(); |
| | 900 | 787 | | } |
| | | 788 | | |
| | | 789 | | private void ConfigureSecurity() |
| | | 790 | | { |
| | | 791 | | // Authentication is Util.WebApi's alone: ASP.NET Core's authentication and authorization |
| | | 792 | | // middlewares are not registered, because nothing here reads HttpContext.User or carries |
| | | 793 | | // ASP.NET Core's [Authorize] — the controllers use the library's attributes, which read the |
| | | 794 | | // user AuthenticationMiddleware attaches. |
| | | 795 | | // |
| | | 796 | | // AuthenticationMiddleware resolves AuthenticationOptions and the token validators from the |
| | | 797 | | // container, and JwtTokenValidator additionally needs JwtConfiguration, JwtHandler, and the |
| | | 798 | | // claims mapper. IdentityUserMapper replaces the library default so tokens carry PublicIds |
| | | 799 | | // and the scope claims of FR-AU-04. Defaults are kept otherwise: app JWT only, read from the |
| | | 800 | | // Authorization header, user rebuilt from claims — so no IAuthenticationProvider is required |
| | | 801 | | // and no database read happens per request. |
| | 899 | 802 | | var jwtConfiguration = BuildJwtConfiguration(); |
| | 899 | 803 | | Builder.Services.AddSingleton(jwtConfiguration); |
| | 899 | 804 | | Builder.Services.AddSingleton<JwtHandler>(); |
| | | 805 | | |
| | | 806 | | // TH-06/TH-35: the challenge and subject-rights-only tokens carry audiences derived from the |
| | | 807 | | // person audience, so a consumer pinning it refuses them. Logged, like the application |
| | | 808 | | // audience below, so a consumer can be checked against them. |
| | 899 | 809 | | var personTokenAudiences = PersonTokenAudiences.From(jwtConfiguration); |
| | 899 | 810 | | Builder.Services.AddSingleton(personTokenAudiences); |
| | 899 | 811 | | Log.Information( |
| | 899 | 812 | | "Person tokens: audience {Audience}; challenge tokens {ChallengeAudience}; " + |
| | 899 | 813 | | "subject-rights-only tokens {SubjectRightsAudience}", |
| | 899 | 814 | | personTokenAudiences.Person, personTokenAudiences.TwoFactorChallenge, |
| | 899 | 815 | | personTokenAudiences.SubjectRights); |
| | | 816 | | |
| | | 817 | | // UC-56/UC-57: application tokens share the signing keys but not the audience. Read here, |
| | | 818 | | // with the rest of the token configuration, so a missing or equal audience stops start-up |
| | | 819 | | // before anything is served (TH-35). |
| | 899 | 820 | | var applicationTokens = ApplicationTokenOptions.FromEnvironment(); |
| | 899 | 821 | | Builder.Services.AddSingleton(applicationTokens); |
| | 899 | 822 | | WarnOnFallback(ApplicationTokenOptions.ExpirationVariable, applicationTokens.RejectedExpiration, |
| | 899 | 823 | | ApplicationTokenOptions.DefaultLifetime.TotalSeconds); |
| | 899 | 824 | | Builder.Services.AddScoped<JwtApplicationTokenService>(); |
| | 899 | 825 | | Builder.Services.AddScoped<IApplicationTokenIssuer>(provider => |
| | 921 | 826 | | provider.GetRequiredService<JwtApplicationTokenService>()); |
| | 899 | 827 | | Builder.Services.AddScoped<IApplicationTokenValidator>(provider => |
| | 911 | 828 | | provider.GetRequiredService<JwtApplicationTokenService>()); |
| | | 829 | | |
| | | 830 | | // The audience is not a secret; logging it makes a mismatch with a consumer diagnosable. |
| | 899 | 831 | | Log.Information( |
| | 899 | 832 | | "Application tokens: audience {Audience}, lifetime {Seconds}s", |
| | 899 | 833 | | applicationTokens.Audience, applicationTokens.Lifetime.TotalSeconds); |
| | | 834 | | |
| | 899 | 835 | | Builder.Services.AddTokenAuthentication<IdentityUserMapper>(options => |
| | 899 | 836 | | { |
| | 899 | 837 | | options.Source = TokenSource.Header; |
| | 899 | 838 | | options.EnableJwt = true; |
| | 899 | 839 | | options.EnableGoogle = false; |
| | 899 | 840 | | options.JwtMode = JwtValidationMode.ClaimsOnly; |
| | 1798 | 841 | | }); |
| | | 842 | | |
| | 899 | 843 | | ReplaceLibraryTokenValidator(Builder.Services); |
| | | 844 | | |
| | 899 | 845 | | AddAuthEndpointRateLimiting(); |
| | | 846 | | |
| | | 847 | | // TH-03: bound how many Argon2id derivations run at once. Read here, with the rest of the |
| | | 848 | | // security configuration, because it belongs to the same defence: the rate limiter caps what |
| | | 849 | | // one address can ask for, and this caps what the process will attempt regardless of how |
| | | 850 | | // many addresses ask. |
| | 899 | 851 | | PasswordHashGate.ConfigureSharedFromEnvironment(); |
| | 899 | 852 | | } |
| | | 853 | | |
| | | 854 | | /// <summary> |
| | | 855 | | /// Believes <c>X-Forwarded-For</c> and <c>X-Forwarded-Proto</c> from the proxies |
| | | 856 | | /// <c>HEIMDALL_TRUSTED_PROXIES</c> names, and from no one else — see |
| | | 857 | | /// <see cref="TrustedProxyOptions" />. Applied by <c>UseForwardedHeaders</c>, which runs |
| | | 858 | | /// ahead of everything that reads the client address: the rate limiter, and the request log. |
| | | 859 | | /// </summary> |
| | | 860 | | private void ConfigureForwardedHeaders() |
| | | 861 | | { |
| | 899 | 862 | | var proxies = TrustedProxyOptions.FromEnvironment(); |
| | | 863 | | |
| | 899 | 864 | | if (proxies.InvalidEntries.Count > 0) |
| | | 865 | | { |
| | 0 | 866 | | Log.Warning( |
| | 0 | 867 | | "Ignoring unusable {Variable} entries {Entries}; they are neither an address nor a CIDR network", |
| | 0 | 868 | | TrustedProxyOptions.ProxiesVariable, proxies.InvalidEntries); |
| | | 869 | | } |
| | | 870 | | |
| | 899 | 871 | | if (!proxies.Configured) |
| | | 872 | | { |
| | 899 | 873 | | Log.Warning( |
| | 899 | 874 | | "No trusted proxy is configured ({Variable}); behind a reverse proxy every caller will " + |
| | 899 | 875 | | "be rate limited and logged under the proxy's address", |
| | 899 | 876 | | TrustedProxyOptions.ProxiesVariable); |
| | | 877 | | |
| | 899 | 878 | | return; |
| | | 879 | | } |
| | | 880 | | |
| | 0 | 881 | | Log.Information( |
| | 0 | 882 | | "Trusting forwarded headers from {Proxies} and {Networks}", |
| | 0 | 883 | | proxies.Proxies.Select(proxy => proxy.ToString()), |
| | 0 | 884 | | proxies.Networks.Select(network => network.ToString())); |
| | | 885 | | |
| | 0 | 886 | | Builder.Services.Configure<ForwardedHeadersOptions>(proxies.Apply); |
| | 0 | 887 | | } |
| | | 888 | | |
| | | 889 | | /// <summary> |
| | | 890 | | /// Throttles the credential-checking endpoints (login, password recovery/reset, email |
| | | 891 | | /// verification, Google sign-in, 2FA challenge verification and resend) per calling IP |
| | | 892 | | /// address. None of these require a bearer token, so nothing else stops a caller from firing |
| | | 893 | | /// an unbounded burst of requests at them — each login attempt alone costs a full Argon2id |
| | | 894 | | /// verification (600 MB / 16 threads by this codebase's hashing library default), and a 2FA |
| | | 895 | | /// email code has only 1,000,000 possible values, so an unthrottled brute force or memory/CPU |
| | | 896 | | /// exhaustion attempt is realistic without this. The authenticated endpoints that check a |
| | | 897 | | /// password or a second factor (2FA confirm, disable and recovery-code regeneration, and the |
| | | 898 | | /// erasure request) carry it too: a bearer token is not the password, and without a limit |
| | | 899 | | /// whoever holds one could test passwords or six-digit codes against them as fast as they |
| | | 900 | | /// could send requests, outside the login lockout. The verification-link resend carries it |
| | | 901 | | /// as well, since each request there mails the account's address. Policy name matches the |
| | | 902 | | /// <see cref="Microsoft.AspNetCore.RateLimiting.EnableRateLimitingAttribute" /> applied to |
| | | 903 | | /// each endpoint in <c>AuthController</c>. |
| | | 904 | | /// </summary> |
| | | 905 | | /// <remarks> |
| | | 906 | | /// Partitioned by <see cref="HttpContext.Connection" />'s remote IP (an IPv6 caller by its /56 — |
| | | 907 | | /// see <see cref="RateLimitPartitionKey" />), which is the caller's own |
| | | 908 | | /// only when <see cref="TrustedProxyOptions" /> names the proxy in front of the API; otherwise |
| | | 909 | | /// every caller shares the proxy's partition. A client system that is itself one caller to |
| | | 910 | | /// Heimdall for many end users may instead name each user's address, with a key from |
| | | 911 | | /// <see cref="TrustedCallerOptions" /> (see <see cref="AuthRateLimitPartition" />). Either way |
| | | 912 | | /// this is a per-instance, defense-in-depth throttle, not a substitute for a WAF or an API |
| | | 913 | | /// gateway's own rate limiting in front of a real deployment. |
| | | 914 | | /// </remarks> |
| | | 915 | | /// <summary> |
| | | 916 | | /// The partition a caller's requests are counted in: the whole address for IPv4, and the |
| | | 917 | | /// <c>/56</c> it belongs to for IPv6. |
| | | 918 | | /// </summary> |
| | | 919 | | /// <remarks> |
| | | 920 | | /// <para> |
| | | 921 | | /// A /64 is the least one IPv6 subscriber is handed, and every address inside it is theirs |
| | | 922 | | /// to use — 2<sup>64</sup> of them. Counted per address, a single caller could give every |
| | | 923 | | /// request a fresh partition and never meet the limit at all. Counted per /64 it is no |
| | | 924 | | /// better for the many subscribers routinely handed a /56: 256 partitions each, enough on |
| | | 925 | | /// their own to keep the password-hash gate full. A /56 is one caller here. |
| | | 926 | | /// </para> |
| | | 927 | | /// <para> |
| | | 928 | | /// Coarser is not safer: a /48 is a whole site, often many unrelated people behind one |
| | | 929 | | /// provider, and they would share one budget of ten a minute. A flood from many unrelated |
| | | 930 | | /// networks is not bounded here at all — that is for the edge (Threat Model TH-03). An |
| | | 931 | | /// IPv4-mapped IPv6 address is one IPv4 caller, and is counted as that address. |
| | | 932 | | /// </para> |
| | | 933 | | /// </remarks> |
| | | 934 | | public static string RateLimitPartitionKey(IPAddress? address) |
| | | 935 | | { |
| | 787 | 936 | | if (address is null) |
| | | 937 | | { |
| | 624 | 938 | | return "unknown"; |
| | | 939 | | } |
| | | 940 | | |
| | 163 | 941 | | if (address.IsIPv4MappedToIPv6) |
| | | 942 | | { |
| | 2 | 943 | | address = address.MapToIPv4(); |
| | | 944 | | } |
| | | 945 | | |
| | 163 | 946 | | if (address.AddressFamily != AddressFamily.InterNetworkV6) |
| | | 947 | | { |
| | 139 | 948 | | return address.ToString(); |
| | | 949 | | } |
| | | 950 | | |
| | 24 | 951 | | var bytes = address.GetAddressBytes(); |
| | 24 | 952 | | Array.Clear(bytes, RateLimitIPv6PrefixLength / 8, 16 - RateLimitIPv6PrefixLength / 8); |
| | | 953 | | |
| | 24 | 954 | | return $"{new IPAddress(bytes)}/{RateLimitIPv6PrefixLength}"; |
| | | 955 | | } |
| | | 956 | | |
| | | 957 | | /// <summary> |
| | | 958 | | /// Warns that a numeric setting was present but unusable and its default applied instead. Absent |
| | | 959 | | /// is not a fallback, so <paramref name="rejectedValue" /> is <c>null</c> for it and nothing is |
| | | 960 | | /// logged. None of these values is a secret, so the rejected text is logged as written. |
| | | 961 | | /// </summary> |
| | | 962 | | private static void WarnOnFallback(string variable, string? rejectedValue, double defaultSeconds) |
| | | 963 | | { |
| | 2697 | 964 | | if (rejectedValue is null) |
| | | 965 | | { |
| | 2697 | 966 | | return; |
| | | 967 | | } |
| | | 968 | | |
| | 0 | 969 | | Log.Warning( |
| | 0 | 970 | | "Ignoring unusable {Variable}={Value}; using the default of {Default} seconds", |
| | 0 | 971 | | variable, rejectedValue, defaultSeconds); |
| | 0 | 972 | | } |
| | | 973 | | |
| | | 974 | | /// <summary> |
| | | 975 | | /// Whether a request spends the account-creation address bound: only an administrator's |
| | | 976 | | /// person access token — the full sign-in audience, neither a two-factor challenge nor a |
| | | 977 | | /// subject-rights token (<see cref="IdentityUserMapper" /> has already held each token's marks |
| | | 978 | | /// to its audience) — and only the roles the three endpoints admit. Anything else is turned |
| | | 979 | | /// away by the endpoint's own checks and must not use up a bound administrators share. |
| | | 980 | | /// </summary> |
| | | 981 | | private static bool CountsAgainstCreationAddress(IdentityUser? actor) => |
| | 184 | 982 | | actor is { MfaPending: false, SubjectRightsOnly: false } && |
| | 184 | 983 | | actor.RoleId is (int)Roles.SystemAdmin or (int)Roles.ScopeAdmin; |
| | | 984 | | |
| | | 985 | | /// <summary> |
| | | 986 | | /// Throttles the three endpoints that create a person with a password (<c>POST /api/persons</c>, |
| | | 987 | | /// <c>POST /api/scopes/{id}/persons</c>, <c>POST /api/scopes/{id}/owners</c>) twice over: |
| | | 988 | | /// <see cref="AccountCreationPermitLimit" /> a minute per acting person, and as many per address. |
| | | 989 | | /// Each derives an Argon2id hash through <c>PasswordHashGate</c>, and each needs only an |
| | | 990 | | /// administrator's token, so without a bound one caller could keep the gate full and starve every |
| | | 991 | | /// sign-in (Threat Model TH-03). |
| | | 992 | | /// </summary> |
| | | 993 | | /// <remarks> |
| | | 994 | | /// <para> |
| | | 995 | | /// The actor bound is the endpoint policy. The address bound — the partition the |
| | | 996 | | /// credential endpoints use (<see cref="AuthRateLimitPartition" />), in a namespace of its |
| | | 997 | | /// own — is the global limiter, which ASP.NET Core applies before the endpoint policy, and |
| | | 998 | | /// which answers "no limit" for every endpoint without that policy. Both apply to every |
| | | 999 | | /// administrator's request, so neither can be stepped around by changing only the other |
| | | 1000 | | /// (Threat Model TH-03). |
| | | 1001 | | /// </para> |
| | | 1002 | | /// <para> |
| | | 1003 | | /// Thirty a minute is more than anyone creates by hand. The limiter runs after |
| | | 1004 | | /// authentication, so the actor is known. The global address bucket is spent only by an |
| | | 1005 | | /// administrator's person access token (<see cref="CountsAgainstCreationAddress" />): a |
| | | 1006 | | /// request without a token, or with a User's, a challenge or a subject-rights token, is |
| | | 1007 | | /// refused by the endpoint anyway and must not use up a bound administrators share. |
| | | 1008 | | /// </para> |
| | | 1009 | | /// </remarks> |
| | | 1010 | | private static void AddAccountCreationRateLimiting(RateLimiterOptions options, TrustedCallerOptions callers) |
| | | 1011 | | { |
| | 57 | 1012 | | FixedWindowRateLimiterOptions Window(string _) => new() |
| | 57 | 1013 | | { |
| | 57 | 1014 | | PermitLimit = AccountCreationPermitLimit, |
| | 57 | 1015 | | Window = TimeSpan.FromSeconds(AuthEndpointRateLimitWindowSeconds), |
| | 57 | 1016 | | QueueLimit = 0 |
| | 57 | 1017 | | }; |
| | | 1018 | | |
| | 899 | 1019 | | options.AddPolicy(AccountCreationRateLimitPolicy, httpContext => |
| | 899 | 1020 | | { |
| | 182 | 1021 | | var actor = httpContext.GetUser<IdentityUser>(); |
| | 899 | 1022 | | |
| | 182 | 1023 | | return RateLimitPartition.GetFixedWindowLimiter( |
| | 182 | 1024 | | partitionKey: actor is null |
| | 182 | 1025 | | ? AuthRateLimitPartition.KeyFor(httpContext, callers) |
| | 182 | 1026 | | : $"actor:{actor.Id}", |
| | 182 | 1027 | | factory: Window); |
| | 899 | 1028 | | }); |
| | | 1029 | | |
| | 899 | 1030 | | options.GlobalLimiter = PartitionedRateLimiter.Create<HttpContext, string>(httpContext => |
| | 2344 | 1031 | | httpContext.GetEndpoint()?.Metadata.GetMetadata<EnableRateLimitingAttribute>()?.PolicyName == |
| | 2344 | 1032 | | AccountCreationRateLimitPolicy && CountsAgainstCreationAddress(httpContext.GetUser<IdentityUser>()) |
| | 2344 | 1033 | | ? RateLimitPartition.GetFixedWindowLimiter( |
| | 2344 | 1034 | | "creation:" + AuthRateLimitPartition.KeyFor(httpContext, callers), Window) |
| | 2344 | 1035 | | : RateLimitPartition.GetNoLimiter(string.Empty)); |
| | 899 | 1036 | | } |
| | | 1037 | | |
| | | 1038 | | private void AddAuthEndpointRateLimiting() |
| | | 1039 | | { |
| | | 1040 | | // Throws on a key too short to be a secret, so a misconfigured deployment stops here. |
| | 899 | 1041 | | var callers = TrustedCallerOptions.FromEnvironment(); |
| | | 1042 | | |
| | 899 | 1043 | | Builder.Services.AddSingleton(callers); |
| | | 1044 | | |
| | | 1045 | | // How many, never which: the keys are secrets and the headers carrying them are not logged. |
| | 899 | 1046 | | Log.Information( |
| | 899 | 1047 | | "{Count} trusted caller key(s) configured ({Variable})", |
| | 899 | 1048 | | callers.Count, TrustedCallerOptions.KeysVariable); |
| | | 1049 | | |
| | 899 | 1050 | | Builder.Services.AddRateLimiter(options => |
| | 899 | 1051 | | { |
| | 899 | 1052 | | options.RejectionStatusCode = StatusCodes.Status429TooManyRequests; |
| | 899 | 1053 | | |
| | 899 | 1054 | | // TH-32: a turned-away caller is told when to come back and gets the API's usual error |
| | 899 | 1055 | | // envelope, not an empty 429. The lease's own hint is used when the limiter gives one; |
| | 899 | 1056 | | // a fixed window gives none, so the window's length is the honest upper bound. |
| | 899 | 1057 | | options.OnRejected = async (context, cancellationToken) => |
| | 899 | 1058 | | { |
| | 16 | 1059 | | var retryAfterSeconds = context.Lease.TryGetMetadata(MetadataName.RetryAfter, out var retryAfter) |
| | 16 | 1060 | | ? (int)Math.Ceiling(retryAfter.TotalSeconds) |
| | 16 | 1061 | | : AuthEndpointRateLimitWindowSeconds; |
| | 899 | 1062 | | |
| | 16 | 1063 | | context.HttpContext.Response.StatusCode = StatusCodes.Status429TooManyRequests; |
| | 16 | 1064 | | context.HttpContext.Response.Headers.RetryAfter = |
| | 16 | 1065 | | Math.Max(1, retryAfterSeconds).ToString(CultureInfo.InvariantCulture); |
| | 899 | 1066 | | |
| | 16 | 1067 | | await context.HttpContext.Response.WriteAsJsonAsync( |
| | 16 | 1068 | | DataOutput<object?>.New.WithError(RateLimitMessages.TooManyRequests), |
| | 16 | 1069 | | cancellationToken); |
| | 915 | 1070 | | }; |
| | 899 | 1071 | | |
| | 899 | 1072 | | options.AddPolicy(AuthEndpointRateLimitPolicy, httpContext => |
| | 1559 | 1073 | | RateLimitPartition.GetFixedWindowLimiter( |
| | 1559 | 1074 | | partitionKey: AuthRateLimitPartition.KeyFor(httpContext, callers), |
| | 1885 | 1075 | | factory: _ => new FixedWindowRateLimiterOptions |
| | 1885 | 1076 | | { |
| | 1885 | 1077 | | PermitLimit = 10, |
| | 1885 | 1078 | | Window = TimeSpan.FromSeconds(AuthEndpointRateLimitWindowSeconds), |
| | 1885 | 1079 | | QueueLimit = 0 |
| | 1885 | 1080 | | })); |
| | 899 | 1081 | | |
| | 899 | 1082 | | AddAccountCreationRateLimiting(options, callers); |
| | 1798 | 1083 | | }); |
| | 899 | 1084 | | } |
| | | 1085 | | |
| | | 1086 | | /// <summary> |
| | | 1087 | | /// Chooses how the two transactional emails — UC-06's verification token and UC-12's |
| | | 1088 | | /// password reset token — are delivered. With Mailgun credentials present, both go out for |
| | | 1089 | | /// real through <c>ArturRios.Messaging</c>; without them, both are logged. |
| | | 1090 | | /// </summary> |
| | | 1091 | | /// <remarks> |
| | | 1092 | | /// The fallback is deliberate rather than a failure: a developer running the API locally, |
| | | 1093 | | /// and the functional suite, both need person creation and password recovery to work without |
| | | 1094 | | /// credentials and without reaching the network. Failing startup instead would make Mailgun |
| | | 1095 | | /// a prerequisite for running the tests. In Production, though, the fallback logs a |
| | | 1096 | | /// verification token, a password reset token, or a 2FA code in plaintext on every send — an |
| | | 1097 | | /// account-takeover primitive for anyone who can read the logs — so an unconfigured Production |
| | | 1098 | | /// deployment fails startup outright instead of silently degrading into that. |
| | | 1099 | | /// </remarks> |
| | | 1100 | | private void AddEmailSenders() |
| | | 1101 | | { |
| | 899 | 1102 | | var options = EmailDeliveryOptions.FromEnvironment(); |
| | | 1103 | | |
| | 899 | 1104 | | Builder.Services.AddSingleton(options); |
| | | 1105 | | |
| | 899 | 1106 | | if (!options.MailgunConfigured) |
| | | 1107 | | { |
| | 899 | 1108 | | if (Builder.Environment.IsProduction()) |
| | | 1109 | | { |
| | 0 | 1110 | | throw new InvalidOperationException( |
| | 0 | 1111 | | $"Mailgun is not configured ({MailgunEmailService.ApiKeyVariable} / " + |
| | 0 | 1112 | | $"{MailgunEmailService.DomainVariable}) in Production. Refusing to start: the " + |
| | 0 | 1113 | | "fallback sender logs verification tokens, password reset tokens, and 2FA codes " + |
| | 0 | 1114 | | "in plaintext, which must never happen outside local development and tests."); |
| | | 1115 | | } |
| | | 1116 | | |
| | 899 | 1117 | | Log.Warning( |
| | 899 | 1118 | | "Mailgun is not configured ({ApiKeyVariable} / {DomainVariable}); verification and " + |
| | 899 | 1119 | | "password reset tokens will be logged instead of emailed", |
| | 899 | 1120 | | MailgunEmailService.ApiKeyVariable, MailgunEmailService.DomainVariable); |
| | | 1121 | | |
| | 899 | 1122 | | Builder.Services.AddScoped<IEmailVerificationSender, LoggingEmailVerificationSender>(); |
| | 899 | 1123 | | Builder.Services.AddScoped<IPasswordResetSender, LoggingPasswordResetSender>(); |
| | 899 | 1124 | | Builder.Services.AddScoped<ITwoFactorEmailSender, LoggingTwoFactorEmailSender>(); |
| | 899 | 1125 | | Builder.Services.AddScoped<IRestrictionLiftNotifier, LoggingRestrictionLiftNotifier>(); |
| | 899 | 1126 | | Builder.Services.AddScoped<IRegistrationAttemptSender, LoggingRegistrationAttemptSender>(); |
| | 899 | 1127 | | Builder.Services.AddScoped<IRegistrationLinkSender, LoggingRegistrationLinkSender>(); |
| | 899 | 1128 | | Builder.Services.AddScoped<IEmailChangeNoticeSender, LoggingEmailChangeNoticeSender>(); |
| | | 1129 | | |
| | 899 | 1130 | | return; |
| | | 1131 | | } |
| | | 1132 | | |
| | | 1133 | | // A typed client, so the Mailgun service reuses pooled connections instead of creating an |
| | | 1134 | | // HttpClient per send. |
| | 0 | 1135 | | Builder.Services.AddHttpClient<IEmailService, MailgunEmailService>(); |
| | 0 | 1136 | | Builder.Services.AddScoped<IEmailVerificationSender, MailgunEmailVerificationSender>(); |
| | 0 | 1137 | | Builder.Services.AddScoped<IPasswordResetSender, MailgunPasswordResetSender>(); |
| | 0 | 1138 | | Builder.Services.AddScoped<ITwoFactorEmailSender, MailgunTwoFactorEmailSender>(); |
| | 0 | 1139 | | Builder.Services.AddScoped<IRestrictionLiftNotifier, MailgunRestrictionLiftNotifier>(); |
| | 0 | 1140 | | Builder.Services.AddScoped<IRegistrationAttemptSender, MailgunRegistrationAttemptSender>(); |
| | 0 | 1141 | | Builder.Services.AddScoped<IRegistrationLinkSender, MailgunRegistrationLinkSender>(); |
| | 0 | 1142 | | Builder.Services.AddScoped<IEmailChangeNoticeSender, MailgunEmailChangeNoticeSender>(); |
| | 0 | 1143 | | } |
| | | 1144 | | |
| | | 1145 | | /// <summary> |
| | | 1146 | | /// Chooses how UC-25 verifies a Google ID token (FR-GO-11, NFR-13). With Google client IDs |
| | | 1147 | | /// configured, tokens are validated against Google; without them, every token is refused, so |
| | | 1148 | | /// an unconfigured deployment answers 401 rather than trusting a token no one checked. |
| | | 1149 | | /// </summary> |
| | | 1150 | | /// <remarks> |
| | | 1151 | | /// The third branch exists for the functional suite, which cannot override a DI registration |
| | | 1152 | | /// (<c>WebApiTest<T></c> exposes neither its factory nor a settable gateway) and must |
| | | 1153 | | /// still reach the flows behind verification. It is guarded twice — never in Production, and |
| | | 1154 | | /// never without an explicitly set signing secret — and is checked before the real verifier so |
| | | 1155 | | /// a test environment cannot accidentally run both. See <see cref="LocalGoogleIdTokenVerifier" />. |
| | | 1156 | | /// </remarks> |
| | | 1157 | | private void AddGoogleSignIn() |
| | | 1158 | | { |
| | 899 | 1159 | | var options = GoogleSignInOptions.FromEnvironment(); |
| | | 1160 | | |
| | 899 | 1161 | | Builder.Services.AddSingleton(options); |
| | | 1162 | | |
| | 899 | 1163 | | if (!Builder.Environment.IsProduction() && options.TestSigningConfigured) |
| | | 1164 | | { |
| | 899 | 1165 | | Log.Warning( |
| | 899 | 1166 | | "Google ID tokens will be verified against a local signing secret ({Variable}), not " + |
| | 899 | 1167 | | "against Google. This is for automated tests only", |
| | 899 | 1168 | | GoogleSignInOptions.TestSigningSecretVariable); |
| | | 1169 | | |
| | 899 | 1170 | | Builder.Services.AddScoped<IGoogleIdTokenVerifier, LocalGoogleIdTokenVerifier>(); |
| | | 1171 | | |
| | 899 | 1172 | | return; |
| | | 1173 | | } |
| | | 1174 | | |
| | 0 | 1175 | | if (!options.GoogleConfigured) |
| | | 1176 | | { |
| | 0 | 1177 | | Log.Warning( |
| | 0 | 1178 | | "No Google client is configured ({Variable}); Google sign-in (UC-25) will refuse " + |
| | 0 | 1179 | | "every token", |
| | 0 | 1180 | | GoogleSignInOptions.ClientIdsVariable); |
| | | 1181 | | |
| | 0 | 1182 | | Builder.Services.AddScoped<IGoogleIdTokenVerifier, UnconfiguredGoogleIdTokenVerifier>(); |
| | | 1183 | | |
| | 0 | 1184 | | return; |
| | | 1185 | | } |
| | | 1186 | | |
| | 0 | 1187 | | Builder.Services.AddScoped<IGoogleIdTokenVerifier, GoogleIdTokenVerifier>(); |
| | 0 | 1188 | | } |
| | | 1189 | | |
| | | 1190 | | /// <summary> |
| | | 1191 | | /// Reads the token settings from the environment. The signing secret is required, and it and |
| | | 1192 | | /// any previous secret must be at least 32 bytes of ASCII (<see cref="TokenSigningSecrets" />): |
| | | 1193 | | /// an unusable one fails start-up rather than signing with a weak key, or failing every |
| | | 1194 | | /// request inside the token validator. |
| | | 1195 | | /// </summary> |
| | | 1196 | | /// <remarks> |
| | | 1197 | | /// <para> |
| | | 1198 | | /// <see cref="PreviousTokenSecretEnvironmentVariable" /> is how a signing secret is |
| | | 1199 | | /// replaced without signing everybody out (Threat Model TH-22). Both secrets are handed |
| | | 1200 | | /// to <c>JwtConfiguration.Keys</c>, so a token signed with either is accepted, while new |
| | | 1201 | | /// tokens are signed with the current one. Rotating is then: set the previous variable to |
| | | 1202 | | /// the secret in use, set the current variable to a new one, restart; and an hour later — |
| | | 1203 | | /// one token lifetime — clear the previous variable and restart again. An empty previous |
| | | 1204 | | /// variable is never passed to the library as a key. |
| | | 1205 | | /// </para> |
| | | 1206 | | /// <para> |
| | | 1207 | | /// The keys carry ids because <c>JwtKey</c> requires them, but no <c>kid</c> is written to |
| | | 1208 | | /// the tokens: <c>SigningKeyId</c> is deliberately left unset, so a token names no key and |
| | | 1209 | | /// validation tries both. That is the point of not using the <c>kid</c> feature here. An |
| | | 1210 | | /// identifier stamped on the token has to stay attached to the same key forever, and these |
| | | 1211 | | /// ids are positional — today's "current" is tomorrow's "previous" — so stamping them |
| | | 1212 | | /// would make every token issued before a rotation name the wrong key afterwards, and be |
| | | 1213 | | /// refused. Deriving stable ids from the secrets instead would mean publishing something |
| | | 1214 | | /// computed from a signing key on every token, which is a poor trade for saving one HMAC |
| | | 1215 | | /// against two keys. |
| | | 1216 | | /// </para> |
| | | 1217 | | /// </remarks> |
| | | 1218 | | private static JwtConfiguration BuildJwtConfiguration() |
| | | 1219 | | { |
| | 899 | 1220 | | var secret = Environment.GetEnvironmentVariable(TokenSecretEnvironmentVariable); |
| | | 1221 | | |
| | | 1222 | | // The current secret is always accepted; the previous one only while it is configured, and |
| | | 1223 | | // only when it is genuinely a different key. |
| | 899 | 1224 | | var keys = TokenSigningSecrets.Parse( |
| | 899 | 1225 | | secret, Environment.GetEnvironmentVariable(PreviousTokenSecretEnvironmentVariable)); |
| | | 1226 | | |
| | 899 | 1227 | | var lifetime = TokenLifetimeSetting.Parse( |
| | 899 | 1228 | | Environment.GetEnvironmentVariable(TokenExpirationEnvironmentVariable), |
| | 899 | 1229 | | DefaultTokenExpirationInSeconds); |
| | | 1230 | | |
| | 899 | 1231 | | WarnOnFallback(TokenExpirationEnvironmentVariable, lifetime.RejectedValue, DefaultTokenExpirationInSeconds); |
| | | 1232 | | |
| | 899 | 1233 | | return new JwtConfiguration( |
| | 899 | 1234 | | lifetime.Seconds, |
| | 899 | 1235 | | Environment.GetEnvironmentVariable(TokenIssuerEnvironmentVariable) ?? string.Empty, |
| | 899 | 1236 | | Environment.GetEnvironmentVariable(TokenAudienceEnvironmentVariable) ?? string.Empty, |
| | 899 | 1237 | | secret!, |
| | 899 | 1238 | | []) |
| | 899 | 1239 | | { |
| | 899 | 1240 | | Keys = keys |
| | 899 | 1241 | | }; |
| | | 1242 | | } |
| | | 1243 | | |
| | | 1244 | | /// <summary> |
| | | 1245 | | /// Runs the database seeder before the host starts serving, so the reference data the |
| | | 1246 | | /// application depends on is guaranteed to exist. Migrations are not applied here — the |
| | | 1247 | | /// seeder throws if any are pending. |
| | | 1248 | | /// </summary> |
| | | 1249 | | private static void SeedDatabase(WebApplication app) |
| | | 1250 | | { |
| | 899 | 1251 | | using var scope = app.Services.CreateScope(); |
| | | 1252 | | |
| | 899 | 1253 | | var seeder = scope.ServiceProvider.GetRequiredService<DatabaseSeeder>(); |
| | | 1254 | | |
| | 899 | 1255 | | seeder.SeedAsync().GetAwaiter().GetResult(); |
| | 1798 | 1256 | | } |
| | | 1257 | | |
| | | 1258 | | private void ConfigureWebApi() |
| | | 1259 | | { |
| | | 1260 | | // The standard sequence has already called AddControllers(); calling it again adds this |
| | | 1261 | | // configuration to the same MVC registration. |
| | | 1262 | | // |
| | | 1263 | | // MfaPendingGuardFilter (FR-2F-10, NFR-17) runs as a global MVC authorization filter, on |
| | | 1264 | | // every controller action — added here rather than per-action, since a UC-38 challenge token |
| | | 1265 | | // must be rejected everywhere except POST /api/auth/2fa/verify, and that endpoint needs no |
| | | 1266 | | // opt-out: it never reads the challenge token as a bearer credential to begin with. |
| | 899 | 1267 | | Builder.Services.AddControllers(options => |
| | 899 | 1268 | | { |
| | 899 | 1269 | | options.Filters.Add<MfaPendingGuardFilter>(); |
| | 899 | 1270 | | |
| | 899 | 1271 | | // NFR-24: a subject-rights-only token, issued to a restricted identity at sign-in, is |
| | 899 | 1272 | | // confined to the data-subject-rights actions — on its claims alone, so before the |
| | 899 | 1273 | | // liveness check's database read, like the guard above. |
| | 899 | 1274 | | options.Filters.Add<SubjectRightsOnlyGuardFilter>(); |
| | 899 | 1275 | | |
| | 899 | 1276 | | // Runs after the guard above, which is the cheap check: a challenge token misused as a |
| | 899 | 1277 | | // bearer credential is rejected on its claims alone, without spending the database read |
| | 899 | 1278 | | // this one makes. Global for the same reason — a token naming a deleted identity must be |
| | 899 | 1279 | | // refused everywhere, and leaving that to each handler is what let a logically deleted |
| | 899 | 1280 | | // System Admin keep acting until their token expired. |
| | 899 | 1281 | | options.Filters.Add<ActorLivenessFilter>(); |
| | 899 | 1282 | | |
| | 899 | 1283 | | // Turns a saturated password-hash gate into 503 rather than 500 (TH-03). An exception |
| | 899 | 1284 | | // filter because the condition is cross-cutting: every endpoint that derives a password |
| | 899 | 1285 | | // can meet it, and each has its own message map. |
| | 899 | 1286 | | options.Filters.Add<PasswordHashSaturationFilter>(); |
| | 899 | 1287 | | |
| | 899 | 1288 | | // NFR-10 (#186): a request MVC could not bind answers 400 instead of running the action with |
| | 899 | 1289 | | // a null or default argument. An action filter, so after the authorization filters above — |
| | 899 | 1290 | | // an unauthenticated caller still gets 401 — and after model binding has filled ModelState. |
| | 899 | 1291 | | options.Filters.Add<MalformedRequestFilter>(); |
| | 899 | 1292 | | |
| | 899 | 1293 | | // Also applied by ArturRios.Heimdall.OpenApiGen/Program.cs, which builds its own |
| | 899 | 1294 | | // AddControllers() rather than running this Startup, so that call site cannot catch a |
| | 899 | 1295 | | // removal here. Without it, [FromQuery] list-query properties marked server-populated |
| | 899 | 1296 | | // would become bindable from the query string again on the running API — the controllers |
| | 899 | 1297 | | // still overwrite them before use, so the query-string forgery tests would keep passing, |
| | 899 | 1298 | | // and the published document (generated from the other call site) would look unchanged. |
| | 899 | 1299 | | // The guarantee that these properties are non-bindable, not merely undocumented, would |
| | 899 | 1300 | | // silently revert with no test catching it. |
| | 899 | 1301 | | ModelBindingConfiguration.Configure(options); |
| | 1798 | 1302 | | }); |
| | 899 | 1303 | | Builder.Services.AddEndpointsApiExplorer(); |
| | 899 | 1304 | | } |
| | | 1305 | | |
| | | 1306 | | /// <summary> |
| | | 1307 | | /// Registers the retention schedule (NFR-19) and the pass that enforces it. |
| | | 1308 | | /// </summary> |
| | | 1309 | | /// <remarks> |
| | | 1310 | | /// The options are a singleton because they are a deployment-wide policy read once at |
| | | 1311 | | /// start-up, and the purge is registered as a hosted service unless it has been switched |
| | | 1312 | | /// off — which is a deployment decision an operator has to take deliberately, since keeping |
| | | 1313 | | /// personal data past its retention period is the thing NFR-19 forbids. The handler is |
| | | 1314 | | /// registered audited like every other command: the entry recording that the schedule was |
| | | 1315 | | /// enforced is as much the point as the deletion is. |
| | | 1316 | | /// </remarks> |
| | | 1317 | | private void AddDataRetention() |
| | | 1318 | | { |
| | 899 | 1319 | | var retention = DataRetentionOptions.FromEnvironment(); |
| | | 1320 | | |
| | 899 | 1321 | | if (retention.InvalidVariables.Count > 0) |
| | | 1322 | | { |
| | 0 | 1323 | | Log.Warning( |
| | 0 | 1324 | | "Ignoring unusable retention settings and applying the documented defaults: {Variables}", |
| | 0 | 1325 | | retention.InvalidVariables); |
| | | 1326 | | } |
| | | 1327 | | |
| | 899 | 1328 | | Builder.Services.AddSingleton(retention); |
| | 899 | 1329 | | Builder.Services |
| | 899 | 1330 | | .AddAuditedCommandHandler<PurgeExpiredTokensCommand, PurgeExpiredTokensCommandOutput, |
| | 899 | 1331 | | PurgeExpiredTokensCommandHandler>(); |
| | 899 | 1332 | | Builder.Services |
| | 899 | 1333 | | .AddAuditedCommandHandler<AnonymiseExpiredDeletionsCommand, AnonymiseExpiredDeletionsCommandOutput, |
| | 899 | 1334 | | AnonymiseExpiredDeletionsCommandHandler>(); |
| | 899 | 1335 | | Builder.Services |
| | 899 | 1336 | | .AddAuditedCommandHandler<PseudonymiseAuditActorsCommand, PseudonymiseAuditActorsCommandOutput, |
| | 899 | 1337 | | PseudonymiseAuditActorsCommandHandler>(); |
| | | 1338 | | |
| | 899 | 1339 | | if (retention.PurgeEnabled) |
| | | 1340 | | { |
| | 899 | 1341 | | Builder.Services.AddHostedService<TokenRetentionService>(); |
| | | 1342 | | } |
| | | 1343 | | else |
| | | 1344 | | { |
| | 0 | 1345 | | Log.Warning( |
| | 0 | 1346 | | "The token retention purge is switched off by {Variable}; expired tokens will not be removed", |
| | 0 | 1347 | | DataRetentionOptions.PurgeEnabledVariable); |
| | | 1348 | | } |
| | | 1349 | | |
| | 899 | 1350 | | if (retention.AnonymisationEnabled) |
| | | 1351 | | { |
| | 899 | 1352 | | Builder.Services.AddHostedService<IdentityAnonymisationService>(); |
| | | 1353 | | } |
| | | 1354 | | else |
| | | 1355 | | { |
| | 0 | 1356 | | Log.Warning( |
| | 0 | 1357 | | "Identity anonymisation is switched off by {Variable}; logically deleted identities " + |
| | 0 | 1358 | | "will be kept past their retention window", |
| | 0 | 1359 | | DataRetentionOptions.AnonymisationEnabledVariable); |
| | | 1360 | | } |
| | | 1361 | | |
| | 899 | 1362 | | if (retention.SecurityMonitoringEnabled) |
| | | 1363 | | { |
| | 899 | 1364 | | Builder.Services.AddHostedService<SecurityMonitoringService>(); |
| | | 1365 | | } |
| | | 1366 | | else |
| | | 1367 | | { |
| | 0 | 1368 | | Log.Warning( |
| | 0 | 1369 | | "Security signal detection is switched off by {Variable}; the audit trail and lockout " + |
| | 0 | 1370 | | "counters will keep recording what happens and nobody will be told about it", |
| | 0 | 1371 | | DataRetentionOptions.SecurityMonitoringEnabledVariable); |
| | | 1372 | | } |
| | | 1373 | | |
| | 899 | 1374 | | if (retention.AuditPseudonymisationEnabled) |
| | | 1375 | | { |
| | 899 | 1376 | | Builder.Services.AddHostedService<AuditRetentionService>(); |
| | | 1377 | | } |
| | | 1378 | | else |
| | | 1379 | | { |
| | 0 | 1380 | | Log.Warning( |
| | 0 | 1381 | | "Audit attribution pseudonymisation is switched off by {Variable}; the trail will " + |
| | 0 | 1382 | | "keep naming people past its attribution period, including erased ones", |
| | 0 | 1383 | | DataRetentionOptions.AuditPseudonymisationEnabledVariable); |
| | | 1384 | | } |
| | 0 | 1385 | | } |
| | | 1386 | | |
| | | 1387 | | /// <summary> |
| | | 1388 | | /// Warns when the database connection string does not require TLS (NFR-25, GDPR Art. 32, |
| | | 1389 | | /// LGPD Art. 46). |
| | | 1390 | | /// </summary> |
| | | 1391 | | /// <remarks> |
| | | 1392 | | /// <para> |
| | | 1393 | | /// Everything this database holds is personal data, and Npgsql's default |
| | | 1394 | | /// <c>SSL Mode</c> is <c>Prefer</c> — which silently falls back to an unencrypted |
| | | 1395 | | /// connection when the server does not offer TLS. A deployment can therefore be sending |
| | | 1396 | | /// credentials and addresses in clear text across the network while looking correctly |
| | | 1397 | | /// configured, which is exactly the failure worth a start-up warning. |
| | | 1398 | | /// </para> |
| | | 1399 | | /// <para> |
| | | 1400 | | /// It warns by default rather than refusing to start, which is a deliberate departure |
| | | 1401 | | /// from how this codebase treats other security configuration — TH-23's email control |
| | | 1402 | | /// fails start-up in Production. The difference is that failing by default would take |
| | | 1403 | | /// down a working deployment on upgrade over a setting the operator may not control |
| | | 1404 | | /// directly. |
| | | 1405 | | /// </para> |
| | | 1406 | | /// <para> |
| | | 1407 | | /// Setting <c>HEIMDALL_DATA_REQUIRE_TLS=true</c> turns the warning into a refusal. It is |
| | | 1408 | | /// opt-in rather than the default for that reason, and it is the right setting for any |
| | | 1409 | | /// deployment that has confirmed TLS is in place: from that point, a connection string |
| | | 1410 | | /// that does not ask for encryption is a misconfiguration rather than a known state, and |
| | | 1411 | | /// starting anyway would be starting in the one condition the check exists to prevent. |
| | | 1412 | | /// </para> |
| | | 1413 | | /// </remarks> |
| | | 1414 | | private static void WarnIfDatabaseConnectionIsNotEncrypted() |
| | | 1415 | | { |
| | 899 | 1416 | | var connectionString = Environment.GetEnvironmentVariable("HEIMDALL_DATA_CONNECTIONSTRING"); |
| | | 1417 | | |
| | 899 | 1418 | | if (string.IsNullOrWhiteSpace(connectionString)) |
| | | 1419 | | { |
| | 0 | 1420 | | return; |
| | | 1421 | | } |
| | | 1422 | | |
| | 899 | 1423 | | var required = string.Equals( |
| | 899 | 1424 | | Environment.GetEnvironmentVariable(RequireDatabaseTlsVariable), |
| | 899 | 1425 | | "true", |
| | 899 | 1426 | | StringComparison.OrdinalIgnoreCase); |
| | | 1427 | | |
| | 899 | 1428 | | var requiresTls = connectionString |
| | 899 | 1429 | | .Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) |
| | 4495 | 1430 | | .Where(part => part.Contains('=')) |
| | 4495 | 1431 | | .Select(part => part.Split('=', 2)) |
| | 4495 | 1432 | | .Where(pair => pair[0].Replace(" ", string.Empty) |
| | 4495 | 1433 | | .Equals("SSLMode", StringComparison.OrdinalIgnoreCase)) |
| | 899 | 1434 | | .Any(pair => pair[1].Trim() is "Require" or "VerifyCA" or "VerifyFull"); |
| | | 1435 | | |
| | 899 | 1436 | | if (requiresTls) |
| | | 1437 | | { |
| | 0 | 1438 | | return; |
| | | 1439 | | } |
| | | 1440 | | |
| | | 1441 | | const string explanation = |
| | | 1442 | | "The database connection does not require TLS. Npgsql defaults to SSL Mode=Prefer, " + |
| | | 1443 | | "which falls back to an unencrypted connection — every credential and address this " + |
| | | 1444 | | "API stores would cross the network in clear text. Set SSL Mode=Require or stronger"; |
| | | 1445 | | |
| | 899 | 1446 | | if (required) |
| | | 1447 | | { |
| | | 1448 | | // Opted in, so the operator has said TLS is in place: a connection string that does not |
| | | 1449 | | // ask for it is then a misconfiguration to fail on rather than warn about. |
| | 0 | 1450 | | throw new InvalidOperationException( |
| | 0 | 1451 | | $"{explanation}. {RequireDatabaseTlsVariable} is set, so this is refused rather than warned about."); |
| | | 1452 | | } |
| | | 1453 | | |
| | 899 | 1454 | | Log.Warning("{Explanation}", explanation); |
| | 899 | 1455 | | } |
| | | 1456 | | |
| | | 1457 | | /// <summary> |
| | | 1458 | | /// Configures the console and file sinks, and bounds how long the files live (NFR-22). |
| | | 1459 | | /// </summary> |
| | | 1460 | | /// <remarks> |
| | | 1461 | | /// <para> |
| | | 1462 | | /// <b>The year/month directory layout is gone, deliberately.</b> The sink used to be |
| | | 1463 | | /// wrapped in <c>WriteTo.Map</c> keyed on <c>yyyy/MM</c>, which created a <em>new sink |
| | | 1464 | | /// per month</em>. A retention limit bounds the files within one sink, so it would have |
| | | 1465 | | /// bounded each month's directory and never removed a month — the files from the first |
| | | 1466 | | /// month a deployment ran were still on disk years later. A flat rolling sink with |
| | | 1467 | | /// <c>retainedFileTimeLimit</c> is the shape where the limit actually applies. |
| | | 1468 | | /// </para> |
| | | 1469 | | /// <para> |
| | | 1470 | | /// Read straight from the environment rather than through the configured options, |
| | | 1471 | | /// because this runs before <c>LoadConfiguration</c> — the same reason the log directory |
| | | 1472 | | /// is read this way. An unusable value falls back to the documented default, silently |
| | | 1473 | | /// here because there is no logger yet to warn with; <c>AddDataRetention</c> reads the |
| | | 1474 | | /// same variable later and does warn. |
| | | 1475 | | /// </para> |
| | | 1476 | | /// <para> |
| | | 1477 | | /// <b>Every request's client IP address is logged</b>, by Util.WebApi's |
| | | 1478 | | /// <c>TraceActivityMiddleware</c>, as the address the request came from — the real caller's |
| | | 1479 | | /// once <see cref="TrustedProxyOptions" /> names the proxy in front of the API. It is |
| | | 1480 | | /// security telemetry, kept for the same period as the rest of the log, and the Data |
| | | 1481 | | /// Retention Schedule and the Privacy Notice say so. |
| | | 1482 | | /// </para> |
| | | 1483 | | /// <para> |
| | | 1484 | | /// <b><c>MailgunEmailService</c> is silenced.</b> The messaging library logs every send |
| | | 1485 | | /// with the recipient's address in the clear — "Sending e-mail to …" and "accepted the |
| | | 1486 | | /// message for …" at Information, the same on a rejection at Error — which is exactly what |
| | | 1487 | | /// NFR-22 forbids, and this codebase's own redaction cannot reach a library's templates. |
| | | 1488 | | /// Nothing is lost: <c>MailgunSender</c> already logs every outcome, refusal and exception |
| | | 1489 | | /// included, under a <c>LogSafeEmail</c> reference. |
| | | 1490 | | /// </para> |
| | | 1491 | | /// </remarks> |
| | | 1492 | | private static void ConfigureLogging() |
| | | 1493 | | { |
| | 899 | 1494 | | var logDirectory = Environment.GetEnvironmentVariable(LogDirectoryEnvironmentVariable) |
| | 899 | 1495 | | ?? DefaultLogDirectory; |
| | | 1496 | | |
| | 899 | 1497 | | var retention = DataRetentionOptions.FromEnvironment().LogRetention; |
| | | 1498 | | |
| | 899 | 1499 | | Log.Logger = new LoggerConfiguration() |
| | 899 | 1500 | | .MinimumLevel.Override(typeof(MailgunEmailService).FullName!, LogEventLevel.Fatal) |
| | 899 | 1501 | | .WriteTo.Console(new JsonFormatter()) |
| | 899 | 1502 | | .WriteTo.File( |
| | 899 | 1503 | | new JsonFormatter(), |
| | 899 | 1504 | | Path.Combine(logDirectory, "log-.json"), |
| | 899 | 1505 | | rollingInterval: RollingInterval.Day, |
| | 899 | 1506 | | retainedFileTimeLimit: retention) |
| | 899 | 1507 | | .CreateLogger(); |
| | 899 | 1508 | | } |
| | | 1509 | | } |