| | | 1 | | using ArturRios.Data.Relational.Core.Interfaces; |
| | | 2 | | using ArturRios.Heimdall.Command.Input; |
| | | 3 | | using ArturRios.Heimdall.Command.Output; |
| | | 4 | | using ArturRios.Heimdall.Command.Services; |
| | | 5 | | using ArturRios.Heimdall.Domain.Entities; |
| | | 6 | | using ArturRios.Heimdall.Domain.Persistence; |
| | | 7 | | using ArturRios.Heimdall.Shared.Messages; |
| | | 8 | | using ArturRios.Mediator.Command.Interfaces; |
| | | 9 | | using ArturRios.Output; |
| | | 10 | | using FluentValidation; |
| | | 11 | | using Microsoft.EntityFrameworkCore; |
| | | 12 | | using OtpNet; |
| | | 13 | | |
| | | 14 | | namespace 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> |
| | 51 | 58 | | public class EnableTwoFactorAuthCommandHandler( |
| | 51 | 59 | | IValidator<EnableTwoFactorAuthCommand> validator, |
| | 51 | 60 | | IAsyncReadOnlyRepository<Person, long> personReader, |
| | 51 | 61 | | IAsyncReadOnlyRepository<TwoFactorAuth, long> twoFactorReader, |
| | 51 | 62 | | IAsyncRepository<TwoFactorAuth, long> twoFactorWriter, |
| | 51 | 63 | | ITwoFactorEmailCodeIssuer emailCodeIssuer, |
| | 51 | 64 | | ITotpSecretProtector totpSecretProtector, |
| | 51 | 65 | | IAtomicWrites atomicWrites, |
| | 51 | 66 | | AdminEmailOptions emailOptions, |
| | 51 | 67 | | 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 | | { |
| | 51 | 76 | | var output = DataOutput<EnableTwoFactorAuthCommandOutput?>.New; |
| | | 77 | | |
| | | 78 | | // AF-36c: validate input shape. |
| | 51 | 79 | | var validation = await validator.ValidateAsync(command); |
| | | 80 | | |
| | 51 | 81 | | if (!validation.IsValid) |
| | | 82 | | { |
| | 6 | 83 | | 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". |
| | 48 | 88 | | var person = await personReader.Query() |
| | 48 | 89 | | .FirstOrDefaultAsync(x => x.PublicId == command.ActingPersonId && !x.IsDeleted); |
| | | 90 | | |
| | 48 | 91 | | if (person is null) |
| | | 92 | | { |
| | 2 | 93 | | return output.WithError(TwoFactorMessages.NotEligible); |
| | | 94 | | } |
| | | 95 | | |
| | 46 | 96 | | var wantsApp = command.Methods.Contains("App", StringComparer.OrdinalIgnoreCase); |
| | 46 | 97 | | var wantsEmail = command.Methods.Contains("Email", StringComparer.OrdinalIgnoreCase); |
| | | 98 | | |
| | 46 | 99 | | var existing = await twoFactorReader.Query() |
| | 46 | 100 | | .FirstOrDefaultAsync(x => x.PersonId == person.Id); |
| | | 101 | | |
| | | 102 | | // AF-36a: an already-active configuration is not touched. |
| | 46 | 103 | | if (existing is { IsActive: true }) |
| | | 104 | | { |
| | 2 | 105 | | 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. |
| | 44 | 110 | | if (wantsEmail && AccountEmailBudget.RefusalOf( |
| | 44 | 111 | | AccountEmailBudget.Peek(person, emailOptions), person, emailOptions, retryAfter) is { } early) |
| | | 112 | | { |
| | 5 | 113 | | 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. |
| | 39 | 124 | | var admitted = await ReauthenticationBudget.TryChargeAsync(atomicWrites, person); |
| | | 125 | | |
| | 39 | 126 | | if (string.IsNullOrWhiteSpace(command.CurrentPassword) || |
| | 39 | 127 | | !await PasswordHashGate.Shared.TextMatchesAsync(command.CurrentPassword, person.PasswordHash, person.Salt) | |
| | 39 | 128 | | !admitted) |
| | | 129 | | { |
| | 8 | 130 | | return output.WithError(AuthMessages.PasswordOrCodeIncorrect); |
| | | 131 | | } |
| | | 132 | | |
| | 31 | 133 | | 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. |
| | 31 | 137 | | if (wantsEmail && AccountEmailBudget.RefusalOf( |
| | 31 | 138 | | await AccountEmailBudget.TryReserveAsync(atomicWrites, person, emailOptions), |
| | 31 | 139 | | person, emailOptions, retryAfter) is { } refusal) |
| | | 140 | | { |
| | 0 | 141 | | 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). |
| | 31 | 145 | | var twoFactorAuth = existing; |
| | | 146 | | |
| | 31 | 147 | | if (twoFactorAuth is null) |
| | | 148 | | { |
| | 26 | 149 | | twoFactorAuth = new TwoFactorAuth { PersonId = person.Id, IsActive = false }; |
| | | 150 | | |
| | 26 | 151 | | var creation = await twoFactorWriter.CreateAsync(twoFactorAuth); |
| | | 152 | | |
| | 26 | 153 | | if (!creation.Success) |
| | | 154 | | { |
| | 0 | 155 | | return output.WithErrors(creation.Errors); |
| | | 156 | | } |
| | | 157 | | } |
| | | 158 | | |
| | 31 | 159 | | 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. |
| | 31 | 164 | | if (!wantsApp) |
| | | 165 | | { |
| | 7 | 166 | | twoFactorAuth.AppEnabled = false; |
| | 7 | 167 | | twoFactorAuth.TotpSecretEncrypted = null; |
| | | 168 | | } |
| | | 169 | | |
| | 31 | 170 | | if (!wantsEmail) |
| | | 171 | | { |
| | 20 | 172 | | twoFactorAuth.EmailEnabled = false; |
| | | 173 | | |
| | 20 | 174 | | var retirement = await emailCodeIssuer.RetireOutstandingAsync(twoFactorAuth.Id); |
| | | 175 | | |
| | 20 | 176 | | if (retirement is not null) |
| | | 177 | | { |
| | 0 | 178 | | 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. |
| | 31 | 184 | | if (wantsApp) |
| | | 185 | | { |
| | 24 | 186 | | var secretKey = KeyGeneration.GenerateRandomKey(TotpSecretLengthInBytes); |
| | 24 | 187 | | var base32Secret = Base32Encoding.ToString(secretKey); |
| | | 188 | | |
| | 24 | 189 | | twoFactorAuth.AppEnabled = true; |
| | 24 | 190 | | twoFactorAuth.TotpSecretEncrypted = totpSecretProtector.Protect(base32Secret); |
| | | 191 | | |
| | 24 | 192 | | 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. |
| | 31 | 198 | | if (wantsEmail) |
| | | 199 | | { |
| | 11 | 200 | | var reissue = await emailCodeIssuer.ReissueAsync(twoFactorAuth, person.Email); |
| | | 201 | | |
| | 11 | 202 | | if (reissue is not null) |
| | | 203 | | { |
| | 0 | 204 | | return output.WithErrors(reissue); |
| | | 205 | | } |
| | | 206 | | |
| | 11 | 207 | | twoFactorAuth.EmailEnabled = true; |
| | 11 | 208 | | responseData.EmailCodeSent = true; |
| | | 209 | | } |
| | | 210 | | |
| | 31 | 211 | | var update = await twoFactorWriter.UpdateAsync(twoFactorAuth); |
| | | 212 | | |
| | 31 | 213 | | if (!update.Success) |
| | | 214 | | { |
| | 0 | 215 | | return output.WithErrors(update.Errors); |
| | | 216 | | } |
| | | 217 | | |
| | 31 | 218 | | return output |
| | 31 | 219 | | .WithData(responseData) |
| | 31 | 220 | | .WithMessage(TwoFactorMessages.SetupInitiated); |
| | 51 | 221 | | } |
| | | 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 | | { |
| | 24 | 237 | | var label = $"{Uri.EscapeDataString(Issuer)}:{Uri.EscapeDataString(email)}"; |
| | | 238 | | |
| | 24 | 239 | | return $"otpauth://totp/{label}" + |
| | 24 | 240 | | $"?secret={Uri.EscapeDataString(base32Secret)}" + |
| | 24 | 241 | | $"&issuer={Uri.EscapeDataString(Issuer)}"; |
| | | 242 | | } |
| | | 243 | | } |