< Summary - Heimdall API

Information
Class: ArturRios.Heimdall.Command.Services.PendingRegistrationConfirmation
Assembly: ArturRios.Heimdall.Command
File(s): /home/runner/work/heimdall-api/heimdall-api/src/Application/ArturRios.Heimdall.Command/Services/PendingRegistrationConfirmer.cs
Line coverage
100%
Covered lines: 3
Uncovered lines: 0
Coverable lines: 3
Total lines: 236
Line coverage: 100%
Branch coverage
N/A
Covered branches: 0
Total branches: 0
Branch coverage: N/A
Method coverage
100%
Covered methods: 3
Fully covered methods: 3
Total methods: 3
Method coverage: 100%
Full method coverage: 100%

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor(...)100%11100%
get_Outcome()100%11100%
Of(...)100%11100%

File(s)

/home/runner/work/heimdall-api/heimdall-api/src/Application/ArturRios.Heimdall.Command/Services/PendingRegistrationConfirmer.cs

#LineLine coverage
 1using ArturRios.Data.Relational.Core.Interfaces;
 2using ArturRios.Data.Relational.Core.Repositories;
 3using ArturRios.Heimdall.Domain.Entities;
 4using ArturRios.Heimdall.Domain.Enums;
 5using ArturRios.Heimdall.Domain.Persistence;
 6using Microsoft.EntityFrameworkCore;
 7using Microsoft.Extensions.Logging;
 8
 9namespace ArturRios.Heimdall.Command.Services;
 10
 11public enum PendingRegistrationOutcome
 12{
 13    NotFound, Expired, AlreadyClaimed, Refused, Failed, Confirmed, PasswordRequired, PasswordIncorrect
 14}
 15
 16916public sealed record PendingRegistrationConfirmation(
 31617    PendingRegistrationOutcome Outcome, IReadOnlyList<string> Errors)
 18{
 13819    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>
 27public 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>
 65public class PendingRegistrationConfirmer(
 66    IAsyncReadOnlyRepository<PendingRegistration, long> pendingReader,
 67    IAsyncRepository<PendingRegistration, long> pendingWriter,
 68    IAsyncReadOnlyRepository<Person, long> personReader,
 69    IAsyncReadOnlyRepository<GoogleUser, long> googleUserReader,
 70    IAsyncRepository<Person, long> personWriter,
 71    IAtomicWrites atomicWrites,
 72    ILogger<PendingRegistrationConfirmer> logger)
 73    : IPendingRegistrationConfirmer
 74{
 75    public async Task<PendingRegistrationConfirmation> ConfirmAsync(
 76        string tokenHash, string? password, DateTime now)
 77    {
 78        var pending = await pendingReader.Query()
 79            .Include(x => x.Scope)
 80            .FirstOrDefaultAsync(x => x.TokenHash == tokenHash);
 81
 82        if (pending is null)
 83        {
 84            return PendingRegistrationConfirmation.Of(PendingRegistrationOutcome.NotFound);
 85        }
 86
 87        if (pending.ExpiresAt <= now)
 88        {
 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).
 94        if (string.IsNullOrEmpty(password))
 95        {
 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.
 103        if (!await atomicWrites.TryChargePendingRegistrationGuessAsync(pending, PendingRegistration.MaxConfirmationGuess
 104        {
 105            await DiscardAsync(pending);
 106
 107            return PendingRegistrationConfirmation.Of(PendingRegistrationOutcome.Refused);
 108        }
 109
 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.
 116            if (await pendingReader.Query().AnyAsync(x => x.Id == pending.Id && x.FailedAttempts >= PendingRegistration.
 117            {
 118                await DiscardAsync(pending);
 119            }
 120
 121            return PendingRegistrationConfirmation.Of(PendingRegistrationOutcome.PasswordIncorrect);
 122        }
 123
 124        var claim = await pendingWriter.DeleteRangeAsync([pending.Id]);
 125
 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.
 131            if (claim.Errors.Contains(RelationalErrors.ConcurrencyMessage))
 132            {
 133                return PendingRegistrationConfirmation.Of(PendingRegistrationOutcome.AlreadyClaimed);
 134            }
 135
 136            logger.LogError("A pending registration could not be claimed: {Errors}", string.Join(" | ", claim.Errors));
 137            return new(PendingRegistrationOutcome.Failed, claim.Errors.ToList());
 138        }
 139
 140        if ((claim.Data?.Count() ?? 0) == 0)
 141        {
 142            return PendingRegistrationConfirmation.Of(PendingRegistrationOutcome.AlreadyClaimed);
 143        }
 144
 145        var scope = pending.Scope;
 146
 147        if (scope.IsDeleted || !scope.SelfRegistrationEnabled || await AddressTakenAsync(pending))
 148        {
 149            return PendingRegistrationConfirmation.Of(PendingRegistrationOutcome.Refused);
 150        }
 151
 152        var person = new Person
 153        {
 154            Name = pending.Name,
 155            Email = pending.Email,
 156            PasswordHash = pending.PasswordHash,
 157            Salt = pending.Salt,
 158            RoleId = (long)Roles.User,
 159            EmailVerified = true,
 160            ScopeMembership = new ScopeUser { ScopeId = scope.Id },
 161            ScopeId = scope.Id
 162        };
 163
 164        LegalBasisRecorder.Record(person, scope, now);
 165
 166        var creation = await personWriter.CreateAsync(person);
 167
 168        if (!creation.Success)
 169        {
 170            if (creation.Errors.Contains(RelationalErrors.UniqueViolationMessage))
 171            {
 172                return PendingRegistrationConfirmation.Of(PendingRegistrationOutcome.Refused);
 173            }
 174
 175            logger.LogError("A confirmed registration could not be stored: {Errors}", string.Join(" | ", creation.Errors
 176            return new(PendingRegistrationOutcome.Failed, creation.Errors.ToList());
 177        }
 178
 179        await RemoveOtherPendingAsync(pending);
 180
 181        return PendingRegistrationConfirmation.Of(PendingRegistrationOutcome.Confirmed);
 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    {
 192        var removal = await pendingWriter.DeleteRangeAsync([pending.Id]);
 193
 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.
 197            logger.LogError("A pending registration with no guesses left could not be discarded");
 198        }
 199    }
 200
 201    private async Task<bool> AddressTakenAsync(PendingRegistration pending)
 202    {
 203        var email = pending.Email.ToLower();
 204
 205        return await personReader.Query().AnyAsync(person =>
 206                   !person.IsDeleted && person.Email.ToLower() == email &&
 207                   person.ScopeMembership != null && person.ScopeMembership.ScopeId == pending.ScopeId) ||
 208               await googleUserReader.Query().AnyAsync(googleUser =>
 209                   googleUser.ScopeId == pending.ScopeId && googleUser.Email.ToLower() == email);
 210    }
 211
 212    private async Task RemoveOtherPendingAsync(PendingRegistration confirmed)
 213    {
 214        var email = confirmed.Email.ToLower();
 215
 216        var others = await pendingReader.Query()
 217            .Where(x => x.ScopeId == confirmed.ScopeId && x.Email.ToLower() == email)
 218            .Select(x => x.Id)
 219            .ToListAsync();
 220
 221        if (others.Count == 0)
 222        {
 223            return;
 224        }
 225
 226        var removal = await pendingWriter.DeleteRangeAsync(others);
 227
 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.
 232            logger.LogWarning("Superseded pending registrations could not be removed: {Errors}",
 233                string.Join(" | ", removal.Errors));
 234        }
 235    }
 236}