< Summary - Heimdall API

Information
Class: ArturRios.Heimdall.Data.Persistence.AtomicWrites
Assembly: ArturRios.Heimdall.Data
File(s): /home/runner/work/heimdall-api/heimdall-api/src/Infrastructure/ArturRios.Heimdall.Data/Persistence/AtomicWrites.cs
Line coverage
100%
Covered lines: 336
Uncovered lines: 0
Coverable lines: 336
Total lines: 655
Line coverage: 100%
Branch coverage
91%
Covered branches: 79
Total branches: 86
Branch coverage: 91.8%
Method coverage
100%
Covered methods: 35
Fully covered methods: 35
Total methods: 35
Method coverage: 100%
Full method coverage: 100%

Metrics

File(s)

/home/runner/work/heimdall-api/heimdall-api/src/Infrastructure/ArturRios.Heimdall.Data/Persistence/AtomicWrites.cs

#LineLine coverage
 1using System.Linq.Expressions;
 2using System.Runtime.CompilerServices;
 3using ArturRios.Heimdall.Data.Configuration;
 4using ArturRios.Heimdall.Domain.Entities;
 5using ArturRios.Heimdall.Domain.Persistence;
 6using Microsoft.EntityFrameworkCore;
 7
 8namespace ArturRios.Heimdall.Data.Persistence;
 9
 10/// <summary>
 11///     <see cref="IAtomicWrites" /> on EF Core's <c>ExecuteUpdate</c>: each method is a single
 12///     <c>UPDATE … WHERE</c> whose condition is the rule being enforced, and whose affected-row count
 13///     says whether this caller won.
 14/// </summary>
 15/// <remarks>
 16///     <para>
 17///         PostgreSQL makes the condition and the write one step: under READ COMMITTED, an
 18///         <c>UPDATE</c> that waits on another transaction's lock re-evaluates its <c>WHERE</c>
 19///         against the row as that transaction left it. So two requests spending the same code both
 20///         reach the row, one writes, and the other finds "unused" no longer true and writes nothing.
 21///         No row version is needed, and no table changes shape for it.
 22///     </para>
 23///     <para>
 24///         <c>ExecuteUpdate</c> bypasses the change tracker, which would leave the caller's tracked
 25///         instance stale — and a later save would write the stale values back over the atomic ones.
 26///         <see cref="Accept{T,TProperty}" /> sets the instance's current and original values to what
 27///         was written, so the tracker sees nothing to save.
 28///     </para>
 29/// </remarks>
 57030public sealed class AtomicWrites(AppDbContext context) : IAtomicWrites
 31{
 32    public async Task<bool> TryReserveLoginAttemptAsync(
 33        Person person, int maxAttempts, DateTime now, DateTime lockedOutUntil)
 34    {
 35        // One statement, so the attempt that reaches the limit is also the one that locks: every
 36        // right-hand side reads the row as it was before the update. A request that dies after this
 37        // returns — shed by the hash gate, or cut off by a database fault — has still left a lock that
 38        // expires, and not a counter at the limit that nothing could ever reset. A row already at or
 39        // past the limit with no lockout date (left by an older instance, or by a lowered limit) is
 40        // admitted too: the same >= test locks it and resets the counter, so it heals itself.
 9741        var reserved = await context.Persons
 9742            .Where(x => x.Id == person.Id &&
 9743                        ((x.FailedLoginAttempts < maxAttempts &&
 9744                          (x.LockedOutUntil == null || x.LockedOutUntil <= now)) ||
 9745                         (x.FailedLoginAttempts >= maxAttempts && x.LockedOutUntil == null)))
 19446            .ExecuteUpdateAsync(set => set
 19447                .SetProperty(x => x.LockedOutUntil,
 19448                    x => x.FailedLoginAttempts + 1 >= maxAttempts ? lockedOutUntil : x.LockedOutUntil)
 19449                .SetProperty(x => x.FailedLoginAttempts,
 19450                    x => x.FailedLoginAttempts + 1 >= maxAttempts ? 0 : x.FailedLoginAttempts + 1)) == 1;
 51
 9752        if (reserved)
 53        {
 9654            var reachedLimit = person.FailedLoginAttempts + 1 >= maxAttempts;
 55
 9656            if (reachedLimit)
 57            {
 358                Accept(person, x => x.LockedOutUntil, lockedOutUntil);
 59            }
 60
 9661            Accept(person, x => x.FailedLoginAttempts, reachedLimit ? 0 : person.FailedLoginAttempts + 1);
 62        }
 63
 9764        return reserved;
 9765    }
 66
 67    public async Task<bool> TryChargePendingRegistrationGuessAsync(PendingRegistration pending, int maxGuesses)
 68    {
 1469        var charged = await context.PendingRegistrations
 1470            .Where(x => x.Id == pending.Id && x.FailedAttempts < maxGuesses)
 2871            .ExecuteUpdateAsync(set => set.SetProperty(x => x.FailedAttempts, x => x.FailedAttempts + 1)) == 1;
 72
 1473        if (charged)
 74        {
 1075            Accept(pending, x => x.FailedAttempts, pending.FailedAttempts + 1);
 76        }
 77
 1478        return charged;
 1479    }
 80
 81    public async Task ClearLoginAttemptsAsync(Person person)
 82    {
 7983        await context.Persons
 7984            .Where(x => x.Id == person.Id)
 15885            .ExecuteUpdateAsync(set => set
 15886                .SetProperty(x => x.FailedLoginAttempts, 0)
 15887                .SetProperty(x => x.LockedOutUntil, (DateTime?)null));
 88
 7989        Accept(person, x => x.FailedLoginAttempts, 0);
 7990        Accept(person, x => x.LockedOutUntil, null);
 7991    }
 92
 93    public async Task<bool> TryReserveSecondFactorAttemptAsync(
 94        Person person, int maxAttempts, DateTime now, IReadOnlyList<DateTime> lockedOutUntil)
 95    {
 5196        ArgumentOutOfRangeException.ThrowIfZero(lockedOutUntil.Count, nameof(lockedOutUntil));
 97
 98        // The login reservation's shape, for the same reasons: the reservation that reaches the limit
 99        // is the one that locks, and a row at or past the limit with no live lock — none at all, or
 100        // one that has run out, as a lowered limit would leave — is admitted and locked, so it heals
 101        // itself rather than being refused for good. What differs
 102        // is the lock's length, which depends on how many locks came before — so it is chosen by the
 103        // stored lockout count, inside the same statement, rather than from the count this request
 104        // read: two requests that both read "one lock so far" cannot both set the second lock's end.
 51105        var reserved = await context.Persons
 51106            .Where(x => x.Id == person.Id &&
 51107                        ((x.FailedSecondFactorAttempts < maxAttempts &&
 51108                          (x.SecondFactorLockedOutUntil == null || x.SecondFactorLockedOutUntil <= now)) ||
 51109                         (x.FailedSecondFactorAttempts >= maxAttempts &&
 51110                          (x.SecondFactorLockedOutUntil == null || x.SecondFactorLockedOutUntil <= now))))
 102111            .ExecuteUpdateAsync(set => set
 102112                .SetProperty(x => x.SecondFactorLockedOutUntil, SecondFactorLockEnd(maxAttempts, lockedOutUntil))
 102113                .SetProperty(x => x.SecondFactorLockouts,
 102114                    x => x.FailedSecondFactorAttempts + 1 >= maxAttempts
 102115                        ? x.SecondFactorLockouts + 1
 102116                        : x.SecondFactorLockouts)
 102117                .SetProperty(x => x.FailedSecondFactorAttempts,
 102118                    x => x.FailedSecondFactorAttempts + 1 >= maxAttempts ? 0 : x.FailedSecondFactorAttempts + 1)) == 1;
 119
 51120        if (reserved)
 121        {
 46122            var reachedLimit = person.FailedSecondFactorAttempts + 1 >= maxAttempts;
 123
 46124            if (reachedLimit)
 125            {
 4126                Accept(person, x => x.SecondFactorLockedOutUntil, LockEndAfter(person.SecondFactorLockouts, lockedOutUnt
 4127                Accept(person, x => x.SecondFactorLockouts, person.SecondFactorLockouts + 1);
 128            }
 129
 46130            Accept(person, x => x.FailedSecondFactorAttempts, reachedLimit ? 0 : person.FailedSecondFactorAttempts + 1);
 131        }
 132        else
 133        {
 5134            await TouchAsync(person);
 135        }
 136
 51137        return reserved;
 51138    }
 139
 140    public async Task ClearSecondFactorAttemptsAsync(Person person)
 141    {
 28142        await context.Persons
 28143            .Where(x => x.Id == person.Id)
 56144            .ExecuteUpdateAsync(set => set
 56145                .SetProperty(x => x.FailedSecondFactorAttempts, 0)
 56146                .SetProperty(x => x.SecondFactorLockedOutUntil, (DateTime?)null)
 56147                .SetProperty(x => x.SecondFactorLockouts, 0));
 148
 28149        Accept(person, x => x.FailedSecondFactorAttempts, 0);
 28150        Accept(person, x => x.SecondFactorLockedOutUntil, null);
 28151        Accept(person, x => x.SecondFactorLockouts, 0);
 28152    }
 153
 154    public async Task<bool> TryReserveReauthenticationAsync(
 155        Person person, int maxFailures, DateTime now, DateTime windowStartedBy, DateTime pausedUntil)
 156    {
 157        // One statement, every right-hand side reading the row as it was. The window is open when it
 158        // started after windowStartedBy (a NULL start is no window, and compares false); the failure
 159        // counted here is then the window's next, or else the first of a new window. The failure that
 160        // reaches the limit pauses and closes the window in the same write, as the login reservation
 161        // locks, so a burst cannot each read "below the limit" and pass it together.
 105162        var reserved = await context.Persons
 105163            .Where(x => x.Id == person.Id &&
 105164                        (x.ReauthenticationPausedUntil == null || x.ReauthenticationPausedUntil <= now))
 210165            .ExecuteUpdateAsync(set => set
 210166                .SetProperty(x => x.ReauthenticationPausedUntil,
 210167                    x => (x.ReauthenticationWindowStartedAt > windowStartedBy
 210168                        ? x.FailedReauthenticationAttempts + 1
 210169                        : 1) >= maxFailures
 210170                        ? (DateTime?)pausedUntil
 210171                        : x.ReauthenticationPausedUntil)
 210172                .SetProperty(x => x.ReauthenticationWindowStartedAt,
 210173                    x => (x.ReauthenticationWindowStartedAt > windowStartedBy
 210174                        ? x.FailedReauthenticationAttempts + 1
 210175                        : 1) >= maxFailures
 210176                        ? null
 210177                        : x.ReauthenticationWindowStartedAt > windowStartedBy
 210178                            ? x.ReauthenticationWindowStartedAt
 210179                            : (DateTime?)now)
 210180                .SetProperty(x => x.FailedReauthenticationAttempts,
 210181                    x => (x.ReauthenticationWindowStartedAt > windowStartedBy
 210182                        ? x.FailedReauthenticationAttempts + 1
 210183                        : 1) >= maxFailures
 210184                        ? 0
 210185                        : x.ReauthenticationWindowStartedAt > windowStartedBy
 210186                            ? x.FailedReauthenticationAttempts + 1
 210187                            : 1)) == 1;
 188
 105189        if (reserved)
 190        {
 96191            var windowOpen = person.ReauthenticationWindowStartedAt > windowStartedBy;
 96192            var failures = windowOpen ? person.FailedReauthenticationAttempts + 1 : 1;
 96193            var paused = failures >= maxFailures;
 194
 96195            if (paused)
 196            {
 2197                Accept(person, x => x.ReauthenticationPausedUntil, pausedUntil);
 198            }
 199
 96200            Accept(person, x => x.ReauthenticationWindowStartedAt,
 96201                paused ? null : windowOpen ? person.ReauthenticationWindowStartedAt : now);
 96202            Accept(person, x => x.FailedReauthenticationAttempts, paused ? 0 : failures);
 203        }
 204        else
 205        {
 9206            await TouchAsync(person);
 207        }
 208
 105209        return reserved;
 105210    }
 211
 212    public async Task<AccountEmailReservation> TryReserveAccountEmailAsync(
 213        Person person, int maxPerWindow, DateTime now, DateTime lastSentBefore, DateTime windowEndsAt)
 214    {
 215        // One statement, every right-hand side reading the row as it was. Admitted only out of the
 216        // cooldown and under the window's count; an ended (or never opened) window starts again at
 217        // this send. Concurrent reservations queue on the row lock and re-read it, so a burst gets
 218        // exactly what is left — one inside a cooldown, at most the cap with none.
 55219        var reserved = await context.Persons
 55220            .Where(x => x.Id == person.Id &&
 55221                        (x.AccountEmailLastSentAt == null || x.AccountEmailLastSentAt <= lastSentBefore) &&
 55222                        (x.AccountEmailWindowEndsAt == null || x.AccountEmailWindowEndsAt <= now ||
 55223                         x.AccountEmailsSent < maxPerWindow))
 110224            .ExecuteUpdateAsync(set => set
 110225                .SetProperty(x => x.AccountEmailsSent,
 110226                    x => x.AccountEmailWindowEndsAt == null || x.AccountEmailWindowEndsAt <= now
 110227                        ? 1
 110228                        : x.AccountEmailsSent + 1)
 110229                .SetProperty(x => x.AccountEmailWindowEndsAt,
 110230                    x => x.AccountEmailWindowEndsAt == null || x.AccountEmailWindowEndsAt <= now
 110231                        ? (DateTime?)windowEndsAt
 110232                        : x.AccountEmailWindowEndsAt)
 110233                .SetProperty(x => x.AccountEmailLastSentAt, (DateTime?)now)) == 1;
 234
 55235        if (reserved)
 236        {
 26237            var windowOpen = person.AccountEmailWindowEndsAt > now;
 238
 26239            Accept(person, x => x.AccountEmailsSent, windowOpen ? person.AccountEmailsSent + 1 : 1);
 26240            Accept(person, x => x.AccountEmailWindowEndsAt, windowOpen ? person.AccountEmailWindowEndsAt : windowEndsAt)
 26241            Accept(person, x => x.AccountEmailLastSentAt, now);
 242
 26243            return AccountEmailReservation.Reserved;
 244        }
 245
 246        // Refused: read the row as it now is, to say which bound refused it and when it ends, and bring
 247        // the instance in line with it. The daily one is the more useful answer when both apply, since
 248        // waiting out the cooldown would not help. A row gone since it was loaded (a hard delete in
 249        // between) is refused too: nothing may be sent to a person who no longer exists.
 29250        var stored = await context.Persons
 29251            .AsNoTracking()
 29252            .Where(x => x.Id == person.Id)
 29253            .Select(x => new { x.AccountEmailsSent, x.AccountEmailWindowEndsAt, x.AccountEmailLastSentAt })
 29254            .SingleOrDefaultAsync();
 255
 29256        if (stored is null)
 257        {
 1258            return AccountEmailReservation.CoolingDown;
 259        }
 260
 28261        Accept(person, x => x.AccountEmailsSent, stored.AccountEmailsSent);
 28262        Accept(person, x => x.AccountEmailWindowEndsAt, stored.AccountEmailWindowEndsAt);
 28263        Accept(person, x => x.AccountEmailLastSentAt, stored.AccountEmailLastSentAt);
 264
 28265        return stored.AccountEmailWindowEndsAt > now && stored.AccountEmailsSent >= maxPerWindow
 28266            ? AccountEmailReservation.DailyLimitReached
 28267            : AccountEmailReservation.CoolingDown;
 55268    }
 269
 270    public async Task ClearAccountEmailsAsync(Person person)
 271    {
 7272        await context.Persons
 7273            .Where(x => x.Id == person.Id)
 14274            .ExecuteUpdateAsync(set => set
 14275                .SetProperty(x => x.AccountEmailsSent, 0)
 14276                .SetProperty(x => x.AccountEmailWindowEndsAt, (DateTime?)null)
 14277                .SetProperty(x => x.AccountEmailLastSentAt, (DateTime?)null));
 278
 7279        Accept(person, x => x.AccountEmailsSent, 0);
 7280        Accept(person, x => x.AccountEmailWindowEndsAt, null);
 7281        Accept(person, x => x.AccountEmailLastSentAt, null);
 7282    }
 283
 284    public async Task ClearReauthenticationAttemptsAsync(Person person)
 285    {
 62286        await context.Persons
 62287            .Where(x => x.Id == person.Id)
 124288            .ExecuteUpdateAsync(set => set
 124289                .SetProperty(x => x.FailedReauthenticationAttempts, 0)
 124290                .SetProperty(x => x.ReauthenticationWindowStartedAt, (DateTime?)null)
 124291                .SetProperty(x => x.ReauthenticationPausedUntil, (DateTime?)null));
 292
 62293        Accept(person, x => x.FailedReauthenticationAttempts, 0);
 62294        Accept(person, x => x.ReauthenticationWindowStartedAt, null);
 62295        Accept(person, x => x.ReauthenticationPausedUntil, null);
 62296    }
 297
 298    public async Task StartChallengeAsync(TwoFactorAuth configuration, Guid challengeId)
 299    {
 28300        await context.TwoFactorAuths
 28301            .Where(x => x.Id == configuration.Id)
 56302            .ExecuteUpdateAsync(set => set
 56303                .SetProperty(x => x.ChallengeId, challengeId)
 56304                .SetProperty(x => x.EmailCodeReissueCount, 0)
 56305                .SetProperty(x => x.ChallengeAttempts, 0));
 306
 28307        Accept(configuration, x => x.ChallengeId, challengeId);
 28308        Accept(configuration, x => x.EmailCodeReissueCount, 0);
 28309        Accept(configuration, x => x.ChallengeAttempts, 0);
 28310    }
 311
 312    public async Task<bool> TryChargeChallengeGuessAsync(TwoFactorAuth configuration, Guid challengeId, int maxGuesses)
 313    {
 38314        var charged = await context.TwoFactorAuths
 38315            .Where(x => x.Id == configuration.Id && x.ChallengeId == challengeId && x.ChallengeAttempts < maxGuesses)
 76316            .ExecuteUpdateAsync(set => set.SetProperty(x => x.ChallengeAttempts, x => x.ChallengeAttempts + 1)) == 1;
 317
 38318        if (charged)
 319        {
 36320            Accept(configuration, x => x.ChallengeAttempts, configuration.ChallengeAttempts + 1);
 321        }
 322
 38323        return charged;
 38324    }
 325
 326    public async Task ExpireChallengeIfExhaustedAsync(TwoFactorAuth configuration, Guid challengeId, int maxGuesses)
 327    {
 19328        var expired = await context.TwoFactorAuths
 19329            .Where(x => x.Id == configuration.Id && x.ChallengeId == challengeId && x.ChallengeAttempts >= maxGuesses)
 38330            .ExecuteUpdateAsync(set => set.SetProperty(x => x.ChallengeId, (Guid?)null)) == 1;
 331
 19332        if (expired)
 333        {
 2334            Accept(configuration, x => x.ChallengeId, null);
 335        }
 19336    }
 337
 338    public async Task<bool> TryRedeemChallengeAsync(TwoFactorAuth configuration, Guid challengeId)
 339    {
 21340        var redeemed = await context.TwoFactorAuths
 21341            .Where(x => x.Id == configuration.Id && x.ChallengeId == challengeId)
 42342            .ExecuteUpdateAsync(set => set.SetProperty(x => x.ChallengeId, (Guid?)null)) == 1;
 343
 21344        if (redeemed)
 345        {
 20346            Accept(configuration, x => x.ChallengeId, null);
 347        }
 348
 21349        return redeemed;
 21350    }
 351
 352    public async Task<bool> TryChargeEmailCodeReissueAsync(TwoFactorAuth configuration, Guid challengeId, int maxReissue
 353    {
 16354        var charged = await context.TwoFactorAuths
 16355            .Where(x => x.Id == configuration.Id && x.ChallengeId == challengeId && x.EmailCodeReissueCount < maxReissue
 32356            .ExecuteUpdateAsync(set => set.SetProperty(x => x.EmailCodeReissueCount, x => x.EmailCodeReissueCount + 1)) 
 357
 16358        if (charged)
 359        {
 16360            Accept(configuration, x => x.EmailCodeReissueCount, configuration.EmailCodeReissueCount + 1);
 361        }
 362
 16363        return charged;
 16364    }
 365
 366    public async Task<bool> TryAdvanceTotpStepAsync(TwoFactorAuth configuration, long timeStep, DateTime now)
 367    {
 15368        var advanced = await context.TwoFactorAuths
 15369            .Where(x => x.Id == configuration.Id &&
 15370                        (x.LastTotpTimeStepUsed == null || x.LastTotpTimeStepUsed < timeStep))
 30371            .ExecuteUpdateAsync(set => set
 30372                .SetProperty(x => x.LastTotpTimeStepUsed, timeStep)
 30373                .SetProperty(x => x.UpdatedAt, now)) == 1;
 374
 15375        if (advanced)
 376        {
 14377            Accept(configuration, x => x.LastTotpTimeStepUsed, timeStep);
 14378            Accept(configuration, x => x.UpdatedAt, now);
 379        }
 380
 15381        return advanced;
 15382    }
 383
 384    public async Task<bool> TryChargeEmailCodeGuessAsync(TwoFactorEmailCode code, int maxGuesses, DateTime now)
 385    {
 21386        var charged = await context.TwoFactorEmailCodes
 21387            .Where(x => x.Id == code.Id && !x.Used && x.ExpiresAt > now && x.FailedAttempts < maxGuesses)
 42388            .ExecuteUpdateAsync(set => set.SetProperty(x => x.FailedAttempts, x => x.FailedAttempts + 1)) == 1;
 389
 21390        if (charged)
 391        {
 16392            Accept(code, x => x.FailedAttempts, code.FailedAttempts + 1);
 393        }
 394
 21395        return charged;
 21396    }
 397
 398    public async Task RetireEmailCodeIfExhaustedAsync(TwoFactorEmailCode code, int maxGuesses)
 399    {
 7400        var retired = await context.TwoFactorEmailCodes
 7401            .Where(x => x.Id == code.Id && x.FailedAttempts >= maxGuesses)
 14402            .ExecuteUpdateAsync(set => set.SetProperty(x => x.Used, true)) == 1;
 403
 7404        if (retired)
 405        {
 5406            Accept(code, x => x.Used, true);
 407        }
 7408    }
 409
 410    public async Task<bool> TryConsumeEmailCodeAsync(TwoFactorEmailCode code)
 411    {
 7412        var consumed = await context.TwoFactorEmailCodes
 7413            .Where(x => x.Id == code.Id && !x.Used)
 14414            .ExecuteUpdateAsync(set => set.SetProperty(x => x.Used, true)) == 1;
 415
 7416        if (consumed)
 417        {
 7418            Accept(code, x => x.Used, true);
 419        }
 420
 7421        return consumed;
 7422    }
 423
 424    public async Task<bool> TryConsumeRecoveryCodeAsync(TwoFactorRecoveryCode code, DateTime usedAt)
 425    {
 20426        var consumed = await context.TwoFactorRecoveryCodes
 20427            .Where(x => x.Id == code.Id && !x.Used)
 40428            .ExecuteUpdateAsync(set => set
 40429                .SetProperty(x => x.Used, true)
 40430                .SetProperty(x => x.UsedAt, usedAt)) == 1;
 431
 20432        if (consumed)
 433        {
 17434            Accept(code, x => x.Used, true);
 17435            Accept(code, x => x.UsedAt, usedAt);
 436        }
 437
 20438        return consumed;
 20439    }
 440
 441    public async Task<bool> TryConsumePasswordResetTokenAsync(PasswordResetToken token, DateTime now)
 442    {
 14443        var consumed = await context.PasswordResetTokens
 14444            .Where(x => x.Id == token.Id && !x.Used && x.ExpiresAt > now)
 28445            .ExecuteUpdateAsync(set => set.SetProperty(x => x.Used, true)) == 1;
 446
 14447        if (consumed)
 448        {
 9449            Accept(token, x => x.Used, true);
 450        }
 451
 14452        return consumed;
 14453    }
 454
 455    public async Task<bool> TryConsumeEmailVerificationTokenAsync(EmailVerificationToken token, DateTime now)
 456    {
 15457        var consumed = await context.EmailVerificationTokens
 15458            .Where(x => x.Id == token.Id && !x.Used && x.ExpiresAt > now)
 30459            .ExecuteUpdateAsync(set => set.SetProperty(x => x.Used, true)) == 1;
 460
 15461        if (consumed)
 462        {
 8463            Accept(token, x => x.Used, true);
 464        }
 465
 15466        return consumed;
 15467    }
 468
 469    public async Task<bool> TrySetPasswordAsync(Person person, byte[] passwordHash, byte[] salt, DateTime now)
 470    {
 13471        var stamp = Guid.NewGuid();
 472
 13473        var written = await context.Persons
 13474            .Where(RedeemableAsRead(person))
 26475            .ExecuteUpdateAsync(set => set
 26476                .SetProperty(x => x.PasswordHash, passwordHash)
 26477                .SetProperty(x => x.Salt, salt)
 26478                .SetProperty(x => x.SecurityStamp, stamp)
 26479                .SetProperty(x => x.UpdatedAt, now)) == 1;
 480
 13481        if (written)
 482        {
 10483            Accept(person, x => x.PasswordHash, passwordHash);
 10484            Accept(person, x => x.Salt, salt);
 10485            Accept(person, x => x.SecurityStamp, stamp);
 10486            Accept(person, x => x.UpdatedAt, now);
 487        }
 488
 13489        return written;
 13490    }
 491
 492    public async Task ClearPasswordAsync(Person person)
 493    {
 7494        byte[] empty = [];
 495
 7496        await context.Persons
 7497            .Where(x => x.Id == person.Id)
 14498            .ExecuteUpdateAsync(set => set
 14499                .SetProperty(x => x.PasswordHash, empty)
 14500                .SetProperty(x => x.Salt, empty));
 501
 7502        Accept(person, x => x.PasswordHash, empty);
 7503        Accept(person, x => x.Salt, empty);
 7504    }
 505
 506    public async Task<bool> TryMarkEmailVerifiedAsync(Person person, DateTime now)
 507    {
 10508        var written = await context.Persons
 10509            .Where(RedeemableAsRead(person))
 20510            .ExecuteUpdateAsync(set => set
 20511                .SetProperty(x => x.EmailVerified, true)
 20512                .SetProperty(x => x.UpdatedAt, now)) == 1;
 513
 10514        if (written)
 515        {
 8516            Accept(person, x => x.EmailVerified, true);
 8517            Accept(person, x => x.UpdatedAt, now);
 518        }
 519
 10520        return written;
 10521    }
 522
 523    public async Task<bool> TryStampCredentialUseAsync(ApplicationCredential credential, DateTime now)
 524    {
 12525        var stamped = await context.ApplicationCredentials
 12526            .Where(x => x.Id == credential.Id)
 12527            .Where(ApplicationCredential.LiveAt(now))
 24528            .ExecuteUpdateAsync(set => set.SetProperty(x => x.LastUsedAt, now)) == 1;
 529
 12530        if (stamped)
 531        {
 11532            Accept(credential, x => x.LastUsedAt, now);
 533        }
 534
 12535        return stamped;
 12536    }
 537
 538    public async Task<bool> TryRevokeCredentialAsync(ApplicationCredential credential, DateTime revokedAt)
 539    {
 6540        var revoked = await context.ApplicationCredentials
 6541            .Where(x => x.Id == credential.Id && x.RevokedAt == null)
 12542            .ExecuteUpdateAsync(set => set.SetProperty(x => x.RevokedAt, revokedAt)) == 1;
 543
 6544        if (revoked)
 545        {
 4546            Accept(credential, x => x.RevokedAt, revokedAt);
 547        }
 548
 6549        return revoked;
 6550    }
 551
 552    public async Task RotateSecurityStampAsync(Person person)
 553    {
 39554        var stamp = Guid.NewGuid();
 555
 39556        await context.Persons
 39557            .Where(x => x.Id == person.Id)
 78558            .ExecuteUpdateAsync(set => set.SetProperty(x => x.SecurityStamp, stamp));
 559
 39560        Accept(person, x => x.SecurityStamp, stamp);
 39561    }
 562
 563    /// <summary>
 564    ///     The row <paramref name="person" /> was read from, if it still holds the address it was
 565    ///     read with and is not deleted, restricted or anonymised: the condition a single-use token
 566    ///     is redeemed under (FR-PR-08, FR-EV-08). The address is captured now, so the comparison is
 567    ///     against the value the caller read, not whatever the row holds by the time it runs.
 568    /// </summary>
 569    private static Expression<Func<Person, bool>> RedeemableAsRead(Person person)
 570    {
 23571        var id = person.Id;
 23572        var email = person.Email;
 573
 23574        return x => x.Id == id && x.Email == email && !x.IsDeleted &&
 23575                    x.ProcessingRestrictedAt == null && x.AnonymisedAt == null;
 576    }
 577
 578    /// <summary>
 579    ///     Writes the person's row without changing it, after a factor reservation was refused.
 580    /// </summary>
 581    /// <remarks>
 582    ///     A refused reservation's <c>UPDATE</c> matches no row, so PostgreSQL assigns it no transaction
 583    ///     and flushes no commit, and it answers measurably sooner than an admitted one. That would
 584    ///     tell a caller the second factor is locked, or re-authentication paused, without a word of
 585    ///     the answer differing (FR-AU-12, FR-AU-13). Setting a column to itself writes a new row
 586    ///     version and commits like the admitted write does; what remains is one more round trip.
 587    /// </remarks>
 588    private Task TouchAsync(Person person) =>
 14589        context.Persons
 14590            .Where(x => x.Id == person.Id)
 28591            .ExecuteUpdateAsync(set => set.SetProperty(x => x.FailedSecondFactorAttempts, x => x.FailedSecondFactorAttem
 592
 593    /// <summary>
 594    ///     The value <see cref="TryReserveSecondFactorAttemptAsync" /> writes to the lock: unchanged,
 595    ///     unless this reservation reaches <paramref name="maxAttempts" />, and then the entry of
 596    ///     <paramref name="lockedOutUntil" /> the stored lockout count selects — a <c>CASE</c> over the
 597    ///     count, with the last entry for every count from its own on.
 598    /// </summary>
 599    /// <remarks>
 600    ///     Built as an expression tree because the number of entries is the caller's. Each end is read
 601    ///     from a box rather than inlined, so EF Core sends it as a parameter, and the statement — and
 602    ///     its cached plan — is the same on every call.
 603    /// </remarks>
 604    private static Expression<Func<Person, DateTime?>> SecondFactorLockEnd(
 605        int maxAttempts, IReadOnlyList<DateTime> lockedOutUntil)
 606    {
 51607        var row = Expression.Parameter(typeof(Person), "x");
 51608        var lockouts = Expression.Property(row, nameof(Person.SecondFactorLockouts));
 609
 51610        Expression end = Boxed(lockedOutUntil[^1]);
 611
 816612        for (var lockout = lockedOutUntil.Count - 2; lockout >= 0; lockout--)
 613        {
 357614            end = Expression.Condition(
 357615                Expression.Equal(lockouts, Expression.Constant(lockout)), Boxed(lockedOutUntil[lockout]), end);
 616        }
 617
 51618        var reachesLimit = Expression.GreaterThanOrEqual(
 51619            Expression.Add(Expression.Property(row, nameof(Person.FailedSecondFactorAttempts)), Expression.Constant(1)),
 51620            Boxed(maxAttempts));
 621
 51622        return Expression.Lambda<Func<Person, DateTime?>>(
 51623            Expression.Condition(reachesLimit, end, Expression.Property(row, nameof(Person.SecondFactorLockedOutUntil)))
 51624            row);
 625    }
 626
 627    /// <summary>The lock end <see cref="SecondFactorLockEnd" /> selects, for the instance.</summary>
 628    private static DateTime LockEndAfter(int lockouts, IReadOnlyList<DateTime> lockedOutUntil) =>
 4629        lockouts >= 0 && lockouts < lockedOutUntil.Count - 1 ? lockedOutUntil[lockouts] : lockedOutUntil[^1];
 630
 631    private static MemberExpression Boxed(DateTime value) =>
 408632        Expression.Field(Expression.Constant(new StrongBox<DateTime?>(value)), nameof(StrongBox<DateTime?>.Value));
 633
 634    private static MemberExpression Boxed(int value) =>
 51635        Expression.Field(Expression.Constant(new StrongBox<int>(value)), nameof(StrongBox<int>.Value));
 636
 637    /// <summary>
 638    ///     Brings the caller's instance in line with a write the change tracker did not see: the new
 639    ///     value becomes both current and original, so the property is not saved again later.
 640    /// </summary>
 641    private void Accept<T, TProperty>(T entity, Expression<Func<T, TProperty>> property, TProperty value)
 642        where T : class
 643    {
 1357644        var entry = context.Entry(entity);
 1357645        var member = entry.Property(property);
 646
 1357647        member.CurrentValue = value;
 648
 1357649        if (entry.State is EntityState.Unchanged or EntityState.Modified)
 650        {
 1357651            member.OriginalValue = value;
 1357652            member.IsModified = false;
 653        }
 1357654    }
 655}