| | | 1 | | using ArturRios.Heimdall.Command.Services; |
| | | 2 | | using ArturRios.Heimdall.Shared.Security; |
| | | 3 | | using ArturRios.Jwt; |
| | | 4 | | using ArturRios.Util.WebApi.Security.Authentication; |
| | | 5 | | using ArturRios.Util.WebApi.Security.Interfaces; |
| | | 6 | | |
| | | 7 | | namespace ArturRios.Heimdall.WebApi.Security; |
| | | 8 | | |
| | | 9 | | /// <summary> |
| | | 10 | | /// Issues and validates UC-38's short-lived challenge token as the application's own HMAC-signed |
| | | 11 | | /// JWT (FR-2F-07…FR-2F-10, NFR-17), signed with the same secret as |
| | | 12 | | /// <see cref="JwtAuthTokenIssuer" />'s full tokens but built and read independently of it, since |
| | | 13 | | /// the two carry deliberately different claims. |
| | | 14 | | /// </summary> |
| | | 15 | | /// <remarks> |
| | | 16 | | /// <para> |
| | | 17 | | /// <b>Expiry.</b> Taken from <see cref="TwoFactorLifetimes.ChallengeToken" /> (10 minutes, |
| | | 18 | | /// NFR-17) rather than added to <see cref="JwtConfiguration" /> as a second environment |
| | | 19 | | /// variable: a full login token's lifetime is meant to be tuned per deployment, but a |
| | | 20 | | /// challenge token's is a security property of the use case itself, so there is no |
| | | 21 | | /// legitimate reason an operator would want it longer, and every reason to keep it fixed. |
| | | 22 | | /// </para> |
| | | 23 | | /// <para> |
| | | 24 | | /// It is read from that shared type rather than written here because the number is not this |
| | | 25 | | /// class's to choose. The App method would be served by a much shorter window — an |
| | | 26 | | /// authenticator generates a code on demand, so its holder really can finish UC-38 within |
| | | 27 | | /// moments of UC-11 returning the token. The Email method cannot: "within moments" is a |
| | | 28 | | /// claim about someone else's mail infrastructure, and FR-2F-03 already committed to |
| | | 29 | | /// tolerating ten minutes of it. Both methods share one challenge token, so its lifetime has |
| | | 30 | | /// to be the longer of the two, and it has to be the <em>same</em> ten minutes the code it |
| | | 31 | | /// was issued with gets — a shorter one silently retracted that tolerance and left a |
| | | 32 | | /// correct, unexpired code with nowhere to be presented, since FR-2F-10 makes second-factor |
| | | 33 | | /// verification the only endpoint that will take a challenge token. |
| | | 34 | | /// </para> |
| | | 35 | | /// <para> |
| | | 36 | | /// <b>Claims.</b> Built directly rather than through <see cref="IdentityUserMapper" />'s |
| | | 37 | | /// normal login path: the token carries the person's <c>PublicId</c>, their role (required by |
| | | 38 | | /// <see cref="IAuthenticatedUserMapper.FromClaims" />, which cannot resolve an |
| | | 39 | | /// <see cref="IdentityUser" /> without one), and <see cref="IdentityUser.MfaPending" /> set — |
| | | 40 | | /// but never a <c>ScopeId</c>, <c>OwnedScopeIds</c>, or scope-permission claim, since |
| | | 41 | | /// <see cref="IdentityUser" /> is constructed here with none of those populated. Carrying a |
| | | 42 | | /// role at all is what lets <see cref="MfaPendingGuardFilter" /> actually authenticate and |
| | | 43 | | /// then reject the token if it is misused as a bearer credential elsewhere (FR-2F-10) — a |
| | | 44 | | /// token the claims mapper could not parse at all would already be rejected by |
| | | 45 | | /// <c>AuthenticationMiddleware</c> before the filter ever ran, which would leave the filter |
| | | 46 | | /// untested by construction rather than genuinely enforcing anything. |
| | | 47 | | /// </para> |
| | | 48 | | /// <para> |
| | | 49 | | /// <b>Audience.</b> <see cref="PersonTokenAudiences.TwoFactorChallenge" />, not the full |
| | | 50 | | /// token's. The claims above mean nothing to a consumer, which validates signature, issuer, |
| | | 51 | | /// audience and lifetime; under the full token's audience it would take a challenge — what a |
| | | 52 | | /// password alone obtains — for a sign-in (Threat Model TH-06). |
| | | 53 | | /// </para> |
| | | 54 | | /// </remarks> |
| | 205 | 55 | | public class JwtTwoFactorChallengeTokenIssuer( |
| | 205 | 56 | | JwtConfiguration configuration, |
| | 205 | 57 | | PersonTokenAudiences audiences, |
| | 205 | 58 | | JwtHandler jwtHandler, |
| | 205 | 59 | | IAuthenticatedUserMapper mapper) : ITwoFactorChallengeTokenIssuer, ITwoFactorChallengeTokenValidator |
| | | 60 | | { |
| | | 61 | | public Task<AuthToken> IssueAsync(Guid personId, int roleId, Guid challengeId, Guid securityStamp) |
| | | 62 | | { |
| | 32 | 63 | | var identity = new IdentityUser(personId, roleId) |
| | 32 | 64 | | { |
| | 32 | 65 | | MfaPending = true, ChallengeId = challengeId, SecurityStamp = securityStamp |
| | 32 | 66 | | }; |
| | | 67 | | |
| | | 68 | | // TH-06: under its own audience, so a consumer pinning the person audience refuses it. |
| | 32 | 69 | | var challengeConfiguration = configuration with |
| | 32 | 70 | | { |
| | 32 | 71 | | Audience = audiences.TwoFactorChallenge, |
| | 32 | 72 | | Claims = mapper.ToClaims(identity), |
| | 32 | 73 | | ExpirationInSeconds = TwoFactorLifetimes.ChallengeToken.TotalSeconds |
| | 32 | 74 | | }; |
| | | 75 | | |
| | 32 | 76 | | var token = jwtHandler.CreateToken(challengeConfiguration); |
| | | 77 | | |
| | 32 | 78 | | return Task.FromResult(new AuthToken(token, DateTime.UtcNow.Add(TwoFactorLifetimes.ChallengeToken))); |
| | | 79 | | } |
| | | 80 | | |
| | | 81 | | /// <summary> |
| | | 82 | | /// Signature (HS256 only) and lifetime, against every configured key when there are any — the |
| | | 83 | | /// same rule the bearer-token validator applies — so rotating the signing secret does not void |
| | | 84 | | /// the challenges already in flight while it keeps every full token issued beside them valid. |
| | | 85 | | /// </summary> |
| | | 86 | | private Task<bool> IsSignedAndLiveAsync(string token) => |
| | 97 | 87 | | configuration.Keys.Count > 0 |
| | 97 | 88 | | ? jwtHandler.IsTokenValidAsync(token, configuration.Keys) |
| | 97 | 89 | | : jwtHandler.IsTokenValidAsync(token, configuration.Secret); |
| | | 90 | | |
| | | 91 | | public async Task<TwoFactorChallengePrincipal?> ValidateAsync(string? token) |
| | | 92 | | { |
| | | 93 | | // Signature and lifetime (JwtHandler.IsTokenValidAsync validates both) — AF-38a's "expired |
| | | 94 | | // or invalid". |
| | 97 | 95 | | if (string.IsNullOrWhiteSpace(token) || !await IsSignedAndLiveAsync(token)) |
| | | 96 | | { |
| | 6 | 97 | | return null; |
| | | 98 | | } |
| | | 99 | | |
| | | 100 | | // TB-5: issued by Heimdall, for the challenge audience alone. The signature proves only that |
| | | 101 | | // the key signed it, and the key is not Heimdall's alone. |
| | 91 | 102 | | if (!TokenOrigin.IsIssuedFor(token, configuration.Issuer, audiences.TwoFactorChallenge)) |
| | | 103 | | { |
| | 9 | 104 | | return null; |
| | | 105 | | } |
| | | 106 | | |
| | 82 | 107 | | var claims = TokenClaimsReader.Read(token); |
| | | 108 | | |
| | 82 | 109 | | if (claims is null) |
| | | 110 | | { |
| | 0 | 111 | | return null; |
| | | 112 | | } |
| | | 113 | | |
| | | 114 | | // FR-2F-10: only a token carrying the MFA-pending claim is a challenge token at all — a full |
| | | 115 | | // login token that happens to still be signature-valid is not accepted here either. The mapper |
| | | 116 | | // reads that claim only under the challenge audience, so one carried by any other token is |
| | | 117 | | // not accepted either (TH-06). |
| | | 118 | | // FR-2F-10 again: a challenge token names which challenge it is, so UC-38 can refuse one that |
| | | 119 | | // was already redeemed or has been superseded by a newer login. One without that claim |
| | | 120 | | // cannot be checked, and so is not accepted either. |
| | 82 | 121 | | return mapper.FromClaims(claims) is IdentityUser { MfaPending: true, ChallengeId: { } challengeId } identity |
| | 82 | 122 | | ? new TwoFactorChallengePrincipal(identity.Id, challengeId, identity.SecurityStamp) |
| | 82 | 123 | | : null; |
| | 97 | 124 | | } |
| | | 125 | | } |