< Summary - Heimdall API

Information
Class: ArturRios.Heimdall.Command.Handlers.VerifyTwoFactorAuthCommandHandler
Assembly: ArturRios.Heimdall.Command
File(s): /home/runner/work/heimdall-api/heimdall-api/src/Application/ArturRios.Heimdall.Command/Handlers/VerifyTwoFactorAuthCommandHandler.cs
Line coverage
98%
Covered lines: 74
Uncovered lines: 1
Coverable lines: 75
Total lines: 220
Line coverage: 98.6%
Branch coverage
95%
Covered branches: 42
Total branches: 44
Branch coverage: 95.4%
Method coverage
100%
Covered methods: 2
Fully covered methods: 1
Total methods: 2
Method coverage: 100%
Full method coverage: 50%

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor(...)100%11100%
HandleAsync()90.9%444495.58%

File(s)

/home/runner/work/heimdall-api/heimdall-api/src/Application/ArturRios.Heimdall.Command/Handlers/VerifyTwoFactorAuthCommandHandler.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="VerifyTwoFactorAuthCommand" /> (UC-38, FR-2F-09): validates the challenge
 16///     token AF-11g issued at login (AF-38a), matches the submitted app code, email code, or recovery
 17///     code against the caller's active <see cref="TwoFactorAuth" /> configuration via
 18///     <see cref="ITwoFactorFactorVerifier" /> (AF-38b/AF-38c), and — only once a factor checks out —
 19///     issues the full authentication token through <see cref="PersonAuthTokenService" />, the same
 20///     service <c>LoginCommandHandler</c> uses, so a 2FA-gated login ends exactly like a direct one.
 21/// </summary>
 22/// <remarks>
 23///     AF-38b (wrong or missing code) and AF-38c (an already-used recovery code) answer identically —
 24///     <see cref="TwoFactorMessages.FactorInvalid" />, 401 — so a caller cannot distinguish a wrong
 25///     code from a reused recovery code, exactly the reasoning UC-11's AF-11a…AF-11e collapse into
 26///     one message for. A person or 2FA configuration the challenge token names but that no longer
 27///     resolves is treated the same as an invalid challenge (AF-38a). A person and configuration that
 28///     do resolve, and a factor that genuinely checks out, but whose scope eligibility (UC-11's own
 29///     AF-11d/AF-11e) no longer holds, gets its own <see cref="TwoFactorMessages.ScopeNoLongerEligible" />
 30///     instead — the token and the factor were both valid, so collapsing that case into "challenge
 31///     invalid" would misdescribe what actually happened.
 32///     <para>
 33///         <b>Once, under concurrency too.</b> Redeeming the challenge and spending the recovery or
 34///         email code are conditional writes (<see cref="IAtomicWrites" />): of any number of
 35///         simultaneous requests presenting the same challenge or the same code, exactly one gets a
 36///         token. Checking in memory and then saving let several through, since each had read the row
 37///         before any of them wrote it.
 38///     </para>
 39///     <para>
 40///         <b>Five guesses per challenge</b> (FR-2F-17) with an app code or a recovery code
 41///         (<see cref="MaxGuessesPerChallenge" />), the cap FR-2F-13 already puts on an email code.
 42///         Each such guess is reserved on the challenge before it is compared, so parallel guesses
 43///         cannot outrun the cap; at the cap the challenge is cleared and its token refused as AF-38a,
 44///         and further guessing costs a fresh password check. An email-only configuration's guesses
 45///         are not charged here: the email code's own cap already bounds them.
 46///     </para>
 47///     <para>
 48///         <b>Ten failures per account</b> (FR-AU-12, <see cref="SecondFactorLockout" />). The
 49///         challenge's budgets start afresh with every login, and a login costs nothing to whoever
 50///         holds the password, so every guess — app code, email code or recovery code — is also
 51///         charged to the account, across challenges. A correct password does not reset that count;
 52///         only a second factor that checks out does. The tenth failure locks the second factor, for
 53///         fifteen minutes and twice as long at each repeat, up to a day, and while it is locked every
 54///         factor is refused as AF-38b, the right one included.
 55///     </para>
 56/// </remarks>
 13857public class VerifyTwoFactorAuthCommandHandler(
 13858    IAsyncReadOnlyRepository<Person, long> personReader,
 13859    IAsyncReadOnlyRepository<TwoFactorAuth, long> twoFactorReader,
 13860    IAtomicWrites atomicWrites,
 13861    ITwoFactorFactorVerifier factorVerifier,
 13862    ITwoFactorChallengeTokenValidator challengeTokenValidator,
 13863    PersonAuthTokenService personAuthTokenService)
 64    : ICommandHandlerAsync<VerifyTwoFactorAuthCommand, VerifyTwoFactorAuthCommandOutput>
 65{
 66    /// <summary>
 67    ///     Guesses one challenge allows with an app code or a recovery code — the same five FR-2F-13
 68    ///     allows an email code (<see cref="TwoFactorEmailCodeVerification.MaxFailedAttempts" />).
 69    /// </summary>
 70    public const int MaxGuessesPerChallenge = TwoFactorEmailCodeVerification.MaxFailedAttempts;
 71
 72    public async Task<DataOutput<VerifyTwoFactorAuthCommandOutput?>> HandleAsync(
 73        VerifyTwoFactorAuthCommand command, CancellationToken cancellationToken = default)
 74    {
 13875        var output = DataOutput<VerifyTwoFactorAuthCommandOutput?>.New;
 76
 77        // AF-38a: signature, expiry, and the MFA-pending claim.
 13878        var principal = await challengeTokenValidator.ValidateAsync(command.ChallengeToken);
 79
 13880        if (principal is null)
 81        {
 782            return output.WithError(TwoFactorMessages.ChallengeTokenInvalid);
 83        }
 84
 13185        var person = await personReader.Query()
 13186            .Include(person => person.ScopeMembership)
 13187            .ThenInclude(membership => membership!.Scope)
 13188            .Include(person => person.ScopeOwnerships)
 13189            .ThenInclude(ownership => ownership.Scope)
 13190            // A suspended person is no longer let in, unless they are restricted: UC-11 issues a
 13191            // restricted person a subject-rights-only token whatever else about their standing, so
 13192            // the login it diverted here ends the same way (NFR-24).
 13193            .FirstOrDefaultAsync(person => person.PublicId == principal.PersonId &&
 13194                                           person.AnonymisedAt == null &&
 13195                                           (!person.IsDeleted || person.ProcessingRestrictedAt != null));
 96
 13197        var twoFactorAuth = person is null
 13198            ? null
 13199            : await twoFactorReader.Query()
 131100                .FirstOrDefaultAsync(x => x.PersonId == person.Id && x.IsActive);
 101
 102        // AF-38a again, for what a signature cannot say. A challenge is redeemable once (FR-2F-10):
 103        // the token must still name the configuration's outstanding challenge, which redemption
 104        // clears and a newer login replaces. And it must carry the person's current security stamp
 105        // (FR-AU-11): a challenge obtained with a password that has since been reset, or before a
 106        // credential change, ends with every other token issued before it.
 131107        if (person is null || twoFactorAuth is null ||
 131108            twoFactorAuth.ChallengeId != principal.ChallengeId ||
 131109            principal.SecurityStamp != person.SecurityStamp)
 110        {
 15111            return output.WithError(TwoFactorMessages.ChallengeTokenInvalid);
 112        }
 113
 116114        var challengeId = principal.ChallengeId;
 115
 116        // FR-2F-17: a guess with an app code or a recovery code is charged to the challenge before it is
 117        // compared. A challenge that has had its five — or that a parallel request has just redeemed
 118        // or retired — is refused as AF-38a, without comparing anything.
 116119        var chargedToChallenge = !string.IsNullOrWhiteSpace(command.RecoveryCode) || twoFactorAuth.AppEnabled;
 120
 116121        if (chargedToChallenge &&
 116122            !await atomicWrites.TryChargeChallengeGuessAsync(twoFactorAuth, challengeId, MaxGuessesPerChallenge))
 123        {
 2124            return output.WithError(TwoFactorMessages.ChallengeTokenInvalid);
 125        }
 126
 127        // FR-AU-12: every guess is charged to the account as well, whatever challenge it is made
 128        // against, and a locked second factor accepts nothing. After the challenge's own charge, so
 129        // the challenge's five still run out and retire it while the account is locked, exactly as
 130        // they would otherwise.
 114131        var now = DateTime.UtcNow;
 114132        var admitted = await atomicWrites.TryReserveSecondFactorAttemptAsync(
 114133            person, SecondFactorLockout.MaxFailedAttempts, now, SecondFactorLockout.LockoutEndsFrom(now));
 134
 135        // AF-38b/AF-38c: an app code, a live email code, or an unused recovery code — or the same
 136        // rejection either way. A locked account's factor is verified too and the result discarded,
 137        // so the refusal costs and reads as a wrong code's, and the lock does not show.
 114138        var verification = await factorVerifier.VerifyAsync(twoFactorAuth, command.Code, command.RecoveryCode);
 139
 114140        if (!admitted || !verification.Matched)
 141        {
 78142            if (chargedToChallenge)
 143            {
 58144                await atomicWrites.ExpireChallengeIfExhaustedAsync(twoFactorAuth, challengeId, MaxGuessesPerChallenge);
 145            }
 146
 78147            return output.WithError(TwoFactorMessages.FactorInvalid);
 148        }
 149
 150        // FR-AU-12: a second factor checked out, which is the only thing that resets the account's
 151        // count — and with it the lock, and the lockout count that sets how long the next one lasts.
 36152        await atomicWrites.ClearSecondFactorAttemptsAsync(person);
 153
 154        // UC-11 step 6 / UC-38 step 5 (FR-2F-09): the same scope-eligibility rules a direct login
 155        // enforces still apply to a 2FA-gated one. The factor already checked out, so this is a
 156        // distinct rejection from AF-38a rather than a reuse of ChallengeTokenInvalid.
 157        //
 158        // A person under a restriction of processing (NFR-24) is issued a subject-rights-only token
 159        // instead, as UC-11 would have issued one had they no second factor — including one
 160        // restricted after their challenge was issued, who is not handed a full token. That token
 161        // claims no scope, so the rules do not apply to it.
 36162        var restricted = person.ProcessingRestrictedAt is not null;
 163
 36164        AuthTokenSubject? subject = null;
 165
 36166        if (!restricted && !personAuthTokenService.TryBuildSubject(person, out subject))
 167        {
 1168            return output.WithError(TwoFactorMessages.ScopeNoLongerEligible);
 169        }
 170
 171        // FR-2F-10: the challenge is spent — by exactly one request, however many arrive together.
 172        // Before the factor is spent, so a request that loses here has burned nothing of the
 173        // caller's; and before anything else is written, so a failure further down leaves the caller
 174        // to log in again rather than holding a challenge still redeemable.
 35175        if (!await atomicWrites.TryRedeemChallengeAsync(twoFactorAuth, challengeId))
 176        {
 1177            return output.WithError(TwoFactorMessages.ChallengeTokenInvalid);
 178        }
 179
 180        // UC-38 step 4: a recovery code can never be replayed — nor spent twice at once, here and in
 181        // UC-40 or another challenge's verification.
 34182        if (verification.ConsumedRecoveryCode is { } consumedRecoveryCode &&
 34183            !await atomicWrites.TryConsumeRecoveryCodeAsync(consumedRecoveryCode, DateTime.UtcNow))
 184        {
 1185            return output.WithError(TwoFactorMessages.FactorInvalid);
 186        }
 187
 188        // The email code that completed this login can never be replayed either, the same way
 189        // ConfirmTwoFactorAuthCommandHandler retires the one that confirmed setup.
 33190        if (verification.ConsumedEmailCode is { } consumedEmailCode &&
 33191            !await atomicWrites.TryConsumeEmailCodeAsync(consumedEmailCode))
 192        {
 0193            return output.WithError(TwoFactorMessages.FactorInvalid);
 194        }
 195
 33196        if (restricted)
 197        {
 5198            var rightsOnly = await personAuthTokenService.IssueSubjectRightsOnlyAsync(person);
 199
 5200            return output
 5201                .WithData(new VerifyTwoFactorAuthCommandOutput
 5202                {
 5203                    Token = rightsOnly.Token,
 5204                    ExpiresAt = rightsOnly.ExpiresAt,
 5205                    EmailVerified = person.EmailVerified,
 5206                    ProcessingRestricted = true
 5207                })
 5208                .WithMessage(AuthMessages.SubjectRightsOnlySignIn);
 209        }
 210
 28211        var token = await personAuthTokenService.IssueAsync(subject!);
 212
 28213        return output
 28214            .WithData(new VerifyTwoFactorAuthCommandOutput
 28215            {
 28216                Token = token.Token, ExpiresAt = token.ExpiresAt, EmailVerified = person.EmailVerified
 28217            })
 28218            .WithMessage(TwoFactorMessages.VerificationSuccessful);
 138219    }
 220}