| | | 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 | | /// The scope is bounded too (#206): at most <see cref="RegistrationOptions.MaxPendingPerScope" /> live |
| | | 42 | | /// registrations, whatever their addresses, since enough source addresses outpace both the per-IP |
| | | 43 | | /// limit and the purge. A request at the cap creates and mails nothing, like one beyond FR-SR-13, and |
| | | 44 | | /// is logged at Warning with <see cref="CapReachedMarker" /> and the scope's id, because a scope |
| | | 45 | | /// that reaches it is being flooded, or has outgrown the cap, and either needs an operator. It is |
| | | 46 | | /// read and then written, as the address count is, so concurrent instances can overshoot it by one |
| | | 47 | | /// each. |
| | | 48 | | /// </para> |
| | | 49 | | /// <para> |
| | | 50 | | /// Every failure here is logged with a fixed message and no address (NFR-22), and none is thrown: |
| | | 51 | | /// the caller already has the uniform answer. |
| | | 52 | | /// </para> |
| | | 53 | | /// </remarks> |
| | 52 | 54 | | public class RegistrationProcessor( |
| | 52 | 55 | | IAsyncReadOnlyRepository<Scope, long> scopeReader, |
| | 52 | 56 | | IAsyncReadOnlyRepository<Person, long> personReader, |
| | 52 | 57 | | IAsyncReadOnlyRepository<GoogleUser, long> googleUserReader, |
| | 52 | 58 | | IAsyncReadOnlyRepository<PendingRegistration, long> pendingReader, |
| | 52 | 59 | | IAsyncRepository<PendingRegistration, long> pendingWriter, |
| | 52 | 60 | | IRegistrationLinkSender linkSender, |
| | 52 | 61 | | IRegistrationAttemptSender attemptSender, |
| | 52 | 62 | | IAttemptNoticeBudgets noticeBudgets, |
| | 52 | 63 | | EmailVerificationOptions options, |
| | 52 | 64 | | RegistrationOptions registration, |
| | 52 | 65 | | ILogger<RegistrationProcessor> logger) |
| | | 66 | | { |
| | | 67 | | /// <summary> |
| | | 68 | | /// The marker of the Warning logged when a scope's cap on live pending registrations turns a request |
| | | 69 | | /// away. Stable and greppable: an alert should match on it rather than on the prose. |
| | | 70 | | /// </summary> |
| | | 71 | | public const string CapReachedMarker = "REGISTRATION_CAP_REACHED"; |
| | | 72 | | |
| | | 73 | | public async Task ProcessAsync(RegistrationRequest request) |
| | | 74 | | { |
| | 61 | 75 | | var scope = await scopeReader.Query().FirstOrDefaultAsync(x => |
| | 61 | 76 | | x.PublicId == request.ScopeId && !x.IsDeleted && x.SelfRegistrationEnabled); |
| | | 77 | | |
| | 61 | 78 | | if (scope is null) |
| | | 79 | | { |
| | 2 | 80 | | return; |
| | | 81 | | } |
| | | 82 | | |
| | 59 | 83 | | var email = request.Email.ToLower(); |
| | | 84 | | |
| | 59 | 85 | | var holder = await personReader.Query().FirstOrDefaultAsync(person => |
| | 59 | 86 | | !person.IsDeleted && person.Email.ToLower() == email && |
| | 59 | 87 | | person.ScopeMembership != null && person.ScopeMembership.ScopeId == scope.Id); |
| | | 88 | | |
| | 59 | 89 | | if (holder is not null) |
| | | 90 | | { |
| | | 91 | | // NFR-24: a restricted identity is not mailed, not even to say someone tried. |
| | 16 | 92 | | if (holder.ProcessingRestrictedAt is null && await AdmitNoticeAsync(holder.Email)) |
| | | 93 | | { |
| | 11 | 94 | | await attemptSender.SendAsync(holder.Email, googleAccount: false); |
| | | 95 | | } |
| | | 96 | | |
| | 16 | 97 | | return; |
| | | 98 | | } |
| | | 99 | | |
| | 43 | 100 | | var googleHolder = await googleUserReader.Query().FirstOrDefaultAsync(googleUser => |
| | 43 | 101 | | googleUser.ScopeId == scope.Id && googleUser.Email.ToLower() == email); |
| | | 102 | | |
| | 43 | 103 | | if (googleHolder is not null) |
| | | 104 | | { |
| | | 105 | | // The address stays blocked either way (FR-GO-07); a deleted or restricted Google holder |
| | | 106 | | // is not told, since it could not act on it — or, if restricted, must not be processed. |
| | 6 | 107 | | if (!googleHolder.IsDeleted && googleHolder.ProcessingRestrictedAt is null && |
| | 6 | 108 | | await AdmitNoticeAsync(googleHolder.Email)) |
| | | 109 | | { |
| | 4 | 110 | | await attemptSender.SendAsync(googleHolder.Email, googleAccount: true); |
| | | 111 | | } |
| | | 112 | | |
| | 6 | 113 | | return; |
| | | 114 | | } |
| | | 115 | | |
| | 37 | 116 | | var token = SingleUseToken.New(); |
| | 37 | 117 | | var now = DateTime.UtcNow; |
| | | 118 | | |
| | | 119 | | // FR-SR-13: the address already has as many live registrations in the scope as it may. Those |
| | | 120 | | // links still work; this request adds no fourth mail. |
| | 37 | 121 | | var live = await pendingReader.Query().CountAsync(pending => |
| | 37 | 122 | | pending.ScopeId == scope.Id && pending.Email.ToLower() == email && pending.ExpiresAt > now); |
| | | 123 | | |
| | 37 | 124 | | if (live >= PendingRegistration.MaxLivePerAddress) |
| | | 125 | | { |
| | | 126 | | // No address in the log (NFR-22). |
| | 2 | 127 | | logger.LogInformation( |
| | 2 | 128 | | "A registration was not stored: its address already has {Live} live in the scope", live); |
| | | 129 | | |
| | 2 | 130 | | return; |
| | | 131 | | } |
| | | 132 | | |
| | | 133 | | // #206: the scope already holds as many live registrations as it may, whatever their addresses. |
| | 35 | 134 | | var liveInScope = await pendingReader.Query().CountAsync(pending => |
| | 35 | 135 | | pending.ScopeId == scope.Id && pending.ExpiresAt > now); |
| | | 136 | | |
| | 35 | 137 | | if (liveInScope >= registration.MaxPendingPerScope) |
| | | 138 | | { |
| | | 139 | | // The scope's id, never the address (NFR-22). |
| | 2 | 140 | | logger.LogWarning( |
| | 2 | 141 | | "{Marker} A registration was not stored: scope {ScopeId} already has {Live} live pending " + |
| | 2 | 142 | | "registrations, its cap", CapReachedMarker, scope.PublicId, liveInScope); |
| | | 143 | | |
| | 2 | 144 | | return; |
| | | 145 | | } |
| | | 146 | | |
| | 33 | 147 | | var creation = await pendingWriter.CreateAsync(new PendingRegistration |
| | 33 | 148 | | { |
| | 33 | 149 | | ScopeId = scope.Id, |
| | 33 | 150 | | Name = request.Name, |
| | 33 | 151 | | Email = request.Email, |
| | 33 | 152 | | PasswordHash = request.PasswordHash, |
| | 33 | 153 | | Salt = request.Salt, |
| | 33 | 154 | | TokenHash = SingleUseTokenHash.Of(token), |
| | 33 | 155 | | ExpiresAt = now.Add(options.TokenLifetime), |
| | 33 | 156 | | CreatedAt = now |
| | 33 | 157 | | }); |
| | | 158 | | |
| | 33 | 159 | | if (!creation.Success) |
| | | 160 | | { |
| | | 161 | | // No address in the log (NFR-22). The caller has their 202; this line is the only trace. |
| | 1 | 162 | | logger.LogError( |
| | 1 | 163 | | "A pending registration could not be stored: {Errors}", string.Join(" | ", creation.Errors)); |
| | | 164 | | |
| | 1 | 165 | | return; |
| | | 166 | | } |
| | | 167 | | |
| | | 168 | | // The link follows UC-14's route, which recognises the token as a pending one; the mail asks for |
| | | 169 | | // the sign-up password too. |
| | 32 | 170 | | await linkSender.SendAsync(request.Email, token, scope.EmailVerificationUrl); |
| | 60 | 171 | | } |
| | | 172 | | |
| | | 173 | | /// <summary> |
| | | 174 | | /// FR-SR-14: charges one attempt notice to <paramref name="address" />, and says whether it may |
| | | 175 | | /// be sent. Counted by the address's digest, so every scope the address holds an account in |
| | | 176 | | /// shares the one budget, as the inbox does. |
| | | 177 | | /// </summary> |
| | | 178 | | private async Task<bool> AdmitNoticeAsync(string address) |
| | | 179 | | { |
| | 19 | 180 | | var now = DateTime.UtcNow; |
| | | 181 | | |
| | 19 | 182 | | var admitted = await noticeBudgets.TryChargeAsync( |
| | 19 | 183 | | AttemptNoticeBudget.AddressHashOf(address), AttemptNoticeBudget.MaxNotices, now, |
| | 19 | 184 | | now + AttemptNoticeBudget.Window); |
| | | 185 | | |
| | 19 | 186 | | if (!admitted) |
| | | 187 | | { |
| | | 188 | | // No address in the log (NFR-22). |
| | 4 | 189 | | logger.LogInformation("A registration-attempt notice was withheld: the address has had its daily notices"); |
| | | 190 | | } |
| | | 191 | | |
| | 19 | 192 | | return admitted; |
| | 19 | 193 | | } |
| | | 194 | | } |