| | | 1 | | using ArturRios.Data.Relational.Core.Interfaces; |
| | | 2 | | using ArturRios.Heimdall.Domain.Entities; |
| | | 3 | | using ArturRios.Util.Random; |
| | | 4 | | using Microsoft.EntityFrameworkCore; |
| | | 5 | | |
| | | 6 | | namespace ArturRios.Heimdall.Command.Services; |
| | | 7 | | |
| | | 8 | | /// <summary> |
| | | 9 | | /// Default <see cref="IEmailVerificationService" />: builds a random, time-limited |
| | | 10 | | /// <see cref="EmailVerificationToken" />, persists it, then delegates delivery to the configured |
| | | 11 | | /// <see cref="IEmailVerificationSender" />. A send failure does not undo the created person — the |
| | | 12 | | /// persisted token can be re-sent later (UC-15). |
| | | 13 | | /// </summary> |
| | | 14 | | /// <remarks> |
| | | 15 | | /// The token matches <see cref="PasswordResetService" />: 48 letters and digits drawn from |
| | | 16 | | /// <c>RandomNumberGenerator</c> by <c>CustomRandom.Text</c>. As of ArturRios.Util 1.5.0 the |
| | | 17 | | /// character-set flags are honoured for every character, so the token is alphanumeric — and |
| | | 18 | | /// therefore URL-safe — throughout. |
| | | 19 | | /// </remarks> |
| | 137 | 20 | | public class EmailVerificationService( |
| | 137 | 21 | | IAsyncRepository<EmailVerificationToken, long> tokenWriter, |
| | 137 | 22 | | IAsyncReadOnlyRepository<Scope, long> scopeReader, |
| | 137 | 23 | | IEmailVerificationSender sender, |
| | 137 | 24 | | EmailVerificationOptions options) |
| | | 25 | | : IEmailVerificationService |
| | | 26 | | { |
| | | 27 | | public async Task IssueAndSendAsync(Person person) |
| | | 28 | | { |
| | 29 | 29 | | var token = CustomRandom.Text(new RandomStringOptions |
| | 29 | 30 | | { |
| | 29 | 31 | | Length = 48, |
| | 29 | 32 | | IncludeLowercase = true, |
| | 29 | 33 | | IncludeUppercase = true, |
| | 29 | 34 | | IncludeDigits = true, |
| | 29 | 35 | | IncludeSpecialCharacters = false |
| | 29 | 36 | | }); |
| | | 37 | | |
| | 29 | 38 | | await tokenWriter.CreateAsync(new EmailVerificationToken |
| | 29 | 39 | | { |
| | 29 | 40 | | PersonId = person.Id, |
| | 29 | 41 | | TokenHash = SingleUseTokenHash.Of(token), |
| | 29 | 42 | | ExpiresAt = DateTime.UtcNow.Add(options.TokenLifetime), |
| | 29 | 43 | | Used = false |
| | 29 | 44 | | }); |
| | | 45 | | |
| | | 46 | | // The person's scope may point the link at its own client application. An administrator has |
| | | 47 | | // no scope, and a scope with no link set leaves this null: both take the global setting. |
| | 29 | 48 | | string? linkBase = null; |
| | | 49 | | |
| | 29 | 50 | | if (person.ScopeId is { } scopeId) |
| | | 51 | | { |
| | 11 | 52 | | linkBase = await scopeReader.Query() |
| | 11 | 53 | | .Where(scope => scope.Id == scopeId) |
| | 11 | 54 | | .Select(scope => scope.EmailVerificationUrl) |
| | 11 | 55 | | .FirstOrDefaultAsync(); |
| | | 56 | | } |
| | | 57 | | |
| | 29 | 58 | | await sender.SendAsync(person.Email, token, linkBase); |
| | 29 | 59 | | } |
| | | 60 | | } |