| | | 1 | | using ArturRios.Data.Relational.Core.Interfaces; |
| | | 2 | | using ArturRios.Heimdall.Domain.Entities; |
| | | 3 | | using ArturRios.Heimdall.Domain.Persistence; |
| | | 4 | | using Microsoft.EntityFrameworkCore; |
| | | 5 | | |
| | | 6 | | namespace ArturRios.Heimdall.Command.Services; |
| | | 7 | | |
| | | 8 | | /// <summary> |
| | | 9 | | /// Matches a submitted email two-factor code against the live codes issued for a configuration, |
| | | 10 | | /// and counts the guesses that miss so a code cannot be brute-forced (FR-2F-13). |
| | | 11 | | /// </summary> |
| | | 12 | | /// <remarks> |
| | | 13 | | /// <para> |
| | | 14 | | /// Shared by UC-37's confirmation and <see cref="ITwoFactorFactorVerifier" />, which had a |
| | | 15 | | /// copy each. A static helper rather than another injected service because it holds no state |
| | | 16 | | /// and no configuration — it is the comparison itself, and giving it an interface would only |
| | | 17 | | /// add a registration for callers that always want exactly this behaviour. |
| | | 18 | | /// </para> |
| | | 19 | | /// <para> |
| | | 20 | | /// <b>Why the counter.</b> The code is six digits, so a million values, and lives ten |
| | | 21 | | /// minutes. The per-IP request limiter bounds one source's rate but not an attacker |
| | | 22 | | /// distributed across many, which is cheap. Retiring a code after |
| | | 23 | | /// <see cref="MaxFailedAttempts" /> misses caps what any single issued code can ever be worth |
| | | 24 | | /// and makes further guessing cost a fresh login — which is itself limited, and which mails |
| | | 25 | | /// the account holder a code they did not ask for. |
| | | 26 | | /// </para> |
| | | 27 | | /// </remarks> |
| | | 28 | | public static class TwoFactorEmailCodeVerification |
| | | 29 | | { |
| | | 30 | | /// <summary>Wrong guesses a single issued code tolerates before it is retired.</summary> |
| | | 31 | | public const int MaxFailedAttempts = 5; |
| | | 32 | | |
| | | 33 | | /// <param name="emailCodeReader">Reads the configuration's outstanding codes.</param> |
| | | 34 | | /// <param name="atomicWrites">Charges each guess, and retires a code at the cap.</param> |
| | | 35 | | /// <param name="twoFactorAuthId">The configuration whose codes are considered.</param> |
| | | 36 | | /// <param name="code">The submitted code.</param> |
| | | 37 | | /// <returns> |
| | | 38 | | /// The matching code, or <c>null</c> when none matches. A missing, incorrect, expired, |
| | | 39 | | /// already-used, or exhausted code all answer alike — UC-37 and UC-38 distinguish none of |
| | | 40 | | /// them, and a caller who could tell "wrong" from "no attempts left" would learn how much |
| | | 41 | | /// budget remained. The caller spends the matching code itself, once its own checks pass. |
| | | 42 | | /// </returns> |
| | | 43 | | public static async Task<TwoFactorEmailCode?> FindMatchingAsync( |
| | | 44 | | IAsyncReadOnlyRepository<TwoFactorEmailCode, long> emailCodeReader, |
| | | 45 | | IAtomicWrites atomicWrites, |
| | | 46 | | long twoFactorAuthId, |
| | | 47 | | string? code) |
| | | 48 | | { |
| | 61 | 49 | | if (string.IsNullOrWhiteSpace(code)) |
| | | 50 | | { |
| | 5 | 51 | | return null; |
| | | 52 | | } |
| | | 53 | | |
| | 56 | 54 | | var now = DateTime.UtcNow; |
| | | 55 | | |
| | 56 | 56 | | var live = await emailCodeReader.Query() |
| | 56 | 57 | | .Where(x => x.TwoFactorAuthId == twoFactorAuthId && !x.Used && x.ExpiresAt > now) |
| | 56 | 58 | | .ToListAsync(); |
| | | 59 | | |
| | | 60 | | // A guess is charged to every code currently outstanding, not to one of them: there is no |
| | | 61 | | // single code it is "aimed at", and charging nothing would leave the budget unspent. In |
| | | 62 | | // practice a configuration holds one live code at a time — both issuing paths retire the |
| | | 63 | | // previous one first. A code whose budget is already spent, or that a parallel request has |
| | | 64 | | // just spent, is not compared at all. |
| | 56 | 65 | | var charged = new List<TwoFactorEmailCode>(live.Count); |
| | | 66 | | |
| | 202 | 67 | | foreach (var outstanding in live) |
| | | 68 | | { |
| | 45 | 69 | | if (await atomicWrites.TryChargeEmailCodeGuessAsync(outstanding, MaxFailedAttempts, now)) |
| | | 70 | | { |
| | 40 | 71 | | charged.Add(outstanding); |
| | | 72 | | } |
| | 45 | 73 | | } |
| | | 74 | | |
| | | 75 | | // Through the gate, like every other Argon2id derivation on a request path (TH-03): the code |
| | | 76 | | // is hashed with the same 600 MB parameters as a password, so a comparison outside the bound |
| | | 77 | | // was memory the bound did not know about. |
| | 175 | 78 | | foreach (var candidate in charged) |
| | | 79 | | { |
| | 40 | 80 | | if (await PasswordHashGate.Shared.TextMatchesAsync(code, candidate.CodeHash, candidate.Salt)) |
| | | 81 | | { |
| | 17 | 82 | | return candidate; |
| | | 83 | | } |
| | 23 | 84 | | } |
| | | 85 | | |
| | | 86 | | // A miss: any code it brought to the cap is retired. Bookkeeping either way — the guess was |
| | | 87 | | // wrong, and neither use case defines a flow in which a caller is told it could not be kept. |
| | 122 | 88 | | foreach (var exhausted in charged) |
| | | 89 | | { |
| | 22 | 90 | | await atomicWrites.RetireEmailCodeIfExhaustedAsync(exhausted, MaxFailedAttempts); |
| | | 91 | | } |
| | | 92 | | |
| | 39 | 93 | | return null; |
| | 61 | 94 | | } |
| | | 95 | | } |