| | | 1 | | using Anichron.API.Settings; |
| | | 2 | | using Anichron.Core.Data; |
| | | 3 | | using Anichron.Core.Domain; |
| | | 4 | | |
| | | 5 | | namespace Anichron.API.Services; |
| | | 6 | | |
| | | 7 | | public interface ILockoutService |
| | | 8 | | { |
| | | 9 | | bool IsLockedOut(User user, Instant now); |
| | | 10 | | Task RecordFailedAttemptAsync(User user, Instant now, CancellationToken ct); |
| | | 11 | | // Mutates the entity only — the caller is responsible for persisting inside a transaction. |
| | | 12 | | void PrepareReset(User user); |
| | | 13 | | } |
| | | 14 | | |
| | 10 | 15 | | public sealed class LockoutService(IUnitOfWork unitOfWork) : ILockoutService |
| | | 16 | | { |
| | | 17 | | public bool IsLockedOut(User user, Instant now) |
| | 4 | 18 | | => user.LockedUntil is { } lockedUntil && lockedUntil > now; |
| | | 19 | | |
| | | 20 | | public async Task RecordFailedAttemptAsync(User user, Instant now, CancellationToken ct) |
| | 5 | 21 | | { |
| | 5 | 22 | | user.FailedLoginAttempts++; |
| | | 23 | | // When backoff = 0 (below threshold), LockedUntil = now is intentional — IsLockedOut uses strict >, |
| | | 24 | | // so the user is not locked out, but the timestamp is always written for consistency. |
| | 5 | 25 | | user.LockedUntil = now.Plus(Duration.FromSeconds(ComputeBackoffSeconds(user.FailedLoginAttempts))); |
| | 5 | 26 | | await unitOfWork.SaveChangesAsync(ct); |
| | 5 | 27 | | } |
| | | 28 | | |
| | | 29 | | public void PrepareReset(User user) |
| | 1 | 30 | | { |
| | 1 | 31 | | user.FailedLoginAttempts = 0; |
| | 1 | 32 | | user.LockedUntil = null; |
| | 1 | 33 | | } |
| | | 34 | | |
| | 12 | 35 | | internal static int ComputeBackoffSeconds(int failedAttempts) => failedAttempts switch |
| | 12 | 36 | | { |
| | 5 | 37 | | <= AppDefaults.Lockout.AllowedAttempts => 0, |
| | 3 | 38 | | >= AppDefaults.Lockout.MaxAttempts => AppDefaults.Lockout.MaxSeconds, |
| | 4 | 39 | | _ => (int)Math.Pow(AppDefaults.Lockout.BackoffBase, failedAttempts - AppDefaults.Lockout.AllowedAttempts), |
| | 12 | 40 | | }; |
| | | 41 | | } |