< Summary - Heimdall API

Information
Class: ArturRios.Heimdall.Command.Services.PendingRegistrationConfirmer
Assembly: ArturRios.Heimdall.Command
File(s): /home/runner/work/heimdall-api/heimdall-api/src/Application/ArturRios.Heimdall.Command/Services/PendingRegistrationConfirmer.cs
Line coverage
92%
Covered lines: 73
Uncovered lines: 6
Coverable lines: 79
Total lines: 236
Line coverage: 92.4%
Branch coverage
90%
Covered branches: 36
Total branches: 40
Branch coverage: 90%
Method coverage
100%
Covered methods: 5
Fully covered methods: 3
Total methods: 5
Method coverage: 100%
Full method coverage: 60%

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor(...)100%11100%
ConfirmAsync()90%313091.66%
DiscardAsync()100%44100%
AddressTakenAsync()100%22100%
RemoveOtherPendingAsync()75%4483.33%

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
 16public 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>
 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>
 16265public class PendingRegistrationConfirmer(
 16266    IAsyncReadOnlyRepository<PendingRegistration, long> pendingReader,
 16267    IAsyncRepository<PendingRegistration, long> pendingWriter,
 16268    IAsyncReadOnlyRepository<Person, long> personReader,
 16269    IAsyncReadOnlyRepository<GoogleUser, long> googleUserReader,
 16270    IAsyncRepository<Person, long> personWriter,
 16271    IAtomicWrites atomicWrites,
 16272    ILogger<PendingRegistrationConfirmer> logger)
 73    : IPendingRegistrationConfirmer
 74{
 75    public async Task<PendingRegistrationConfirmation> ConfirmAsync(
 76        string tokenHash, string? password, DateTime now)
 77    {
 13878        var pending = await pendingReader.Query()
 13879            .Include(x => x.Scope)
 13880            .FirstOrDefaultAsync(x => x.TokenHash == tokenHash);
 81
 13882        if (pending is null)
 83        {
 7684            return PendingRegistrationConfirmation.Of(PendingRegistrationOutcome.NotFound);
 85        }
 86
 6287        if (pending.ExpiresAt <= now)
 88        {
 489            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).
 5894        if (string.IsNullOrEmpty(password))
 95        {
 496            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.
 54103        if (!await atomicWrites.TryChargePendingRegistrationGuessAsync(pending, PendingRegistration.MaxConfirmationGuess
 104        {
 7105            await DiscardAsync(pending);
 106
 7107            return PendingRegistrationConfirmation.Of(PendingRegistrationOutcome.Refused);
 108        }
 109
 47110        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.
 32116            if (await pendingReader.Query().AnyAsync(x => x.Id == pending.Id && x.FailedAttempts >= PendingRegistration.
 117            {
 4118                await DiscardAsync(pending);
 119            }
 120
 32121            return PendingRegistrationConfirmation.Of(PendingRegistrationOutcome.PasswordIncorrect);
 122        }
 123
 15124        var claim = await pendingWriter.DeleteRangeAsync([pending.Id]);
 125
 15126        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.
 1131            if (claim.Errors.Contains(RelationalErrors.ConcurrencyMessage))
 132            {
 1133                return PendingRegistrationConfirmation.Of(PendingRegistrationOutcome.AlreadyClaimed);
 134            }
 135
 0136            logger.LogError("A pending registration could not be claimed: {Errors}", string.Join(" | ", claim.Errors));
 0137            return new(PendingRegistrationOutcome.Failed, claim.Errors.ToList());
 138        }
 139
 14140        if ((claim.Data?.Count() ?? 0) == 0)
 141        {
 1142            return PendingRegistrationConfirmation.Of(PendingRegistrationOutcome.AlreadyClaimed);
 143        }
 144
 13145        var scope = pending.Scope;
 146
 13147        if (scope.IsDeleted || !scope.SelfRegistrationEnabled || await AddressTakenAsync(pending))
 148        {
 4149            return PendingRegistrationConfirmation.Of(PendingRegistrationOutcome.Refused);
 150        }
 151
 9152        var person = new Person
 9153        {
 9154            Name = pending.Name,
 9155            Email = pending.Email,
 9156            PasswordHash = pending.PasswordHash,
 9157            Salt = pending.Salt,
 9158            RoleId = (long)Roles.User,
 9159            EmailVerified = true,
 9160            ScopeMembership = new ScopeUser { ScopeId = scope.Id },
 9161            ScopeId = scope.Id
 9162        };
 163
 9164        LegalBasisRecorder.Record(person, scope, now);
 165
 9166        var creation = await personWriter.CreateAsync(person);
 167
 9168        if (!creation.Success)
 169        {
 1170            if (creation.Errors.Contains(RelationalErrors.UniqueViolationMessage))
 171            {
 1172                return PendingRegistrationConfirmation.Of(PendingRegistrationOutcome.Refused);
 173            }
 174
 0175            logger.LogError("A confirmed registration could not be stored: {Errors}", string.Join(" | ", creation.Errors
 0176            return new(PendingRegistrationOutcome.Failed, creation.Errors.ToList());
 177        }
 178
 8179        await RemoveOtherPendingAsync(pending);
 180
 8181        return PendingRegistrationConfirmation.Of(PendingRegistrationOutcome.Confirmed);
 138182    }
 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    {
 11192        var removal = await pendingWriter.DeleteRangeAsync([pending.Id]);
 193
 11194        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.
 1197            logger.LogError("A pending registration with no guesses left could not be discarded");
 198        }
 11199    }
 200
 201    private async Task<bool> AddressTakenAsync(PendingRegistration pending)
 202    {
 11203        var email = pending.Email.ToLower();
 204
 11205        return await personReader.Query().AnyAsync(person =>
 11206                   !person.IsDeleted && person.Email.ToLower() == email &&
 11207                   person.ScopeMembership != null && person.ScopeMembership.ScopeId == pending.ScopeId) ||
 11208               await googleUserReader.Query().AnyAsync(googleUser =>
 11209                   googleUser.ScopeId == pending.ScopeId && googleUser.Email.ToLower() == email);
 11210    }
 211
 212    private async Task RemoveOtherPendingAsync(PendingRegistration confirmed)
 213    {
 8214        var email = confirmed.Email.ToLower();
 215
 8216        var others = await pendingReader.Query()
 8217            .Where(x => x.ScopeId == confirmed.ScopeId && x.Email.ToLower() == email)
 8218            .Select(x => x.Id)
 8219            .ToListAsync();
 220
 8221        if (others.Count == 0)
 222        {
 7223            return;
 224        }
 225
 1226        var removal = await pendingWriter.DeleteRangeAsync(others);
 227
 1228        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.
 0232            logger.LogWarning("Superseded pending registrations could not be removed: {Errors}",
 0233                string.Join(" | ", removal.Errors));
 234        }
 8235    }
 236}