| | | 1 | | using System.Globalization; |
| | | 2 | | using ArturRios.Data.Relational.Core.Entities; |
| | | 3 | | using ArturRios.Data.Relational.Core.Interfaces; |
| | | 4 | | using ArturRios.Heimdall.Domain.Entities; |
| | | 5 | | using Microsoft.EntityFrameworkCore; |
| | | 6 | | |
| | | 7 | | namespace ArturRios.Heimdall.Command.Services; |
| | | 8 | | |
| | | 9 | | /// <summary> |
| | | 10 | | /// The per-person cooldown on account email (FR-EV-07, FR-PR-06, FR-PR-07, FR-PE-16): at most one |
| | | 11 | | /// email of each kind per person per <see cref="Cooldown" />, whoever asks. Read from |
| | | 12 | | /// <c>HEIMDALL_ADMIN_EMAIL_COOLDOWN_IN_SECONDS</c>; five minutes by default. |
| | | 13 | | /// </summary> |
| | | 14 | | /// <remarks> |
| | | 15 | | /// <para> |
| | | 16 | | /// The cooldown is derived, not stored. A token's issue time is its <c>ExpiresAt</c> minus its |
| | | 17 | | /// kind's lifetime, so "an email of this kind went to this person within the window" is a |
| | | 18 | | /// comparison on token rows that already exist — self-service sends (UC-12, UC-15) included, |
| | | 19 | | /// and whether or not the token has since been used. Changing a token lifetime shifts the |
| | | 20 | | /// derived times of tokens already issued; on a five-minute window that is accepted. |
| | | 21 | | /// </para> |
| | | 22 | | /// <para> |
| | | 23 | | /// It began as the administrators' cooldown (UC-51, UC-52) and keeps that variable name, so no |
| | | 24 | | /// deployment has to change its configuration. Anonymous password recovery (UC-12) asks it the |
| | | 25 | | /// same way. A person's own sends — a verification link again, a setup code, an email-change |
| | | 26 | | /// notice — use its <see cref="Cooldown" /> too, but through <see cref="AccountEmailBudget" />, |
| | | 27 | | /// a conditional write on the person row, because a bearer token can send a burst of them. |
| | | 28 | | /// </para> |
| | | 29 | | /// <para> |
| | | 30 | | /// Values from 0 to 86400 seconds are accepted, and 0 switches the cooldown off. Anything |
| | | 31 | | /// else — unparsable, negative, larger — falls back to the default rather than to no cooldown |
| | | 32 | | /// at all, because a typo should not remove a protection. |
| | | 33 | | /// </para> |
| | | 34 | | /// </remarks> |
| | | 35 | | public class AdminEmailOptions |
| | | 36 | | { |
| | | 37 | | public const string CooldownVariable = "HEIMDALL_ADMIN_EMAIL_COOLDOWN_IN_SECONDS"; |
| | | 38 | | |
| | | 39 | | /// <summary>The cooldown in seconds applied when none, or an unusable one, is configured.</summary> |
| | | 40 | | public const double DefaultCooldownSeconds = 300; |
| | | 41 | | |
| | | 42 | | private const double MaxCooldownSeconds = 86400; |
| | | 43 | | |
| | | 44 | | /// <summary>The window within which a second email of one kind to one person is refused.</summary> |
| | 2426 | 45 | | public TimeSpan Cooldown { get; init; } = TimeSpan.FromSeconds(DefaultCooldownSeconds); |
| | | 46 | | |
| | | 47 | | /// <summary> |
| | | 48 | | /// The configured text when it was present but unusable and the default was applied instead; |
| | | 49 | | /// <c>null</c> when the variable was absent or usable. Start-up warns on it, so a typo does not |
| | | 50 | | /// pass for the value the operator meant. |
| | | 51 | | /// </summary> |
| | 1831 | 52 | | public string? RejectedCooldown { get; init; } |
| | | 53 | | |
| | | 54 | | /// <summary>These options with the cooldown at most <paramref name="limit" />.</summary> |
| | | 55 | | public AdminEmailOptions CappedAt(TimeSpan limit) => |
| | 34 | 56 | | Cooldown <= limit ? this : new AdminEmailOptions { Cooldown = limit, RejectedCooldown = RejectedCooldown }; |
| | | 57 | | |
| | 899 | 58 | | public static AdminEmailOptions FromEnvironment() => From(Environment.GetEnvironmentVariable(CooldownVariable)); |
| | | 59 | | |
| | | 60 | | /// <summary>Reads a configured number of seconds, falling back to the default when it is unusable.</summary> |
| | | 61 | | public static AdminEmailOptions From(string? configured) |
| | | 62 | | { |
| | 919 | 63 | | var usable = |
| | 919 | 64 | | double.TryParse(configured, NumberStyles.Float, CultureInfo.InvariantCulture, out var value) |
| | 919 | 65 | | && value is >= 0 and <= MaxCooldownSeconds; |
| | | 66 | | |
| | 919 | 67 | | return new AdminEmailOptions |
| | 919 | 68 | | { |
| | 919 | 69 | | Cooldown = TimeSpan.FromSeconds(usable ? value : DefaultCooldownSeconds), |
| | 919 | 70 | | RejectedCooldown = usable || string.IsNullOrWhiteSpace(configured) ? null : configured |
| | 919 | 71 | | }; |
| | | 72 | | } |
| | | 73 | | |
| | | 74 | | /// <summary> |
| | | 75 | | /// For a token kind living <paramref name="tokenLifetime" />: a token of that kind was issued |
| | | 76 | | /// inside the cooldown window if and only if its <c>ExpiresAt</c> is later than this. |
| | | 77 | | /// </summary> |
| | 58 | 78 | | public DateTime CooldownThreshold(DateTime now, TimeSpan tokenLifetime) => now + tokenLifetime - Cooldown; |
| | | 79 | | |
| | | 80 | | /// <summary> |
| | | 81 | | /// Whether any token of this kind (used, superseded or live, self-service or not) was issued for |
| | | 82 | | /// the person with internal id <paramref name="personId" /> inside the cooldown window, so that |
| | | 83 | | /// UC-51 and UC-52 ask the question the same way (AF-51e, AF-52e). |
| | | 84 | | /// </summary> |
| | | 85 | | public async Task<bool> IsRunningAsync<TToken>( |
| | | 86 | | IAsyncReadOnlyRepository<TToken, long> tokenReader, |
| | | 87 | | long personId, |
| | | 88 | | TimeSpan tokenLifetime, |
| | | 89 | | DateTime now, |
| | | 90 | | CancellationToken cancellationToken = default) |
| | | 91 | | where TToken : Entity<long>, IPersonToken |
| | | 92 | | { |
| | 57 | 93 | | var threshold = CooldownThreshold(now, tokenLifetime); |
| | | 94 | | |
| | 57 | 95 | | return await tokenReader.Query() |
| | 57 | 96 | | .AnyAsync(x => x.PersonId == personId && x.ExpiresAt > threshold, cancellationToken); |
| | 57 | 97 | | } |
| | | 98 | | } |