< Summary - Heimdall API

Information
Class: ArturRios.Heimdall.Command.Handlers.LoginCommandHandler
Assembly: ArturRios.Heimdall.Command
File(s): /home/runner/work/heimdall-api/heimdall-api/src/Application/ArturRios.Heimdall.Command/Handlers/LoginCommandHandler.cs
Line coverage
99%
Covered lines: 104
Uncovered lines: 1
Coverable lines: 105
Total lines: 315
Line coverage: 99%
Branch coverage
96%
Covered branches: 29
Total branches: 30
Branch coverage: 96.6%
Method coverage
100%
Covered methods: 7
Fully covered methods: 6
Total methods: 7
Method coverage: 100%
Full method coverage: 85.7%

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor(...)100%11100%
.cctor()100%11100%
CreateDecoy()100%11100%
HandleAsync()100%2020100%
VerifyAgainstDecoy(...)100%11100%
IssueChallengeAsync()87.5%8895%
FindPersonAsync()100%22100%

File(s)

/home/runner/work/heimdall-api/heimdall-api/src/Application/ArturRios.Heimdall.Command/Handlers/LoginCommandHandler.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.Enums;
 7using ArturRios.Heimdall.Domain.Persistence;
 8using ArturRios.Heimdall.Shared.Messages;
 9using ArturRios.Mediator.Command.Interfaces;
 10using ArturRios.Output;
 11using ArturRios.Util.Hashing;
 12using ArturRios.Util.Random;
 13using FluentValidation;
 14using Microsoft.EntityFrameworkCore;
 15
 16namespace ArturRios.Heimdall.Command.Handlers;
 17
 18/// <summary>
 19///     Handles <see cref="LoginCommand" /> (UC-11): locates the person by the lookup their role
 20///     implies (FR-AU-01/02), verifies the password against the stored hash and salt, confirms
 21///     neither the person nor the scope backing them is logically deleted (FR-AU-05/06/07), and
 22///     issues a token carrying their <c>PublicId</c>, role, and scope claims (FR-AU-03/04) — unless
 23///     the person has active two-factor authentication, in which case AF-11g diverts to a short-lived
 24///     challenge token instead (FR-2F-07…FR-2F-08; see UC-38 for how the login is then completed).
 25///     A person whose processing is restricted (NFR-24) is issued a subject-rights-only token
 26///     instead of a full one.
 27/// </summary>
 28/// <remarks>
 29///     <para>
 30///         AF-11a…AF-11e all return the same <see cref="AuthMessages.InvalidCredentials" /> error, so
 31///         the endpoint reveals nothing about which emails exist or which accounts and scopes are
 32///         deleted. The checks nonetheless run in the specification's order, so the code reads
 33///         against UC-11.
 34///     </para>
 35///     <para>
 36///         AF-11a additionally verifies the submitted password against a decoy hash before answering.
 37///         Argon2id is deliberately expensive — 600 MB and 16 threads by the hashing library's
 38///         default — so a request that skipped it because no person matched returned in single-digit
 39///         milliseconds while every other rejection took hundreds. That gap is readable from outside
 40///         and answers the exact question the shared message exists to refuse: whether an address is
 41///         registered, and (by varying <see cref="LoginCommand.ScopeId" />) which scope it sits in.
 42///         One uniform message is not anti-enumeration on its own if the timing disagrees with it.
 43///     </para>
 44/// </remarks>
 14045public class LoginCommandHandler(
 14046    IValidator<LoginCommand> validator,
 14047    IAsyncReadOnlyRepository<Person, long> personReader,
 14048    IAsyncReadOnlyRepository<TwoFactorAuth, long> twoFactorReader,
 14049    IAtomicWrites atomicWrites,
 14050    ITwoFactorEmailCodeIssuer emailCodeIssuer,
 14051    ITwoFactorChallengeTokenIssuer challengeTokenIssuer,
 14052    PersonAuthTokenService personAuthTokenService)
 53    : ICommandHandlerAsync<LoginCommand, LoginCommandOutput>
 54{
 55    private const int MaxFailedLoginAttempts = 10;
 56
 257    private static readonly TimeSpan LockoutDuration = TimeSpan.FromMinutes(15);
 58
 59    // A hash of a random secret nobody knows, used only to spend the same Argon2id work on AF-11a
 60    // that a real password check spends. Generated per process rather than hard-coded so it is never
 61    // a value an attacker could recognise, and computed once so the cost sits at start-up rather
 62    // than on the request that needs to look like every other request.
 263    private static readonly (byte[] Hash, byte[] Salt) Decoy = CreateDecoy();
 64
 65    private static (byte[] Hash, byte[] Salt) CreateDecoy()
 66    {
 267        var hash = Hash.EncodeWithRandomSalt(
 268            CustomRandom.Text(new RandomStringOptions { Length = 32 }), out var salt);
 69
 270        return (hash, salt);
 71    }
 72
 73    public async Task<DataOutput<LoginCommandOutput?>> HandleAsync(LoginCommand command, CancellationToken cancellationT
 74    {
 16275        var output = DataOutput<LoginCommandOutput?>.New;
 76
 77        // AF-11f: validate input shape.
 16278        var validation = await validator.ValidateAsync(command);
 79
 16280        if (!validation.IsValid)
 81        {
 1182            return output.WithErrors(validation.Errors.Select(failure => failure.ErrorMessage));
 83        }
 84
 85        // UC-11 step 2. The lookup deliberately omits an !IsDeleted filter: AF-11c exists to reject a
 86        // logically deleted person, so they must be found first.
 15787        var person = await FindPersonAsync(command);
 88
 89        // AF-11a. The decoy verification is what keeps this path indistinguishable from the ones
 90        // below by response time — see the remarks. Its result is discarded: the answer is already
 91        // decided, and only the work matters.
 15792        if (person is null)
 93        {
 794            await VerifyAgainstDecoy(command.Password);
 95
 796            return output.WithError(AuthMessages.InvalidCredentials);
 97        }
 98
 15099        var now = DateTime.UtcNow;
 100
 101        // FR-AU-09: the attempt is charged before the password is considered, and an account that
 102        // is locked out — or whose remaining attempts parallel requests have already taken — is
 103        // refused without considering it at all. The decoy keeps the cost of that refusal equal to a
 104        // real check's, so a lockout cannot be detected by how quickly it answers.
 150105        if (!await atomicWrites.TryReserveLoginAttemptAsync(
 150106                person, MaxFailedLoginAttempts, now, now.Add(LockoutDuration)))
 107        {
 3108            await VerifyAgainstDecoy(command.Password);
 109
 3110            return output.WithError(AuthMessages.InvalidCredentials);
 111        }
 112
 113        // UC-11 step 3 (AF-11b). The reserved attempt stands as a failure. The tenth has already
 114        // locked the account, in the reserving write, so nothing here has to finish for the lock to
 115        // hold — a comparison the hash gate sheds, or that fails, leaves a lock that expires.
 147116        if (!await PasswordHashGate.Shared.TextMatchesAsync(command.Password, person.PasswordHash, person.Salt))
 117        {
 38118            return output.WithError(AuthMessages.InvalidCredentials);
 119        }
 120
 121        // The password checked out: the threshold counts consecutive failures, so the count — this
 122        // attempt's reservation included — starts again.
 106123        await atomicWrites.ClearLoginAttemptsAsync(person);
 124
 125        // NFR-20: an anonymised identity is nobody's any more. Its address is overwritten, so the
 126        // lookup should never find one; this is the guard for the day that assumption breaks.
 106127        if (person.AnonymisedAt is not null)
 128        {
 1129            return output.WithError(AuthMessages.InvalidCredentials);
 130        }
 131
 132        // NFR-24 (UC-44 step 4): processing is restricted, so the person may not authenticate for
 133        // anything but their own rights over the record. They get a subject-rights-only token rather
 134        // than a refusal, because a refusal left them no way to lift their own restriction (UC-45)
 135        // once every token issued before it had expired. Only the holder of the password learns
 136        // that the account is restricted: every refusal above still answers alike.
 137        //
 138        // The deletion and scope checks below do not apply to such a token. It claims no scope, and
 139        // a suspended subject keeps their rights over the record until it is anonymised (UC-41,
 140        // UC-44) — the use cases behind the token decide what each of them may do.
 105141        var restricted = person.ProcessingRestrictedAt is not null;
 142
 105143        AuthTokenSubject? subject = null;
 144
 105145        if (!restricted)
 146        {
 147            // UC-11 step 4 (AF-11c, FR-AU-05).
 94148            if (person.IsDeleted)
 149            {
 3150                return output.WithError(AuthMessages.InvalidCredentials);
 151            }
 152
 153            // UC-11 step 5 (AF-11d/AF-11e, FR-AU-06/07).
 91154            if (!personAuthTokenService.TryBuildSubject(person, out subject))
 155            {
 4156                return output.WithError(AuthMessages.InvalidCredentials);
 157            }
 158        }
 159
 160        // UC-11 step 6, AF-11g (FR-2F-07): an active 2FA configuration diverts to a challenge token —
 161        // for a restricted person too. A second factor guards the rights the token admits as much as
 162        // it guards anything else, and the email code goes out at the subject's own request, which
 163        // is the consent Art. 18(2) asks for (NFR-24).
 98164        var twoFactorAuth = await twoFactorReader.Query()
 98165            .FirstOrDefaultAsync(x => x.PersonId == person.Id && x.IsActive);
 166
 98167        if (twoFactorAuth is not null)
 168        {
 33169            return await IssueChallengeAsync(output, person, twoFactorAuth);
 170        }
 171
 65172        if (restricted)
 173        {
 9174            var rightsOnly = await personAuthTokenService.IssueSubjectRightsOnlyAsync(person);
 175
 9176            return output
 9177                .WithData(new LoginCommandOutput
 9178                {
 9179                    Token = rightsOnly.Token,
 9180                    ExpiresAt = rightsOnly.ExpiresAt,
 9181                    EmailVerified = person.EmailVerified,
 9182                    ProcessingRestricted = true
 9183                })
 9184                .WithMessage(AuthMessages.SubjectRightsOnlySignIn);
 185        }
 186
 56187        var token = await personAuthTokenService.IssueAsync(subject!);
 188
 56189        return output
 56190            .WithData(new LoginCommandOutput
 56191            {
 56192                Token = token.Token, ExpiresAt = token.ExpiresAt, EmailVerified = person.EmailVerified
 56193            })
 56194            .WithMessage(AuthMessages.LoginSuccessful);
 159195    }
 196
 197    // FR-AU-09, how the count is kept.
 198    //
 199    // The per-IP limiter in Startup bounds how fast one source can guess; the count bounds how many
 200    // guesses an account will accept in total, which is the half a distributed attacker defeats by
 201    // spreading requests across addresses. The lockout is a window rather than a latch an
 202    // administrator has to clear: a permanent lock would hand any anonymous caller a denial of
 203    // service against any account whose address they know. Fifteen minutes cuts a sustained guessing
 204    // rate to a few hundred attempts a day while costing a caller who mistyped one coffee break.
 205    //
 206    // Each attempt reserves itself in the count before the password is derived (IAtomicWrites), and
 207    // a wrong password leaves the reservation standing. Counting after the derivation instead let a
 208    // burst of parallel guesses all read the same count, all pass the "not locked" check, and then
 209    // overwrite each other's increments — ten simultaneous wrong passwords were recorded as one, and
 210    // the lock never came. Reserving first means no more than ten derivations can ever run between
 211    // lockouts, however the requests are timed.
 212
 213    /// <summary>
 214    ///     Runs one password verification against a hash that belongs to nobody, so that AF-11a costs
 215    ///     what AF-11b costs. The salt and hash are computed once, at type initialisation, from a
 216    ///     random secret no one holds; only the per-request Argon2id derivation is repeated, which is
 217    ///     the whole of the expense being matched.
 218    /// </summary>
 219    private static Task VerifyAgainstDecoy(string password) =>
 10220        PasswordHashGate.Shared.TextMatchesAsync(password, Decoy.Hash, Decoy.Salt);
 221
 222    /// <summary>
 223    ///     AF-11g: issues the short-lived challenge token instead of a full one, and — per FR-2F-08 —
 224    ///     a fresh email code when the Email method is enabled, through the same
 225    ///     <see cref="ITwoFactorEmailCodeIssuer" /> UC-36 and UC-46 use, so the retire-then-issue step
 226    ///     cannot drift between them.
 227    /// </summary>
 228    private async Task<DataOutput<LoginCommandOutput?>> IssueChallengeAsync(
 229        DataOutput<LoginCommandOutput?> output, Person person, TwoFactorAuth twoFactorAuth)
 230    {
 231        // FR-2F-10: this challenge becomes the configuration's one outstanding challenge, replacing
 232        // any earlier one, and UC-38 honours a challenge token only while it still names it — so a
 233        // challenge is redeemable once, and never after a newer login.
 234        //
 235        // UC-46's reissue budget is per authentication attempt (FR-2F-13), and so is the guess
 236        // budget of FR-2F-17: this challenge starts with all of both available, whatever the previous
 237        // one spent. All are written before the code goes out, so a send that fails cannot leave the
 238        // budget overstating what was used.
 239        //
 240        // Written as targeted columns rather than the whole row, so a login cannot write back a stale
 241        // copy of what a parallel request has just recorded — an accepted TOTP step, for one.
 33242        var challengeId = Guid.NewGuid();
 243
 33244        await atomicWrites.StartChallengeAsync(twoFactorAuth, challengeId);
 245
 33246        if (twoFactorAuth.EmailEnabled)
 247        {
 17248            var emailCodeErrors = await emailCodeIssuer.ReissueAsync(twoFactorAuth, person.Email);
 249
 17250            if (emailCodeErrors is not null)
 251            {
 0252                return output.WithErrors(emailCodeErrors);
 253            }
 254        }
 255
 33256        var challenge = await challengeTokenIssuer.IssueAsync(
 33257            person.PublicId, (int)person.RoleId, challengeId, person.SecurityStamp);
 258
 33259        var methods = new List<string>();
 260
 33261        if (twoFactorAuth.AppEnabled)
 262        {
 17263            methods.Add("App");
 264        }
 265
 33266        if (twoFactorAuth.EmailEnabled)
 267        {
 17268            methods.Add("Email");
 269        }
 270
 33271        return output
 33272            .WithData(new LoginCommandOutput
 33273            {
 33274                RequiresTwoFactor = true, ChallengeToken = challenge.Token, AvailableMethods = methods
 33275            })
 33276            .WithMessage(AuthMessages.TwoFactorRequired);
 33277    }
 278
 279    /// <summary>
 280    ///     UC-11 step 2: a <c>User</c> is sought within the scope the request names, since their
 281    ///     email is only unique there (FR-AU-01); a <c>ScopeAdmin</c>/<c>SystemAdmin</c> is sought
 282    ///     system-wide among admins (FR-AU-02). Emails are compared case-insensitively (LOWER() in
 283    ///     SQL), matching how uniqueness is enforced when a person is created.
 284    /// </summary>
 285    private async Task<Person?> FindPersonAsync(LoginCommand command)
 286    {
 157287        var email = command.Email.ToLower();
 288
 157289        var query = personReader.Query()
 157290            .Include(person => person.ScopeMembership)
 157291            .ThenInclude(membership => membership!.Scope)
 157292            .Include(person => person.ScopeOwnerships)
 157293            .ThenInclude(ownership => ownership.Scope)
 157294            // Live persons first. Email uniqueness (FR-PE-09) holds among live persons only, so a
 157295            // logically deleted person awaiting anonymisation can share an address with the live
 157296            // person who replaced them. Without an order the database chose between the two, and
 157297            // choosing the deleted one refused the live person for as long as the other remained.
 157298            // A deleted person is still found when nobody live holds the address, which is what
 157299            // lets the deleted-person check reject them.
 157300            .OrderBy(person => person.IsDeleted);
 301
 157302        if (command.ScopeId is null)
 303        {
 142304            return await query.FirstOrDefaultAsync(person =>
 142305                person.Email.ToLower() == email &&
 142306                (person.RoleId == (long)Roles.SystemAdmin || person.RoleId == (long)Roles.ScopeAdmin));
 307        }
 308
 15309        return await query.FirstOrDefaultAsync(person =>
 15310            person.Email.ToLower() == email &&
 15311            person.RoleId == (long)Roles.User &&
 15312            person.ScopeMembership != null &&
 15313            person.ScopeMembership.Scope.PublicId == command.ScopeId);
 157314    }
 315}