| | | 1 | | using ArturRios.Data.Relational.Core.Interfaces; |
| | | 2 | | using ArturRios.Heimdall.Domain.Entities; |
| | | 3 | | using ArturRios.Heimdall.Domain.Enums; |
| | | 4 | | using Microsoft.EntityFrameworkCore; |
| | | 5 | | using Microsoft.Extensions.Logging; |
| | | 6 | | |
| | | 7 | | namespace ArturRios.Heimdall.Command.Services; |
| | | 8 | | |
| | | 9 | | /// <summary> |
| | | 10 | | /// UC-12 steps 2 to 4, run in the background for a <see cref="PasswordRecoveryRequest" />: finds |
| | | 11 | | /// the person by the lookup their role implies, and issues and mails them a reset token if they |
| | | 12 | | /// could use one (FR-PR-01/02). AF-12a — nobody holds the address — is the absence of that work. |
| | | 13 | | /// </summary> |
| | | 14 | | /// <remarks> |
| | | 15 | | /// <para> |
| | | 16 | | /// <b>Why this is not on the request path.</b> The answer UC-12 gives is the same on every |
| | | 17 | | /// path, but the time it took was not: a registered address waited for a token insert and a |
| | | 18 | | /// Mailgun round trip, an unknown one returned at once. That difference is readable from |
| | | 19 | | /// outside and answers the question the uniform answer exists to refuse. The handler now only |
| | | 20 | | /// queues the request, so the request path does the same work whoever the address belongs |
| | | 21 | | /// to, and the lookup, the insert and the send all happen here, after the caller has their |
| | | 22 | | /// answer. |
| | | 23 | | /// </para> |
| | | 24 | | /// <para> |
| | | 25 | | /// Run in its own DI scope per request (<c>PasswordRecoveryDispatcher</c>), so the database |
| | | 26 | | /// context and the Mailgun client it uses belong to this work and not to an HTTP request that |
| | | 27 | | /// has already completed. |
| | | 28 | | /// </para> |
| | | 29 | | /// </remarks> |
| | 47 | 30 | | public class PasswordRecoveryProcessor( |
| | 47 | 31 | | IAsyncReadOnlyRepository<Person, long> personReader, |
| | 47 | 32 | | IAsyncReadOnlyRepository<PasswordResetToken, long> tokenReader, |
| | 47 | 33 | | IPasswordResetService passwordReset, |
| | 47 | 34 | | PasswordResetOptions resetOptions, |
| | 47 | 35 | | AdminEmailOptions emailOptions, |
| | 47 | 36 | | ILogger<PasswordRecoveryProcessor> logger) |
| | | 37 | | { |
| | | 38 | | public async Task ProcessAsync(PasswordRecoveryRequest request) |
| | | 39 | | { |
| | | 40 | | // UC-12 step 2. As in UC-11, the lookup omits an !IsDeleted filter so a deleted person is |
| | | 41 | | // found and then declined below, rather than being indistinguishable from a missing row for |
| | | 42 | | // the wrong reason. |
| | 47 | 43 | | var person = await FindPersonAsync(request); |
| | | 44 | | |
| | | 45 | | // UC-12 steps 3 and 4. Skipped entirely for AF-12a and for accounts that could not log in |
| | | 46 | | // anyway — sending a reset link to a deleted person's address would restore nothing. |
| | 47 | 47 | | if (person is null || PasswordRecoveryEligibility.Of(person) != RecoveryEligibility.Eligible) |
| | | 48 | | { |
| | 15 | 49 | | return; |
| | | 50 | | } |
| | | 51 | | |
| | | 52 | | // FR-PR-07 (AF-12b): one reset link per person per cooldown, whoever asks — asked exactly as |
| | | 53 | | // UC-52 asks it, so a link either sent counts. The caller was answered long ago, so a request |
| | | 54 | | // inside it simply sends nothing, and the link already sent keeps working: the cooldown is |
| | | 55 | | // held to the link's lifetime, so it never outlasts the last link sent. Requests are worked one |
| | | 56 | | // at a time per instance, so only a second instance could overlap one. No daily cap: recovery |
| | | 57 | | // is anonymous, and a cap anyone could spend would deny the owner their own recovery. |
| | 32 | 58 | | var cooldown = emailOptions.CappedAt(resetOptions.TokenLifetime); |
| | | 59 | | |
| | 32 | 60 | | if (await cooldown.IsRunningAsync(tokenReader, person.Id, resetOptions.TokenLifetime, DateTime.UtcNow)) |
| | | 61 | | { |
| | | 62 | | // No address in the log (NFR-22). |
| | 2 | 63 | | logger.LogInformation("A password reset was not sent: one went to the person within the cooldown"); |
| | | 64 | | |
| | 2 | 65 | | return; |
| | | 66 | | } |
| | | 67 | | |
| | 30 | 68 | | await passwordReset.IssueAndSendAsync(person); |
| | 46 | 69 | | } |
| | | 70 | | |
| | | 71 | | /// <summary> |
| | | 72 | | /// UC-12 step 2, the same role-driven lookup as UC-11: a <c>User</c> is sought within the |
| | | 73 | | /// scope the request names, since their email is only unique there; a <c>ScopeAdmin</c> or |
| | | 74 | | /// <c>SystemAdmin</c> system-wide among admins. Emails are compared case-insensitively |
| | | 75 | | /// (LOWER() in SQL), matching how uniqueness is enforced when a person is created. |
| | | 76 | | /// </summary> |
| | | 77 | | private async Task<Person?> FindPersonAsync(PasswordRecoveryRequest request) |
| | | 78 | | { |
| | 47 | 79 | | var email = request.Email.ToLower(); |
| | | 80 | | |
| | 47 | 81 | | var query = personReader.Query() |
| | 47 | 82 | | .Include(person => person.ScopeMembership) |
| | 47 | 83 | | .ThenInclude(membership => membership!.Scope) |
| | 47 | 84 | | .Include(person => person.ScopeOwnerships) |
| | 47 | 85 | | .ThenInclude(ownership => ownership.Scope) |
| | 47 | 86 | | // Live persons first. Email uniqueness (FR-PE-09) holds among live persons only, so a |
| | 47 | 87 | | // logically deleted person awaiting anonymisation can share an address with the live |
| | 47 | 88 | | // person who replaced them. Without an order the database chose between the two, and |
| | 47 | 89 | | // choosing the deleted one refused the live person for as long as the other remained. |
| | 47 | 90 | | // A deleted person is still found when nobody live holds the address, which is what |
| | 47 | 91 | | // lets the deleted-person check reject them. |
| | 47 | 92 | | .OrderBy(person => person.IsDeleted); |
| | | 93 | | |
| | 47 | 94 | | if (request.ScopeId is null) |
| | | 95 | | { |
| | 41 | 96 | | return await query.FirstOrDefaultAsync(person => |
| | 41 | 97 | | person.Email.ToLower() == email && |
| | 41 | 98 | | (person.RoleId == (long)Roles.SystemAdmin || person.RoleId == (long)Roles.ScopeAdmin)); |
| | | 99 | | } |
| | | 100 | | |
| | 6 | 101 | | return await query.FirstOrDefaultAsync(person => |
| | 6 | 102 | | person.Email.ToLower() == email && |
| | 6 | 103 | | person.RoleId == (long)Roles.User && |
| | 6 | 104 | | person.ScopeMembership != null && |
| | 6 | 105 | | person.ScopeMembership.Scope.PublicId == request.ScopeId); |
| | 47 | 106 | | } |
| | | 107 | | } |