< Summary - Heimdall API

Information
Class: ArturRios.Heimdall.Command.Handlers.ConfirmTwoFactorAuthCommandHandler
Assembly: ArturRios.Heimdall.Command
File(s): /home/runner/work/heimdall-api/heimdall-api/src/Application/ArturRios.Heimdall.Command/Handlers/ConfirmTwoFactorAuthCommandHandler.cs
Line coverage
95%
Covered lines: 62
Uncovered lines: 3
Coverable lines: 65
Total lines: 224
Line coverage: 95.3%
Branch coverage
90%
Covered branches: 29
Total branches: 32
Branch coverage: 90.6%
Method coverage
100%
Covered methods: 4
Fully covered methods: 2
Total methods: 4
Method coverage: 100%
Full method coverage: 50%

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor(...)100%11100%
HandleAsync()92.3%262694.73%
FindMatchingEmailCodeAsync(...)100%11100%
IssueRecoveryCodesAsync()83.33%6690.9%

File(s)

/home/runner/work/heimdall-api/heimdall-api/src/Application/ArturRios.Heimdall.Command/Handlers/ConfirmTwoFactorAuthCommandHandler.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="ConfirmTwoFactorAuthCommand" /> (UC-37, FR-2F-04/05): loads the caller's
 16///     pending <see cref="TwoFactorAuth" /> row (AF-37a), refuses a caller who has already confirmed
 17///     (AF-37d), verifies a currently valid code for every method the row has enabled (AF-37b,
 18///     AF-37c), then — only once every required check passes — activates the configuration and
 19///     issues ten recovery codes, returned in plaintext exactly once.
 20/// </summary>
 21/// <remarks>
 22///     <para>
 23///         There is no shape validator, unlike <see cref="EnableTwoFactorAuthCommandHandler" />'s:
 24///         which code(s) are required depends on <see cref="TwoFactorAuth.AppEnabled" />/
 25///         <see cref="TwoFactorAuth.EmailEnabled" />, a database read no validator has business
 26///         making. A caller who names no live person at all — a Google User, or a bearer token
 27///         naming a person since hard deleted — is indistinguishable from AF-37a here: neither could
 28///         ever hold a <c>TwoFactorAuth</c> row to confirm, so both answer the same 404 rather than
 29///         inventing a second refusal UC-37 does not define.
 30///     </para>
 31///     <para>
 32///         The App and Email checks both run, in that order, and a request that fails either one
 33///         activates nothing — the pending row stays pending and can be confirmed by a later attempt.
 34///         One thing does change on the way through, though: an app code that verified is spent, so
 35///         a request whose App check passed and whose Email check then failed has to be retried with
 36///         a <em>fresh</em> app code. That is the single-use rule working as intended
 37///         (<see cref="ITotpCodeVerifier" />), not an accident of ordering — a code presented to this
 38///         endpoint has been presented, whatever the rest of the request went on to do.
 39///     </para>
 40///     <para>
 41///         <b>One refusal, one budget.</b> A missing or wrong code for either method answers the one
 42///         re-authentication refusal (<c>AuthMessages.PasswordOrCodeIncorrect</c>, 401), and every
 43///         confirmation is charged to the account's re-authentication budget
 44///         (<see cref="ReauthenticationBudget" />, FR-AU-13), so a bearer token alone cannot guess
 45///         the pending factor's codes without bound, nor learn which of the two codes was wrong.
 46///     </para>
 47///     <para>
 48///         <b>Write order.</b> The recovery codes are persisted <em>before</em>
 49///         <see cref="TwoFactorAuth.IsActive" /> is set, and in a single
 50///         <see cref="IAsyncRepository{T, TKey}.CreateRangeAsync" /> rather than ten separate inserts. The
 51///         repository layer exposes no transaction, so ordering is what stands in for one: the
 52///         dangerous half-write is "two-factor is active but the caller holds no recovery codes",
 53///         which would lock a caller out of their own account on the strength of a request this
 54///         handler reported as failed. Writing the codes first makes the surviving failure modes
 55///         harmless — codes stored against a configuration that never activated, which the next
 56///         attempt replaces, and which authenticate nothing on their own while
 57///         <see cref="TwoFactorAuth.IsActive" /> is <c>false</c>.
 58///     </para>
 59/// </remarks>
 5560public class ConfirmTwoFactorAuthCommandHandler(
 5561    IAsyncReadOnlyRepository<Person, long> personReader,
 5562    IAsyncReadOnlyRepository<TwoFactorAuth, long> twoFactorReader,
 5563    IAsyncRepository<TwoFactorAuth, long> twoFactorWriter,
 5564    IAsyncReadOnlyRepository<TwoFactorEmailCode, long> emailCodeReader,
 5565    IAtomicWrites atomicWrites,
 5566    IAsyncReadOnlyRepository<TwoFactorRecoveryCode, long> recoveryCodeReader,
 5567    IAsyncRepository<TwoFactorRecoveryCode, long> recoveryCodeWriter,
 5568    ITotpCodeVerifier totpCodeVerifier)
 69    : ICommandHandlerAsync<ConfirmTwoFactorAuthCommand, ConfirmTwoFactorAuthCommandOutput>
 70{
 71    public async Task<DataOutput<ConfirmTwoFactorAuthCommandOutput?>> HandleAsync(
 72        ConfirmTwoFactorAuthCommand command, CancellationToken cancellationToken = default)
 73    {
 5574        var output = DataOutput<ConfirmTwoFactorAuthCommandOutput?>.New;
 75
 76        // AF-37a (and, implicitly, the Google User / hard-deleted-person case UC-36's AF-37b handles
 77        // separately for enable — here it collapses into "no pending setup", since neither could ever
 78        // hold a row to confirm).
 5579        var person = await personReader.Query()
 5580            .FirstOrDefaultAsync(x => x.PublicId == command.ActingPersonId && !x.IsDeleted);
 81
 5582        var twoFactorAuth = person is null
 5583            ? null
 5584            : await twoFactorReader.Query().FirstOrDefaultAsync(x => x.PersonId == person.Id);
 85
 5586        if (person is null || twoFactorAuth is null)
 87        {
 388            return output.WithError(TwoFactorMessages.NoPendingSetup);
 89        }
 90
 91        // AF-37d.
 5292        if (twoFactorAuth.IsActive)
 93        {
 394            return output.WithError(TwoFactorMessages.AlreadyActive);
 95        }
 96
 97        // FR-AU-13: the codes are a re-authentication like any other made under a bearer token, so
 98        // the check is charged to the account's budget before anything is compared. A paused budget
 99        // still has the codes compared, and refuses them whatever the result.
 49100        var admitted = await ReauthenticationBudget.TryChargeAsync(atomicWrites, person);
 101
 102        // AF-37b: the App method's code, if the method was selected. ITotpCodeVerifier owns the
 103        // secret, the clock-drift window, and the single-use rule, shared with UC-38/39/40's
 104        // ITwoFactorFactorVerifier.
 49105        if (twoFactorAuth.AppEnabled && !await totpCodeVerifier.VerifyAsync(twoFactorAuth, command.AppCode))
 106        {
 11107            return output.WithError(AuthMessages.PasswordOrCodeIncorrect);
 108        }
 109
 110        // AF-37c: the Email method's code, if the method was selected.
 38111        TwoFactorEmailCode? consumedEmailCode = null;
 112
 38113        if (twoFactorAuth.EmailEnabled)
 114        {
 24115            consumedEmailCode = await FindMatchingEmailCodeAsync(twoFactorAuth.Id, command.EmailCode);
 116
 24117            if (consumedEmailCode is null)
 118            {
 19119                return output.WithError(AuthMessages.PasswordOrCodeIncorrect);
 120            }
 121        }
 122
 123        // Only now is the pause decided, so that it answers as a wrong code does, and before the
 124        // email code is spent, so a paused request leaves it for the account holder.
 19125        if (!admitted)
 126        {
 2127            return output.WithError(AuthMessages.PasswordOrCodeIncorrect);
 128        }
 129
 130        // UC-37 step 7, brought forward: the code is spent before anything is issued, in a write only
 131        // one request can win, so two confirmations racing on the same code cannot both issue a set
 132        // of recovery codes and activate.
 17133        if (consumedEmailCode is not null && !await atomicWrites.TryConsumeEmailCodeAsync(consumedEmailCode))
 134        {
 0135            return output.WithError(AuthMessages.PasswordOrCodeIncorrect);
 136        }
 137
 17138        await ReauthenticationBudget.ClearAsync(atomicWrites, person);
 139
 140        // FR-AU-11: every token issued before the second factor was required is revoked — including
 141        // the one confirming it, so the caller signs in again, this time through UC-38. A token that
 142        // predates the factor proves only the password, which is what the factor guards against.
 143        // Before the writes that activate it, so a failure among them costs one extra sign-in at most.
 17144        await atomicWrites.RotateSecurityStampAsync(person);
 145
 146        // UC-37 steps 4-5 (FR-2F-05, FR-2F-20): ten fresh recovery codes, hashed at rest, returned
 147        // once — written before the activation below, per the write-order note in the remarks.
 17148        var recoveryCodes = RecoveryCodes.Issue();
 149
 17150        var issuance = await IssueRecoveryCodesAsync(twoFactorAuth.Id, recoveryCodes);
 151
 17152        if (issuance is not null)
 153        {
 1154            return output.WithErrors(issuance);
 155        }
 156
 157        // UC-37 step 3: every required check passed and the caller now holds their recovery codes,
 158        // so the configuration can be activated.
 16159        twoFactorAuth.IsActive = true;
 160
 16161        var activation = await twoFactorWriter.UpdateAsync(twoFactorAuth);
 162
 16163        if (!activation.Success)
 164        {
 0165            return output.WithErrors(activation.Errors);
 166        }
 167
 16168        return output
 16169            .WithData(new ConfirmTwoFactorAuthCommandOutput
 16170            {
 160171                Enabled = true, RecoveryCodes = recoveryCodes.Select(issued => issued.Code).ToList()
 16172            })
 16173            .WithMessage(TwoFactorMessages.SetupConfirmed);
 55174    }
 175
 176    /// <summary>
 177    ///     Finds the latest not-yet-used, not-yet-expired email code for this configuration that
 178    ///     <paramref name="emailCode" /> hashes to, the same comparison
 179    ///     <see cref="EnableTwoFactorAuthCommandHandler" /> stores it with (AF-37c). Returns
 180    ///     <c>null</c> for a missing code, an incorrect one, or one that has expired or was already
 181    ///     used — UC-37 does not distinguish between them.
 182    /// </summary>
 183    private Task<TwoFactorEmailCode?> FindMatchingEmailCodeAsync(long twoFactorAuthId, string? emailCode) =>
 24184        TwoFactorEmailCodeVerification.FindMatchingAsync(
 24185            emailCodeReader, atomicWrites, twoFactorAuthId, emailCode);
 186
 187    /// <summary>
 188    ///     Persists the digests of <paramref name="recoveryCodes" /> as the configuration's whole
 189    ///     recovery code set, replacing anything already stored against it, in one insert. Returns the
 190    ///     persistence errors, or <c>null</c> on success.
 191    /// </summary>
 192    /// <remarks>
 193    ///     The delete is what makes a retry after a failed attempt safe: a prior run that stored its
 194    ///     codes and then failed to activate left ten rows nobody was ever told about, and without
 195    ///     this the next attempt would leave them in place alongside the ten it does return — the
 196    ///     same "old codes valid alongside new ones" UC-40 exists to prevent.
 197    /// </remarks>
 198    private async Task<IEnumerable<string>?> IssueRecoveryCodesAsync(
 199        long twoFactorAuthId, IReadOnlyCollection<IssuedRecoveryCode> recoveryCodes)
 200    {
 17201        var supersededIds = await recoveryCodeReader.Query()
 17202            .Where(x => x.TwoFactorAuthId == twoFactorAuthId)
 17203            .Select(x => x.Id)
 17204            .ToListAsync();
 205
 17206        if (supersededIds.Count > 0)
 207        {
 1208            var deletion = await recoveryCodeWriter.DeleteRangeAsync(supersededIds);
 209
 1210            if (!deletion.Success)
 211            {
 0212                return deletion.Errors;
 213            }
 214        }
 215
 17216        var creation = await recoveryCodeWriter.CreateRangeAsync(recoveryCodes.Select(recoveryCode =>
 177217            new TwoFactorRecoveryCode
 177218            {
 177219                TwoFactorAuthId = twoFactorAuthId, CodeHash = recoveryCode.CodeHash, Used = false
 177220            }));
 221
 17222        return creation.Success ? null : creation.Errors;
 17223    }
 224}