| | | 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 | | /// <inheritdoc cref="ITwoFactorFactorVerifier" /> |
| | 323 | 9 | | public class TwoFactorFactorVerifier( |
| | 323 | 10 | | IAsyncReadOnlyRepository<TwoFactorEmailCode, long> emailCodeReader, |
| | 323 | 11 | | IAtomicWrites atomicWrites, |
| | 323 | 12 | | IAsyncReadOnlyRepository<TwoFactorRecoveryCode, long> recoveryCodeReader, |
| | 323 | 13 | | ITotpCodeVerifier totpCodeVerifier) : ITwoFactorFactorVerifier |
| | | 14 | | { |
| | | 15 | | public async Task<TwoFactorFactorVerificationResult> VerifyAsync( |
| | | 16 | | TwoFactorAuth twoFactorAuth, string? code, string? recoveryCode) |
| | | 17 | | { |
| | 201 | 18 | | if (!string.IsNullOrWhiteSpace(recoveryCode)) |
| | | 19 | | { |
| | 71 | 20 | | var matchingRecoveryCode = await FindMatchingRecoveryCodeAsync(twoFactorAuth.Id, recoveryCode); |
| | | 21 | | |
| | 71 | 22 | | return matchingRecoveryCode is null |
| | 71 | 23 | | ? TwoFactorFactorVerificationResult.NoMatch |
| | 71 | 24 | | : TwoFactorFactorVerificationResult.ForRecoveryCode(matchingRecoveryCode); |
| | | 25 | | } |
| | | 26 | | |
| | | 27 | | // ITotpCodeVerifier owns the secret, the window, and the single-use rule; this class only |
| | | 28 | | // decides which factor to try. |
| | 130 | 29 | | if (twoFactorAuth.AppEnabled && await totpCodeVerifier.VerifyAsync(twoFactorAuth, code)) |
| | | 30 | | { |
| | 27 | 31 | | return TwoFactorFactorVerificationResult.AppCodeMatch; |
| | | 32 | | } |
| | | 33 | | |
| | 103 | 34 | | if (twoFactorAuth.EmailEnabled) |
| | | 35 | | { |
| | 37 | 36 | | var matchingEmailCode = await FindMatchingEmailCodeAsync(twoFactorAuth.Id, code); |
| | | 37 | | |
| | 37 | 38 | | if (matchingEmailCode is not null) |
| | | 39 | | { |
| | 12 | 40 | | return TwoFactorFactorVerificationResult.ForEmailCode(matchingEmailCode); |
| | | 41 | | } |
| | | 42 | | } |
| | | 43 | | |
| | 91 | 44 | | return TwoFactorFactorVerificationResult.NoMatch; |
| | 201 | 45 | | } |
| | | 46 | | |
| | | 47 | | /// <summary> |
| | | 48 | | /// Finds a not-yet-used, not-yet-expired email code for this configuration that |
| | | 49 | | /// <paramref name="code" /> hashes to — the same comparison |
| | | 50 | | /// <c>ConfirmTwoFactorAuthCommandHandler</c> uses, through the shared |
| | | 51 | | /// <see cref="TwoFactorEmailCodeVerification" />, which charges the guess against the code's |
| | | 52 | | /// budget first. Returns <c>null</c> for a missing, incorrect, expired, already-used, or |
| | | 53 | | /// exhausted code, all alike. |
| | | 54 | | /// </summary> |
| | | 55 | | private Task<TwoFactorEmailCode?> FindMatchingEmailCodeAsync(long twoFactorAuthId, string? code) => |
| | 37 | 56 | | TwoFactorEmailCodeVerification.FindMatchingAsync( |
| | 37 | 57 | | emailCodeReader, atomicWrites, twoFactorAuthId, code); |
| | | 58 | | |
| | | 59 | | /// <summary> |
| | | 60 | | /// Finds an unused recovery code for this configuration whose hash matches |
| | | 61 | | /// <paramref name="recoveryCode" />, normalised by <see cref="RecoveryCodes.DigestOf" /> — |
| | | 62 | | /// hyphens, spaces and case ignored, in the current shape or the one issued before it; a |
| | | 63 | | /// value of neither shape is not looked up at all. An unknown code and an already-used one |
| | | 64 | | /// both return <c>null</c> here — the query itself excludes used rows, so the two cases are |
| | | 65 | | /// indistinguishable by construction. A match is not spent here: the caller spends it, in a |
| | | 66 | | /// write that only one request can win (<see cref="IAtomicWrites.TryConsumeRecoveryCodeAsync" />). |
| | | 67 | | /// </summary> |
| | | 68 | | private async Task<TwoFactorRecoveryCode?> FindMatchingRecoveryCodeAsync( |
| | | 69 | | long twoFactorAuthId, string recoveryCode) |
| | | 70 | | { |
| | 71 | 71 | | var hash = RecoveryCodes.DigestOf(recoveryCode); |
| | | 72 | | |
| | 71 | 73 | | if (hash is null) |
| | | 74 | | { |
| | 16 | 75 | | return null; |
| | | 76 | | } |
| | | 77 | | |
| | 55 | 78 | | var unused = await recoveryCodeReader.Query() |
| | 55 | 79 | | .Where(x => x.TwoFactorAuthId == twoFactorAuthId && !x.Used) |
| | 55 | 80 | | .ToListAsync(); |
| | | 81 | | |
| | 124 | 82 | | return unused.FirstOrDefault(x => x.CodeHash.SequenceEqual(hash)); |
| | 71 | 83 | | } |
| | | 84 | | } |