| | | 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="VerifyTwoFactorAuthCommand" /> (UC-38, FR-2F-09): validates the challenge |
| | | 16 | | /// token AF-11g issued at login (AF-38a), matches the submitted app code, email code, or recovery |
| | | 17 | | /// code against the caller's active <see cref="TwoFactorAuth" /> configuration via |
| | | 18 | | /// <see cref="ITwoFactorFactorVerifier" /> (AF-38b/AF-38c), and — only once a factor checks out — |
| | | 19 | | /// issues the full authentication token through <see cref="PersonAuthTokenService" />, the same |
| | | 20 | | /// service <c>LoginCommandHandler</c> uses, so a 2FA-gated login ends exactly like a direct one. |
| | | 21 | | /// </summary> |
| | | 22 | | /// <remarks> |
| | | 23 | | /// AF-38b (wrong or missing code) and AF-38c (an already-used recovery code) answer identically — |
| | | 24 | | /// <see cref="TwoFactorMessages.FactorInvalid" />, 401 — so a caller cannot distinguish a wrong |
| | | 25 | | /// code from a reused recovery code, exactly the reasoning UC-11's AF-11a…AF-11e collapse into |
| | | 26 | | /// one message for. A person or 2FA configuration the challenge token names but that no longer |
| | | 27 | | /// resolves is treated the same as an invalid challenge (AF-38a). A person and configuration that |
| | | 28 | | /// do resolve, and a factor that genuinely checks out, but whose scope eligibility (UC-11's own |
| | | 29 | | /// AF-11d/AF-11e) no longer holds, gets its own <see cref="TwoFactorMessages.ScopeNoLongerEligible" /> |
| | | 30 | | /// instead — the token and the factor were both valid, so collapsing that case into "challenge |
| | | 31 | | /// invalid" would misdescribe what actually happened. |
| | | 32 | | /// <para> |
| | | 33 | | /// <b>Once, under concurrency too.</b> Redeeming the challenge and spending the recovery or |
| | | 34 | | /// email code are conditional writes (<see cref="IAtomicWrites" />): of any number of |
| | | 35 | | /// simultaneous requests presenting the same challenge or the same code, exactly one gets a |
| | | 36 | | /// token. Checking in memory and then saving let several through, since each had read the row |
| | | 37 | | /// before any of them wrote it. |
| | | 38 | | /// </para> |
| | | 39 | | /// <para> |
| | | 40 | | /// <b>Five guesses per challenge</b> (FR-2F-17) with an app code or a recovery code |
| | | 41 | | /// (<see cref="MaxGuessesPerChallenge" />), the cap FR-2F-13 already puts on an email code. |
| | | 42 | | /// Each such guess is reserved on the challenge before it is compared, so parallel guesses |
| | | 43 | | /// cannot outrun the cap; at the cap the challenge is cleared and its token refused as AF-38a, |
| | | 44 | | /// and further guessing costs a fresh password check. An email-only configuration's guesses |
| | | 45 | | /// are not charged here: the email code's own cap already bounds them. |
| | | 46 | | /// </para> |
| | | 47 | | /// <para> |
| | | 48 | | /// <b>Ten failures per account</b> (FR-AU-12, <see cref="SecondFactorLockout" />). The |
| | | 49 | | /// challenge's budgets start afresh with every login, and a login costs nothing to whoever |
| | | 50 | | /// holds the password, so every guess — app code, email code or recovery code — is also |
| | | 51 | | /// charged to the account, across challenges. A correct password does not reset that count; |
| | | 52 | | /// only a second factor that checks out does. The tenth failure locks the second factor, for |
| | | 53 | | /// fifteen minutes and twice as long at each repeat, up to a day, and while it is locked every |
| | | 54 | | /// factor is refused as AF-38b, the right one included. |
| | | 55 | | /// </para> |
| | | 56 | | /// </remarks> |
| | 138 | 57 | | public class VerifyTwoFactorAuthCommandHandler( |
| | 138 | 58 | | IAsyncReadOnlyRepository<Person, long> personReader, |
| | 138 | 59 | | IAsyncReadOnlyRepository<TwoFactorAuth, long> twoFactorReader, |
| | 138 | 60 | | IAtomicWrites atomicWrites, |
| | 138 | 61 | | ITwoFactorFactorVerifier factorVerifier, |
| | 138 | 62 | | ITwoFactorChallengeTokenValidator challengeTokenValidator, |
| | 138 | 63 | | PersonAuthTokenService personAuthTokenService) |
| | | 64 | | : ICommandHandlerAsync<VerifyTwoFactorAuthCommand, VerifyTwoFactorAuthCommandOutput> |
| | | 65 | | { |
| | | 66 | | /// <summary> |
| | | 67 | | /// Guesses one challenge allows with an app code or a recovery code — the same five FR-2F-13 |
| | | 68 | | /// allows an email code (<see cref="TwoFactorEmailCodeVerification.MaxFailedAttempts" />). |
| | | 69 | | /// </summary> |
| | | 70 | | public const int MaxGuessesPerChallenge = TwoFactorEmailCodeVerification.MaxFailedAttempts; |
| | | 71 | | |
| | | 72 | | public async Task<DataOutput<VerifyTwoFactorAuthCommandOutput?>> HandleAsync( |
| | | 73 | | VerifyTwoFactorAuthCommand command, CancellationToken cancellationToken = default) |
| | | 74 | | { |
| | 138 | 75 | | var output = DataOutput<VerifyTwoFactorAuthCommandOutput?>.New; |
| | | 76 | | |
| | | 77 | | // AF-38a: signature, expiry, and the MFA-pending claim. |
| | 138 | 78 | | var principal = await challengeTokenValidator.ValidateAsync(command.ChallengeToken); |
| | | 79 | | |
| | 138 | 80 | | if (principal is null) |
| | | 81 | | { |
| | 7 | 82 | | return output.WithError(TwoFactorMessages.ChallengeTokenInvalid); |
| | | 83 | | } |
| | | 84 | | |
| | 131 | 85 | | var person = await personReader.Query() |
| | 131 | 86 | | .Include(person => person.ScopeMembership) |
| | 131 | 87 | | .ThenInclude(membership => membership!.Scope) |
| | 131 | 88 | | .Include(person => person.ScopeOwnerships) |
| | 131 | 89 | | .ThenInclude(ownership => ownership.Scope) |
| | 131 | 90 | | // A suspended person is no longer let in, unless they are restricted: UC-11 issues a |
| | 131 | 91 | | // restricted person a subject-rights-only token whatever else about their standing, so |
| | 131 | 92 | | // the login it diverted here ends the same way (NFR-24). |
| | 131 | 93 | | .FirstOrDefaultAsync(person => person.PublicId == principal.PersonId && |
| | 131 | 94 | | person.AnonymisedAt == null && |
| | 131 | 95 | | (!person.IsDeleted || person.ProcessingRestrictedAt != null)); |
| | | 96 | | |
| | 131 | 97 | | var twoFactorAuth = person is null |
| | 131 | 98 | | ? null |
| | 131 | 99 | | : await twoFactorReader.Query() |
| | 131 | 100 | | .FirstOrDefaultAsync(x => x.PersonId == person.Id && x.IsActive); |
| | | 101 | | |
| | | 102 | | // AF-38a again, for what a signature cannot say. A challenge is redeemable once (FR-2F-10): |
| | | 103 | | // the token must still name the configuration's outstanding challenge, which redemption |
| | | 104 | | // clears and a newer login replaces. And it must carry the person's current security stamp |
| | | 105 | | // (FR-AU-11): a challenge obtained with a password that has since been reset, or before a |
| | | 106 | | // credential change, ends with every other token issued before it. |
| | 131 | 107 | | if (person is null || twoFactorAuth is null || |
| | 131 | 108 | | twoFactorAuth.ChallengeId != principal.ChallengeId || |
| | 131 | 109 | | principal.SecurityStamp != person.SecurityStamp) |
| | | 110 | | { |
| | 15 | 111 | | return output.WithError(TwoFactorMessages.ChallengeTokenInvalid); |
| | | 112 | | } |
| | | 113 | | |
| | 116 | 114 | | var challengeId = principal.ChallengeId; |
| | | 115 | | |
| | | 116 | | // FR-2F-17: a guess with an app code or a recovery code is charged to the challenge before it is |
| | | 117 | | // compared. A challenge that has had its five — or that a parallel request has just redeemed |
| | | 118 | | // or retired — is refused as AF-38a, without comparing anything. |
| | 116 | 119 | | var chargedToChallenge = !string.IsNullOrWhiteSpace(command.RecoveryCode) || twoFactorAuth.AppEnabled; |
| | | 120 | | |
| | 116 | 121 | | if (chargedToChallenge && |
| | 116 | 122 | | !await atomicWrites.TryChargeChallengeGuessAsync(twoFactorAuth, challengeId, MaxGuessesPerChallenge)) |
| | | 123 | | { |
| | 2 | 124 | | return output.WithError(TwoFactorMessages.ChallengeTokenInvalid); |
| | | 125 | | } |
| | | 126 | | |
| | | 127 | | // FR-AU-12: every guess is charged to the account as well, whatever challenge it is made |
| | | 128 | | // against, and a locked second factor accepts nothing. After the challenge's own charge, so |
| | | 129 | | // the challenge's five still run out and retire it while the account is locked, exactly as |
| | | 130 | | // they would otherwise. |
| | 114 | 131 | | var now = DateTime.UtcNow; |
| | 114 | 132 | | var admitted = await atomicWrites.TryReserveSecondFactorAttemptAsync( |
| | 114 | 133 | | person, SecondFactorLockout.MaxFailedAttempts, now, SecondFactorLockout.LockoutEndsFrom(now)); |
| | | 134 | | |
| | | 135 | | // AF-38b/AF-38c: an app code, a live email code, or an unused recovery code — or the same |
| | | 136 | | // rejection either way. A locked account's factor is verified too and the result discarded, |
| | | 137 | | // so the refusal costs and reads as a wrong code's, and the lock does not show. |
| | 114 | 138 | | var verification = await factorVerifier.VerifyAsync(twoFactorAuth, command.Code, command.RecoveryCode); |
| | | 139 | | |
| | 114 | 140 | | if (!admitted || !verification.Matched) |
| | | 141 | | { |
| | 78 | 142 | | if (chargedToChallenge) |
| | | 143 | | { |
| | 58 | 144 | | await atomicWrites.ExpireChallengeIfExhaustedAsync(twoFactorAuth, challengeId, MaxGuessesPerChallenge); |
| | | 145 | | } |
| | | 146 | | |
| | 78 | 147 | | return output.WithError(TwoFactorMessages.FactorInvalid); |
| | | 148 | | } |
| | | 149 | | |
| | | 150 | | // FR-AU-12: a second factor checked out, which is the only thing that resets the account's |
| | | 151 | | // count — and with it the lock, and the lockout count that sets how long the next one lasts. |
| | 36 | 152 | | await atomicWrites.ClearSecondFactorAttemptsAsync(person); |
| | | 153 | | |
| | | 154 | | // UC-11 step 6 / UC-38 step 5 (FR-2F-09): the same scope-eligibility rules a direct login |
| | | 155 | | // enforces still apply to a 2FA-gated one. The factor already checked out, so this is a |
| | | 156 | | // distinct rejection from AF-38a rather than a reuse of ChallengeTokenInvalid. |
| | | 157 | | // |
| | | 158 | | // A person under a restriction of processing (NFR-24) is issued a subject-rights-only token |
| | | 159 | | // instead, as UC-11 would have issued one had they no second factor — including one |
| | | 160 | | // restricted after their challenge was issued, who is not handed a full token. That token |
| | | 161 | | // claims no scope, so the rules do not apply to it. |
| | 36 | 162 | | var restricted = person.ProcessingRestrictedAt is not null; |
| | | 163 | | |
| | 36 | 164 | | AuthTokenSubject? subject = null; |
| | | 165 | | |
| | 36 | 166 | | if (!restricted && !personAuthTokenService.TryBuildSubject(person, out subject)) |
| | | 167 | | { |
| | 1 | 168 | | return output.WithError(TwoFactorMessages.ScopeNoLongerEligible); |
| | | 169 | | } |
| | | 170 | | |
| | | 171 | | // FR-2F-10: the challenge is spent — by exactly one request, however many arrive together. |
| | | 172 | | // Before the factor is spent, so a request that loses here has burned nothing of the |
| | | 173 | | // caller's; and before anything else is written, so a failure further down leaves the caller |
| | | 174 | | // to log in again rather than holding a challenge still redeemable. |
| | 35 | 175 | | if (!await atomicWrites.TryRedeemChallengeAsync(twoFactorAuth, challengeId)) |
| | | 176 | | { |
| | 1 | 177 | | return output.WithError(TwoFactorMessages.ChallengeTokenInvalid); |
| | | 178 | | } |
| | | 179 | | |
| | | 180 | | // UC-38 step 4: a recovery code can never be replayed — nor spent twice at once, here and in |
| | | 181 | | // UC-40 or another challenge's verification. |
| | 34 | 182 | | if (verification.ConsumedRecoveryCode is { } consumedRecoveryCode && |
| | 34 | 183 | | !await atomicWrites.TryConsumeRecoveryCodeAsync(consumedRecoveryCode, DateTime.UtcNow)) |
| | | 184 | | { |
| | 1 | 185 | | return output.WithError(TwoFactorMessages.FactorInvalid); |
| | | 186 | | } |
| | | 187 | | |
| | | 188 | | // The email code that completed this login can never be replayed either, the same way |
| | | 189 | | // ConfirmTwoFactorAuthCommandHandler retires the one that confirmed setup. |
| | 33 | 190 | | if (verification.ConsumedEmailCode is { } consumedEmailCode && |
| | 33 | 191 | | !await atomicWrites.TryConsumeEmailCodeAsync(consumedEmailCode)) |
| | | 192 | | { |
| | 0 | 193 | | return output.WithError(TwoFactorMessages.FactorInvalid); |
| | | 194 | | } |
| | | 195 | | |
| | 33 | 196 | | if (restricted) |
| | | 197 | | { |
| | 5 | 198 | | var rightsOnly = await personAuthTokenService.IssueSubjectRightsOnlyAsync(person); |
| | | 199 | | |
| | 5 | 200 | | return output |
| | 5 | 201 | | .WithData(new VerifyTwoFactorAuthCommandOutput |
| | 5 | 202 | | { |
| | 5 | 203 | | Token = rightsOnly.Token, |
| | 5 | 204 | | ExpiresAt = rightsOnly.ExpiresAt, |
| | 5 | 205 | | EmailVerified = person.EmailVerified, |
| | 5 | 206 | | ProcessingRestricted = true |
| | 5 | 207 | | }) |
| | 5 | 208 | | .WithMessage(AuthMessages.SubjectRightsOnlySignIn); |
| | | 209 | | } |
| | | 210 | | |
| | 28 | 211 | | var token = await personAuthTokenService.IssueAsync(subject!); |
| | | 212 | | |
| | 28 | 213 | | return output |
| | 28 | 214 | | .WithData(new VerifyTwoFactorAuthCommandOutput |
| | 28 | 215 | | { |
| | 28 | 216 | | Token = token.Token, ExpiresAt = token.ExpiresAt, EmailVerified = person.EmailVerified |
| | 28 | 217 | | }) |
| | 28 | 218 | | .WithMessage(TwoFactorMessages.VerificationSuccessful); |
| | 138 | 219 | | } |
| | | 220 | | } |