| | | 1 | | using ArturRios.Heimdall.Domain.Entities; |
| | | 2 | | using ArturRios.Heimdall.Domain.Persistence; |
| | | 3 | | |
| | | 4 | | namespace ArturRios.Heimdall.Command.Services; |
| | | 5 | | |
| | | 6 | | /// <summary> |
| | | 7 | | /// FR-PE-16: the per-person budget on the account emails a person's own requests can have sent — |
| | | 8 | | /// a verification link asked for again (UC-15), a two-factor setup code (UC-36, email method) and |
| | | 9 | | /// the notice an own email change sends to the previous address (UC-08, FR-PE-15). At most one per |
| | | 10 | | /// <see cref="AdminEmailOptions.Cooldown" />, and at most <see cref="MaxPerDay" /> in each day-long window, which |
| | | 11 | | /// starts at its first send. |
| | | 12 | | /// </summary> |
| | | 13 | | /// <remarks> |
| | | 14 | | /// <para> |
| | | 15 | | /// <b>Why it exists.</b> Each of these can be repeated with nothing but a bearer token (and, for |
| | | 16 | | /// the email change, the account's password), and the address they go to is one the account |
| | | 17 | | /// holder chose without proving it is theirs. A cooldown alone still let one account send an |
| | | 18 | | /// address hundreds of mails a day, and an email change switched back and forth mails the |
| | | 19 | | /// address it leaves every time. One budget across the three bounds the account, not each |
| | | 20 | | /// endpoint. |
| | | 21 | | /// </para> |
| | | 22 | | /// <para> |
| | | 23 | | /// <b>Atomic.</b> The send is reserved by one conditional write before anything is issued or |
| | | 24 | | /// mailed, so a burst of simultaneous requests gets exactly what the budget has left — a |
| | | 25 | | /// check that read the token rows first let every request of a burst through. |
| | | 26 | | /// </para> |
| | | 27 | | /// <para> |
| | | 28 | | /// The window is fixed from its first send, so "a day" is a day-long window, not any 24 hours: |
| | | 29 | | /// five just before a window ends and five just after it are ten within a day. A refusal says |
| | | 30 | | /// when to come back through <see cref="IRetryAfterAdvisor" />. A send reserved and then not |
| | | 31 | | /// made, because a later step failed, is not given back: the cost is one slot, and it fails |
| | | 32 | | /// closed. |
| | | 33 | | /// </para> |
| | | 34 | | /// <para> |
| | | 35 | | /// Administrator-initiated mail (UC-51, UC-52) is not charged here: it keeps |
| | | 36 | | /// <see cref="AdminEmailOptions" />' own per-kind cooldown, and an administrator's mail must |
| | | 37 | | /// stay possible when a person has spent their own day. |
| | | 38 | | /// </para> |
| | | 39 | | /// </remarks> |
| | | 40 | | public static class AccountEmailBudget |
| | | 41 | | { |
| | | 42 | | /// <summary>The self-service account emails one person may have sent in one <see cref="Window" />.</summary> |
| | | 43 | | public const int MaxPerDay = 5; |
| | | 44 | | |
| | | 45 | | /// <summary>How long a window lasts, from the first send in it.</summary> |
| | 2 | 46 | | public static readonly TimeSpan Window = TimeSpan.FromDays(1); |
| | | 47 | | |
| | | 48 | | /// <summary> |
| | | 49 | | /// Reserves one send for <paramref name="person" />. Anything but |
| | | 50 | | /// <see cref="AccountEmailReservation.Reserved" /> means nothing may be sent. |
| | | 51 | | /// </summary> |
| | | 52 | | public static Task<AccountEmailReservation> TryReserveAsync( |
| | | 53 | | IAtomicWrites atomicWrites, Person person, AdminEmailOptions options) |
| | | 54 | | { |
| | 85 | 55 | | var now = DateTime.UtcNow; |
| | | 56 | | |
| | 85 | 57 | | return atomicWrites.TryReserveAccountEmailAsync(person, MaxPerDay, now, now - options.Cooldown, now + Window); |
| | | 58 | | } |
| | | 59 | | |
| | | 60 | | /// <summary> |
| | | 61 | | /// What a reservation would answer, read from the instance the caller holds, without charging |
| | | 62 | | /// anything — so a request that could not send is refused before it pays for a password check. |
| | | 63 | | /// Advisory only: the reservation is what decides. |
| | | 64 | | /// </summary> |
| | | 65 | | public static AccountEmailReservation Peek(Person person, AdminEmailOptions options) |
| | | 66 | | { |
| | 20 | 67 | | var now = DateTime.UtcNow; |
| | | 68 | | |
| | 20 | 69 | | if (person.AccountEmailWindowEndsAt > now && person.AccountEmailsSent >= MaxPerDay) |
| | | 70 | | { |
| | 2 | 71 | | return AccountEmailReservation.DailyLimitReached; |
| | | 72 | | } |
| | | 73 | | |
| | 18 | 74 | | return person.AccountEmailLastSentAt > now - options.Cooldown |
| | 18 | 75 | | ? AccountEmailReservation.CoolingDown |
| | 18 | 76 | | : AccountEmailReservation.Reserved; |
| | | 77 | | } |
| | | 78 | | |
| | | 79 | | /// <summary> |
| | | 80 | | /// The refusal a reservation answers with, or <c>null</c> when it was reserved. A refusal also |
| | | 81 | | /// advises how long to wait: until the cooldown ends for the cooldown, and for the daily limit |
| | | 82 | | /// until the window ends or the cooldown does, whichever is later, since a send is accepted |
| | | 83 | | /// only once both have — read from <paramref name="person" />, which a refused reservation has |
| | | 84 | | /// brought in line with the stored row. |
| | | 85 | | /// </summary> |
| | | 86 | | public static string? RefusalOf( |
| | | 87 | | AccountEmailReservation reservation, Person person, AdminEmailOptions options, IRetryAfterAdvisor retryAfter) |
| | | 88 | | { |
| | 109 | 89 | | var retryAt = reservation switch |
| | 109 | 90 | | { |
| | 14 | 91 | | AccountEmailReservation.DailyLimitReached => Later( |
| | 14 | 92 | | person.AccountEmailWindowEndsAt, person.AccountEmailLastSentAt + options.Cooldown), |
| | 28 | 93 | | AccountEmailReservation.CoolingDown => person.AccountEmailLastSentAt + options.Cooldown, |
| | 67 | 94 | | _ => null |
| | 109 | 95 | | }; |
| | | 96 | | |
| | 109 | 97 | | if (retryAt is { } at) |
| | | 98 | | { |
| | 42 | 99 | | retryAfter.Advise(at - DateTime.UtcNow); |
| | | 100 | | } |
| | | 101 | | |
| | 109 | 102 | | return reservation switch |
| | 109 | 103 | | { |
| | 28 | 104 | | AccountEmailReservation.CoolingDown => Shared.Messages.AuthMessages.EmailSentRecently, |
| | 14 | 105 | | AccountEmailReservation.DailyLimitReached => Shared.Messages.AuthMessages.AccountEmailLimitReached, |
| | 67 | 106 | | _ => null |
| | 109 | 107 | | }; |
| | | 108 | | } |
| | | 109 | | |
| | | 110 | | private static DateTime? Later(DateTime? first, DateTime? second) => |
| | 14 | 111 | | first is null ? second : second is null ? first : first > second ? first : second; |
| | | 112 | | } |