| | | 1 | | using ArturRios.Data.Relational.Core.Interfaces; |
| | | 2 | | using ArturRios.Heimdall.Domain.Entities; |
| | | 3 | | using ArturRios.Heimdall.Domain.Persistence; |
| | | 4 | | using Microsoft.EntityFrameworkCore; |
| | | 5 | | using Microsoft.Extensions.Logging; |
| | | 6 | | |
| | | 7 | | namespace ArturRios.Heimdall.Command.Services; |
| | | 8 | | |
| | | 9 | | /// <summary> |
| | | 10 | | /// UC-47 steps 4 to 6, run in the background for a <see cref="RegistrationRequest" />: stores a |
| | | 11 | | /// <see cref="PendingRegistration" /> and mails its confirmation link when the address is free in the |
| | | 12 | | /// scope, or tells the address's holder of the attempt, at the address the holder registered with, |
| | | 13 | | /// when it is not (AF-47b). No <c>Person</c> is created here: the verified <c>User</c> exists only |
| | | 14 | | /// once the link is followed (UC-14). |
| | | 15 | | /// </summary> |
| | | 16 | | /// <remarks> |
| | | 17 | | /// <para> |
| | | 18 | | /// Off the request path for the reason <see cref="PasswordRecoveryProcessor" /> is: the lookup, |
| | | 19 | | /// the insert and the email take a different time depending on the answer, and the answer is |
| | | 20 | | /// what the uniform response exists to withhold. |
| | | 21 | | /// </para> |
| | | 22 | | /// <para> |
| | | 23 | | /// The scope is re-read and re-checked because it may have been closed or deleted while the |
| | | 24 | | /// request waited. The uniqueness rule is <see cref="Handlers.CreateUserCommandHandler" />'s: |
| | | 25 | | /// live persons of the scope and every Google User of the scope, compared case-insensitively. |
| | | 26 | | /// Pending registrations are not part of it: several may wait for one address, and the first |
| | | 27 | | /// link followed wins (the confirmer re-checks the rule when it creates the <c>User</c>). |
| | | 28 | | /// </para> |
| | | 29 | | /// <para> |
| | | 30 | | /// Each request can mail an address, and the caller never learns whether it did, so the mail |
| | | 31 | | /// one address can be sent this way is bounded here rather than by the caller: at most |
| | | 32 | | /// <see cref="PendingRegistration.MaxLivePerAddress" /> live registrations per scope and |
| | | 33 | | /// address, each with its one confirmation mail (FR-SR-13), and at most |
| | | 34 | | /// <see cref="AttemptNoticeBudget.MaxNotices" /> attempt notices per address a day, whichever |
| | | 35 | | /// scope the attempt named (FR-SR-14). A request beyond either creates and mails nothing. The |
| | | 36 | | /// notice budget is charged atomically; the registration count is read and then written, so |
| | | 37 | | /// two instances processing requests for one address at the same moment can each add one — |
| | | 38 | | /// the dispatcher takes one request at a time, so that takes a second instance. |
| | | 39 | | /// </para> |
| | | 40 | | /// <para> |
| | | 41 | | /// Every failure here is logged with a fixed message and no address (NFR-22), and none is thrown: |
| | | 42 | | /// the caller already has the uniform answer. |
| | | 43 | | /// </para> |
| | | 44 | | /// </remarks> |
| | 41 | 45 | | public class RegistrationProcessor( |
| | 41 | 46 | | IAsyncReadOnlyRepository<Scope, long> scopeReader, |
| | 41 | 47 | | IAsyncReadOnlyRepository<Person, long> personReader, |
| | 41 | 48 | | IAsyncReadOnlyRepository<GoogleUser, long> googleUserReader, |
| | 41 | 49 | | IAsyncReadOnlyRepository<PendingRegistration, long> pendingReader, |
| | 41 | 50 | | IAsyncRepository<PendingRegistration, long> pendingWriter, |
| | 41 | 51 | | IRegistrationLinkSender linkSender, |
| | 41 | 52 | | IRegistrationAttemptSender attemptSender, |
| | 41 | 53 | | IAttemptNoticeBudgets noticeBudgets, |
| | 41 | 54 | | EmailVerificationOptions options, |
| | 41 | 55 | | ILogger<RegistrationProcessor> logger) |
| | | 56 | | { |
| | | 57 | | public async Task ProcessAsync(RegistrationRequest request) |
| | | 58 | | { |
| | 50 | 59 | | var scope = await scopeReader.Query().FirstOrDefaultAsync(x => |
| | 50 | 60 | | x.PublicId == request.ScopeId && !x.IsDeleted && x.SelfRegistrationEnabled); |
| | | 61 | | |
| | 50 | 62 | | if (scope is null) |
| | | 63 | | { |
| | 2 | 64 | | return; |
| | | 65 | | } |
| | | 66 | | |
| | 48 | 67 | | var email = request.Email.ToLower(); |
| | | 68 | | |
| | 48 | 69 | | var holder = await personReader.Query().FirstOrDefaultAsync(person => |
| | 48 | 70 | | !person.IsDeleted && person.Email.ToLower() == email && |
| | 48 | 71 | | person.ScopeMembership != null && person.ScopeMembership.ScopeId == scope.Id); |
| | | 72 | | |
| | 48 | 73 | | if (holder is not null) |
| | | 74 | | { |
| | | 75 | | // NFR-24: a restricted identity is not mailed, not even to say someone tried. |
| | 16 | 76 | | if (holder.ProcessingRestrictedAt is null && await AdmitNoticeAsync(holder.Email)) |
| | | 77 | | { |
| | 11 | 78 | | await attemptSender.SendAsync(holder.Email, googleAccount: false); |
| | | 79 | | } |
| | | 80 | | |
| | 16 | 81 | | return; |
| | | 82 | | } |
| | | 83 | | |
| | 32 | 84 | | var googleHolder = await googleUserReader.Query().FirstOrDefaultAsync(googleUser => |
| | 32 | 85 | | googleUser.ScopeId == scope.Id && googleUser.Email.ToLower() == email); |
| | | 86 | | |
| | 32 | 87 | | if (googleHolder is not null) |
| | | 88 | | { |
| | | 89 | | // The address stays blocked either way (FR-GO-07); a deleted or restricted Google holder |
| | | 90 | | // is not told, since it could not act on it — or, if restricted, must not be processed. |
| | 6 | 91 | | if (!googleHolder.IsDeleted && googleHolder.ProcessingRestrictedAt is null && |
| | 6 | 92 | | await AdmitNoticeAsync(googleHolder.Email)) |
| | | 93 | | { |
| | 4 | 94 | | await attemptSender.SendAsync(googleHolder.Email, googleAccount: true); |
| | | 95 | | } |
| | | 96 | | |
| | 6 | 97 | | return; |
| | | 98 | | } |
| | | 99 | | |
| | 26 | 100 | | var token = SingleUseToken.New(); |
| | 26 | 101 | | var now = DateTime.UtcNow; |
| | | 102 | | |
| | | 103 | | // FR-SR-13: the address already has as many live registrations in the scope as it may. Those |
| | | 104 | | // links still work; this request adds no fourth mail. |
| | 26 | 105 | | var live = await pendingReader.Query().CountAsync(pending => |
| | 26 | 106 | | pending.ScopeId == scope.Id && pending.Email.ToLower() == email && pending.ExpiresAt > now); |
| | | 107 | | |
| | 26 | 108 | | if (live >= PendingRegistration.MaxLivePerAddress) |
| | | 109 | | { |
| | | 110 | | // No address in the log (NFR-22). |
| | 2 | 111 | | logger.LogInformation( |
| | 2 | 112 | | "A registration was not stored: its address already has {Live} live in the scope", live); |
| | | 113 | | |
| | 2 | 114 | | return; |
| | | 115 | | } |
| | | 116 | | |
| | 24 | 117 | | var creation = await pendingWriter.CreateAsync(new PendingRegistration |
| | 24 | 118 | | { |
| | 24 | 119 | | ScopeId = scope.Id, |
| | 24 | 120 | | Name = request.Name, |
| | 24 | 121 | | Email = request.Email, |
| | 24 | 122 | | PasswordHash = request.PasswordHash, |
| | 24 | 123 | | Salt = request.Salt, |
| | 24 | 124 | | TokenHash = SingleUseTokenHash.Of(token), |
| | 24 | 125 | | ExpiresAt = now.Add(options.TokenLifetime), |
| | 24 | 126 | | CreatedAt = now |
| | 24 | 127 | | }); |
| | | 128 | | |
| | 24 | 129 | | if (!creation.Success) |
| | | 130 | | { |
| | | 131 | | // No address in the log (NFR-22). The caller has their 202; this line is the only trace. |
| | 1 | 132 | | logger.LogError( |
| | 1 | 133 | | "A pending registration could not be stored: {Errors}", string.Join(" | ", creation.Errors)); |
| | | 134 | | |
| | 1 | 135 | | return; |
| | | 136 | | } |
| | | 137 | | |
| | | 138 | | // The link follows UC-14's route, which recognises the token as a pending one; the mail asks for |
| | | 139 | | // the sign-up password too. |
| | 23 | 140 | | await linkSender.SendAsync(request.Email, token, scope.EmailVerificationUrl); |
| | 50 | 141 | | } |
| | | 142 | | |
| | | 143 | | /// <summary> |
| | | 144 | | /// FR-SR-14: charges one attempt notice to <paramref name="address" />, and says whether it may |
| | | 145 | | /// be sent. Counted by the address's digest, so every scope the address holds an account in |
| | | 146 | | /// shares the one budget, as the inbox does. |
| | | 147 | | /// </summary> |
| | | 148 | | private async Task<bool> AdmitNoticeAsync(string address) |
| | | 149 | | { |
| | 19 | 150 | | var now = DateTime.UtcNow; |
| | | 151 | | |
| | 19 | 152 | | var admitted = await noticeBudgets.TryChargeAsync( |
| | 19 | 153 | | AttemptNoticeBudget.AddressHashOf(address), AttemptNoticeBudget.MaxNotices, now, |
| | 19 | 154 | | now + AttemptNoticeBudget.Window); |
| | | 155 | | |
| | 19 | 156 | | if (!admitted) |
| | | 157 | | { |
| | | 158 | | // No address in the log (NFR-22). |
| | 4 | 159 | | logger.LogInformation("A registration-attempt notice was withheld: the address has had its daily notices"); |
| | | 160 | | } |
| | | 161 | | |
| | 19 | 162 | | return admitted; |
| | 19 | 163 | | } |
| | | 164 | | } |