| | | 1 | | using ArturRios.Data.Relational.Core.Interfaces; |
| | | 2 | | using ArturRios.Heimdall.Domain.Entities; |
| | | 3 | | using ArturRios.Heimdall.Shared.Security; |
| | | 4 | | using ArturRios.Util.Random; |
| | | 5 | | using Microsoft.EntityFrameworkCore; |
| | | 6 | | |
| | | 7 | | namespace ArturRios.Heimdall.Command.Services; |
| | | 8 | | |
| | | 9 | | /// <inheritdoc cref="ITwoFactorEmailCodeIssuer" /> |
| | 228 | 10 | | public class TwoFactorEmailCodeIssuer( |
| | 228 | 11 | | IAsyncReadOnlyRepository<TwoFactorEmailCode, long> emailCodeReader, |
| | 228 | 12 | | IAsyncRepository<TwoFactorEmailCode, long> emailCodeWriter, |
| | 228 | 13 | | ITwoFactorEmailSender emailSender) : ITwoFactorEmailCodeIssuer |
| | | 14 | | { |
| | | 15 | | private const int CodeLength = 6; |
| | | 16 | | |
| | | 17 | | public async Task<IEnumerable<string>?> ReissueAsync(TwoFactorAuth twoFactorAuth, string email) |
| | | 18 | | { |
| | 52 | 19 | | var retirement = await RetireOutstandingAsync(twoFactorAuth.Id); |
| | | 20 | | |
| | 52 | 21 | | if (retirement is not null) |
| | | 22 | | { |
| | 0 | 23 | | return retirement; |
| | | 24 | | } |
| | | 25 | | |
| | 52 | 26 | | var code = CustomRandom.Text(new RandomStringOptions |
| | 52 | 27 | | { |
| | 52 | 28 | | Length = CodeLength, |
| | 52 | 29 | | IncludeDigits = true, |
| | 52 | 30 | | IncludeLowercase = false, |
| | 52 | 31 | | IncludeUppercase = false, |
| | 52 | 32 | | IncludeSpecialCharacters = false |
| | 52 | 33 | | }); |
| | | 34 | | |
| | | 35 | | // Under the process-wide Argon2id bound (TH-03), as every derivation on a request path is. |
| | 52 | 36 | | var (codeHash, salt) = await PasswordHashGate.Shared.EncodeWithRandomSaltAsync(code); |
| | | 37 | | |
| | 52 | 38 | | var creation = await emailCodeWriter.CreateAsync(new TwoFactorEmailCode |
| | 52 | 39 | | { |
| | 52 | 40 | | TwoFactorAuthId = twoFactorAuth.Id, |
| | 52 | 41 | | CodeHash = codeHash, |
| | 52 | 42 | | Salt = salt, |
| | 52 | 43 | | ExpiresAt = DateTime.UtcNow.Add(TwoFactorLifetimes.EmailCode), |
| | 52 | 44 | | Used = false |
| | 52 | 45 | | }); |
| | | 46 | | |
| | 52 | 47 | | if (!creation.Success) |
| | | 48 | | { |
| | 0 | 49 | | return creation.Errors; |
| | | 50 | | } |
| | | 51 | | |
| | | 52 | | // Delivery is attempted only after the code is persisted, and its outcome is deliberately |
| | | 53 | | // not reported. A caller who receives nothing asks for another (UC-46) or logs in again; |
| | | 54 | | // telling them the send failed would say something about the address, and telling them it |
| | | 55 | | // succeeded would not be true of the inbox anyway. |
| | 52 | 56 | | await emailSender.SendAsync(email, code); |
| | | 57 | | |
| | 52 | 58 | | return null; |
| | 52 | 59 | | } |
| | | 60 | | |
| | | 61 | | public async Task<IEnumerable<string>?> RetireOutstandingAsync(long twoFactorAuthId) |
| | | 62 | | { |
| | 73 | 63 | | var now = DateTime.UtcNow; |
| | | 64 | | |
| | 73 | 65 | | var live = await emailCodeReader.Query() |
| | 73 | 66 | | .Where(x => x.TwoFactorAuthId == twoFactorAuthId && !x.Used && x.ExpiresAt > now) |
| | 73 | 67 | | .ToListAsync(); |
| | | 68 | | |
| | 188 | 69 | | foreach (var outstanding in live) |
| | | 70 | | { |
| | 21 | 71 | | outstanding.Used = true; |
| | | 72 | | |
| | 21 | 73 | | var retirement = await emailCodeWriter.UpdateAsync(outstanding); |
| | | 74 | | |
| | 21 | 75 | | if (!retirement.Success) |
| | | 76 | | { |
| | 0 | 77 | | return retirement.Errors; |
| | | 78 | | } |
| | | 79 | | } |
| | | 80 | | |
| | 73 | 81 | | return null; |
| | 73 | 82 | | } |
| | | 83 | | } |