| | | 1 | | using ArturRios.Data.Relational.Core.Interfaces; |
| | | 2 | | using ArturRios.Data.Relational.Core.Repositories; |
| | | 3 | | using ArturRios.Heimdall.Domain.Entities; |
| | | 4 | | using ArturRios.Heimdall.Domain.Enums; |
| | | 5 | | using ArturRios.Heimdall.Domain.Persistence; |
| | | 6 | | using Microsoft.EntityFrameworkCore; |
| | | 7 | | using Microsoft.Extensions.Logging; |
| | | 8 | | |
| | | 9 | | namespace ArturRios.Heimdall.Command.Services; |
| | | 10 | | |
| | | 11 | | public enum PendingRegistrationOutcome |
| | | 12 | | { |
| | | 13 | | NotFound, Expired, AlreadyClaimed, Refused, Failed, Confirmed, PasswordRequired, PasswordIncorrect |
| | | 14 | | } |
| | | 15 | | |
| | | 16 | | public sealed record PendingRegistrationConfirmation( |
| | | 17 | | PendingRegistrationOutcome Outcome, IReadOnlyList<string> Errors) |
| | | 18 | | { |
| | | 19 | | public static PendingRegistrationConfirmation Of(PendingRegistrationOutcome outcome) => new(outcome, []); |
| | | 20 | | } |
| | | 21 | | |
| | | 22 | | /// <summary> |
| | | 23 | | /// UC-47 confirmation, reached through UC-14 when a presented token names a pending registration |
| | | 24 | | /// rather than a verification token: creates the verified <c>User</c> the registration described, |
| | | 25 | | /// once the caller also presents the password chosen at sign-up (spec §4.3a). |
| | | 26 | | /// </summary> |
| | | 27 | | public interface IPendingRegistrationConfirmer |
| | | 28 | | { |
| | | 29 | | Task<PendingRegistrationConfirmation> ConfirmAsync(string tokenHash, string? password, DateTime now); |
| | | 30 | | } |
| | | 31 | | |
| | | 32 | | /// <remarks> |
| | | 33 | | /// <para> |
| | | 34 | | /// The row is claimed by deleting it before anything is created, so of two requests carrying one |
| | | 35 | | /// token only one can create the person. The scope and the address are re-checked because either |
| | | 36 | | /// may have changed since the registration was queued; the per-scope unique index is the last |
| | | 37 | | /// guard against two instances confirming one address at once. |
| | | 38 | | /// </para> |
| | | 39 | | /// <para> |
| | | 40 | | /// The link alone does not confirm: the password chosen at sign-up must accompany it, so a |
| | | 41 | | /// squatter who registered someone else's address cannot be handed a working login by a scanner, a |
| | | 42 | | /// mistaken click or the wrong one of two identical emails. A missing or wrong password is refused |
| | | 43 | | /// after the expiry check and before the claim, so it consumes nothing and the registrant may try |
| | | 44 | | /// again. Each guess is charged against the row before it is compared, by one conditional write |
| | | 45 | | /// (<see cref="IAtomicWrites" />, NFR-27), so parallel guesses cannot outrun the budget of ten: |
| | | 46 | | /// the tenth wrong password discards the row, the answer for that attempt is still "incorrect", |
| | | 47 | | /// and the next attempt finds no row. A row found with the budget already spent is discarded |
| | | 48 | | /// without a comparison. |
| | | 49 | | /// </para> |
| | | 50 | | /// <para> |
| | | 51 | | /// With parallel requests the discard can come early: a wrong guess charged before the tenth may |
| | | 52 | | /// finish after it and, finding the stored count at ten, delete the row while the tenth guess, |
| | | 53 | | /// the correct one, is still being compared. That guess then finds the row gone at the claim |
| | | 54 | | /// and answers "already used" (<see cref="PendingRegistrationOutcome.AlreadyClaimed" />). It is |
| | | 55 | | /// the safe side of the race, since nothing is created and no more than ten guesses were ever |
| | | 56 | | /// compared, so the cap holds. |
| | | 57 | | /// </para> |
| | | 58 | | /// <para> |
| | | 59 | | /// Every failure is logged with a fixed message and no address (NFR-22), and this class |
| | | 60 | | /// throws none itself: the outcome is the caller's to turn into an answer. What can still surface |
| | | 61 | | /// is the <c>PasswordHashGateSaturatedException</c> of a saturated <c>PasswordHashGate</c> during |
| | | 62 | | /// the password check, which <c>PasswordHashSaturationFilter</c> answers with 503. |
| | | 63 | | /// </para> |
| | | 64 | | /// </remarks> |
| | 162 | 65 | | public class PendingRegistrationConfirmer( |
| | 162 | 66 | | IAsyncReadOnlyRepository<PendingRegistration, long> pendingReader, |
| | 162 | 67 | | IAsyncRepository<PendingRegistration, long> pendingWriter, |
| | 162 | 68 | | IAsyncReadOnlyRepository<Person, long> personReader, |
| | 162 | 69 | | IAsyncReadOnlyRepository<GoogleUser, long> googleUserReader, |
| | 162 | 70 | | IAsyncRepository<Person, long> personWriter, |
| | 162 | 71 | | IAtomicWrites atomicWrites, |
| | 162 | 72 | | ILogger<PendingRegistrationConfirmer> logger) |
| | | 73 | | : IPendingRegistrationConfirmer |
| | | 74 | | { |
| | | 75 | | public async Task<PendingRegistrationConfirmation> ConfirmAsync( |
| | | 76 | | string tokenHash, string? password, DateTime now) |
| | | 77 | | { |
| | 138 | 78 | | var pending = await pendingReader.Query() |
| | 138 | 79 | | .Include(x => x.Scope) |
| | 138 | 80 | | .FirstOrDefaultAsync(x => x.TokenHash == tokenHash); |
| | | 81 | | |
| | 138 | 82 | | if (pending is null) |
| | | 83 | | { |
| | 76 | 84 | | return PendingRegistrationConfirmation.Of(PendingRegistrationOutcome.NotFound); |
| | | 85 | | } |
| | | 86 | | |
| | 62 | 87 | | if (pending.ExpiresAt <= now) |
| | | 88 | | { |
| | 4 | 89 | | return PendingRegistrationConfirmation.Of(PendingRegistrationOutcome.Expired); |
| | | 90 | | } |
| | | 91 | | |
| | | 92 | | // Following the link is not enough: a scanner, a mistaken click or the wrong one of two identical |
| | | 93 | | // emails would otherwise create an account whose password the registrant chose (spec §4.3a, 2). |
| | 58 | 94 | | if (string.IsNullOrEmpty(password)) |
| | | 95 | | { |
| | 4 | 96 | | return PendingRegistrationConfirmation.Of(PendingRegistrationOutcome.PasswordRequired); |
| | | 97 | | } |
| | | 98 | | |
| | | 99 | | // The guess is paid for before it is compared, by the write that checks the budget (NFR-27): of |
| | | 100 | | // any number of simultaneous requests only as many reach the comparison as the budget allows. A |
| | | 101 | | // spent budget discards the row without comparing, so a right password is no better than a wrong |
| | | 102 | | // one once the guesses are gone. |
| | 54 | 103 | | if (!await atomicWrites.TryChargePendingRegistrationGuessAsync(pending, PendingRegistration.MaxConfirmationGuess |
| | | 104 | | { |
| | 7 | 105 | | await DiscardAsync(pending); |
| | | 106 | | |
| | 7 | 107 | | return PendingRegistrationConfirmation.Of(PendingRegistrationOutcome.Refused); |
| | | 108 | | } |
| | | 109 | | |
| | 47 | 110 | | if (!await PasswordHashGate.Shared.TextMatchesAsync(password, pending.PasswordHash, pending.Salt)) |
| | | 111 | | { |
| | | 112 | | // Asked of the database, not read off the instance: the instance holds the count as this |
| | | 113 | | // request first read it plus its own charge, which under parallel guesses is behind the |
| | | 114 | | // stored one. The count only rises, so whichever wrong guess finishes last sees ten and |
| | | 115 | | // discards the row, however the ten interleaved. |
| | 32 | 116 | | if (await pendingReader.Query().AnyAsync(x => x.Id == pending.Id && x.FailedAttempts >= PendingRegistration. |
| | | 117 | | { |
| | 4 | 118 | | await DiscardAsync(pending); |
| | | 119 | | } |
| | | 120 | | |
| | 32 | 121 | | return PendingRegistrationConfirmation.Of(PendingRegistrationOutcome.PasswordIncorrect); |
| | | 122 | | } |
| | | 123 | | |
| | 15 | 124 | | var claim = await pendingWriter.DeleteRangeAsync([pending.Id]); |
| | | 125 | | |
| | 15 | 126 | | if (!claim.Success) |
| | | 127 | | { |
| | | 128 | | // Another request deleted the row between this delete's re-read and its commit: EF reports the |
| | | 129 | | // lost race as a concurrency failure. It is the same outcome as finding the row already gone, |
| | | 130 | | // not a fault, so a double submit answers "already used" rather than a conflict. |
| | 1 | 131 | | if (claim.Errors.Contains(RelationalErrors.ConcurrencyMessage)) |
| | | 132 | | { |
| | 1 | 133 | | return PendingRegistrationConfirmation.Of(PendingRegistrationOutcome.AlreadyClaimed); |
| | | 134 | | } |
| | | 135 | | |
| | 0 | 136 | | logger.LogError("A pending registration could not be claimed: {Errors}", string.Join(" | ", claim.Errors)); |
| | 0 | 137 | | return new(PendingRegistrationOutcome.Failed, claim.Errors.ToList()); |
| | | 138 | | } |
| | | 139 | | |
| | 14 | 140 | | if ((claim.Data?.Count() ?? 0) == 0) |
| | | 141 | | { |
| | 1 | 142 | | return PendingRegistrationConfirmation.Of(PendingRegistrationOutcome.AlreadyClaimed); |
| | | 143 | | } |
| | | 144 | | |
| | 13 | 145 | | var scope = pending.Scope; |
| | | 146 | | |
| | 13 | 147 | | if (scope.IsDeleted || !scope.SelfRegistrationEnabled || await AddressTakenAsync(pending)) |
| | | 148 | | { |
| | 4 | 149 | | return PendingRegistrationConfirmation.Of(PendingRegistrationOutcome.Refused); |
| | | 150 | | } |
| | | 151 | | |
| | 9 | 152 | | var person = new Person |
| | 9 | 153 | | { |
| | 9 | 154 | | Name = pending.Name, |
| | 9 | 155 | | Email = pending.Email, |
| | 9 | 156 | | PasswordHash = pending.PasswordHash, |
| | 9 | 157 | | Salt = pending.Salt, |
| | 9 | 158 | | RoleId = (long)Roles.User, |
| | 9 | 159 | | EmailVerified = true, |
| | 9 | 160 | | ScopeMembership = new ScopeUser { ScopeId = scope.Id }, |
| | 9 | 161 | | ScopeId = scope.Id |
| | 9 | 162 | | }; |
| | | 163 | | |
| | 9 | 164 | | LegalBasisRecorder.Record(person, scope, now); |
| | | 165 | | |
| | 9 | 166 | | var creation = await personWriter.CreateAsync(person); |
| | | 167 | | |
| | 9 | 168 | | if (!creation.Success) |
| | | 169 | | { |
| | 1 | 170 | | if (creation.Errors.Contains(RelationalErrors.UniqueViolationMessage)) |
| | | 171 | | { |
| | 1 | 172 | | return PendingRegistrationConfirmation.Of(PendingRegistrationOutcome.Refused); |
| | | 173 | | } |
| | | 174 | | |
| | 0 | 175 | | logger.LogError("A confirmed registration could not be stored: {Errors}", string.Join(" | ", creation.Errors |
| | 0 | 176 | | return new(PendingRegistrationOutcome.Failed, creation.Errors.ToList()); |
| | | 177 | | } |
| | | 178 | | |
| | 8 | 179 | | await RemoveOtherPendingAsync(pending); |
| | | 180 | | |
| | 8 | 181 | | return PendingRegistrationConfirmation.Of(PendingRegistrationOutcome.Confirmed); |
| | 138 | 182 | | } |
| | | 183 | | |
| | | 184 | | /// <summary> |
| | | 185 | | /// Deletes a row whose guess budget is spent, so a leaked link cannot be used to guess the sign-up |
| | | 186 | | /// password without limit. The answer for the attempt does not depend on whether this succeeds: |
| | | 187 | | /// the budget is spent either way, so no further comparison is admitted. A delete that loses a |
| | | 188 | | /// race to another request's is the row already gone, not a fault. |
| | | 189 | | /// </summary> |
| | | 190 | | private async Task DiscardAsync(PendingRegistration pending) |
| | | 191 | | { |
| | 11 | 192 | | var removal = await pendingWriter.DeleteRangeAsync([pending.Id]); |
| | | 193 | | |
| | 11 | 194 | | if (!removal.Success && !removal.Errors.Contains(RelationalErrors.ConcurrencyMessage)) |
| | | 195 | | { |
| | | 196 | | // Fixed message, no address (NFR-22). The row stays, but it admits no further comparison. |
| | 1 | 197 | | logger.LogError("A pending registration with no guesses left could not be discarded"); |
| | | 198 | | } |
| | 11 | 199 | | } |
| | | 200 | | |
| | | 201 | | private async Task<bool> AddressTakenAsync(PendingRegistration pending) |
| | | 202 | | { |
| | 11 | 203 | | var email = pending.Email.ToLower(); |
| | | 204 | | |
| | 11 | 205 | | return await personReader.Query().AnyAsync(person => |
| | 11 | 206 | | !person.IsDeleted && person.Email.ToLower() == email && |
| | 11 | 207 | | person.ScopeMembership != null && person.ScopeMembership.ScopeId == pending.ScopeId) || |
| | 11 | 208 | | await googleUserReader.Query().AnyAsync(googleUser => |
| | 11 | 209 | | googleUser.ScopeId == pending.ScopeId && googleUser.Email.ToLower() == email); |
| | 11 | 210 | | } |
| | | 211 | | |
| | | 212 | | private async Task RemoveOtherPendingAsync(PendingRegistration confirmed) |
| | | 213 | | { |
| | 8 | 214 | | var email = confirmed.Email.ToLower(); |
| | | 215 | | |
| | 8 | 216 | | var others = await pendingReader.Query() |
| | 8 | 217 | | .Where(x => x.ScopeId == confirmed.ScopeId && x.Email.ToLower() == email) |
| | 8 | 218 | | .Select(x => x.Id) |
| | 8 | 219 | | .ToListAsync(); |
| | | 220 | | |
| | 8 | 221 | | if (others.Count == 0) |
| | | 222 | | { |
| | 7 | 223 | | return; |
| | | 224 | | } |
| | | 225 | | |
| | 1 | 226 | | var removal = await pendingWriter.DeleteRangeAsync(others); |
| | | 227 | | |
| | 1 | 228 | | if (!removal.Success) |
| | | 229 | | { |
| | | 230 | | // Harmless if it fails: the rows expire and are purged, and confirming one later is refused |
| | | 231 | | // because the address is now taken. |
| | 0 | 232 | | logger.LogWarning("Superseded pending registrations could not be removed: {Errors}", |
| | 0 | 233 | | string.Join(" | ", removal.Errors)); |
| | | 234 | | } |
| | 8 | 235 | | } |
| | | 236 | | } |