| | | 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="RegenerateRecoveryCodesCommand" /> (UC-40, FR-2F-12): loads the caller's |
| | | 16 | | /// active <see cref="TwoFactorAuth" /> row (AF-40a), verifies the submitted second factor through |
| | | 17 | | /// the same <see cref="ITwoFactorFactorVerifier" /> <c>VerifyTwoFactorAuthCommandHandler</c> |
| | | 18 | | /// (UC-38) and <c>DisableTwoFactorAuthCommandHandler</c> (UC-39) use (AF-40b), and — only once |
| | | 19 | | /// that check passes — permanently removes every existing recovery code row for the |
| | | 20 | | /// configuration, including any still unused, and issues ten fresh ones through |
| | | 21 | | /// <see cref="RecoveryCodes" />, as <c>ConfirmTwoFactorAuthCommandHandler</c> (UC-37) does. |
| | | 22 | | /// </summary> |
| | | 23 | | /// <remarks> |
| | | 24 | | /// Per the Use Case Specification Document's closing note on UC-40: regeneration replaces the |
| | | 25 | | /// whole set at once rather than topping up the used ones back to ten — a partial refill would |
| | | 26 | | /// leave old, already-distributed codes valid alongside new ones, defeating the point of rotating |
| | | 27 | | /// them. The matched row the verifier returns (an email code or a recovery code) never needs |
| | | 28 | | /// marking used here — every existing recovery code is about to be deleted regardless, and an |
| | | 29 | | /// email code that authorized a regeneration carries no further significance once the request |
| | | 30 | | /// succeeds. |
| | | 31 | | /// <para> |
| | | 32 | | /// <b>Write order.</b> The replacements are inserted before the old set is deleted, in a |
| | | 33 | | /// single <see cref="IAsyncRepository{T, TKey}.CreateRangeAsync" /> rather than ten inserts. The |
| | | 34 | | /// repository layer exposes no transaction, so ordering is what decides which way a partial |
| | | 35 | | /// failure falls: delete-then-create can leave an account with no recovery codes at all, |
| | | 36 | | /// while create-then-delete leaves it with the set the caller already holds — the state it |
| | | 37 | | /// was in before the request, which is the only safe direction to fail in for a credential |
| | | 38 | | /// of last resort. |
| | | 39 | | /// </para> |
| | | 40 | | /// <para> |
| | | 41 | | /// A missing, wrong or already-spent factor answers the one re-authentication refusal |
| | | 42 | | /// (<c>AuthMessages.PasswordOrCodeIncorrect</c>, 401), and every attempt is charged to the |
| | | 43 | | /// account's re-authentication budget (<see cref="ReauthenticationBudget" />, FR-AU-13). |
| | | 44 | | /// </para> |
| | | 45 | | /// </remarks> |
| | 36 | 46 | | public class RegenerateRecoveryCodesCommandHandler( |
| | 36 | 47 | | IAsyncReadOnlyRepository<Person, long> personReader, |
| | 36 | 48 | | IAsyncReadOnlyRepository<TwoFactorAuth, long> twoFactorReader, |
| | 36 | 49 | | IAsyncReadOnlyRepository<TwoFactorRecoveryCode, long> recoveryCodeReader, |
| | 36 | 50 | | IAsyncRepository<TwoFactorRecoveryCode, long> recoveryCodeWriter, |
| | 36 | 51 | | IAtomicWrites atomicWrites, |
| | 36 | 52 | | ITwoFactorFactorVerifier factorVerifier) |
| | | 53 | | : ICommandHandlerAsync<RegenerateRecoveryCodesCommand, RegenerateRecoveryCodesCommandOutput> |
| | | 54 | | { |
| | | 55 | | public async Task<DataOutput<RegenerateRecoveryCodesCommandOutput?>> HandleAsync( |
| | | 56 | | RegenerateRecoveryCodesCommand command, CancellationToken cancellationToken = default) |
| | | 57 | | { |
| | 36 | 58 | | var output = DataOutput<RegenerateRecoveryCodesCommandOutput?>.New; |
| | | 59 | | |
| | 36 | 60 | | var person = await personReader.Query() |
| | 36 | 61 | | .FirstOrDefaultAsync(x => x.PublicId == command.ActingPersonId && !x.IsDeleted); |
| | | 62 | | |
| | 36 | 63 | | var twoFactorAuth = person is null |
| | 36 | 64 | | ? null |
| | 36 | 65 | | : await twoFactorReader.Query() |
| | 36 | 66 | | .FirstOrDefaultAsync(x => x.PersonId == person.Id && x.IsActive); |
| | | 67 | | |
| | | 68 | | // AF-40a: nothing to regenerate — whether because two-factor was never activated, or because |
| | | 69 | | // the caller could not be resolved as a live person at all (a Google User, or a bearer token |
| | | 70 | | // naming a person since hard deleted), neither of which could ever hold an active row. |
| | 36 | 71 | | if (person is null || twoFactorAuth is null) |
| | | 72 | | { |
| | 5 | 73 | | return output.WithError(TwoFactorMessages.NotActive); |
| | | 74 | | } |
| | | 75 | | |
| | | 76 | | // FR-AU-13: charged to the re-authentication budget before the factor is compared. A paused |
| | | 77 | | // budget still has it compared, and refuses whatever the result. |
| | 31 | 78 | | var admitted = await ReauthenticationBudget.TryChargeAsync(atomicWrites, person); |
| | | 79 | | |
| | | 80 | | // UC-40 step 2 (AF-40b): the same second-factor check UC-38 and UC-39 make, refused as every |
| | | 81 | | // re-authentication is. |
| | 31 | 82 | | var verification = await factorVerifier.VerifyAsync(twoFactorAuth, command.Code, command.RecoveryCode); |
| | | 83 | | |
| | 31 | 84 | | if (!verification.Matched || !admitted) |
| | | 85 | | { |
| | 17 | 86 | | return output.WithError(AuthMessages.PasswordOrCodeIncorrect); |
| | | 87 | | } |
| | | 88 | | |
| | | 89 | | // An email code is single-use wherever it is accepted (FR-2F-03): the one that authorized |
| | | 90 | | // this regeneration is retired as UC-38 retires the one that completes a login, rather than |
| | | 91 | | // staying redeemable there for the rest of its ten minutes. A recovery code is spent too, |
| | | 92 | | // although its whole set is replaced below: spending it is what lets only one of two |
| | | 93 | | // simultaneous regenerations — or a regeneration and a login — use it. Both are spent before |
| | | 94 | | // anything is written, by writes only one request can win. |
| | 14 | 95 | | if (verification.ConsumedEmailCode is { } consumedEmailCode && |
| | 14 | 96 | | !await atomicWrites.TryConsumeEmailCodeAsync(consumedEmailCode)) |
| | | 97 | | { |
| | 0 | 98 | | return output.WithError(AuthMessages.PasswordOrCodeIncorrect); |
| | | 99 | | } |
| | | 100 | | |
| | 14 | 101 | | if (verification.ConsumedRecoveryCode is { } consumedRecoveryCode && |
| | 14 | 102 | | !await atomicWrites.TryConsumeRecoveryCodeAsync(consumedRecoveryCode, DateTime.UtcNow)) |
| | | 103 | | { |
| | 3 | 104 | | return output.WithError(AuthMessages.PasswordOrCodeIncorrect); |
| | | 105 | | } |
| | | 106 | | |
| | 11 | 107 | | await ReauthenticationBudget.ClearAsync(atomicWrites, person); |
| | | 108 | | |
| | | 109 | | // FR-AU-11: a new set of recovery codes is a new second factor, and revokes every token issued |
| | | 110 | | // before it, the caller's own included — the codes are usually regenerated because the old |
| | | 111 | | // set may be known to someone else. Before the writes that replace them, as everywhere. |
| | 11 | 112 | | await atomicWrites.RotateSecurityStampAsync(person); |
| | | 113 | | |
| | | 114 | | // UC-40 step 3: the rows this call replaces, captured before anything is written. Taking the |
| | | 115 | | // set first means a run that failed partway through last time — leaving rows behind — has |
| | | 116 | | // those rows swept up here too, rather than accumulating. |
| | 11 | 117 | | var supersededRecoveryCodeIds = await recoveryCodeReader.Query() |
| | 11 | 118 | | .Where(x => x.TwoFactorAuthId == twoFactorAuth.Id) |
| | 11 | 119 | | .Select(x => x.Id) |
| | 11 | 120 | | .ToListAsync(); |
| | | 121 | | |
| | | 122 | | // UC-40 steps 4-5 (FR-2F-12, FR-2F-20): ten fresh recovery codes, hashed at rest, returned |
| | | 123 | | // once, written in one insert before the old set is removed — see the write-order note below. |
| | | 124 | | // Always the current shape, so regenerating is also how a set issued before it is replaced. |
| | 11 | 125 | | var recoveryCodes = RecoveryCodes.Issue(); |
| | | 126 | | |
| | 11 | 127 | | var creation = await recoveryCodeWriter.CreateRangeAsync(recoveryCodes.Select(recoveryCode => |
| | 121 | 128 | | new TwoFactorRecoveryCode |
| | 121 | 129 | | { |
| | 121 | 130 | | TwoFactorAuthId = twoFactorAuth.Id, CodeHash = recoveryCode.CodeHash, Used = false |
| | 121 | 131 | | })); |
| | | 132 | | |
| | 11 | 133 | | if (!creation.Success) |
| | | 134 | | { |
| | 0 | 135 | | return output.WithErrors(creation.Errors); |
| | | 136 | | } |
| | | 137 | | |
| | 11 | 138 | | if (supersededRecoveryCodeIds.Count > 0) |
| | | 139 | | { |
| | 8 | 140 | | var deletion = await recoveryCodeWriter.DeleteRangeAsync(supersededRecoveryCodeIds); |
| | | 141 | | |
| | 8 | 142 | | if (!deletion.Success) |
| | | 143 | | { |
| | 0 | 144 | | return output.WithErrors(deletion.Errors); |
| | | 145 | | } |
| | | 146 | | } |
| | | 147 | | |
| | 11 | 148 | | return output |
| | 11 | 149 | | .WithData(new RegenerateRecoveryCodesCommandOutput |
| | 11 | 150 | | { |
| | 110 | 151 | | RecoveryCodes = recoveryCodes.Select(issued => issued.Code).ToList() |
| | 11 | 152 | | }) |
| | 11 | 153 | | .WithMessage(TwoFactorMessages.RecoveryCodesRegenerated); |
| | 36 | 154 | | } |
| | | 155 | | } |