| | | 1 | | using ArturRios.Data.Relational.Core.DependencyInjection; |
| | | 2 | | using ArturRios.Heimdall.Command.Services; |
| | | 3 | | using ArturRios.Heimdall.Shared.Security; |
| | | 4 | | using ArturRios.Heimdall.WebApi.Security; |
| | | 5 | | using ArturRios.Jwt; |
| | | 6 | | using ArturRios.Util.WebApi.Configuration; |
| | | 7 | | using ArturRios.Util.WebApi.Security.Authentication; |
| | | 8 | | using ArturRios.Util.WebApi.Security.Enums; |
| | | 9 | | using ArturRios.Util.WebApi.Security.Extensions; |
| | | 10 | | using ArturRios.Util.WebApi.Security.Interfaces; |
| | | 11 | | using FluentValidation; |
| | | 12 | | using Serilog; |
| | | 13 | | |
| | | 14 | | namespace ArturRios.Heimdall.WebApi; |
| | | 15 | | |
| | | 16 | | public static class TokenSecurityRegistrationExtensions |
| | | 17 | | { |
| | | 18 | | /// <summary> |
| | | 19 | | /// The shared HS256 secret and the previous one, which 3.x still verified Heimdall's earlier |
| | | 20 | | /// tokens with and 4.0.0 retired (Threat Model TH-43). Their values are never read: start-up |
| | | 21 | | /// only warns while either is set, so the operator removes it. |
| | | 22 | | /// </summary> |
| | 2 | 23 | | private static readonly string[] RetiredTokenSecretVariables = |
| | 2 | 24 | | ["HEIMDALL_AUTH_TOKEN_SECRET", "HEIMDALL_AUTH_TOKEN_SECRET_PREVIOUS"]; |
| | | 25 | | |
| | | 26 | | /// <summary> |
| | | 27 | | /// Puts <see cref="PersonTokenValidator" /> in the place of the <see cref="JwtTokenValidator" /> |
| | | 28 | | /// that <c>AddTokenAuthentication</c> registers as an <see cref="ITokenValidator" />, keeping the |
| | | 29 | | /// library's validator as the inner one it wraps. |
| | | 30 | | /// </summary> |
| | | 31 | | /// <remarks> |
| | | 32 | | /// TB-5: the library's validator checks the signature and lifetime but not the issuer or the |
| | | 33 | | /// audience, and Heimdall's key signs every kind of token. PersonTokenValidator wraps it and |
| | | 34 | | /// takes its place, so a token reaches the middleware's user only if Heimdall issued it, for |
| | | 35 | | /// one of the person audiences. When the library registers its validator in some other way, |
| | | 36 | | /// start-up stops and says why, rather than running with the library's validator alone. |
| | | 37 | | /// </remarks> |
| | | 38 | | /// <exception cref="InvalidOperationException"> |
| | | 39 | | /// The services hold no <see cref="ITokenValidator" /> registered as <see cref="JwtTokenValidator" />. |
| | | 40 | | /// </exception> |
| | | 41 | | public static void ReplaceLibraryTokenValidator(IServiceCollection services) |
| | | 42 | | { |
| | 968 | 43 | | var libraryValidator = services.SingleOrDefault(descriptor => |
| | 511023 | 44 | | descriptor.ServiceType == typeof(ITokenValidator) && |
| | 511023 | 45 | | descriptor.ImplementationType == typeof(JwtTokenValidator)); |
| | | 46 | | |
| | 968 | 47 | | if (libraryValidator is null) |
| | | 48 | | { |
| | 1 | 49 | | throw new InvalidOperationException( |
| | 1 | 50 | | "ArturRios.Util.WebApi no longer registers JwtTokenValidator as an ITokenValidator; " + |
| | 1 | 51 | | "PersonTokenValidator cannot replace it. Refusing to start."); |
| | | 52 | | } |
| | | 53 | | |
| | 967 | 54 | | services.Remove(libraryValidator); |
| | 967 | 55 | | services.AddSingleton<JwtTokenValidator>(); |
| | 967 | 56 | | services.AddSingleton<ITokenValidator, PersonTokenValidator>(); |
| | 967 | 57 | | } |
| | | 58 | | |
| | | 59 | | /// <summary> |
| | | 60 | | /// Registers token signing, the person and application token services and the token |
| | | 61 | | /// authentication, then the rate limiting and the password hash gate that sit beside them. |
| | | 62 | | /// </summary> |
| | | 63 | | public static IServiceCollection AddTokenSecurity(this IServiceCollection services) |
| | | 64 | | { |
| | | 65 | | // Authentication is Util.WebApi's alone: ASP.NET Core's authentication and authorization |
| | | 66 | | // middlewares are not registered, because nothing here reads HttpContext.User or carries |
| | | 67 | | // ASP.NET Core's [Authorize] — the controllers use the library's attributes, which read the |
| | | 68 | | // user AuthenticationMiddleware attaches. |
| | | 69 | | // |
| | | 70 | | // AuthenticationMiddleware resolves AuthenticationOptions and the token validators from the |
| | | 71 | | // container, and JwtTokenValidator additionally needs JwtConfiguration, JwtHandler, and the |
| | | 72 | | // claims mapper. IdentityUserMapper replaces the library default so tokens carry PublicIds |
| | | 73 | | // and the scope claims of FR-AU-04. Defaults are kept otherwise: app JWT only, read from the |
| | | 74 | | // Authorization header, user rebuilt from claims — so no IAuthenticationProvider is required |
| | | 75 | | // and no database read happens per request. |
| | | 76 | | // |
| | | 77 | | // Every token is signed ES256 with the current key; a missing or unusable key stops start-up |
| | | 78 | | // here, naming the variable. HS256 is refused: the shared secret is retired (TH-43), and its |
| | | 79 | | // variables are only looked at to tell the operator to remove them. |
| | 973 | 80 | | var signingKeys = TokenSigningKeys.FromEnvironment(); |
| | | 81 | | |
| | 966 | 82 | | var jwtConfiguration = JwtConfigurationBuilder.BuildJwtConfiguration(signingKeys); |
| | 966 | 83 | | services.AddSingleton(signingKeys); |
| | 966 | 84 | | services.AddSingleton(jwtConfiguration); |
| | 966 | 85 | | services.AddSingleton<JwtHandler>(); |
| | | 86 | | |
| | | 87 | | // Thumbprints only, which are public: they are what the published key set and every token's |
| | | 88 | | // kid carry, so a consumer can be checked against them. No key material is logged. |
| | 966 | 89 | | Log.Information( |
| | 966 | 90 | | "Tokens: signed ES256 with key {KeyId}; published keys {KeyIds}; HS256 tokens {LegacyState}", |
| | 966 | 91 | | signingKeys.Current.Id, |
| | 1932 | 92 | | signingKeys.ConfigurationKeys.Select(key => key.Id).ToArray(), |
| | 966 | 93 | | "refused"); |
| | | 94 | | |
| | 966 | 95 | | WarnOnRetiredTokenSecrets(); |
| | | 96 | | |
| | | 97 | | // TH-06/TH-35: the challenge and subject-rights-only tokens carry audiences derived from the |
| | | 98 | | // person audience, so a consumer pinning it refuses them. Logged, like the application |
| | | 99 | | // audience below, so a consumer can be checked against them. |
| | 966 | 100 | | var personTokenAudiences = PersonTokenAudiences.From(jwtConfiguration); |
| | 966 | 101 | | services.AddSingleton(personTokenAudiences); |
| | 966 | 102 | | Log.Information( |
| | 966 | 103 | | "Person tokens: audience {Audience}; challenge tokens {ChallengeAudience}; " + |
| | 966 | 104 | | "subject-rights-only tokens {SubjectRightsAudience}", |
| | 966 | 105 | | personTokenAudiences.Person, personTokenAudiences.TwoFactorChallenge, |
| | 966 | 106 | | personTokenAudiences.SubjectRights); |
| | | 107 | | |
| | | 108 | | // UC-56/UC-57: application tokens share the signing keys but not the audience. Read here, |
| | | 109 | | // with the rest of the token configuration, so a missing or equal audience stops start-up |
| | | 110 | | // before anything is served (TH-35). |
| | 966 | 111 | | var applicationTokens = ApplicationTokenOptions.FromEnvironment(); |
| | 966 | 112 | | services.AddSingleton(applicationTokens); |
| | 966 | 113 | | ConfigurationWarnings.WarnOnFallback(ApplicationTokenOptions.ExpirationVariable, applicationTokens.RejectedExpir |
| | 966 | 114 | | ApplicationTokenOptions.DefaultLifetime.TotalSeconds); |
| | 966 | 115 | | services.AddScoped<JwtApplicationTokenService>(); |
| | 966 | 116 | | services.AddScoped<IApplicationTokenIssuer>(provider => |
| | 988 | 117 | | provider.GetRequiredService<JwtApplicationTokenService>()); |
| | 966 | 118 | | services.AddScoped<IApplicationTokenValidator>(provider => |
| | 978 | 119 | | provider.GetRequiredService<JwtApplicationTokenService>()); |
| | | 120 | | |
| | | 121 | | // The audience is not a secret; logging it makes a mismatch with a consumer diagnosable. |
| | 966 | 122 | | Log.Information( |
| | 966 | 123 | | "Application tokens: audience {Audience}, lifetime {Seconds}s", |
| | 966 | 124 | | applicationTokens.Audience, applicationTokens.Lifetime.TotalSeconds); |
| | | 125 | | |
| | 966 | 126 | | services.AddTokenAuthentication<IdentityUserMapper>(options => |
| | 966 | 127 | | { |
| | 966 | 128 | | options.Source = TokenSource.Header; |
| | 966 | 129 | | options.EnableJwt = true; |
| | 966 | 130 | | options.EnableGoogle = false; |
| | 966 | 131 | | options.JwtMode = JwtValidationMode.ClaimsOnly; |
| | 1932 | 132 | | }); |
| | | 133 | | |
| | 966 | 134 | | ReplaceLibraryTokenValidator(services); |
| | | 135 | | |
| | 966 | 136 | | services.AddAuthEndpointRateLimiting(); |
| | | 137 | | |
| | | 138 | | // TH-03: bound how many Argon2id derivations run at once. Read here, with the rest of the |
| | | 139 | | // security configuration, because it belongs to the same defence: the rate limiter caps what |
| | | 140 | | // one address can ask for, and this caps what the process will attempt regardless of how |
| | | 141 | | // many addresses ask. |
| | 966 | 142 | | PasswordHashGate.ConfigureSharedFromEnvironment(); |
| | | 143 | | |
| | 966 | 144 | | return services; |
| | | 145 | | } |
| | | 146 | | |
| | | 147 | | /// <summary> |
| | | 148 | | /// One Warning for each retired token secret variable that is still set, naming it and never its |
| | | 149 | | /// value, so the operator removes it. Nothing else reads them: a value 3.x refused to start |
| | | 150 | | /// with no longer stops start-up either. |
| | | 151 | | /// </summary> |
| | | 152 | | private static void WarnOnRetiredTokenSecrets() |
| | | 153 | | { |
| | 5788 | 154 | | foreach (var variable in SetRetiredTokenSecretVariables(Environment.GetEnvironmentVariable)) |
| | | 155 | | { |
| | 1928 | 156 | | Log.Warning("{Variable} is set but no longer read; remove it", variable); |
| | | 157 | | } |
| | 966 | 158 | | } |
| | | 159 | | |
| | | 160 | | /// <summary> |
| | | 161 | | /// The retired token secret variables <paramref name="read" /> finds set. A value that is empty |
| | | 162 | | /// or whitespace only is unset, as 3.x treated it: an env file's <c>VARIABLE=</c> reaches the |
| | | 163 | | /// process as the empty string. |
| | | 164 | | /// </summary> |
| | | 165 | | public static IReadOnlyList<string> SetRetiredTokenSecretVariables(Func<string, string?> read) => |
| | 2913 | 166 | | [.. RetiredTokenSecretVariables.Where(variable => !string.IsNullOrWhiteSpace(read(variable)))]; |
| | | 167 | | } |