< Summary - Heimdall API

Information
Class: ArturRios.Heimdall.Command.Handlers.DisableTwoFactorAuthCommandHandler
Assembly: ArturRios.Heimdall.Command
File(s): /home/runner/work/heimdall-api/heimdall-api/src/Application/ArturRios.Heimdall.Command/Handlers/DisableTwoFactorAuthCommandHandler.cs
Line coverage
96%
Covered lines: 29
Uncovered lines: 1
Coverable lines: 30
Total lines: 100
Line coverage: 96.6%
Branch coverage
92%
Covered branches: 13
Total branches: 14
Branch coverage: 92.8%
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()92.85%141495.83%

File(s)

/home/runner/work/heimdall-api/heimdall-api/src/Application/ArturRios.Heimdall.Command/Handlers/DisableTwoFactorAuthCommandHandler.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 ArturRios.Util.Hashing;
 11using Microsoft.EntityFrameworkCore;
 12
 13namespace ArturRios.Heimdall.Command.Handlers;
 14
 15/// <summary>
 16///     Handles <see cref="DisableTwoFactorAuthCommand" /> (UC-39, FR-2F-11): loads the caller's
 17///     active <see cref="TwoFactorAuth" /> row (AF-39a), verifies the submitted password against the
 18///     stored hash and salt exactly as <c>LoginCommandHandler</c> does (AF-39b), verifies the
 19///     submitted second factor through the same <see cref="ITwoFactorFactorVerifier" />
 20///     <c>VerifyTwoFactorAuthCommandHandler</c> (UC-38) uses (AF-39c), and — only once both checks
 21///     pass — permanently deletes the row, cascading at the database level to its recovery codes and
 22///     email codes.
 23/// </summary>
 24/// <remarks>
 25///     AF-39b (wrong password) and AF-39c (wrong second factor) answer identically —
 26///     <c>AuthMessages.PasswordOrCodeIncorrect</c>, 401 — so the answer never says which of the two
 27///     was wrong. Both checks are charged, together, to the account's re-authentication budget
 28///     (<see cref="ReauthenticationBudget" />, FR-AU-13), and a paused budget is refused the same way,
 29///     after the same checks, so it does not show either.
 30/// </remarks>
 4631public class DisableTwoFactorAuthCommandHandler(
 4632    IAsyncReadOnlyRepository<Person, long> personReader,
 4633    IAsyncReadOnlyRepository<TwoFactorAuth, long> twoFactorReader,
 4634    IAsyncRepository<TwoFactorAuth, long> twoFactorWriter,
 4635    ITwoFactorFactorVerifier factorVerifier,
 4636    IAtomicWrites atomicWrites)
 37    : ICommandHandlerAsync<DisableTwoFactorAuthCommand, DisableTwoFactorAuthCommandOutput>
 38{
 39    public async Task<DataOutput<DisableTwoFactorAuthCommandOutput?>> HandleAsync(
 40        DisableTwoFactorAuthCommand command, CancellationToken cancellationToken = default)
 41    {
 4642        var output = DataOutput<DisableTwoFactorAuthCommandOutput?>.New;
 43
 4644        var person = await personReader.Query()
 4645            .FirstOrDefaultAsync(x => x.PublicId == command.ActingPersonId && !x.IsDeleted);
 46
 4647        var twoFactorAuth = person is null
 4648            ? null
 4649            : await twoFactorReader.Query()
 4650                .FirstOrDefaultAsync(x => x.PersonId == person.Id && x.IsActive);
 51
 52        // AF-39a: nothing to disable — whether because two-factor was never active, or because the
 53        // caller could not be resolved as a live person at all (a Google User, or a bearer token
 54        // naming a person since hard deleted), neither of which could ever hold an active row.
 4655        if (person is null || twoFactorAuth is null)
 56        {
 557            return output.WithError(TwoFactorMessages.NotActive);
 58        }
 59
 60        // FR-AU-13: one charge for the request, before either credential is compared. A paused
 61        // budget still has both compared, as far as they get, and refuses whatever the result.
 4162        var admitted = await ReauthenticationBudget.TryChargeAsync(atomicWrites, person);
 63
 64        // UC-39 step 2 (AF-39b): the same check LoginCommandHandler makes.
 4165        var passwordMatches =
 4166            await PasswordHashGate.Shared.TextMatchesAsync(command.Password, person.PasswordHash, person.Salt);
 67
 68        // UC-39 step 3 (AF-39c): the same second-factor check UC-38 makes, answered as AF-39b is — and
 69        // made whatever the password, so a wrong password takes as long to refuse as a wrong factor
 70        // does. The matched row (if any) never needs marking used here — the whole configuration,
 71        // recovery codes included, is about to be removed regardless of which factor matched.
 4172        var verification = await factorVerifier.VerifyAsync(twoFactorAuth, command.Code, command.RecoveryCode);
 73
 4174        if (!passwordMatches || !verification.Matched || !admitted)
 75        {
 3276            return output.WithError(AuthMessages.PasswordOrCodeIncorrect);
 77        }
 78
 979        await ReauthenticationBudget.ClearAsync(atomicWrites, person);
 80
 81        // FR-AU-11: removing a credential revokes every token issued while it stood, the caller's own
 82        // included, so the account's sessions after this point are only ones signed in without it.
 83        // Before the deletion, so a failed one costs an extra sign-in rather than a live old session.
 984        await atomicWrites.RotateSecurityStampAsync(person);
 85
 86        // UC-39 step 4: only once both checks pass. ON DELETE CASCADE (TwoFactorAuthDbMap,
 87        // TwoFactorRecoveryCodeDbMap, TwoFactorEmailCodeDbMap) removes the recovery codes and email
 88        // codes at the database level — no explicit child deletion needed.
 989        var deletion = await twoFactorWriter.DeleteAsync(twoFactorAuth);
 90
 991        if (!deletion.Success)
 92        {
 093            return output.WithErrors(deletion.Errors);
 94        }
 95
 996        return output
 997            .WithData(new DisableTwoFactorAuthCommandOutput { Disabled = true })
 998            .WithMessage(TwoFactorMessages.Disabled);
 4699    }
 100}