| | | 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="ConfirmTwoFactorAuthCommand" /> (UC-37, FR-2F-04/05): loads the caller's |
| | | 16 | | /// pending <see cref="TwoFactorAuth" /> row (AF-37a), refuses a caller who has already confirmed |
| | | 17 | | /// (AF-37d), verifies a currently valid code for every method the row has enabled (AF-37b, |
| | | 18 | | /// AF-37c), then — only once every required check passes — activates the configuration and |
| | | 19 | | /// issues ten recovery codes, returned in plaintext exactly once. |
| | | 20 | | /// </summary> |
| | | 21 | | /// <remarks> |
| | | 22 | | /// <para> |
| | | 23 | | /// There is no shape validator, unlike <see cref="EnableTwoFactorAuthCommandHandler" />'s: |
| | | 24 | | /// which code(s) are required depends on <see cref="TwoFactorAuth.AppEnabled" />/ |
| | | 25 | | /// <see cref="TwoFactorAuth.EmailEnabled" />, a database read no validator has business |
| | | 26 | | /// making. A caller who names no live person at all — a Google User, or a bearer token |
| | | 27 | | /// naming a person since hard deleted — is indistinguishable from AF-37a here: neither could |
| | | 28 | | /// ever hold a <c>TwoFactorAuth</c> row to confirm, so both answer the same 404 rather than |
| | | 29 | | /// inventing a second refusal UC-37 does not define. |
| | | 30 | | /// </para> |
| | | 31 | | /// <para> |
| | | 32 | | /// The Email and App checks both run, in that order, and a request that fails either one |
| | | 33 | | /// activates nothing — the pending row stays pending and can be confirmed by a later attempt. |
| | | 34 | | /// The email code goes first because checking it spends nothing, where an app code that |
| | | 35 | | /// verified is spent (<see cref="ITotpCodeVerifier" />): a wrong email code therefore leaves |
| | | 36 | | /// the app code usable for the retry. A request whose Email check passed and whose App check |
| | | 37 | | /// then failed leaves the email code unspent too, since it is consumed only once every check |
| | | 38 | | /// has passed. |
| | | 39 | | /// </para> |
| | | 40 | | /// <para> |
| | | 41 | | /// <b>One refusal, one budget.</b> A missing or wrong code for either method answers the one |
| | | 42 | | /// re-authentication refusal (<c>AuthMessages.PasswordOrCodeIncorrect</c>, 401), and every |
| | | 43 | | /// confirmation is charged to the account's re-authentication budget |
| | | 44 | | /// (<see cref="ReauthenticationBudget" />, FR-AU-13), so a bearer token alone cannot guess |
| | | 45 | | /// the pending factor's codes without bound, nor learn which of the two codes was wrong. |
| | | 46 | | /// </para> |
| | | 47 | | /// <para> |
| | | 48 | | /// <b>One transaction</b> (#209). Spending the email code, clearing the re-authentication |
| | | 49 | | /// budget, replacing the security stamp, storing the recovery codes and activating the |
| | | 50 | | /// configuration share one <see cref="IWriteTransactions" /> transaction, so a failure among |
| | | 51 | | /// them keeps none of it: the email code can be presented again, no session is revoked and no |
| | | 52 | | /// codes are left that nobody was shown. The half-write that mattered most — "two-factor is |
| | | 53 | | /// active but the caller holds no recovery codes", which would lock a caller out on the strength |
| | | 54 | | /// of a request reported as failed — cannot happen at all. The codes are still written before |
| | | 55 | | /// the activation, in one <see cref="IAsyncRepository{T, TKey}.CreateRangeAsync" />, as before. |
| | | 56 | | /// An accepted app code is the exception: <see cref="ITotpCodeVerifier" /> spends its time step |
| | | 57 | | /// before the transaction opens, with the budget's charge, so a failed confirmation still costs |
| | | 58 | | /// the caller that code and they present the next one. |
| | | 59 | | /// </para> |
| | | 60 | | /// </remarks> |
| | 65 | 61 | | public class ConfirmTwoFactorAuthCommandHandler( |
| | 65 | 62 | | IAsyncReadOnlyRepository<Person, long> personReader, |
| | 65 | 63 | | IAsyncReadOnlyRepository<TwoFactorAuth, long> twoFactorReader, |
| | 65 | 64 | | IAsyncRepository<TwoFactorAuth, long> twoFactorWriter, |
| | 65 | 65 | | IAsyncReadOnlyRepository<TwoFactorEmailCode, long> emailCodeReader, |
| | 65 | 66 | | IAtomicWrites atomicWrites, |
| | 65 | 67 | | IAsyncReadOnlyRepository<TwoFactorRecoveryCode, long> recoveryCodeReader, |
| | 65 | 68 | | IAsyncRepository<TwoFactorRecoveryCode, long> recoveryCodeWriter, |
| | 65 | 69 | | ITotpCodeVerifier totpCodeVerifier, |
| | 65 | 70 | | IWriteTransactions transactions) |
| | | 71 | | : ICommandHandlerAsync<ConfirmTwoFactorAuthCommand, ConfirmTwoFactorAuthCommandOutput> |
| | | 72 | | { |
| | | 73 | | public async Task<DataOutput<ConfirmTwoFactorAuthCommandOutput?>> HandleAsync( |
| | | 74 | | ConfirmTwoFactorAuthCommand command, CancellationToken cancellationToken = default) |
| | | 75 | | { |
| | 65 | 76 | | var output = DataOutput<ConfirmTwoFactorAuthCommandOutput?>.New; |
| | | 77 | | |
| | | 78 | | // AF-37a (and, implicitly, the Google User / hard-deleted-person case UC-36's AF-37b handles |
| | | 79 | | // separately for enable — here it collapses into "no pending setup", since neither could ever |
| | | 80 | | // hold a row to confirm). |
| | 65 | 81 | | var person = await personReader.Query() |
| | 65 | 82 | | .FirstOrDefaultAsync(x => x.PublicId == command.ActingPersonId && !x.IsDeleted); |
| | | 83 | | |
| | 65 | 84 | | var twoFactorAuth = person is null |
| | 65 | 85 | | ? null |
| | 65 | 86 | | : await twoFactorReader.Query().FirstOrDefaultAsync(x => x.PersonId == person.Id); |
| | | 87 | | |
| | 65 | 88 | | if (person is null || twoFactorAuth is null) |
| | | 89 | | { |
| | 3 | 90 | | return output.WithError(TwoFactorMessages.NoPendingSetup); |
| | | 91 | | } |
| | | 92 | | |
| | | 93 | | // AF-37d. |
| | 62 | 94 | | if (twoFactorAuth.IsActive) |
| | | 95 | | { |
| | 2 | 96 | | return output.WithError(TwoFactorMessages.AlreadyActive); |
| | | 97 | | } |
| | | 98 | | |
| | | 99 | | // FR-AU-13: the codes are a re-authentication like any other made under a bearer token, so |
| | | 100 | | // the check is charged to the account's budget before anything is compared. A paused budget |
| | | 101 | | // still has the codes compared, and refuses them whatever the result. |
| | 60 | 102 | | var admitted = await ReauthenticationBudget.TryChargeAsync(atomicWrites, person); |
| | | 103 | | |
| | | 104 | | // AF-37c: the Email method's code, if the method was selected. Checked first, because looking |
| | | 105 | | // an email code up spends nothing but its guess budget, where an app code that verifies is |
| | | 106 | | // spent (FR-2F-14): the other order let a wrong email code burn a right app code. |
| | 60 | 107 | | TwoFactorEmailCode? consumedEmailCode = null; |
| | | 108 | | |
| | 60 | 109 | | if (twoFactorAuth.EmailEnabled) |
| | | 110 | | { |
| | 29 | 111 | | consumedEmailCode = await FindMatchingEmailCodeAsync(twoFactorAuth.Id, command.EmailCode); |
| | | 112 | | |
| | 29 | 113 | | if (consumedEmailCode is null) |
| | | 114 | | { |
| | 20 | 115 | | return output.WithError(AuthMessages.PasswordOrCodeIncorrect); |
| | | 116 | | } |
| | | 117 | | } |
| | | 118 | | |
| | | 119 | | // AF-37b: the App method's code, if the method was selected. ITotpCodeVerifier owns the |
| | | 120 | | // secret, the clock-drift window, and the single-use rule, shared with UC-38/39/40's |
| | | 121 | | // ITwoFactorFactorVerifier. |
| | 40 | 122 | | if (twoFactorAuth.AppEnabled && !await totpCodeVerifier.VerifyAsync(twoFactorAuth, command.AppCode)) |
| | | 123 | | { |
| | 18 | 124 | | return output.WithError(AuthMessages.PasswordOrCodeIncorrect); |
| | | 125 | | } |
| | | 126 | | |
| | | 127 | | // Only now is the pause decided, so that it answers as a wrong code does, and before the |
| | | 128 | | // email code is spent, so a paused request leaves it for the account holder. |
| | 22 | 129 | | if (!admitted) |
| | | 130 | | { |
| | 2 | 131 | | return output.WithError(AuthMessages.PasswordOrCodeIncorrect); |
| | | 132 | | } |
| | | 133 | | |
| | 20 | 134 | | var recoveryCodes = RecoveryCodes.Issue(); |
| | | 135 | | |
| | | 136 | | // Everything from spending the email code to the activation lands together or not at all |
| | | 137 | | // (#209): leaving the transaction by any return or exception before the commit rolls it back. |
| | 20 | 138 | | await using (var transaction = await transactions.BeginAsync()) |
| | | 139 | | { |
| | | 140 | | // UC-37 step 7, brought forward: the code is spent before anything is issued, in a write |
| | | 141 | | // only one request can win, so two confirmations racing on the same code cannot both issue |
| | | 142 | | // a set of recovery codes and activate. |
| | 20 | 143 | | if (consumedEmailCode is not null && !await atomicWrites.TryConsumeEmailCodeAsync(consumedEmailCode)) |
| | | 144 | | { |
| | 0 | 145 | | return output.WithError(AuthMessages.PasswordOrCodeIncorrect); |
| | | 146 | | } |
| | | 147 | | |
| | 20 | 148 | | await ReauthenticationBudget.ClearAsync(atomicWrites, person); |
| | | 149 | | |
| | | 150 | | // FR-AU-11: every token issued before the second factor was required is revoked — |
| | | 151 | | // including the one confirming it, so the caller signs in again, this time through UC-38. |
| | | 152 | | // A token that predates the factor proves only the password, which is what the factor |
| | | 153 | | // guards against. |
| | 20 | 154 | | await atomicWrites.RotateSecurityStampAsync(person); |
| | | 155 | | |
| | | 156 | | // UC-37 steps 4-5 (FR-2F-05, FR-2F-20): ten fresh recovery codes, hashed at rest, returned |
| | | 157 | | // once — written before the activation below. |
| | 20 | 158 | | var issuance = await IssueRecoveryCodesAsync(twoFactorAuth.Id, recoveryCodes); |
| | | 159 | | |
| | 20 | 160 | | if (issuance is not null) |
| | | 161 | | { |
| | 1 | 162 | | return output.WithErrors(issuance); |
| | | 163 | | } |
| | | 164 | | |
| | | 165 | | // UC-37 step 3: every required check passed and the caller now holds their recovery |
| | | 166 | | // codes, so the configuration can be activated. |
| | 19 | 167 | | twoFactorAuth.IsActive = true; |
| | | 168 | | |
| | 19 | 169 | | var activation = await twoFactorWriter.UpdateAsync(twoFactorAuth); |
| | | 170 | | |
| | 19 | 171 | | if (!activation.Success) |
| | | 172 | | { |
| | 1 | 173 | | return output.WithErrors(activation.Errors); |
| | | 174 | | } |
| | | 175 | | |
| | 18 | 176 | | await transaction.CommitAsync(); |
| | | 177 | | } |
| | | 178 | | |
| | 18 | 179 | | return output |
| | 18 | 180 | | .WithData(new ConfirmTwoFactorAuthCommandOutput |
| | 18 | 181 | | { |
| | 180 | 182 | | Enabled = true, RecoveryCodes = recoveryCodes.Select(issued => issued.Code).ToList() |
| | 18 | 183 | | }) |
| | 18 | 184 | | .WithMessage(TwoFactorMessages.SetupConfirmed); |
| | 65 | 185 | | } |
| | | 186 | | |
| | | 187 | | /// <summary> |
| | | 188 | | /// Finds the latest not-yet-used, not-yet-expired email code for this configuration that |
| | | 189 | | /// <paramref name="emailCode" /> hashes to, the same comparison |
| | | 190 | | /// <see cref="EnableTwoFactorAuthCommandHandler" /> stores it with (AF-37c). Returns |
| | | 191 | | /// <c>null</c> for a missing code, an incorrect one, or one that has expired or was already |
| | | 192 | | /// used — UC-37 does not distinguish between them. |
| | | 193 | | /// </summary> |
| | | 194 | | private Task<TwoFactorEmailCode?> FindMatchingEmailCodeAsync(long twoFactorAuthId, string? emailCode) => |
| | 29 | 195 | | TwoFactorEmailCodeVerification.FindMatchingAsync( |
| | 29 | 196 | | emailCodeReader, atomicWrites, twoFactorAuthId, emailCode); |
| | | 197 | | |
| | | 198 | | /// <summary> |
| | | 199 | | /// Persists the digests of <paramref name="recoveryCodes" /> as the configuration's whole |
| | | 200 | | /// recovery code set, replacing anything already stored against it, in one insert. Returns the |
| | | 201 | | /// persistence errors, or <c>null</c> on success. |
| | | 202 | | /// </summary> |
| | | 203 | | /// <remarks> |
| | | 204 | | /// The delete keeps the set at exactly the ten this attempt returns, whatever is stored against |
| | | 205 | | /// the configuration — such as codes a run before #209's transaction stored and then failed to |
| | | 206 | | /// activate, which nobody was ever told about — so old codes are never valid alongside new ones, |
| | | 207 | | /// what UC-40 exists to prevent. |
| | | 208 | | /// </remarks> |
| | | 209 | | private async Task<IEnumerable<string>?> IssueRecoveryCodesAsync( |
| | | 210 | | long twoFactorAuthId, IReadOnlyCollection<IssuedRecoveryCode> recoveryCodes) |
| | | 211 | | { |
| | 20 | 212 | | var supersededIds = await recoveryCodeReader.Query() |
| | 20 | 213 | | .Where(x => x.TwoFactorAuthId == twoFactorAuthId) |
| | 20 | 214 | | .Select(x => x.Id) |
| | 20 | 215 | | .ToListAsync(); |
| | | 216 | | |
| | 20 | 217 | | if (supersededIds.Count > 0) |
| | | 218 | | { |
| | 1 | 219 | | var deletion = await recoveryCodeWriter.DeleteRangeAsync(supersededIds); |
| | | 220 | | |
| | 1 | 221 | | if (!deletion.Success) |
| | | 222 | | { |
| | 0 | 223 | | return deletion.Errors; |
| | | 224 | | } |
| | | 225 | | } |
| | | 226 | | |
| | 20 | 227 | | var creation = await recoveryCodeWriter.CreateRangeAsync(recoveryCodes.Select(recoveryCode => |
| | 210 | 228 | | new TwoFactorRecoveryCode |
| | 210 | 229 | | { |
| | 210 | 230 | | TwoFactorAuthId = twoFactorAuthId, CodeHash = recoveryCode.CodeHash, Used = false |
| | 210 | 231 | | })); |
| | | 232 | | |
| | 20 | 233 | | return creation.Success ? null : creation.Errors; |
| | 20 | 234 | | } |
| | | 235 | | } |