< Summary - Heimdall API

Information
Class: ArturRios.Heimdall.Command.Services.PendingRegistrationConfirmer
Assembly: ArturRios.Heimdall.Command
File(s): /home/ci/agent/workspace/heimdall-api-docs_main/app/src/Application/ArturRios.Heimdall.Command/Services/PendingRegistrationConfirmer.cs
Line coverage
91%
Covered lines: 86
Uncovered lines: 8
Coverable lines: 94
Total lines: 287
Line coverage: 91.4%
Branch coverage
90%
Covered branches: 38
Total branches: 42
Branch coverage: 90.4%
Method coverage
100%
Covered methods: 6
Fully covered methods: 4
Total methods: 6
Method coverage: 100%
Full method coverage: 66.6%

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor(...)100%11100%
ConfirmAsync()100%1414100%
ClaimAndCreateAsync()83.33%191887.09%
DiscardAsync()100%44100%
AddressTakenAsync()100%22100%
RemoveOtherPendingAsync()75%5468.42%

File(s)

/home/ci/agent/workspace/heimdall-api-docs_main/app/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///         <b>The claim stands only if the account is created</b> (#209). The claim, the re-checks and the
 41///         insert share one <see cref="IWriteTransactions" /> transaction, committed only once the person
 42///         exists, so a refusal — the scope closed, the address taken, the unique index — or a failed
 43///         insert puts the row back as it was, and the registrant can follow the link again before it
 44///         expires. A second request carrying the token waits on the first's claim and, once that
 45///         commits, finds the row gone. Removing the address's other pending registrations runs after
 46///         the commit and is best effort.
 47///     </para>
 48///     <para>
 49///         The link alone does not confirm: the password chosen at sign-up must accompany it, so a
 50///         squatter who registered someone else's address cannot be handed a working login by a scanner, a
 51///         mistaken click or the wrong one of two identical emails. A missing or wrong password is refused
 52///         after the expiry check and before the claim, so it consumes nothing and the registrant may try
 53///         again. Each guess is charged against the row before it is compared, by one conditional write
 54///         (<see cref="IAtomicWrites" />, NFR-27), so parallel guesses cannot outrun the budget of ten:
 55///         the tenth wrong password discards the row, the answer for that attempt is still "incorrect",
 56///         and the next attempt finds no row. A row found with the budget already spent is discarded
 57///         without a comparison.
 58///     </para>
 59///     <para>
 60///         With parallel requests the discard can come early: a wrong guess charged before the tenth may
 61///         finish after it and, finding the stored count at ten, delete the row while the tenth guess,
 62///         the correct one, is still being compared. That guess then finds the row gone at the claim
 63///         and answers "already used" (<see cref="PendingRegistrationOutcome.AlreadyClaimed" />). It is
 64///         the safe side of the race, since nothing is created and no more than ten guesses were ever
 65///         compared, so the cap holds.
 66///     </para>
 67///     <para>
 68///         Every failure is logged with a fixed message and no address (NFR-22), and this class
 69///         throws none itself: the outcome is the caller's to turn into an answer. What can still surface
 70///         is the <c>PasswordHashGateSaturatedException</c> of a saturated <c>PasswordHashGate</c> during
 71///         the password check, which <c>PasswordHashSaturationFilter</c> answers with 503.
 72///     </para>
 73/// </remarks>
 17174public class PendingRegistrationConfirmer(
 17175    IAsyncReadOnlyRepository<PendingRegistration, long> pendingReader,
 17176    IAsyncRepository<PendingRegistration, long> pendingWriter,
 17177    IAsyncReadOnlyRepository<Person, long> personReader,
 17178    IAsyncReadOnlyRepository<GoogleUser, long> googleUserReader,
 17179    IAsyncRepository<Person, long> personWriter,
 17180    IAtomicWrites atomicWrites,
 17181    IWriteTransactions transactions,
 17182    ILogger<PendingRegistrationConfirmer> logger)
 83    : IPendingRegistrationConfirmer
 84{
 85    public async Task<PendingRegistrationConfirmation> ConfirmAsync(
 86        string tokenHash, string? password, DateTime now)
 87    {
 14388        var pending = await pendingReader.Query()
 14389            .Include(x => x.Scope)
 14390            .FirstOrDefaultAsync(x => x.TokenHash == tokenHash);
 91
 14392        if (pending is null)
 93        {
 7794            return PendingRegistrationConfirmation.Of(PendingRegistrationOutcome.NotFound);
 95        }
 96
 6697        if (pending.ExpiresAt <= now)
 98        {
 499            return PendingRegistrationConfirmation.Of(PendingRegistrationOutcome.Expired);
 100        }
 101
 102        // Following the link is not enough: a scanner, a mistaken click or the wrong one of two identical
 103        // emails would otherwise create an account whose password the registrant chose (spec §4.3a, 2).
 62104        if (string.IsNullOrEmpty(password))
 105        {
 4106            return PendingRegistrationConfirmation.Of(PendingRegistrationOutcome.PasswordRequired);
 107        }
 108
 109        // The guess is paid for before it is compared, by the write that checks the budget (NFR-27): of
 110        // any number of simultaneous requests only as many reach the comparison as the budget allows. A
 111        // spent budget discards the row without comparing, so a right password is no better than a wrong
 112        // one once the guesses are gone.
 58113        if (!await atomicWrites.TryChargePendingRegistrationGuessAsync(pending, PendingRegistration.MaxConfirmationGuess
 114        {
 6115            await DiscardAsync(pending);
 116
 6117            return PendingRegistrationConfirmation.Of(PendingRegistrationOutcome.Refused);
 118        }
 119
 52120        if (!await PasswordHashGate.Shared.TextMatchesAsync(password, pending.PasswordHash, pending.Salt))
 121        {
 122            // Asked of the database, not read off the instance: the instance holds the count as this
 123            // request first read it plus its own charge, which under parallel guesses is behind the
 124            // stored one. The count only rises, so whichever wrong guess finishes last sees ten and
 125            // discards the row, however the ten interleaved.
 32126            if (await pendingReader.Query().AnyAsync(x => x.Id == pending.Id && x.FailedAttempts >= PendingRegistration.
 127            {
 5128                await DiscardAsync(pending);
 129            }
 130
 32131            return PendingRegistrationConfirmation.Of(PendingRegistrationOutcome.PasswordIncorrect);
 132        }
 133
 134        // Opened after the password check, so no row lock waits on Argon2. Every return before the
 135        // commit rolls the claim back.
 20136        await using (var transaction = await transactions.BeginAsync())
 137        {
 20138            var outcome = await ClaimAndCreateAsync(pending, now);
 139
 20140            if (outcome.Outcome != PendingRegistrationOutcome.Confirmed)
 141            {
 10142                return outcome;
 143            }
 144
 10145            await transaction.CommitAsync();
 146        }
 147
 10148        await RemoveOtherPendingAsync(pending);
 149
 10150        return PendingRegistrationConfirmation.Of(PendingRegistrationOutcome.Confirmed);
 143151    }
 152
 153    /// <summary>
 154    ///     Claims the row, re-checks the scope and the address, and creates the person, inside the
 155    ///     caller's transaction: anything but <see cref="PendingRegistrationOutcome.Confirmed" /> is for the
 156    ///     caller to roll back.
 157    /// </summary>
 158    private async Task<PendingRegistrationConfirmation> ClaimAndCreateAsync(PendingRegistration pending, DateTime now)
 159    {
 20160        var claim = await pendingWriter.DeleteRangeAsync([pending.Id]);
 161
 20162        if (!claim.Success)
 163        {
 164            // Another request deleted the row between this delete's re-read and its commit: EF reports the
 165            // lost race as a concurrency failure. It is the same outcome as finding the row already gone,
 166            // not a fault, so a double submit answers "already used" rather than a conflict.
 2167            if (claim.Errors.Contains(RelationalErrors.ConcurrencyMessage))
 168            {
 2169                return PendingRegistrationConfirmation.Of(PendingRegistrationOutcome.AlreadyClaimed);
 170            }
 171
 0172            logger.LogError("A pending registration could not be claimed: {Errors}", string.Join(" | ", claim.Errors));
 0173            return new(PendingRegistrationOutcome.Failed, claim.Errors.ToList());
 174        }
 175
 18176        if ((claim.Data?.Count() ?? 0) == 0)
 177        {
 1178            return PendingRegistrationConfirmation.Of(PendingRegistrationOutcome.AlreadyClaimed);
 179        }
 180
 17181        var scope = pending.Scope;
 182
 17183        if (scope.IsDeleted || !scope.SelfRegistrationEnabled || await AddressTakenAsync(pending))
 184        {
 5185            return PendingRegistrationConfirmation.Of(PendingRegistrationOutcome.Refused);
 186        }
 187
 12188        var person = new Person
 12189        {
 12190            Name = pending.Name,
 12191            Email = pending.Email,
 12192            PasswordHash = pending.PasswordHash,
 12193            Salt = pending.Salt,
 12194            RoleId = (long)Roles.User,
 12195            EmailVerified = true,
 12196            ScopeMembership = new ScopeUser { ScopeId = scope.Id },
 12197            ScopeId = scope.Id
 12198        };
 199
 12200        LegalBasisRecorder.Record(person, scope, now);
 201
 12202        var creation = await personWriter.CreateAsync(person);
 203
 12204        if (!creation.Success)
 205        {
 2206            if (creation.Errors.Contains(RelationalErrors.UniqueViolationMessage))
 207            {
 1208                return PendingRegistrationConfirmation.Of(PendingRegistrationOutcome.Refused);
 209            }
 210
 1211            logger.LogError("A confirmed registration could not be stored: {Errors}", string.Join(" | ", creation.Errors
 1212            return new(PendingRegistrationOutcome.Failed, creation.Errors.ToList());
 213        }
 214
 10215        return PendingRegistrationConfirmation.Of(PendingRegistrationOutcome.Confirmed);
 20216    }
 217
 218    /// <summary>
 219    ///     Deletes a row whose guess budget is spent, so a leaked link cannot be used to guess the sign-up
 220    ///     password without limit. The answer for the attempt does not depend on whether this succeeds:
 221    ///     the budget is spent either way, so no further comparison is admitted. A delete that loses a
 222    ///     race to another request's is the row already gone, not a fault.
 223    /// </summary>
 224    private async Task DiscardAsync(PendingRegistration pending)
 225    {
 11226        var removal = await pendingWriter.DeleteRangeAsync([pending.Id]);
 227
 11228        if (!removal.Success && !removal.Errors.Contains(RelationalErrors.ConcurrencyMessage))
 229        {
 230            // Fixed message, no address (NFR-22). The row stays, but it admits no further comparison.
 1231            logger.LogError("A pending registration with no guesses left could not be discarded");
 232        }
 11233    }
 234
 235    private async Task<bool> AddressTakenAsync(PendingRegistration pending)
 236    {
 15237        var email = pending.Email.ToLower();
 238
 15239        return await personReader.Query().AnyAsync(person =>
 15240                   !person.IsDeleted && person.Email.ToLower() == email &&
 15241                   person.ScopeMembership != null && person.ScopeMembership.ScopeId == pending.ScopeId) ||
 15242               await googleUserReader.Query().AnyAsync(googleUser =>
 15243                   googleUser.ScopeId == pending.ScopeId && googleUser.Email.ToLower() == email);
 15244    }
 245
 246    /// <remarks>
 247    ///     Harmless if it fails: the rows expire and are purged, and confirming one later is refused
 248    ///     because the address is now taken. So a failure is logged, never reported, and it runs in a
 249    ///     transaction of its own, which leaves nothing pending for the next save — the audit entry's —
 250    ///     when it fails.
 251    /// </remarks>
 252    private async Task RemoveOtherPendingAsync(PendingRegistration confirmed)
 253    {
 254        try
 255        {
 10256            var email = confirmed.Email.ToLower();
 257
 10258            var others = await pendingReader.Query()
 10259                .Where(x => x.ScopeId == confirmed.ScopeId && x.Email.ToLower() == email)
 10260                .Select(x => x.Id)
 10261                .ToListAsync();
 262
 10263            if (others.Count == 0)
 264            {
 9265                return;
 266            }
 267
 1268            await using var transaction = await transactions.BeginAsync();
 269
 1270            var removal = await pendingWriter.DeleteRangeAsync(others);
 271
 1272            if (!removal.Success)
 273            {
 0274                logger.LogWarning("Superseded pending registrations could not be removed: {Errors}",
 0275                    string.Join(" | ", removal.Errors));
 276
 0277                return;
 278            }
 279
 1280            await transaction.CommitAsync();
 1281        }
 0282        catch (Exception exception)
 283        {
 0284            logger.LogWarning(exception, "Superseded pending registrations could not be removed");
 0285        }
 10286    }
 287}