< Summary - Heimdall API

Information
Class: ArturRios.Heimdall.Command.Handlers.ResendTwoFactorChallengeCodeCommandHandler
Assembly: ArturRios.Heimdall.Command
File(s): /home/runner/work/heimdall-api/heimdall-api/src/Application/ArturRios.Heimdall.Command/Handlers/ResendTwoFactorChallengeCodeCommandHandler.cs
Line coverage
97%
Covered lines: 39
Uncovered lines: 1
Coverable lines: 40
Total lines: 163
Line coverage: 97.5%
Branch coverage
90%
Covered branches: 20
Total branches: 22
Branch coverage: 90.9%
Method coverage
100%
Covered methods: 4
Fully covered methods: 3
Total methods: 4
Method coverage: 100%
Full method coverage: 75%

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor(...)100%11100%
HandleAsync()100%22100%
ReissueAsync()50%2275%
ResolveReissuableAsync()94.44%1818100%

File(s)

/home/runner/work/heimdall-api/heimdall-api/src/Application/ArturRios.Heimdall.Command/Handlers/ResendTwoFactorChallengeCodeCommandHandler.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 Microsoft.EntityFrameworkCore;
 11
 12namespace ArturRios.Heimdall.Command.Handlers;
 13
 14/// <summary>
 15///     Handles <see cref="ResendTwoFactorChallengeCodeCommand" /> (UC-46, FR-2F-16): reissues the
 16///     email code for a challenge that is still outstanding, so someone who never received the first
 17///     one can ask for another without starting sign-in over.
 18/// </summary>
 19/// <remarks>
 20///     <para>
 21///         <b>Every path answers the same.</b> A valid challenge, an unknown one, a forged one, an
 22///         expired one, one naming a person who no longer exists or has been suspended, one
 23///         naming a person with no email method, and one that has spent its reissues all return
 24///         <see cref="TwoFactorMessages.ChallengeCodeResent" /> and no data. This is the endpoint's
 25///         security design rather than an economy of writing: it is anonymous, and an answer that
 26///         varied would say whether an address is registered and has email two-factor enabled — the
 27///         question UC-11's AF-11a…AF-11e and UC-38's AF-38a collapse their own answers to refuse.
 28///         The difference is only the sign: those collapse to one <c>401</c> because the caller asked
 29///         to be let in; this collapses to one <c>200</c> because they asked for something to be
 30///         sent, and "it has been sent if there was anything to send" is the most that can be said.
 31///     </para>
 32///     <para>
 33///         <b>It never extends the challenge.</b> Extending the window would mean returning a new
 34///         challenge token, and one could only be returned for a challenge that was real — exactly
 35///         the oracle above. So a reissue puts a fresh code inside the existing window and never
 36///         lengthens it. That window's ceiling is NFR-17's, which since the two lifetimes were
 37///         reconciled is the same ten minutes FR-2F-03 gives the code — which is what makes a reissue
 38///         inside it worth having rather than a formality.
 39///     </para>
 40///     <para>
 41///         <b>Why the reissues are capped.</b> FR-2F-13 retires an email code after five wrong
 42///         guesses. Without a cap this endpoint would relax that into "five guesses times however
 43///         many reissues the rate limiter permits", making a security bound a property of deployment
 44///         configuration. <see cref="MaxReissuesPerChallenge" /> puts it back in the requirement: at
 45///         most twenty guesses at a six-digit code per authentication, after which a further code
 46///         costs a fresh password check — which is the price FR-2F-13 always charged.
 47///     </para>
 48///     <para>
 49///         The count lives on the <c>TWO_FACTOR_AUTH</c> row rather than in a token claim, because a
 50///         claim could only be incremented by issuing a new token and this endpoint must never return
 51///         one. <c>LoginCommandHandler</c> clears it whenever it issues a challenge, which is what
 52///         makes the budget per-challenge rather than per-account.
 53///     </para>
 54/// </remarks>
 4455public class ResendTwoFactorChallengeCodeCommandHandler(
 4456    IAsyncReadOnlyRepository<Person, long> personReader,
 4457    IAsyncReadOnlyRepository<TwoFactorAuth, long> twoFactorReader,
 4458    IAtomicWrites atomicWrites,
 4459    ITwoFactorChallengeTokenValidator challengeTokenValidator,
 4460    ITwoFactorEmailCodeIssuer emailCodeIssuer)
 61    : ICommandHandlerAsync<ResendTwoFactorChallengeCodeCommand, ResendTwoFactorChallengeCodeCommandOutput>
 62{
 63    /// <summary>
 64    ///     Reissues one challenge may authorize (FR-2F-13). Three, so the guessing bound stays a
 65    ///     stated constant — five attempts per code across at most four codes — rather than whatever
 66    ///     the rate limiter happens to permit.
 67    /// </summary>
 68    public const int MaxReissuesPerChallenge = 3;
 69
 70    public async Task<DataOutput<ResendTwoFactorChallengeCodeCommandOutput?>> HandleAsync(
 71        ResendTwoFactorChallengeCodeCommand command, CancellationToken cancellationToken = default)
 72    {
 73        // UC-46 steps 2-6. Resolved before the answer is built, but the answer does not depend on
 74        // it: each alternative flow is the absence of work rather than a refusal of its own, the
 75        // same way UC-12's AF-12a is.
 4476        var reissuable = await ResolveReissuableAsync(command.ChallengeToken);
 77
 4478        if (reissuable is { } target)
 79        {
 2380            await ReissueAsync(target.Configuration, target.ChallengeId, target.Email);
 81        }
 82
 83        // UC-46 step 7: the same answer either way.
 4484        return DataOutput<ResendTwoFactorChallengeCodeCommandOutput?>.New
 4485            .WithData(new ResendTwoFactorChallengeCodeCommandOutput())
 4486            .WithMessage(TwoFactorMessages.ChallengeCodeResent);
 4487    }
 88
 89    /// <summary>
 90    ///     Charges one reissue against the challenge, then sends. The budget is spent before the
 91    ///     code goes out, and a failure to spend it stops the send: overcharging costs a person one
 92    ///     more wait, while undercharging would let the bound be stepped around by whatever made the
 93    ///     write fail. Neither outcome reaches the caller.
 94    /// </summary>
 95    /// <remarks>
 96    ///     The charge is one conditional write (<see cref="IAtomicWrites" />) that only succeeds while
 97    ///     the challenge is still outstanding and under the cap. Incrementing a count read earlier
 98    ///     let parallel resends all read "under the cap" and overwrite each other's increments, so a
 99    ///     burst could mail many more codes — and buy many more guesses — than FR-2F-13 allows.
 100    /// </remarks>
 101    private async Task ReissueAsync(TwoFactorAuth twoFactorAuth, Guid challengeId, string email)
 102    {
 23103        if (!await atomicWrites.TryChargeEmailCodeReissueAsync(twoFactorAuth, challengeId, MaxReissuesPerChallenge))
 104        {
 0105            return;
 106        }
 107
 23108        await emailCodeIssuer.ReissueAsync(twoFactorAuth, email);
 23109    }
 110
 111    /// <summary>
 112    ///     The configuration a reissue should be sent for and the address to send it to, or
 113    ///     <see langword="null" /> when there is nothing to send: the challenge did not validate
 114    ///     (AF-46a/AF-46e), it names nobody eligible (AF-46b), that person has no active
 115    ///     configuration or no email method (AF-46c), or its reissues are spent (AF-46d). The caller
 116    ///     cannot tell these apart, and neither can this method's result.
 117    /// </summary>
 118    private async Task<(TwoFactorAuth Configuration, Guid ChallengeId, string Email)?> ResolveReissuableAsync(
 119        string? challengeToken)
 120    {
 121        // AF-46a: signature, expiry, and the MFA-pending claim — exactly UC-38 step 2's check, made
 122        // by the same validator, so the two endpoints agree on what a challenge token is. AF-46e
 123        // falls out of the same call: a full login token carries no MFA-pending claim.
 44124        var principal = await challengeTokenValidator.ValidateAsync(challengeToken);
 125
 44126        if (principal is null)
 127        {
 5128            return null;
 129        }
 130
 39131        var person = await personReader.Query()
 39132            // AF-46b. A restricted person is the exception to both halves (NFR-24): UC-11 lets them
 39133            // sign in for their own rights, suspended or not, and a code they asked for is sent at
 39134            // their request — the consent Art. 18(2) asks for — so the login it belongs to can finish.
 39135            .FirstOrDefaultAsync(person => person.PublicId == principal.PersonId &&
 39136                                           person.AnonymisedAt == null &&
 39137                                           (!person.IsDeleted || person.ProcessingRestrictedAt != null));
 138
 139        // FR-AU-11: a challenge issued before the person's last credential change is as dead here as
 140        // at UC-38, which would refuse it — so a code sent for it could never be used. It is AF-46a's
 141        // absence of work, like any other challenge that does not validate.
 39142        if (person is null || principal.SecurityStamp != person.SecurityStamp)
 143        {
 3144            return null;
 145        }
 146
 36147        var twoFactorAuth = await twoFactorReader.Query()
 36148            .FirstOrDefaultAsync(configuration =>
 36149                configuration.PersonId == person.Id && configuration.IsActive);
 150
 151        // AF-46c — no active configuration, or one without the email method: an authenticator-app
 152        // holder has no code to resend. AF-46d — the challenge has spent its reissues.
 153        // FR-2F-10/FR-2F-16: only the outstanding challenge has a code to resend — not one already
 154        // redeemed, and not one a newer login has replaced.
 36155        return twoFactorAuth is { EmailEnabled: true } &&
 36156               twoFactorAuth.ChallengeId == principal.ChallengeId &&
 36157               twoFactorAuth.EmailCodeReissueCount < MaxReissuesPerChallenge
 36158            // The address is the person's stored one, read here rather than taken from the request:
 36159            // the command carries no address field, and this is why.
 36160            ? (twoFactorAuth, principal.ChallengeId, person.Email)
 36161            : null;
 44162    }
 163}