< Summary - Heimdall API

Information
Class: ArturRios.Heimdall.Command.Handlers.EnableTwoFactorAuthCommandHandler
Assembly: ArturRios.Heimdall.Command
File(s): /home/runner/work/heimdall-api/heimdall-api/src/Application/ArturRios.Heimdall.Command/Handlers/EnableTwoFactorAuthCommandHandler.cs
Line coverage
93%
Covered lines: 70
Uncovered lines: 5
Coverable lines: 75
Total lines: 243
Line coverage: 93.3%
Branch coverage
87%
Covered branches: 35
Total branches: 40
Branch coverage: 87.5%
Method coverage
100%
Covered methods: 3
Fully covered methods: 2
Total methods: 3
Method coverage: 100%
Full method coverage: 66.6%

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor(...)100%11100%
HandleAsync()87.5%414091.66%
BuildOtpAuthUri(...)100%11100%

File(s)

/home/runner/work/heimdall-api/heimdall-api/src/Application/ArturRios.Heimdall.Command/Handlers/EnableTwoFactorAuthCommandHandler.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 FluentValidation;
 11using Microsoft.EntityFrameworkCore;
 12using OtpNet;
 13
 14namespace ArturRios.Heimdall.Command.Handlers;
 15
 16/// <summary>
 17///     Handles <see cref="EnableTwoFactorAuthCommand" /> (UC-36, FR-2F-01…FR-2F-03): validates input
 18///     (AF-36c), confirms the caller is a live person eligible to opt in (AF-36b), refuses a caller
 19///     whose configuration is already active (AF-36a), refuses an Email selection the person's
 20///     account-email budget would refuse (AF-36f, AF-36g), verifies their current password (AF-36e,
 21///     FR-2F-18), then creates or overwrites (AF-36d) a pending
 22///     <see cref="TwoFactorAuth" /> row for the method(s) selected — generating and encrypting a TOTP
 23///     secret for the App method, and issuing a fresh 6-digit email code for the Email method.
 24/// </summary>
 25/// <remarks>
 26///     <para>
 27///         A Google User is never resolvable here: <see cref="GoogleUser" /> and <see cref="Person" />
 28///         are separate tables with separate <c>PublicId</c> spaces, and a Google-issued token's
 29///         subject names a <see cref="GoogleUser" />, not a <see cref="Person" /> (UC-25 step 8). The
 30///         person lookup below therefore already excludes every Google User — there is no live Google
 31///         session that could reach this handler with a person-shaped actor to begin with — and the
 32///         same lookup covers a bearer token naming a person who no longer exists, exactly as
 33///         <see cref="ResendVerificationEmailCommandHandler" />'s does. Both answer AF-36b's 403 alike,
 34///         so the endpoint never distinguishes "you're a Google User" from "you don't exist" from a
 35///         caller who could exploit the difference.
 36///     </para>
 37///     <para>
 38///         Nothing is activated here — <see cref="TwoFactorAuth.IsActive" /> stays <c>false</c> until
 39///         UC-37 confirms every selected method's code. AF-36d re-initiation is handled by finding the
 40///         caller's not-yet-active row (if any) and updating it in place rather than inserting a
 41///         second one, since §4.9 allows at most one row per person.
 42///     </para>
 43///     <para>
 44///         The password check is charged to the account's re-authentication budget
 45///         (<see cref="ReauthenticationBudget" />, FR-AU-13), and a missing or wrong password, or a
 46///         paused budget, is refused with the one message every re-authentication shares.
 47///     </para>
 48///     <para>
 49///         Selecting the Email method mails a code, so it is charged to the person's account-email
 50///         budget (<see cref="AccountEmailBudget" />, FR-2F-19), whatever else was selected with it.
 51///         The budget is read before the password, as AF-36a is, so a request that could not send costs
 52///         neither an Argon2id derivation nor a re-authentication charge (AF-36f, AF-36g); the send is
 53///         reserved — atomically, so a burst gets one — only once the password has checked out, so a
 54///         token without the password cannot spend the owner's day. App alone mails nothing and is
 55///         never held back by it.
 56///     </para>
 57/// </remarks>
 5158public class EnableTwoFactorAuthCommandHandler(
 5159    IValidator<EnableTwoFactorAuthCommand> validator,
 5160    IAsyncReadOnlyRepository<Person, long> personReader,
 5161    IAsyncReadOnlyRepository<TwoFactorAuth, long> twoFactorReader,
 5162    IAsyncRepository<TwoFactorAuth, long> twoFactorWriter,
 5163    ITwoFactorEmailCodeIssuer emailCodeIssuer,
 5164    ITotpSecretProtector totpSecretProtector,
 5165    IAtomicWrites atomicWrites,
 5166    AdminEmailOptions emailOptions,
 5167    IRetryAfterAdvisor retryAfter)
 68    : ICommandHandlerAsync<EnableTwoFactorAuthCommand, EnableTwoFactorAuthCommandOutput>
 69{
 70    private const string Issuer = "Heimdall";
 71    private const int TotpSecretLengthInBytes = 20; // 160 bits, the RFC 6238-recommended minimum.
 72
 73    public async Task<DataOutput<EnableTwoFactorAuthCommandOutput?>> HandleAsync(
 74        EnableTwoFactorAuthCommand command, CancellationToken cancellationToken = default)
 75    {
 5176        var output = DataOutput<EnableTwoFactorAuthCommandOutput?>.New;
 77
 78        // AF-36c: validate input shape.
 5179        var validation = await validator.ValidateAsync(command);
 80
 5181        if (!validation.IsValid)
 82        {
 683            return output.WithErrors(validation.Errors.Select(failure => failure.ErrorMessage));
 84        }
 85
 86        // AF-36b: the caller must be a live person. See the remarks above for why this alone also
 87        // covers "the caller is a Google User".
 4888        var person = await personReader.Query()
 4889            .FirstOrDefaultAsync(x => x.PublicId == command.ActingPersonId && !x.IsDeleted);
 90
 4891        if (person is null)
 92        {
 293            return output.WithError(TwoFactorMessages.NotEligible);
 94        }
 95
 4696        var wantsApp = command.Methods.Contains("App", StringComparer.OrdinalIgnoreCase);
 4697        var wantsEmail = command.Methods.Contains("Email", StringComparer.OrdinalIgnoreCase);
 98
 4699        var existing = await twoFactorReader.Query()
 46100            .FirstOrDefaultAsync(x => x.PersonId == person.Id);
 101
 102        // AF-36a: an already-active configuration is not touched.
 46103        if (existing is { IsActive: true })
 104        {
 2105            return output.WithError(TwoFactorMessages.AlreadyActive);
 106        }
 107
 108        // AF-36f, AF-36g (FR-2F-19): Email would mail a code the budget would refuse. Read from the
 109        // row already loaded, so it costs nothing; the reservation below is what decides.
 44110        if (wantsEmail && AccountEmailBudget.RefusalOf(
 44111                AccountEmailBudget.Peek(person, emailOptions), person, emailOptions, retryAfter) is { } early)
 112        {
 5113            return output.WithError(early);
 114        }
 115
 116        // AF-36e (FR-2F-18): the current password, missing or wrong alike, through the gate every
 117        // password check goes through (TH-03). Adding a factor requires proof of identity, as removing
 118        // one does (UC-39): the factor chosen here is the one UC-38 will demand of the account holder,
 119        // and a bearer token is not proof that they are present. After AF-36a, so a repeat
 120        // request costs no Argon2id derivation — the same order UC-42 keeps.
 121        //
 122        // FR-AU-13: charged to the re-authentication budget first. A paused budget still has the
 123        // password compared, and refuses it whatever the result, with the answer a wrong one gets.
 39124        var admitted = await ReauthenticationBudget.TryChargeAsync(atomicWrites, person);
 125
 39126        if (string.IsNullOrWhiteSpace(command.CurrentPassword) ||
 39127            !await PasswordHashGate.Shared.TextMatchesAsync(command.CurrentPassword, person.PasswordHash, person.Salt) |
 39128            !admitted)
 129        {
 8130            return output.WithError(AuthMessages.PasswordOrCodeIncorrect);
 131        }
 132
 31133        await ReauthenticationBudget.ClearAsync(atomicWrites, person);
 134
 135        // FR-2F-19: the password checked out, so the send is reserved now, by one conditional write —
 136        // of a burst that all passed the read above, exactly what the budget has left goes through.
 31137        if (wantsEmail && AccountEmailBudget.RefusalOf(
 31138                await AccountEmailBudget.TryReserveAsync(atomicWrites, person, emailOptions),
 31139                person, emailOptions, retryAfter) is { } refusal)
 140        {
 0141            return output.WithError(refusal);
 142        }
 143
 144        // UC-36 step 2: create a pending row, or reuse the existing not-yet-confirmed one (AF-36d).
 31145        var twoFactorAuth = existing;
 146
 31147        if (twoFactorAuth is null)
 148        {
 26149            twoFactorAuth = new TwoFactorAuth { PersonId = person.Id, IsActive = false };
 150
 26151            var creation = await twoFactorWriter.CreateAsync(twoFactorAuth);
 152
 26153            if (!creation.Success)
 154            {
 0155                return output.WithErrors(creation.Errors);
 156            }
 157        }
 158
 31159        var responseData = new EnableTwoFactorAuthCommandOutput();
 160
 161        // AF-36d: re-initiation overwrites the pending configuration with the new selection — a
 162        // method dropped from a prior pending selection is cleared, not left active alongside the
 163        // new one, so UC-37 only ever asks for codes on the methods just (re)selected.
 31164        if (!wantsApp)
 165        {
 7166            twoFactorAuth.AppEnabled = false;
 7167            twoFactorAuth.TotpSecretEncrypted = null;
 168        }
 169
 31170        if (!wantsEmail)
 171        {
 20172            twoFactorAuth.EmailEnabled = false;
 173
 20174            var retirement = await emailCodeIssuer.RetireOutstandingAsync(twoFactorAuth.Id);
 175
 20176            if (retirement is not null)
 177            {
 0178                return output.WithErrors(retirement);
 179            }
 180        }
 181
 182        // UC-36 step 3 (FR-2F-02): a fresh secret every time App is (re)selected, never returned
 183        // again once encrypted.
 31184        if (wantsApp)
 185        {
 24186            var secretKey = KeyGeneration.GenerateRandomKey(TotpSecretLengthInBytes);
 24187            var base32Secret = Base32Encoding.ToString(secretKey);
 188
 24189            twoFactorAuth.AppEnabled = true;
 24190            twoFactorAuth.TotpSecretEncrypted = totpSecretProtector.Protect(base32Secret);
 191
 24192            responseData.OtpAuthUri = BuildOtpAuthUri(person.Email, base32Secret);
 193        }
 194
 195        // UC-36 step 4 (FR-2F-03): a fresh 6-digit code every time Email is (re)selected, through
 196        // the same issuer UC-11's AF-11g and UC-46 use — prior outstanding codes retired first, so
 197        // only the code just mailed can ever be confirmed.
 31198        if (wantsEmail)
 199        {
 11200            var reissue = await emailCodeIssuer.ReissueAsync(twoFactorAuth, person.Email);
 201
 11202            if (reissue is not null)
 203            {
 0204                return output.WithErrors(reissue);
 205            }
 206
 11207            twoFactorAuth.EmailEnabled = true;
 11208            responseData.EmailCodeSent = true;
 209        }
 210
 31211        var update = await twoFactorWriter.UpdateAsync(twoFactorAuth);
 212
 31213        if (!update.Success)
 214        {
 0215            return output.WithErrors(update.Errors);
 216        }
 217
 31218        return output
 31219            .WithData(responseData)
 31220            .WithMessage(TwoFactorMessages.SetupInitiated);
 51221    }
 222
 223    /// <summary>
 224    ///     Builds the <c>otpauth://</c> provisioning URI a caller's authenticator app scans (FR-2F-02).
 225    ///     Otp.NET generates the secret; the URI format itself is RFC-conventional and built here.
 226    /// </summary>
 227    /// <remarks>
 228    ///     The label and the issuer are percent-encoded, the address included. An email is not a
 229    ///     URI-safe string: <c>bob+heimdall@example.com</c> interpolated raw yields a label whose
 230    ///     <c>+</c> an authenticator decodes as a space, so the account is provisioned under a name
 231    ///     that is not the caller's — or, for an address carrying <c>?</c> or <c>#</c>, the URI is cut
 232    ///     short and the secret lost. The secret itself is base32 and needs no encoding, but goes
 233    ///     through the same call rather than relying on that.
 234    /// </remarks>
 235    private static string BuildOtpAuthUri(string email, string base32Secret)
 236    {
 24237        var label = $"{Uri.EscapeDataString(Issuer)}:{Uri.EscapeDataString(email)}";
 238
 24239        return $"otpauth://totp/{label}" +
 24240               $"?secret={Uri.EscapeDataString(base32Secret)}" +
 24241               $"&issuer={Uri.EscapeDataString(Issuer)}";
 242    }
 243}