| | | 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 ArturRios.Util.Hashing; |
| | | 11 | | using Microsoft.EntityFrameworkCore; |
| | | 12 | | |
| | | 13 | | namespace ArturRios.Heimdall.Command.Handlers; |
| | | 14 | | |
| | | 15 | | /// <summary> |
| | | 16 | | /// Handles <see cref="DisableTwoFactorAuthCommand" /> (UC-39, FR-2F-11): loads the caller's |
| | | 17 | | /// active <see cref="TwoFactorAuth" /> row (AF-39a), verifies the submitted password against the |
| | | 18 | | /// stored hash and salt exactly as <c>LoginCommandHandler</c> does (AF-39b), verifies the |
| | | 19 | | /// submitted second factor through the same <see cref="ITwoFactorFactorVerifier" /> |
| | | 20 | | /// <c>VerifyTwoFactorAuthCommandHandler</c> (UC-38) uses (AF-39c), and — only once both checks |
| | | 21 | | /// pass — permanently deletes the row, cascading at the database level to its recovery codes and |
| | | 22 | | /// email codes. |
| | | 23 | | /// </summary> |
| | | 24 | | /// <remarks> |
| | | 25 | | /// AF-39b (wrong password) and AF-39c (wrong second factor) answer identically — |
| | | 26 | | /// <c>AuthMessages.PasswordOrCodeIncorrect</c>, 401 — so the answer never says which of the two |
| | | 27 | | /// was wrong. Both checks are charged, together, to the account's re-authentication budget |
| | | 28 | | /// (<see cref="ReauthenticationBudget" />, FR-AU-13), and a paused budget is refused the same way, |
| | | 29 | | /// after the same checks, so it does not show either. |
| | | 30 | | /// </remarks> |
| | 46 | 31 | | public class DisableTwoFactorAuthCommandHandler( |
| | 46 | 32 | | IAsyncReadOnlyRepository<Person, long> personReader, |
| | 46 | 33 | | IAsyncReadOnlyRepository<TwoFactorAuth, long> twoFactorReader, |
| | 46 | 34 | | IAsyncRepository<TwoFactorAuth, long> twoFactorWriter, |
| | 46 | 35 | | ITwoFactorFactorVerifier factorVerifier, |
| | 46 | 36 | | IAtomicWrites atomicWrites) |
| | | 37 | | : ICommandHandlerAsync<DisableTwoFactorAuthCommand, DisableTwoFactorAuthCommandOutput> |
| | | 38 | | { |
| | | 39 | | public async Task<DataOutput<DisableTwoFactorAuthCommandOutput?>> HandleAsync( |
| | | 40 | | DisableTwoFactorAuthCommand command, CancellationToken cancellationToken = default) |
| | | 41 | | { |
| | 46 | 42 | | var output = DataOutput<DisableTwoFactorAuthCommandOutput?>.New; |
| | | 43 | | |
| | 46 | 44 | | var person = await personReader.Query() |
| | 46 | 45 | | .FirstOrDefaultAsync(x => x.PublicId == command.ActingPersonId && !x.IsDeleted); |
| | | 46 | | |
| | 46 | 47 | | var twoFactorAuth = person is null |
| | 46 | 48 | | ? null |
| | 46 | 49 | | : await twoFactorReader.Query() |
| | 46 | 50 | | .FirstOrDefaultAsync(x => x.PersonId == person.Id && x.IsActive); |
| | | 51 | | |
| | | 52 | | // AF-39a: nothing to disable — whether because two-factor was never active, or because the |
| | | 53 | | // caller could not be resolved as a live person at all (a Google User, or a bearer token |
| | | 54 | | // naming a person since hard deleted), neither of which could ever hold an active row. |
| | 46 | 55 | | if (person is null || twoFactorAuth is null) |
| | | 56 | | { |
| | 5 | 57 | | return output.WithError(TwoFactorMessages.NotActive); |
| | | 58 | | } |
| | | 59 | | |
| | | 60 | | // FR-AU-13: one charge for the request, before either credential is compared. A paused |
| | | 61 | | // budget still has both compared, as far as they get, and refuses whatever the result. |
| | 41 | 62 | | var admitted = await ReauthenticationBudget.TryChargeAsync(atomicWrites, person); |
| | | 63 | | |
| | | 64 | | // UC-39 step 2 (AF-39b): the same check LoginCommandHandler makes. |
| | 41 | 65 | | var passwordMatches = |
| | 41 | 66 | | await PasswordHashGate.Shared.TextMatchesAsync(command.Password, person.PasswordHash, person.Salt); |
| | | 67 | | |
| | | 68 | | // UC-39 step 3 (AF-39c): the same second-factor check UC-38 makes, answered as AF-39b is — and |
| | | 69 | | // made whatever the password, so a wrong password takes as long to refuse as a wrong factor |
| | | 70 | | // does. The matched row (if any) never needs marking used here — the whole configuration, |
| | | 71 | | // recovery codes included, is about to be removed regardless of which factor matched. |
| | 41 | 72 | | var verification = await factorVerifier.VerifyAsync(twoFactorAuth, command.Code, command.RecoveryCode); |
| | | 73 | | |
| | 41 | 74 | | if (!passwordMatches || !verification.Matched || !admitted) |
| | | 75 | | { |
| | 32 | 76 | | return output.WithError(AuthMessages.PasswordOrCodeIncorrect); |
| | | 77 | | } |
| | | 78 | | |
| | 9 | 79 | | await ReauthenticationBudget.ClearAsync(atomicWrites, person); |
| | | 80 | | |
| | | 81 | | // FR-AU-11: removing a credential revokes every token issued while it stood, the caller's own |
| | | 82 | | // included, so the account's sessions after this point are only ones signed in without it. |
| | | 83 | | // Before the deletion, so a failed one costs an extra sign-in rather than a live old session. |
| | 9 | 84 | | await atomicWrites.RotateSecurityStampAsync(person); |
| | | 85 | | |
| | | 86 | | // UC-39 step 4: only once both checks pass. ON DELETE CASCADE (TwoFactorAuthDbMap, |
| | | 87 | | // TwoFactorRecoveryCodeDbMap, TwoFactorEmailCodeDbMap) removes the recovery codes and email |
| | | 88 | | // codes at the database level — no explicit child deletion needed. |
| | 9 | 89 | | var deletion = await twoFactorWriter.DeleteAsync(twoFactorAuth); |
| | | 90 | | |
| | 9 | 91 | | if (!deletion.Success) |
| | | 92 | | { |
| | 0 | 93 | | return output.WithErrors(deletion.Errors); |
| | | 94 | | } |
| | | 95 | | |
| | 9 | 96 | | return output |
| | 9 | 97 | | .WithData(new DisableTwoFactorAuthCommandOutput { Disabled = true }) |
| | 9 | 98 | | .WithMessage(TwoFactorMessages.Disabled); |
| | 46 | 99 | | } |
| | | 100 | | } |