< Summary - Heimdall API

Information
Class: ArturRios.Heimdall.Command.Handlers.ResetPasswordCommandHandler
Assembly: ArturRios.Heimdall.Command
File(s): /home/ci/agent/workspace/heimdall-api-docs_main/app/src/Application/ArturRios.Heimdall.Command/Handlers/ResetPasswordCommandHandler.cs
Line coverage
95%
Covered lines: 57
Uncovered lines: 3
Coverable lines: 60
Total lines: 226
Line coverage: 95%
Branch coverage
100%
Covered branches: 18
Total branches: 18
Branch coverage: 100%
Method coverage
100%
Covered methods: 3
Fully covered methods: 2
Total methods: 3
Method coverage: 100%
Full method coverage: 66.6%

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor(...)100%11100%
HandleAsync()100%1414100%
RetireOtherTokensAsync()100%4482.35%

File(s)

/home/ci/agent/workspace/heimdall-api-docs_main/app/src/Application/ArturRios.Heimdall.Command/Handlers/ResetPasswordCommandHandler.cs

#LineLine coverage
 1using ArturRios.Data.Relational.Core.Interfaces;
 2using ArturRios.Heimdall.Command.Input;
 3using ArturRios.Heimdall.Command.Output;
 4using ArturRios.Heimdall.Command.Services;
 5using ArturRios.Heimdall.Domain.Entities;
 6using ArturRios.Heimdall.Domain.Persistence;
 7using ArturRios.Heimdall.Shared.Messages;
 8using ArturRios.Mediator.Command.Interfaces;
 9using ArturRios.Output;
 10using ArturRios.Util.Hashing;
 11using FluentValidation;
 12using Microsoft.EntityFrameworkCore;
 13using Microsoft.Extensions.Logging;
 14
 15namespace ArturRios.Heimdall.Command.Handlers;
 16
 17/// <summary>
 18///     Handles <see cref="ResetPasswordCommand" /> (UC-13): finds the token UC-12 issued, refuses it
 19///     if it is unknown, if its account may no longer be acted on, or if it is expired or spent
 20///     (AF-13c/AF-13e/AF-13a/AF-13b, FR-PR-04, FR-PR-08), then re-hashes the new password under a
 21///     freshly generated salt and consumes the token (FR-PR-03).
 22/// </summary>
 23/// <remarks>
 24///     <para>
 25///         The token is the whole of the caller's claim — no email, no scope, no password is asked
 26///         for — so the four rejections are checked in the order UC-13's main flow states: exists,
 27///         its person may still be acted on (answered as "exists" fails), not expired, not used. The
 28///         token's own three are named separately, unlike UC-11's uniform 401, because there is no
 29///         account to enumerate here: the token is a 48-character random string, and a caller either
 30///         holds one that was mailed to them or holds nothing.
 31///     </para>
 32///     <para>
 33///         <b>The account is checked again when the link is followed</b> (AF-13e, FR-PR-08). UC-12
 34///         and UC-52 decide who a link is worth sending to (<see cref="PasswordRecoveryEligibility" />),
 35///         but a link lives for an hour, and the person can be restricted, deleted or anonymised, or
 36///         lose the scope they sign in through, while it waits in a mailbox. The same rule is run
 37///         at redemption, and a person it no longer admits is answered exactly as an unknown token is
 38///         (AF-13c), with nothing written: the token stays as it was, and a restricted record is
 39///         preserved as NFR-24 requires. The answer reveals nothing a caller holding a dead link did
 40///         not already have.
 41///     </para>
 42///     <para>
 43///         <b>The password is written by one conditional statement</b>
 44///         (<see cref="IAtomicWrites.TrySetPasswordAsync" />), not by saving the person read with
 45///         the token. The condition repeats the state checks against the row as it is at that moment
 46///         and requires the address the person was read with, so a restriction, a deletion or an
 47///         address change that lands between the check and the write wins (FR-PE-17), and a save of
 48///         the stale copy cannot put the old address back.
 49///     </para>
 50///     <para>
 51///         <b>The token is spent only with the write it unlocks</b> (#209). Spending it and writing the
 52///         password share one transaction (<see cref="IWriteTransactions" />): spending it first is what
 53///         lets only one of two requests carrying it go on, and the transaction is what stops a failed
 54///         write from taking the token with it, so the person can follow the same link again. A write
 55///         refused because the person changed is not a failure, and the token stays spent (FR-PR-08).
 56///         Retiring the person's other links runs after that transaction has committed: it is clean-up
 57///         after a reset that has happened, so a failure there is logged and the reset is still
 58///         reported as done.
 59///     </para>
 60/// </remarks>
 6561public class ResetPasswordCommandHandler(
 6562    IValidator<ResetPasswordCommand> validator,
 6563    IAsyncReadOnlyRepository<PasswordResetToken, long> tokenReader,
 6564    IAsyncRepository<PasswordResetToken, long> tokenWriter,
 6565    IAtomicWrites atomicWrites,
 6566    IWriteTransactions transactions,
 6567    ILogger<ResetPasswordCommandHandler> logger)
 68    : ICommandHandlerAsync<ResetPasswordCommand, ResetPasswordCommandOutput>
 69{
 70    public async Task<DataOutput<ResetPasswordCommandOutput?>> HandleAsync(ResetPasswordCommand command, CancellationTok
 71    {
 6572        var output = DataOutput<ResetPasswordCommandOutput?>.New;
 73
 74        // AF-13d: validate input shape (NFR-10).
 6575        var validation = await validator.ValidateAsync(command);
 76
 6577        if (!validation.IsValid)
 78        {
 979            return output.WithErrors(validation.Errors.Select(failure => failure.ErrorMessage));
 80        }
 81
 6182        var now = DateTime.UtcNow;
 83
 84        // UC-13 step 2. Matched by hash, because the token itself is not stored (TH-14): the
 85        // presented value is hashed the same way the issued one was and looked up against the unique
 86        // index on the digest. Still an exact, case-sensitive match — hashing preserves that, since
 87        // any difference in the input changes the digest.
 6188        var presented = SingleUseTokenHash.Of(command.Token);
 89
 90        // Both scope navigations are loaded: the eligibility rule reads them.
 6191        var token = await tokenReader.Query()
 6192            .Include(x => x.Person)
 6193            .ThenInclude(person => person.ScopeMembership!)
 6194            .ThenInclude(membership => membership.Scope)
 6195            .Include(x => x.Person)
 6196            .ThenInclude(person => person.ScopeOwnerships)
 6197            .ThenInclude(ownership => ownership.Scope)
 6198            .FirstOrDefaultAsync(x => x.TokenHash == presented);
 99
 100        // AF-13c.
 61101        if (token is null)
 102        {
 11103            return output.WithError(AuthMessages.TokenInvalid);
 104        }
 105
 106        // AF-13e (FR-PR-08): UC-12's rule, run again now the link is followed, and answered as
 107        // AF-13c whatever the reason, before the token's own state, so a link for an account that
 108        // may no longer be acted on says nothing more than an unknown one.
 50109        if (PasswordRecoveryEligibility.Of(token.Person) != RecoveryEligibility.Eligible)
 110        {
 10111            return output.WithError(AuthMessages.TokenInvalid);
 112        }
 113
 114        // AF-13a.
 40115        if (token.ExpiresAt <= now)
 116        {
 4117            return output.WithError(AuthMessages.TokenExpired);
 118        }
 119
 120        // AF-13b.
 36121        if (token.Used)
 122        {
 5123            return output.WithError(AuthMessages.TokenAlreadyUsed);
 124        }
 125
 126        // UC-13 step 3 (FR-PR-03, FR-RO-04, NFR-02): a new random salt, not the person's existing
 127        // one, so the stored hash shares nothing with the one it replaces.
 31128        var person = token.Person;
 129
 31130        var (newHash, newSalt) = await PasswordHashGate.Shared.EncodeWithRandomSaltAsync(command.NewPassword);
 131
 132        // The token and the password it unlocks land together or not at all (#209). Opened after the
 133        // hash, so no row lock waits on Argon2.
 31134        await using (var transaction = await transactions.BeginAsync())
 135        {
 136            // UC-13 step 4, for the presented token, brought forward and made atomic: the token is
 137            // spent before the password changes, by a write only one request can win. Two requests
 138            // carrying the same token both pass the checks above; only one may set a password with it,
 139            // and the other is told what is now true — the token was used (AF-13b).
 31140            if (!await atomicWrites.TryConsumePasswordResetTokenAsync(token, now))
 141            {
 4142                return output.WithError(AuthMessages.TokenAlreadyUsed);
 143            }
 144
 145            // UC-13 step 3 and FR-AU-11, in one conditional statement: the new hash and salt and a
 146            // fresh security stamp, which revokes every token issued under the old password — a reset
 147            // is the remedy for a password someone else may know. Written only while the person is
 148            // still as found above (FR-PR-08, FR-PE-17); otherwise one of the account changes AF-13e
 149            // refuses, or an address change, landed in between, the link is answered as AF-13e would
 150            // now answer it, and nothing about the person changes, the stamp included. One statement
 151            // also means no sign-in can read the new password beside the old stamp, so no second
 152            // rotation is needed.
 27153            var written = await atomicWrites.TrySetPasswordAsync(person, newHash, newSalt, now);
 154
 155            // Committed whether or not the password was written: a write refused because the person
 156            // changed is an answer, not a failure, and the token stays spent (FR-PR-08). Only a write
 157            // that fails — an exception, here — leaves the transaction to roll back.
 26158            await transaction.CommitAsync();
 159
 26160            if (!written)
 161            {
 5162                return output.WithError(AuthMessages.TokenInvalid);
 163            }
 164        }
 165
 166        // UC-13 step 4, for every other token the person still holds.
 21167        await RetireOtherTokensAsync(token, now);
 168
 169        // UC-13 step 5.
 21170        return output
 21171            .WithData(new ResetPasswordCommandOutput())
 21172            .WithMessage(AuthMessages.PasswordResetSuccessful);
 64173    }
 174
 175    /// <summary>
 176    ///     UC-13 step 4, applied to every reset token the person still holds rather than only the one
 177    ///     presented. UC-12 issues a token per request without retiring the last, so a person who
 178    ///     clicked "forgot password" twice has two live links. Once one of them has changed the
 179    ///     password, the others are exactly what this use case exists to expire: a way to change it
 180    ///     again, from an inbox that may be the reason the reset was needed.
 181    /// </summary>
 182    /// <remarks>
 183    ///     <para>
 184    ///         Already-expired tokens are left alone. They are refused by AF-13a either way, and
 185    ///         rewriting them would only make a dead token report a different reason for being dead.
 186    ///     </para>
 187    ///     <para>
 188    ///         Best effort (#209): the password has already been reset, so a failure here is logged and
 189    ///         not reported — the links it leaves live expire within the hour. It runs in a transaction
 190    ///         of its own so that a failure retires none of them and leaves nothing pending for the
 191    ///         next save, the audit entry's.
 192    ///     </para>
 193    /// </remarks>
 194    private async Task RetireOtherTokensAsync(PasswordResetToken token, DateTime now)
 195    {
 196        try
 197        {
 21198            await using var transaction = await transactions.BeginAsync();
 199
 21200            var live = await tokenReader.Query()
 21201                .Where(x => x.PersonId == token.PersonId && !x.Used && x.ExpiresAt > now)
 21202                .ToListAsync();
 203
 47204            foreach (var outstanding in live)
 205            {
 3206                outstanding.Used = true;
 207
 3208                var update = await tokenWriter.UpdateAsync(outstanding);
 209
 3210                if (!update.Success)
 211                {
 1212                    logger.LogWarning("The other password reset links of a reset password could not be retired: {Errors}
 1213                        string.Join(" | ", update.Errors));
 214
 1215                    return;
 216                }
 217            }
 218
 20219            await transaction.CommitAsync();
 20220        }
 0221        catch (Exception exception)
 222        {
 0223            logger.LogWarning(exception, "The other password reset links of a reset password could not be retired");
 0224        }
 21225    }
 226}