| | | 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="ResendVerificationEmailCommand" /> (UC-15): refuses an address that is |
| | | 16 | | /// already verified (AF-15a) and a request the person's account-email budget refuses (AF-15b, |
| | | 17 | | /// AF-15c, FR-PE-16), retires every verification link the caller still holds, and has a fresh one |
| | | 18 | | /// issued and mailed (FR-EV-04). |
| | | 19 | | /// </summary> |
| | | 20 | | /// <remarks> |
| | | 21 | | /// <para> |
| | | 22 | | /// The counterpart of <see cref="VerifyEmailCommandHandler" />: that one spends a token, this |
| | | 23 | | /// one replaces it. Issuing is not done here — <see cref="IEmailVerificationService" /> has |
| | | 24 | | /// owned the token's length, alphabet, and lifetime since UC-06, and this handler decides only |
| | | 25 | | /// whether and when to call it. |
| | | 26 | | /// </para> |
| | | 27 | | /// <para> |
| | | 28 | | /// There is no input to validate and no authorization rule to enforce, because the request has |
| | | 29 | | /// no body: the person comes from the bearer token, so the caller can only ever act on |
| | | 30 | | /// themselves. That is also why the command needs no validator, as |
| | | 31 | | /// <see cref="DeletePersonCommand" /> does not. |
| | | 32 | | /// </para> |
| | | 33 | | /// <para> |
| | | 34 | | /// The send is reserved against the person's account-email budget (<see cref="AccountEmailBudget" />) |
| | | 35 | | /// by one conditional write, before anything is retired or issued: without it, whoever holds a |
| | | 36 | | /// token could loop this endpoint and mail the account's address — which may be one they set |
| | | 37 | | /// themselves — once per request, and a check that read the token rows first let a whole burst |
| | | 38 | | /// through. A link an administrator sent (UC-06, UC-51) is not charged to it. |
| | | 39 | | /// </para> |
| | | 40 | | /// <para> |
| | | 41 | | /// A person who has since been logically deleted is still served. UC-15 defines no |
| | | 42 | | /// alternative flow for it, so a refusal would be one this handler invented, and a verified |
| | | 43 | | /// address grants nothing on its own — UC-11 refuses the login by AF-11c regardless. This |
| | | 44 | | /// deliberately differs from UC-12, which withholds a reset link from a deleted person: UC-12 |
| | | 45 | | /// must answer identically either way for anti-enumeration reasons, so withholding costs it |
| | | 46 | | /// nothing, while here it would have to be a named error no document defines. |
| | | 47 | | /// </para> |
| | | 48 | | /// </remarks> |
| | 52 | 49 | | public class ResendVerificationEmailCommandHandler( |
| | 52 | 50 | | IAsyncReadOnlyRepository<Person, long> personReader, |
| | 52 | 51 | | IAsyncReadOnlyRepository<EmailVerificationToken, long> tokenReader, |
| | 52 | 52 | | IAsyncRepository<EmailVerificationToken, long> tokenWriter, |
| | 52 | 53 | | IEmailVerificationService emailVerification, |
| | 52 | 54 | | AdminEmailOptions emailOptions, |
| | 52 | 55 | | IAtomicWrites atomicWrites, |
| | 52 | 56 | | IRetryAfterAdvisor retryAfter) |
| | | 57 | | : ICommandHandlerAsync<ResendVerificationEmailCommand, ResendVerificationEmailCommandOutput> |
| | | 58 | | { |
| | | 59 | | public async Task<DataOutput<ResendVerificationEmailCommandOutput?>> HandleAsync( |
| | | 60 | | ResendVerificationEmailCommand command, CancellationToken cancellationToken = default) |
| | | 61 | | { |
| | 52 | 62 | | var output = DataOutput<ResendVerificationEmailCommandOutput?>.New; |
| | | 63 | | |
| | | 64 | | // UC-15 step 1. The lookup omits an !IsDeleted filter deliberately: a logically deleted person |
| | | 65 | | // is found and served, per the remarks above. |
| | 52 | 66 | | var person = await personReader.Query() |
| | 52 | 67 | | .FirstOrDefaultAsync(x => x.PublicId == command.ActingPersonId); |
| | | 68 | | |
| | | 69 | | // Authentication runs in ClaimsOnly mode — no database read per request — so a valid bearer |
| | | 70 | | // token outlives the person it names once they are hard deleted (UC-10). The token is fine; |
| | | 71 | | // there is simply no address left to send to. |
| | 52 | 72 | | if (person is null) |
| | | 73 | | { |
| | 1 | 74 | | return output.WithError(AuthMessages.PersonNotFound); |
| | | 75 | | } |
| | | 76 | | |
| | | 77 | | // UC-15 step 2 (AF-15a). Checked before step 3, so a refused request retires nothing. |
| | 51 | 78 | | if (person.EmailVerified) |
| | | 79 | | { |
| | 4 | 80 | | return output.WithError(AuthMessages.EmailAlreadyVerified); |
| | | 81 | | } |
| | | 82 | | |
| | | 83 | | // AF-15b, AF-15c (FR-PE-16): reserve the send, or refuse it. Also before step 3, so a refused |
| | | 84 | | // request retires nothing and the link the person already has keeps working. |
| | 47 | 85 | | var refusal = AccountEmailBudget.RefusalOf( |
| | 47 | 86 | | await AccountEmailBudget.TryReserveAsync(atomicWrites, person, emailOptions), |
| | 47 | 87 | | person, emailOptions, retryAfter); |
| | | 88 | | |
| | 47 | 89 | | if (refusal is not null) |
| | | 90 | | { |
| | 25 | 91 | | return output.WithError(refusal); |
| | | 92 | | } |
| | | 93 | | |
| | 22 | 94 | | var now = DateTime.UtcNow; |
| | | 95 | | |
| | | 96 | | // UC-15 step 3: retire every live link, so the new one is the only one that works. |
| | 22 | 97 | | var retirement = await EmailVerificationTokenRetirement.RetireLiveAsync( |
| | 22 | 98 | | tokenReader, tokenWriter, person.Id, now); |
| | | 99 | | |
| | 22 | 100 | | if (retirement is not null) |
| | | 101 | | { |
| | 0 | 102 | | return output.WithErrors(retirement); |
| | | 103 | | } |
| | | 104 | | |
| | | 105 | | // UC-15 steps 4 and 5 (FR-EV-04). A delivery failure never reaches here: MailgunSender logs |
| | | 106 | | // and swallows it so UC-12's AF-12a cannot become an enumeration oracle. The token is |
| | | 107 | | // persisted before delivery is attempted, so a caller who receives nothing calls this endpoint |
| | | 108 | | // again — which is the whole point of the use case. |
| | 22 | 109 | | await emailVerification.IssueAndSendAsync(person); |
| | | 110 | | |
| | | 111 | | // UC-15 step 6. |
| | 22 | 112 | | return output |
| | 22 | 113 | | .WithData(new ResendVerificationEmailCommandOutput()) |
| | 22 | 114 | | .WithMessage(AuthMessages.VerificationEmailSent); |
| | 52 | 115 | | } |
| | | 116 | | } |