| | | 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 App and Email 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 | | /// One thing does change on the way through, though: an app code that verified is spent, so |
| | | 35 | | /// a request whose App check passed and whose Email check then failed has to be retried with |
| | | 36 | | /// a <em>fresh</em> app code. That is the single-use rule working as intended |
| | | 37 | | /// (<see cref="ITotpCodeVerifier" />), not an accident of ordering — a code presented to this |
| | | 38 | | /// endpoint has been presented, whatever the rest of the request went on to do. |
| | | 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>Write order.</b> The recovery codes are persisted <em>before</em> |
| | | 49 | | /// <see cref="TwoFactorAuth.IsActive" /> is set, and in a single |
| | | 50 | | /// <see cref="IAsyncRepository{T, TKey}.CreateRangeAsync" /> rather than ten separate inserts. The |
| | | 51 | | /// repository layer exposes no transaction, so ordering is what stands in for one: the |
| | | 52 | | /// dangerous half-write is "two-factor is active but the caller holds no recovery codes", |
| | | 53 | | /// which would lock a caller out of their own account on the strength of a request this |
| | | 54 | | /// handler reported as failed. Writing the codes first makes the surviving failure modes |
| | | 55 | | /// harmless — codes stored against a configuration that never activated, which the next |
| | | 56 | | /// attempt replaces, and which authenticate nothing on their own while |
| | | 57 | | /// <see cref="TwoFactorAuth.IsActive" /> is <c>false</c>. |
| | | 58 | | /// </para> |
| | | 59 | | /// </remarks> |
| | 55 | 60 | | public class ConfirmTwoFactorAuthCommandHandler( |
| | 55 | 61 | | IAsyncReadOnlyRepository<Person, long> personReader, |
| | 55 | 62 | | IAsyncReadOnlyRepository<TwoFactorAuth, long> twoFactorReader, |
| | 55 | 63 | | IAsyncRepository<TwoFactorAuth, long> twoFactorWriter, |
| | 55 | 64 | | IAsyncReadOnlyRepository<TwoFactorEmailCode, long> emailCodeReader, |
| | 55 | 65 | | IAtomicWrites atomicWrites, |
| | 55 | 66 | | IAsyncReadOnlyRepository<TwoFactorRecoveryCode, long> recoveryCodeReader, |
| | 55 | 67 | | IAsyncRepository<TwoFactorRecoveryCode, long> recoveryCodeWriter, |
| | 55 | 68 | | ITotpCodeVerifier totpCodeVerifier) |
| | | 69 | | : ICommandHandlerAsync<ConfirmTwoFactorAuthCommand, ConfirmTwoFactorAuthCommandOutput> |
| | | 70 | | { |
| | | 71 | | public async Task<DataOutput<ConfirmTwoFactorAuthCommandOutput?>> HandleAsync( |
| | | 72 | | ConfirmTwoFactorAuthCommand command, CancellationToken cancellationToken = default) |
| | | 73 | | { |
| | 55 | 74 | | var output = DataOutput<ConfirmTwoFactorAuthCommandOutput?>.New; |
| | | 75 | | |
| | | 76 | | // AF-37a (and, implicitly, the Google User / hard-deleted-person case UC-36's AF-37b handles |
| | | 77 | | // separately for enable — here it collapses into "no pending setup", since neither could ever |
| | | 78 | | // hold a row to confirm). |
| | 55 | 79 | | var person = await personReader.Query() |
| | 55 | 80 | | .FirstOrDefaultAsync(x => x.PublicId == command.ActingPersonId && !x.IsDeleted); |
| | | 81 | | |
| | 55 | 82 | | var twoFactorAuth = person is null |
| | 55 | 83 | | ? null |
| | 55 | 84 | | : await twoFactorReader.Query().FirstOrDefaultAsync(x => x.PersonId == person.Id); |
| | | 85 | | |
| | 55 | 86 | | if (person is null || twoFactorAuth is null) |
| | | 87 | | { |
| | 3 | 88 | | return output.WithError(TwoFactorMessages.NoPendingSetup); |
| | | 89 | | } |
| | | 90 | | |
| | | 91 | | // AF-37d. |
| | 52 | 92 | | if (twoFactorAuth.IsActive) |
| | | 93 | | { |
| | 3 | 94 | | return output.WithError(TwoFactorMessages.AlreadyActive); |
| | | 95 | | } |
| | | 96 | | |
| | | 97 | | // FR-AU-13: the codes are a re-authentication like any other made under a bearer token, so |
| | | 98 | | // the check is charged to the account's budget before anything is compared. A paused budget |
| | | 99 | | // still has the codes compared, and refuses them whatever the result. |
| | 49 | 100 | | var admitted = await ReauthenticationBudget.TryChargeAsync(atomicWrites, person); |
| | | 101 | | |
| | | 102 | | // AF-37b: the App method's code, if the method was selected. ITotpCodeVerifier owns the |
| | | 103 | | // secret, the clock-drift window, and the single-use rule, shared with UC-38/39/40's |
| | | 104 | | // ITwoFactorFactorVerifier. |
| | 49 | 105 | | if (twoFactorAuth.AppEnabled && !await totpCodeVerifier.VerifyAsync(twoFactorAuth, command.AppCode)) |
| | | 106 | | { |
| | 11 | 107 | | return output.WithError(AuthMessages.PasswordOrCodeIncorrect); |
| | | 108 | | } |
| | | 109 | | |
| | | 110 | | // AF-37c: the Email method's code, if the method was selected. |
| | 38 | 111 | | TwoFactorEmailCode? consumedEmailCode = null; |
| | | 112 | | |
| | 38 | 113 | | if (twoFactorAuth.EmailEnabled) |
| | | 114 | | { |
| | 24 | 115 | | consumedEmailCode = await FindMatchingEmailCodeAsync(twoFactorAuth.Id, command.EmailCode); |
| | | 116 | | |
| | 24 | 117 | | if (consumedEmailCode is null) |
| | | 118 | | { |
| | 19 | 119 | | return output.WithError(AuthMessages.PasswordOrCodeIncorrect); |
| | | 120 | | } |
| | | 121 | | } |
| | | 122 | | |
| | | 123 | | // Only now is the pause decided, so that it answers as a wrong code does, and before the |
| | | 124 | | // email code is spent, so a paused request leaves it for the account holder. |
| | 19 | 125 | | if (!admitted) |
| | | 126 | | { |
| | 2 | 127 | | return output.WithError(AuthMessages.PasswordOrCodeIncorrect); |
| | | 128 | | } |
| | | 129 | | |
| | | 130 | | // UC-37 step 7, brought forward: the code is spent before anything is issued, in a write only |
| | | 131 | | // one request can win, so two confirmations racing on the same code cannot both issue a set |
| | | 132 | | // of recovery codes and activate. |
| | 17 | 133 | | if (consumedEmailCode is not null && !await atomicWrites.TryConsumeEmailCodeAsync(consumedEmailCode)) |
| | | 134 | | { |
| | 0 | 135 | | return output.WithError(AuthMessages.PasswordOrCodeIncorrect); |
| | | 136 | | } |
| | | 137 | | |
| | 17 | 138 | | await ReauthenticationBudget.ClearAsync(atomicWrites, person); |
| | | 139 | | |
| | | 140 | | // FR-AU-11: every token issued before the second factor was required is revoked — including |
| | | 141 | | // the one confirming it, so the caller signs in again, this time through UC-38. A token that |
| | | 142 | | // predates the factor proves only the password, which is what the factor guards against. |
| | | 143 | | // Before the writes that activate it, so a failure among them costs one extra sign-in at most. |
| | 17 | 144 | | await atomicWrites.RotateSecurityStampAsync(person); |
| | | 145 | | |
| | | 146 | | // UC-37 steps 4-5 (FR-2F-05, FR-2F-20): ten fresh recovery codes, hashed at rest, returned |
| | | 147 | | // once — written before the activation below, per the write-order note in the remarks. |
| | 17 | 148 | | var recoveryCodes = RecoveryCodes.Issue(); |
| | | 149 | | |
| | 17 | 150 | | var issuance = await IssueRecoveryCodesAsync(twoFactorAuth.Id, recoveryCodes); |
| | | 151 | | |
| | 17 | 152 | | if (issuance is not null) |
| | | 153 | | { |
| | 1 | 154 | | return output.WithErrors(issuance); |
| | | 155 | | } |
| | | 156 | | |
| | | 157 | | // UC-37 step 3: every required check passed and the caller now holds their recovery codes, |
| | | 158 | | // so the configuration can be activated. |
| | 16 | 159 | | twoFactorAuth.IsActive = true; |
| | | 160 | | |
| | 16 | 161 | | var activation = await twoFactorWriter.UpdateAsync(twoFactorAuth); |
| | | 162 | | |
| | 16 | 163 | | if (!activation.Success) |
| | | 164 | | { |
| | 0 | 165 | | return output.WithErrors(activation.Errors); |
| | | 166 | | } |
| | | 167 | | |
| | 16 | 168 | | return output |
| | 16 | 169 | | .WithData(new ConfirmTwoFactorAuthCommandOutput |
| | 16 | 170 | | { |
| | 160 | 171 | | Enabled = true, RecoveryCodes = recoveryCodes.Select(issued => issued.Code).ToList() |
| | 16 | 172 | | }) |
| | 16 | 173 | | .WithMessage(TwoFactorMessages.SetupConfirmed); |
| | 55 | 174 | | } |
| | | 175 | | |
| | | 176 | | /// <summary> |
| | | 177 | | /// Finds the latest not-yet-used, not-yet-expired email code for this configuration that |
| | | 178 | | /// <paramref name="emailCode" /> hashes to, the same comparison |
| | | 179 | | /// <see cref="EnableTwoFactorAuthCommandHandler" /> stores it with (AF-37c). Returns |
| | | 180 | | /// <c>null</c> for a missing code, an incorrect one, or one that has expired or was already |
| | | 181 | | /// used — UC-37 does not distinguish between them. |
| | | 182 | | /// </summary> |
| | | 183 | | private Task<TwoFactorEmailCode?> FindMatchingEmailCodeAsync(long twoFactorAuthId, string? emailCode) => |
| | 24 | 184 | | TwoFactorEmailCodeVerification.FindMatchingAsync( |
| | 24 | 185 | | emailCodeReader, atomicWrites, twoFactorAuthId, emailCode); |
| | | 186 | | |
| | | 187 | | /// <summary> |
| | | 188 | | /// Persists the digests of <paramref name="recoveryCodes" /> as the configuration's whole |
| | | 189 | | /// recovery code set, replacing anything already stored against it, in one insert. Returns the |
| | | 190 | | /// persistence errors, or <c>null</c> on success. |
| | | 191 | | /// </summary> |
| | | 192 | | /// <remarks> |
| | | 193 | | /// The delete is what makes a retry after a failed attempt safe: a prior run that stored its |
| | | 194 | | /// codes and then failed to activate left ten rows nobody was ever told about, and without |
| | | 195 | | /// this the next attempt would leave them in place alongside the ten it does return — the |
| | | 196 | | /// same "old codes valid alongside new ones" UC-40 exists to prevent. |
| | | 197 | | /// </remarks> |
| | | 198 | | private async Task<IEnumerable<string>?> IssueRecoveryCodesAsync( |
| | | 199 | | long twoFactorAuthId, IReadOnlyCollection<IssuedRecoveryCode> recoveryCodes) |
| | | 200 | | { |
| | 17 | 201 | | var supersededIds = await recoveryCodeReader.Query() |
| | 17 | 202 | | .Where(x => x.TwoFactorAuthId == twoFactorAuthId) |
| | 17 | 203 | | .Select(x => x.Id) |
| | 17 | 204 | | .ToListAsync(); |
| | | 205 | | |
| | 17 | 206 | | if (supersededIds.Count > 0) |
| | | 207 | | { |
| | 1 | 208 | | var deletion = await recoveryCodeWriter.DeleteRangeAsync(supersededIds); |
| | | 209 | | |
| | 1 | 210 | | if (!deletion.Success) |
| | | 211 | | { |
| | 0 | 212 | | return deletion.Errors; |
| | | 213 | | } |
| | | 214 | | } |
| | | 215 | | |
| | 17 | 216 | | var creation = await recoveryCodeWriter.CreateRangeAsync(recoveryCodes.Select(recoveryCode => |
| | 177 | 217 | | new TwoFactorRecoveryCode |
| | 177 | 218 | | { |
| | 177 | 219 | | TwoFactorAuthId = twoFactorAuthId, CodeHash = recoveryCode.CodeHash, Used = false |
| | 177 | 220 | | })); |
| | | 221 | | |
| | 17 | 222 | | return creation.Success ? null : creation.Errors; |
| | 17 | 223 | | } |
| | | 224 | | } |