| | | 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.Shared.Messages; |
| | | 7 | | using ArturRios.Heimdall.Shared.Services; |
| | | 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="SendPersonPasswordResetCommand" /> (UC-52, FR-PR-05/06): an administrator has |
| | | 16 | | /// a password-reset link mailed to a person's own address. |
| | | 17 | | /// </summary> |
| | | 18 | | /// <remarks> |
| | | 19 | | /// <para> |
| | | 20 | | /// Eligibility is UC-12's, through <see cref="PasswordRecoveryEligibility" />, so the anonymous |
| | | 21 | | /// recovery and this request cannot disagree about who may recover. Refusals, in order: not |
| | | 22 | | /// found (AF-52a); the caller may not act on this person (AF-52b); restricted, then deleted |
| | | 23 | | /// (AF-52c); the account could not sign in (AF-52d); the cooldown is running (AF-52e). |
| | | 24 | | /// </para> |
| | | 25 | | /// <para> |
| | | 26 | | /// The send is made here, not through UC-12's anonymous queue: that queue exists so an |
| | | 27 | | /// anonymous caller cannot tell a registered address from an unknown one by timing, and an |
| | | 28 | | /// authenticated administrator is owed a real answer instead. Earlier live reset links are left |
| | | 29 | | /// as <see cref="IPasswordResetService.IssueAndSendAsync" /> leaves them for UC-12. |
| | | 30 | | /// </para> |
| | | 31 | | /// </remarks> |
| | 24 | 32 | | public class SendPersonPasswordResetCommandHandler( |
| | 24 | 33 | | IAsyncReadOnlyRepository<Person, long> personReader, |
| | 24 | 34 | | IAsyncReadOnlyRepository<PasswordResetToken, long> tokenReader, |
| | 24 | 35 | | IScopeOwnershipChecker scopeOwnership, |
| | 24 | 36 | | IPasswordResetService passwordReset, |
| | 24 | 37 | | PasswordResetOptions resetOptions, |
| | 24 | 38 | | AdminEmailOptions adminEmailOptions) |
| | | 39 | | : ICommandHandlerAsync<SendPersonPasswordResetCommand, SendPersonPasswordResetCommandOutput> |
| | | 40 | | { |
| | | 41 | | public async Task<DataOutput<SendPersonPasswordResetCommandOutput?>> HandleAsync( |
| | | 42 | | SendPersonPasswordResetCommand command, CancellationToken cancellationToken = default) |
| | | 43 | | { |
| | 24 | 44 | | var output = DataOutput<SendPersonPasswordResetCommandOutput?>.New; |
| | | 45 | | |
| | | 46 | | // AF-52a. Both scope navigations are loaded: the eligibility rule reads them. |
| | 24 | 47 | | var person = await personReader.Query() |
| | 24 | 48 | | .Include(x => x.ScopeMembership) |
| | 24 | 49 | | .ThenInclude(membership => membership!.Scope) |
| | 24 | 50 | | .Include(x => x.ScopeOwnerships) |
| | 24 | 51 | | .ThenInclude(ownership => ownership.Scope) |
| | 24 | 52 | | .FirstOrDefaultAsync(x => x.PublicId == command.Id, cancellationToken); |
| | | 53 | | |
| | 24 | 54 | | if (person is null) |
| | | 55 | | { |
| | 2 | 56 | | return output.WithError(PersonMessages.PersonNotFound); |
| | | 57 | | } |
| | | 58 | | |
| | | 59 | | // AF-52b. |
| | 22 | 60 | | if (!await PersonEmailAuthorization.ActorMayEmailAsync( |
| | 22 | 61 | | scopeOwnership, command.ActingRole, command.ActingPersonId, person)) |
| | | 62 | | { |
| | 3 | 63 | | return output.WithError(PersonMessages.NotAuthorizedToEmailPerson); |
| | | 64 | | } |
| | | 65 | | |
| | | 66 | | // AF-52c and AF-52d: UC-12's rule, reported rather than silently applied. |
| | 19 | 67 | | switch (PasswordRecoveryEligibility.Of(person)) |
| | | 68 | | { |
| | | 69 | | case RecoveryEligibility.Restricted: |
| | 2 | 70 | | return output.WithError(PersonMessages.PersonProcessingRestricted); |
| | | 71 | | case RecoveryEligibility.Deleted: |
| | 2 | 72 | | return output.WithError(PersonMessages.PersonIsDeleted); |
| | | 73 | | case RecoveryEligibility.CannotSignIn: |
| | 3 | 74 | | return output.WithError(PersonMessages.PersonCannotSignIn); |
| | | 75 | | } |
| | | 76 | | |
| | | 77 | | // AF-52e. Any reset token issued for the person inside the window — UC-12's included, used |
| | | 78 | | // or not — means one was sent recently. |
| | | 79 | | // Best-effort: concurrent requests for the same person can both pass this check (no lock). |
| | | 80 | | // Accepted, because only an authenticated administrator can trigger it. |
| | 12 | 81 | | if (await adminEmailOptions.IsRunningAsync( |
| | 12 | 82 | | tokenReader, person.Id, resetOptions.TokenLifetime, DateTime.UtcNow, cancellationToken)) |
| | | 83 | | { |
| | 3 | 84 | | return output.WithError(PersonMessages.AccountEmailSentRecently); |
| | | 85 | | } |
| | | 86 | | |
| | 9 | 87 | | await passwordReset.IssueAndSendAsync(person); |
| | | 88 | | |
| | 9 | 89 | | return output |
| | 9 | 90 | | .WithData(new SendPersonPasswordResetCommandOutput { Id = person.PublicId }) |
| | 9 | 91 | | .WithMessage(PersonMessages.PasswordResetSentToPerson); |
| | 24 | 92 | | } |
| | | 93 | | } |