< Summary - Heimdall API

Information
Class: ArturRios.Heimdall.Command.Handlers.ResetPasswordCommandHandler
Assembly: ArturRios.Heimdall.Command
File(s): /home/runner/work/heimdall-api/heimdall-api/src/Application/ArturRios.Heimdall.Command/Handlers/ResetPasswordCommandHandler.cs
Line coverage
96%
Covered lines: 48
Uncovered lines: 2
Coverable lines: 50
Total lines: 186
Line coverage: 96%
Branch coverage
90%
Covered branches: 18
Total branches: 20
Branch coverage: 90%
Method coverage
100%
Covered methods: 3
Fully covered methods: 1
Total methods: 3
Method coverage: 100%
Full method coverage: 33.3%

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor(...)100%11100%
HandleAsync()87.5%161694.11%
ConsumeTokensAsync()75%4490%

File(s)

/home/runner/work/heimdall-api/heimdall-api/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;
 13
 14namespace ArturRios.Heimdall.Command.Handlers;
 15
 16/// <summary>
 17///     Handles <see cref="ResetPasswordCommand" /> (UC-13): finds the token UC-12 issued, refuses it
 18///     if it is unknown, if its account may no longer be acted on, or if it is expired or spent
 19///     (AF-13c/AF-13e/AF-13a/AF-13b, FR-PR-04, FR-PR-08), then re-hashes the new password under a
 20///     freshly generated salt and consumes the token (FR-PR-03).
 21/// </summary>
 22/// <remarks>
 23///     <para>
 24///         The token is the whole of the caller's claim — no email, no scope, no password is asked
 25///         for — so the four rejections are checked in the order UC-13's main flow states: exists,
 26///         its person may still be acted on (answered as "exists" fails), not expired, not used. The
 27///         token's own three are named separately, unlike UC-11's uniform 401, because there is no
 28///         account to enumerate here: the token is a 48-character random string, and a caller either
 29///         holds one that was mailed to them or holds nothing.
 30///     </para>
 31///     <para>
 32///         <b>The account is checked again when the link is followed</b> (AF-13e, FR-PR-08). UC-12
 33///         and UC-52 decide who a link is worth sending to (<see cref="PasswordRecoveryEligibility" />),
 34///         but a link lives for an hour, and the person can be restricted, deleted or anonymised, or
 35///         lose the scope they sign in through, while it waits in a mailbox. The same rule is run
 36///         at redemption, and a person it no longer admits is answered exactly as an unknown token is
 37///         (AF-13c), with nothing written: the token stays as it was, and a restricted record is
 38///         preserved as NFR-24 requires. The answer reveals nothing a caller holding a dead link did
 39///         not already have.
 40///     </para>
 41///     <para>
 42///         <b>The password is written by one conditional statement</b>
 43///         (<see cref="IAtomicWrites.TrySetPasswordAsync" />), not by saving the person read with
 44///         the token. The condition repeats the state checks against the row as it is at that moment
 45///         and requires the address the person was read with, so a restriction, a deletion or an
 46///         address change that lands between the check and the write wins (FR-PE-17), and a save of
 47///         the stale copy cannot put the old address back.
 48///     </para>
 49/// </remarks>
 6050public class ResetPasswordCommandHandler(
 6051    IValidator<ResetPasswordCommand> validator,
 6052    IAsyncReadOnlyRepository<PasswordResetToken, long> tokenReader,
 6053    IAsyncRepository<PasswordResetToken, long> tokenWriter,
 6054    IAtomicWrites atomicWrites)
 55    : ICommandHandlerAsync<ResetPasswordCommand, ResetPasswordCommandOutput>
 56{
 57    public async Task<DataOutput<ResetPasswordCommandOutput?>> HandleAsync(ResetPasswordCommand command, CancellationTok
 58    {
 6059        var output = DataOutput<ResetPasswordCommandOutput?>.New;
 60
 61        // AF-13d: validate input shape (NFR-10).
 6062        var validation = await validator.ValidateAsync(command);
 63
 6064        if (!validation.IsValid)
 65        {
 966            return output.WithErrors(validation.Errors.Select(failure => failure.ErrorMessage));
 67        }
 68
 5669        var now = DateTime.UtcNow;
 70
 71        // UC-13 step 2. Matched by hash, because the token itself is not stored (TH-14): the
 72        // presented value is hashed the same way the issued one was and looked up against the unique
 73        // index on the digest. Still an exact, case-sensitive match — hashing preserves that, since
 74        // any difference in the input changes the digest.
 5675        var presented = SingleUseTokenHash.Of(command.Token);
 76
 77        // Both scope navigations are loaded: the eligibility rule reads them.
 5678        var token = await tokenReader.Query()
 5679            .Include(x => x.Person)
 5680            .ThenInclude(person => person.ScopeMembership!)
 5681            .ThenInclude(membership => membership.Scope)
 5682            .Include(x => x.Person)
 5683            .ThenInclude(person => person.ScopeOwnerships)
 5684            .ThenInclude(ownership => ownership.Scope)
 5685            .FirstOrDefaultAsync(x => x.TokenHash == presented);
 86
 87        // AF-13c.
 5688        if (token is null)
 89        {
 1190            return output.WithError(AuthMessages.TokenInvalid);
 91        }
 92
 93        // AF-13e (FR-PR-08): UC-12's rule, run again now the link is followed, and answered as
 94        // AF-13c whatever the reason, before the token's own state, so a link for an account that
 95        // may no longer be acted on says nothing more than an unknown one.
 4596        if (PasswordRecoveryEligibility.Of(token.Person) != RecoveryEligibility.Eligible)
 97        {
 1098            return output.WithError(AuthMessages.TokenInvalid);
 99        }
 100
 101        // AF-13a.
 35102        if (token.ExpiresAt <= now)
 103        {
 4104            return output.WithError(AuthMessages.TokenExpired);
 105        }
 106
 107        // AF-13b.
 31108        if (token.Used)
 109        {
 4110            return output.WithError(AuthMessages.TokenAlreadyUsed);
 111        }
 112
 113        // UC-13 step 3 (FR-PR-03, FR-RO-04, NFR-02): a new random salt, not the person's existing
 114        // one, so the stored hash shares nothing with the one it replaces.
 27115        var person = token.Person;
 116
 27117        var (newHash, newSalt) = await PasswordHashGate.Shared.EncodeWithRandomSaltAsync(command.NewPassword);
 118
 119        // UC-13 step 4, for the presented token, brought forward and made atomic: the token is spent
 120        // before the password changes, by a write only one request can win. Two requests carrying
 121        // the same token both pass the checks above; only one may set a password with it, and the
 122        // other is told what is now true — the token was used (AF-13b).
 27123        if (!await atomicWrites.TryConsumePasswordResetTokenAsync(token, now))
 124        {
 5125            return output.WithError(AuthMessages.TokenAlreadyUsed);
 126        }
 127
 128        // UC-13 step 3 and FR-AU-11, in one conditional statement: the new hash and salt and a
 129        // fresh security stamp, which revokes every token issued under the old password — a reset is
 130        // the remedy for a password someone else may know. Written only while the person is still as
 131        // found above (FR-PR-08, FR-PE-17); otherwise one of the account changes AF-13e refuses, or
 132        // an address change, landed in between, the link is answered as AF-13e would now answer it,
 133        // and nothing about the person changes, the stamp included. The token stays spent. One
 134        // statement also means no sign-in can read the new password beside the old stamp, so no
 135        // second rotation is needed.
 22136        if (!await atomicWrites.TrySetPasswordAsync(person, newHash, newSalt, now))
 137        {
 3138            return output.WithError(AuthMessages.TokenInvalid);
 139        }
 140
 141        // UC-13 step 4, for every other token the person still holds.
 19142        var consumption = await ConsumeTokensAsync(token, now);
 143
 19144        if (consumption is not null)
 145        {
 0146            return output.WithErrors(consumption);
 147        }
 148
 149        // UC-13 step 5.
 19150        return output
 19151            .WithData(new ResetPasswordCommandOutput())
 19152            .WithMessage(AuthMessages.PasswordResetSuccessful);
 60153    }
 154
 155    /// <summary>
 156    ///     UC-13 step 4, applied to every reset token the person still holds rather than only the one
 157    ///     presented. UC-12 issues a token per request without retiring the last, so a person who
 158    ///     clicked "forgot password" twice has two live links. Once one of them has changed the
 159    ///     password, the others are exactly what this use case exists to expire: a way to change it
 160    ///     again, from an inbox that may be the reason the reset was needed.
 161    /// </summary>
 162    /// <remarks>
 163    ///     Already-expired tokens are left alone. They are refused by AF-13a either way, and
 164    ///     rewriting them would only make a dead token report a different reason for being dead.
 165    /// </remarks>
 166    private async Task<IEnumerable<string>?> ConsumeTokensAsync(PasswordResetToken token, DateTime now)
 167    {
 19168        var live = await tokenReader.Query()
 19169            .Where(x => x.PersonId == token.PersonId && !x.Used && x.ExpiresAt > now)
 19170            .ToListAsync();
 171
 42172        foreach (var outstanding in live)
 173        {
 2174            outstanding.Used = true;
 175
 2176            var update = await tokenWriter.UpdateAsync(outstanding);
 177
 2178            if (!update.Success)
 179            {
 0180                return update.Errors;
 181            }
 182        }
 183
 19184        return null;
 19185    }
 186}