| | | 1 | | using ArturRios.Data.Relational.Core.Interfaces; |
| | | 2 | | using ArturRios.Heimdall.Command.Input; |
| | | 3 | | using ArturRios.Heimdall.Command.Output; |
| | | 4 | | using ArturRios.Heimdall.Command.Services; |
| | | 5 | | using ArturRios.Heimdall.Domain.Entities; |
| | | 6 | | using ArturRios.Heimdall.Domain.Persistence; |
| | | 7 | | using ArturRios.Heimdall.Shared.Messages; |
| | | 8 | | using ArturRios.Mediator.Command.Interfaces; |
| | | 9 | | using ArturRios.Output; |
| | | 10 | | using Microsoft.EntityFrameworkCore; |
| | | 11 | | |
| | | 12 | | namespace ArturRios.Heimdall.Command.Handlers; |
| | | 13 | | |
| | | 14 | | /// <summary> |
| | | 15 | | /// Handles <see cref="ResendTwoFactorChallengeCodeCommand" /> (UC-46, FR-2F-16): reissues the |
| | | 16 | | /// email code for a challenge that is still outstanding, so someone who never received the first |
| | | 17 | | /// one can ask for another without starting sign-in over. |
| | | 18 | | /// </summary> |
| | | 19 | | /// <remarks> |
| | | 20 | | /// <para> |
| | | 21 | | /// <b>Every path answers the same.</b> A valid challenge, an unknown one, a forged one, an |
| | | 22 | | /// expired one, one naming a person who no longer exists or has been suspended, one |
| | | 23 | | /// naming a person with no email method, and one that has spent its reissues all return |
| | | 24 | | /// <see cref="TwoFactorMessages.ChallengeCodeResent" /> and no data. This is the endpoint's |
| | | 25 | | /// security design rather than an economy of writing: it is anonymous, and an answer that |
| | | 26 | | /// varied would say whether an address is registered and has email two-factor enabled — the |
| | | 27 | | /// question UC-11's AF-11a…AF-11e and UC-38's AF-38a collapse their own answers to refuse. |
| | | 28 | | /// The difference is only the sign: those collapse to one <c>401</c> because the caller asked |
| | | 29 | | /// to be let in; this collapses to one <c>200</c> because they asked for something to be |
| | | 30 | | /// sent, and "it has been sent if there was anything to send" is the most that can be said. |
| | | 31 | | /// </para> |
| | | 32 | | /// <para> |
| | | 33 | | /// <b>It never extends the challenge.</b> Extending the window would mean returning a new |
| | | 34 | | /// challenge token, and one could only be returned for a challenge that was real — exactly |
| | | 35 | | /// the oracle above. So a reissue puts a fresh code inside the existing window and never |
| | | 36 | | /// lengthens it. That window's ceiling is NFR-17's, which since the two lifetimes were |
| | | 37 | | /// reconciled is the same ten minutes FR-2F-03 gives the code — which is what makes a reissue |
| | | 38 | | /// inside it worth having rather than a formality. |
| | | 39 | | /// </para> |
| | | 40 | | /// <para> |
| | | 41 | | /// <b>Why the reissues are capped.</b> FR-2F-13 retires an email code after five wrong |
| | | 42 | | /// guesses. Without a cap this endpoint would relax that into "five guesses times however |
| | | 43 | | /// many reissues the rate limiter permits", making a security bound a property of deployment |
| | | 44 | | /// configuration. <see cref="MaxReissuesPerChallenge" /> puts it back in the requirement: at |
| | | 45 | | /// most twenty guesses at a six-digit code per authentication, after which a further code |
| | | 46 | | /// costs a fresh password check — which is the price FR-2F-13 always charged. |
| | | 47 | | /// </para> |
| | | 48 | | /// <para> |
| | | 49 | | /// The count lives on the <c>TWO_FACTOR_AUTH</c> row rather than in a token claim, because a |
| | | 50 | | /// claim could only be incremented by issuing a new token and this endpoint must never return |
| | | 51 | | /// one. <c>LoginCommandHandler</c> clears it whenever it issues a challenge, which is what |
| | | 52 | | /// makes the budget per-challenge rather than per-account. |
| | | 53 | | /// </para> |
| | | 54 | | /// </remarks> |
| | 44 | 55 | | public class ResendTwoFactorChallengeCodeCommandHandler( |
| | 44 | 56 | | IAsyncReadOnlyRepository<Person, long> personReader, |
| | 44 | 57 | | IAsyncReadOnlyRepository<TwoFactorAuth, long> twoFactorReader, |
| | 44 | 58 | | IAtomicWrites atomicWrites, |
| | 44 | 59 | | ITwoFactorChallengeTokenValidator challengeTokenValidator, |
| | 44 | 60 | | ITwoFactorEmailCodeIssuer emailCodeIssuer) |
| | | 61 | | : ICommandHandlerAsync<ResendTwoFactorChallengeCodeCommand, ResendTwoFactorChallengeCodeCommandOutput> |
| | | 62 | | { |
| | | 63 | | /// <summary> |
| | | 64 | | /// Reissues one challenge may authorize (FR-2F-13). Three, so the guessing bound stays a |
| | | 65 | | /// stated constant — five attempts per code across at most four codes — rather than whatever |
| | | 66 | | /// the rate limiter happens to permit. |
| | | 67 | | /// </summary> |
| | | 68 | | public const int MaxReissuesPerChallenge = 3; |
| | | 69 | | |
| | | 70 | | public async Task<DataOutput<ResendTwoFactorChallengeCodeCommandOutput?>> HandleAsync( |
| | | 71 | | ResendTwoFactorChallengeCodeCommand command, CancellationToken cancellationToken = default) |
| | | 72 | | { |
| | | 73 | | // UC-46 steps 2-6. Resolved before the answer is built, but the answer does not depend on |
| | | 74 | | // it: each alternative flow is the absence of work rather than a refusal of its own, the |
| | | 75 | | // same way UC-12's AF-12a is. |
| | 44 | 76 | | var reissuable = await ResolveReissuableAsync(command.ChallengeToken); |
| | | 77 | | |
| | 44 | 78 | | if (reissuable is { } target) |
| | | 79 | | { |
| | 23 | 80 | | await ReissueAsync(target.Configuration, target.ChallengeId, target.Email); |
| | | 81 | | } |
| | | 82 | | |
| | | 83 | | // UC-46 step 7: the same answer either way. |
| | 44 | 84 | | return DataOutput<ResendTwoFactorChallengeCodeCommandOutput?>.New |
| | 44 | 85 | | .WithData(new ResendTwoFactorChallengeCodeCommandOutput()) |
| | 44 | 86 | | .WithMessage(TwoFactorMessages.ChallengeCodeResent); |
| | 44 | 87 | | } |
| | | 88 | | |
| | | 89 | | /// <summary> |
| | | 90 | | /// Charges one reissue against the challenge, then sends. The budget is spent before the |
| | | 91 | | /// code goes out, and a failure to spend it stops the send: overcharging costs a person one |
| | | 92 | | /// more wait, while undercharging would let the bound be stepped around by whatever made the |
| | | 93 | | /// write fail. Neither outcome reaches the caller. |
| | | 94 | | /// </summary> |
| | | 95 | | /// <remarks> |
| | | 96 | | /// The charge is one conditional write (<see cref="IAtomicWrites" />) that only succeeds while |
| | | 97 | | /// the challenge is still outstanding and under the cap. Incrementing a count read earlier |
| | | 98 | | /// let parallel resends all read "under the cap" and overwrite each other's increments, so a |
| | | 99 | | /// burst could mail many more codes — and buy many more guesses — than FR-2F-13 allows. |
| | | 100 | | /// </remarks> |
| | | 101 | | private async Task ReissueAsync(TwoFactorAuth twoFactorAuth, Guid challengeId, string email) |
| | | 102 | | { |
| | 23 | 103 | | if (!await atomicWrites.TryChargeEmailCodeReissueAsync(twoFactorAuth, challengeId, MaxReissuesPerChallenge)) |
| | | 104 | | { |
| | 0 | 105 | | return; |
| | | 106 | | } |
| | | 107 | | |
| | 23 | 108 | | await emailCodeIssuer.ReissueAsync(twoFactorAuth, email); |
| | 23 | 109 | | } |
| | | 110 | | |
| | | 111 | | /// <summary> |
| | | 112 | | /// The configuration a reissue should be sent for and the address to send it to, or |
| | | 113 | | /// <see langword="null" /> when there is nothing to send: the challenge did not validate |
| | | 114 | | /// (AF-46a/AF-46e), it names nobody eligible (AF-46b), that person has no active |
| | | 115 | | /// configuration or no email method (AF-46c), or its reissues are spent (AF-46d). The caller |
| | | 116 | | /// cannot tell these apart, and neither can this method's result. |
| | | 117 | | /// </summary> |
| | | 118 | | private async Task<(TwoFactorAuth Configuration, Guid ChallengeId, string Email)?> ResolveReissuableAsync( |
| | | 119 | | string? challengeToken) |
| | | 120 | | { |
| | | 121 | | // AF-46a: signature, expiry, and the MFA-pending claim — exactly UC-38 step 2's check, made |
| | | 122 | | // by the same validator, so the two endpoints agree on what a challenge token is. AF-46e |
| | | 123 | | // falls out of the same call: a full login token carries no MFA-pending claim. |
| | 44 | 124 | | var principal = await challengeTokenValidator.ValidateAsync(challengeToken); |
| | | 125 | | |
| | 44 | 126 | | if (principal is null) |
| | | 127 | | { |
| | 5 | 128 | | return null; |
| | | 129 | | } |
| | | 130 | | |
| | 39 | 131 | | var person = await personReader.Query() |
| | 39 | 132 | | // AF-46b. A restricted person is the exception to both halves (NFR-24): UC-11 lets them |
| | 39 | 133 | | // sign in for their own rights, suspended or not, and a code they asked for is sent at |
| | 39 | 134 | | // their request — the consent Art. 18(2) asks for — so the login it belongs to can finish. |
| | 39 | 135 | | .FirstOrDefaultAsync(person => person.PublicId == principal.PersonId && |
| | 39 | 136 | | person.AnonymisedAt == null && |
| | 39 | 137 | | (!person.IsDeleted || person.ProcessingRestrictedAt != null)); |
| | | 138 | | |
| | | 139 | | // FR-AU-11: a challenge issued before the person's last credential change is as dead here as |
| | | 140 | | // at UC-38, which would refuse it — so a code sent for it could never be used. It is AF-46a's |
| | | 141 | | // absence of work, like any other challenge that does not validate. |
| | 39 | 142 | | if (person is null || principal.SecurityStamp != person.SecurityStamp) |
| | | 143 | | { |
| | 3 | 144 | | return null; |
| | | 145 | | } |
| | | 146 | | |
| | 36 | 147 | | var twoFactorAuth = await twoFactorReader.Query() |
| | 36 | 148 | | .FirstOrDefaultAsync(configuration => |
| | 36 | 149 | | configuration.PersonId == person.Id && configuration.IsActive); |
| | | 150 | | |
| | | 151 | | // AF-46c — no active configuration, or one without the email method: an authenticator-app |
| | | 152 | | // holder has no code to resend. AF-46d — the challenge has spent its reissues. |
| | | 153 | | // FR-2F-10/FR-2F-16: only the outstanding challenge has a code to resend — not one already |
| | | 154 | | // redeemed, and not one a newer login has replaced. |
| | 36 | 155 | | return twoFactorAuth is { EmailEnabled: true } && |
| | 36 | 156 | | twoFactorAuth.ChallengeId == principal.ChallengeId && |
| | 36 | 157 | | twoFactorAuth.EmailCodeReissueCount < MaxReissuesPerChallenge |
| | 36 | 158 | | // The address is the person's stored one, read here rather than taken from the request: |
| | 36 | 159 | | // the command carries no address field, and this is why. |
| | 36 | 160 | | ? (twoFactorAuth, principal.ChallengeId, person.Email) |
| | 36 | 161 | | : null; |
| | 44 | 162 | | } |
| | | 163 | | } |