< Summary - Heimdall API

Information
Class: ArturRios.Heimdall.Command.Handlers.ConfirmTwoFactorAuthCommandHandler
Assembly: ArturRios.Heimdall.Command
File(s): /home/ci/agent/workspace/heimdall-api-docs_main/app/src/Application/ArturRios.Heimdall.Command/Handlers/ConfirmTwoFactorAuthCommandHandler.cs
Line coverage
97%
Covered lines: 66
Uncovered lines: 2
Coverable lines: 68
Total lines: 235
Line coverage: 97%
Branch coverage
93%
Covered branches: 30
Total branches: 32
Branch coverage: 93.7%
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%262695%
FindMatchingEmailCodeAsync(...)100%11100%
IssueRecoveryCodesAsync()83.33%6690.9%

File(s)

/home/ci/agent/workspace/heimdall-api-docs_main/app/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 Email and App 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///         The email code goes first because checking it spends nothing, where an app code that
 35///         verified is spent (<see cref="ITotpCodeVerifier" />): a wrong email code therefore leaves
 36///         the app code usable for the retry. A request whose Email check passed and whose App check
 37///         then failed leaves the email code unspent too, since it is consumed only once every check
 38///         has passed.
 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>One transaction</b> (#209). Spending the email code, clearing the re-authentication
 49///         budget, replacing the security stamp, storing the recovery codes and activating the
 50///         configuration share one <see cref="IWriteTransactions" /> transaction, so a failure among
 51///         them keeps none of it: the email code can be presented again, no session is revoked and no
 52///         codes are left that nobody was shown. The half-write that mattered most — "two-factor is
 53///         active but the caller holds no recovery codes", which would lock a caller out on the strength
 54///         of a request reported as failed — cannot happen at all. The codes are still written before
 55///         the activation, in one <see cref="IAsyncRepository{T, TKey}.CreateRangeAsync" />, as before.
 56///         An accepted app code is the exception: <see cref="ITotpCodeVerifier" /> spends its time step
 57///         before the transaction opens, with the budget's charge, so a failed confirmation still costs
 58///         the caller that code and they present the next one.
 59///     </para>
 60/// </remarks>
 6561public class ConfirmTwoFactorAuthCommandHandler(
 6562    IAsyncReadOnlyRepository<Person, long> personReader,
 6563    IAsyncReadOnlyRepository<TwoFactorAuth, long> twoFactorReader,
 6564    IAsyncRepository<TwoFactorAuth, long> twoFactorWriter,
 6565    IAsyncReadOnlyRepository<TwoFactorEmailCode, long> emailCodeReader,
 6566    IAtomicWrites atomicWrites,
 6567    IAsyncReadOnlyRepository<TwoFactorRecoveryCode, long> recoveryCodeReader,
 6568    IAsyncRepository<TwoFactorRecoveryCode, long> recoveryCodeWriter,
 6569    ITotpCodeVerifier totpCodeVerifier,
 6570    IWriteTransactions transactions)
 71    : ICommandHandlerAsync<ConfirmTwoFactorAuthCommand, ConfirmTwoFactorAuthCommandOutput>
 72{
 73    public async Task<DataOutput<ConfirmTwoFactorAuthCommandOutput?>> HandleAsync(
 74        ConfirmTwoFactorAuthCommand command, CancellationToken cancellationToken = default)
 75    {
 6576        var output = DataOutput<ConfirmTwoFactorAuthCommandOutput?>.New;
 77
 78        // AF-37a (and, implicitly, the Google User / hard-deleted-person case UC-36's AF-37b handles
 79        // separately for enable — here it collapses into "no pending setup", since neither could ever
 80        // hold a row to confirm).
 6581        var person = await personReader.Query()
 6582            .FirstOrDefaultAsync(x => x.PublicId == command.ActingPersonId && !x.IsDeleted);
 83
 6584        var twoFactorAuth = person is null
 6585            ? null
 6586            : await twoFactorReader.Query().FirstOrDefaultAsync(x => x.PersonId == person.Id);
 87
 6588        if (person is null || twoFactorAuth is null)
 89        {
 390            return output.WithError(TwoFactorMessages.NoPendingSetup);
 91        }
 92
 93        // AF-37d.
 6294        if (twoFactorAuth.IsActive)
 95        {
 296            return output.WithError(TwoFactorMessages.AlreadyActive);
 97        }
 98
 99        // FR-AU-13: the codes are a re-authentication like any other made under a bearer token, so
 100        // the check is charged to the account's budget before anything is compared. A paused budget
 101        // still has the codes compared, and refuses them whatever the result.
 60102        var admitted = await ReauthenticationBudget.TryChargeAsync(atomicWrites, person);
 103
 104        // AF-37c: the Email method's code, if the method was selected. Checked first, because looking
 105        // an email code up spends nothing but its guess budget, where an app code that verifies is
 106        // spent (FR-2F-14): the other order let a wrong email code burn a right app code.
 60107        TwoFactorEmailCode? consumedEmailCode = null;
 108
 60109        if (twoFactorAuth.EmailEnabled)
 110        {
 29111            consumedEmailCode = await FindMatchingEmailCodeAsync(twoFactorAuth.Id, command.EmailCode);
 112
 29113            if (consumedEmailCode is null)
 114            {
 20115                return output.WithError(AuthMessages.PasswordOrCodeIncorrect);
 116            }
 117        }
 118
 119        // AF-37b: the App method's code, if the method was selected. ITotpCodeVerifier owns the
 120        // secret, the clock-drift window, and the single-use rule, shared with UC-38/39/40's
 121        // ITwoFactorFactorVerifier.
 40122        if (twoFactorAuth.AppEnabled && !await totpCodeVerifier.VerifyAsync(twoFactorAuth, command.AppCode))
 123        {
 18124            return output.WithError(AuthMessages.PasswordOrCodeIncorrect);
 125        }
 126
 127        // Only now is the pause decided, so that it answers as a wrong code does, and before the
 128        // email code is spent, so a paused request leaves it for the account holder.
 22129        if (!admitted)
 130        {
 2131            return output.WithError(AuthMessages.PasswordOrCodeIncorrect);
 132        }
 133
 20134        var recoveryCodes = RecoveryCodes.Issue();
 135
 136        // Everything from spending the email code to the activation lands together or not at all
 137        // (#209): leaving the transaction by any return or exception before the commit rolls it back.
 20138        await using (var transaction = await transactions.BeginAsync())
 139        {
 140            // UC-37 step 7, brought forward: the code is spent before anything is issued, in a write
 141            // only one request can win, so two confirmations racing on the same code cannot both issue
 142            // a set of recovery codes and activate.
 20143            if (consumedEmailCode is not null && !await atomicWrites.TryConsumeEmailCodeAsync(consumedEmailCode))
 144            {
 0145                return output.WithError(AuthMessages.PasswordOrCodeIncorrect);
 146            }
 147
 20148            await ReauthenticationBudget.ClearAsync(atomicWrites, person);
 149
 150            // FR-AU-11: every token issued before the second factor was required is revoked —
 151            // including the one confirming it, so the caller signs in again, this time through UC-38.
 152            // A token that predates the factor proves only the password, which is what the factor
 153            // guards against.
 20154            await atomicWrites.RotateSecurityStampAsync(person);
 155
 156            // UC-37 steps 4-5 (FR-2F-05, FR-2F-20): ten fresh recovery codes, hashed at rest, returned
 157            // once — written before the activation below.
 20158            var issuance = await IssueRecoveryCodesAsync(twoFactorAuth.Id, recoveryCodes);
 159
 20160            if (issuance is not null)
 161            {
 1162                return output.WithErrors(issuance);
 163            }
 164
 165            // UC-37 step 3: every required check passed and the caller now holds their recovery
 166            // codes, so the configuration can be activated.
 19167            twoFactorAuth.IsActive = true;
 168
 19169            var activation = await twoFactorWriter.UpdateAsync(twoFactorAuth);
 170
 19171            if (!activation.Success)
 172            {
 1173                return output.WithErrors(activation.Errors);
 174            }
 175
 18176            await transaction.CommitAsync();
 177        }
 178
 18179        return output
 18180            .WithData(new ConfirmTwoFactorAuthCommandOutput
 18181            {
 180182                Enabled = true, RecoveryCodes = recoveryCodes.Select(issued => issued.Code).ToList()
 18183            })
 18184            .WithMessage(TwoFactorMessages.SetupConfirmed);
 65185    }
 186
 187    /// <summary>
 188    ///     Finds the latest not-yet-used, not-yet-expired email code for this configuration that
 189    ///     <paramref name="emailCode" /> hashes to, the same comparison
 190    ///     <see cref="EnableTwoFactorAuthCommandHandler" /> stores it with (AF-37c). Returns
 191    ///     <c>null</c> for a missing code, an incorrect one, or one that has expired or was already
 192    ///     used — UC-37 does not distinguish between them.
 193    /// </summary>
 194    private Task<TwoFactorEmailCode?> FindMatchingEmailCodeAsync(long twoFactorAuthId, string? emailCode) =>
 29195        TwoFactorEmailCodeVerification.FindMatchingAsync(
 29196            emailCodeReader, atomicWrites, twoFactorAuthId, emailCode);
 197
 198    /// <summary>
 199    ///     Persists the digests of <paramref name="recoveryCodes" /> as the configuration's whole
 200    ///     recovery code set, replacing anything already stored against it, in one insert. Returns the
 201    ///     persistence errors, or <c>null</c> on success.
 202    /// </summary>
 203    /// <remarks>
 204    ///     The delete keeps the set at exactly the ten this attempt returns, whatever is stored against
 205    ///     the configuration — such as codes a run before #209's transaction stored and then failed to
 206    ///     activate, which nobody was ever told about — so old codes are never valid alongside new ones,
 207    ///     what UC-40 exists to prevent.
 208    /// </remarks>
 209    private async Task<IEnumerable<string>?> IssueRecoveryCodesAsync(
 210        long twoFactorAuthId, IReadOnlyCollection<IssuedRecoveryCode> recoveryCodes)
 211    {
 20212        var supersededIds = await recoveryCodeReader.Query()
 20213            .Where(x => x.TwoFactorAuthId == twoFactorAuthId)
 20214            .Select(x => x.Id)
 20215            .ToListAsync();
 216
 20217        if (supersededIds.Count > 0)
 218        {
 1219            var deletion = await recoveryCodeWriter.DeleteRangeAsync(supersededIds);
 220
 1221            if (!deletion.Success)
 222            {
 0223                return deletion.Errors;
 224            }
 225        }
 226
 20227        var creation = await recoveryCodeWriter.CreateRangeAsync(recoveryCodes.Select(recoveryCode =>
 210228            new TwoFactorRecoveryCode
 210229            {
 210230                TwoFactorAuthId = twoFactorAuthId, CodeHash = recoveryCode.CodeHash, Used = false
 210231            }));
 232
 20233        return creation.Success ? null : creation.Errors;
 20234    }
 235}