< Summary - Heimdall API

Information
Class: ArturRios.Heimdall.Command.Handlers.RegenerateRecoveryCodesCommandHandler
Assembly: ArturRios.Heimdall.Command
File(s): /home/runner/work/heimdall-api/heimdall-api/src/Application/ArturRios.Heimdall.Command/Handlers/RegenerateRecoveryCodesCommandHandler.cs
Line coverage
94%
Covered lines: 48
Uncovered lines: 3
Coverable lines: 51
Total lines: 155
Line coverage: 94.1%
Branch coverage
87%
Covered branches: 21
Total branches: 24
Branch coverage: 87.5%
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()83.33%242492.3%

File(s)

/home/runner/work/heimdall-api/heimdall-api/src/Application/ArturRios.Heimdall.Command/Handlers/RegenerateRecoveryCodesCommandHandler.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="RegenerateRecoveryCodesCommand" /> (UC-40, FR-2F-12): loads the caller's
 16///     active <see cref="TwoFactorAuth" /> row (AF-40a), verifies the submitted second factor through
 17///     the same <see cref="ITwoFactorFactorVerifier" /> <c>VerifyTwoFactorAuthCommandHandler</c>
 18///     (UC-38) and <c>DisableTwoFactorAuthCommandHandler</c> (UC-39) use (AF-40b), and — only once
 19///     that check passes — permanently removes every existing recovery code row for the
 20///     configuration, including any still unused, and issues ten fresh ones through
 21///     <see cref="RecoveryCodes" />, as <c>ConfirmTwoFactorAuthCommandHandler</c> (UC-37) does.
 22/// </summary>
 23/// <remarks>
 24///     Per the Use Case Specification Document's closing note on UC-40: regeneration replaces the
 25///     whole set at once rather than topping up the used ones back to ten — a partial refill would
 26///     leave old, already-distributed codes valid alongside new ones, defeating the point of rotating
 27///     them. The matched row the verifier returns (an email code or a recovery code) never needs
 28///     marking used here — every existing recovery code is about to be deleted regardless, and an
 29///     email code that authorized a regeneration carries no further significance once the request
 30///     succeeds.
 31///     <para>
 32///         <b>Write order.</b> The replacements are inserted before the old set is deleted, in a
 33///         single <see cref="IAsyncRepository{T, TKey}.CreateRangeAsync" /> rather than ten inserts. The
 34///         repository layer exposes no transaction, so ordering is what decides which way a partial
 35///         failure falls: delete-then-create can leave an account with no recovery codes at all,
 36///         while create-then-delete leaves it with the set the caller already holds — the state it
 37///         was in before the request, which is the only safe direction to fail in for a credential
 38///         of last resort.
 39///     </para>
 40///     <para>
 41///         A missing, wrong or already-spent factor answers the one re-authentication refusal
 42///         (<c>AuthMessages.PasswordOrCodeIncorrect</c>, 401), and every attempt is charged to the
 43///         account's re-authentication budget (<see cref="ReauthenticationBudget" />, FR-AU-13).
 44///     </para>
 45/// </remarks>
 3646public class RegenerateRecoveryCodesCommandHandler(
 3647    IAsyncReadOnlyRepository<Person, long> personReader,
 3648    IAsyncReadOnlyRepository<TwoFactorAuth, long> twoFactorReader,
 3649    IAsyncReadOnlyRepository<TwoFactorRecoveryCode, long> recoveryCodeReader,
 3650    IAsyncRepository<TwoFactorRecoveryCode, long> recoveryCodeWriter,
 3651    IAtomicWrites atomicWrites,
 3652    ITwoFactorFactorVerifier factorVerifier)
 53    : ICommandHandlerAsync<RegenerateRecoveryCodesCommand, RegenerateRecoveryCodesCommandOutput>
 54{
 55    public async Task<DataOutput<RegenerateRecoveryCodesCommandOutput?>> HandleAsync(
 56        RegenerateRecoveryCodesCommand command, CancellationToken cancellationToken = default)
 57    {
 3658        var output = DataOutput<RegenerateRecoveryCodesCommandOutput?>.New;
 59
 3660        var person = await personReader.Query()
 3661            .FirstOrDefaultAsync(x => x.PublicId == command.ActingPersonId && !x.IsDeleted);
 62
 3663        var twoFactorAuth = person is null
 3664            ? null
 3665            : await twoFactorReader.Query()
 3666                .FirstOrDefaultAsync(x => x.PersonId == person.Id && x.IsActive);
 67
 68        // AF-40a: nothing to regenerate — whether because two-factor was never activated, or because
 69        // the caller could not be resolved as a live person at all (a Google User, or a bearer token
 70        // naming a person since hard deleted), neither of which could ever hold an active row.
 3671        if (person is null || twoFactorAuth is null)
 72        {
 573            return output.WithError(TwoFactorMessages.NotActive);
 74        }
 75
 76        // FR-AU-13: charged to the re-authentication budget before the factor is compared. A paused
 77        // budget still has it compared, and refuses whatever the result.
 3178        var admitted = await ReauthenticationBudget.TryChargeAsync(atomicWrites, person);
 79
 80        // UC-40 step 2 (AF-40b): the same second-factor check UC-38 and UC-39 make, refused as every
 81        // re-authentication is.
 3182        var verification = await factorVerifier.VerifyAsync(twoFactorAuth, command.Code, command.RecoveryCode);
 83
 3184        if (!verification.Matched || !admitted)
 85        {
 1786            return output.WithError(AuthMessages.PasswordOrCodeIncorrect);
 87        }
 88
 89        // An email code is single-use wherever it is accepted (FR-2F-03): the one that authorized
 90        // this regeneration is retired as UC-38 retires the one that completes a login, rather than
 91        // staying redeemable there for the rest of its ten minutes. A recovery code is spent too,
 92        // although its whole set is replaced below: spending it is what lets only one of two
 93        // simultaneous regenerations — or a regeneration and a login — use it. Both are spent before
 94        // anything is written, by writes only one request can win.
 1495        if (verification.ConsumedEmailCode is { } consumedEmailCode &&
 1496            !await atomicWrites.TryConsumeEmailCodeAsync(consumedEmailCode))
 97        {
 098            return output.WithError(AuthMessages.PasswordOrCodeIncorrect);
 99        }
 100
 14101        if (verification.ConsumedRecoveryCode is { } consumedRecoveryCode &&
 14102            !await atomicWrites.TryConsumeRecoveryCodeAsync(consumedRecoveryCode, DateTime.UtcNow))
 103        {
 3104            return output.WithError(AuthMessages.PasswordOrCodeIncorrect);
 105        }
 106
 11107        await ReauthenticationBudget.ClearAsync(atomicWrites, person);
 108
 109        // FR-AU-11: a new set of recovery codes is a new second factor, and revokes every token issued
 110        // before it, the caller's own included — the codes are usually regenerated because the old
 111        // set may be known to someone else. Before the writes that replace them, as everywhere.
 11112        await atomicWrites.RotateSecurityStampAsync(person);
 113
 114        // UC-40 step 3: the rows this call replaces, captured before anything is written. Taking the
 115        // set first means a run that failed partway through last time — leaving rows behind — has
 116        // those rows swept up here too, rather than accumulating.
 11117        var supersededRecoveryCodeIds = await recoveryCodeReader.Query()
 11118            .Where(x => x.TwoFactorAuthId == twoFactorAuth.Id)
 11119            .Select(x => x.Id)
 11120            .ToListAsync();
 121
 122        // UC-40 steps 4-5 (FR-2F-12, FR-2F-20): ten fresh recovery codes, hashed at rest, returned
 123        // once, written in one insert before the old set is removed — see the write-order note below.
 124        // Always the current shape, so regenerating is also how a set issued before it is replaced.
 11125        var recoveryCodes = RecoveryCodes.Issue();
 126
 11127        var creation = await recoveryCodeWriter.CreateRangeAsync(recoveryCodes.Select(recoveryCode =>
 121128            new TwoFactorRecoveryCode
 121129            {
 121130                TwoFactorAuthId = twoFactorAuth.Id, CodeHash = recoveryCode.CodeHash, Used = false
 121131            }));
 132
 11133        if (!creation.Success)
 134        {
 0135            return output.WithErrors(creation.Errors);
 136        }
 137
 11138        if (supersededRecoveryCodeIds.Count > 0)
 139        {
 8140            var deletion = await recoveryCodeWriter.DeleteRangeAsync(supersededRecoveryCodeIds);
 141
 8142            if (!deletion.Success)
 143            {
 0144                return output.WithErrors(deletion.Errors);
 145            }
 146        }
 147
 11148        return output
 11149            .WithData(new RegenerateRecoveryCodesCommandOutput
 11150            {
 110151                RecoveryCodes = recoveryCodes.Select(issued => issued.Code).ToList()
 11152            })
 11153            .WithMessage(TwoFactorMessages.RecoveryCodesRegenerated);
 36154    }
 155}