| | | 1 | | using Anichron.API.Security; |
| | | 2 | | using Anichron.API.Settings; |
| | | 3 | | using Anichron.Core.Data; |
| | | 4 | | using Anichron.Core.Data.Repository; |
| | | 5 | | using Anichron.Core.Domain; |
| | | 6 | | using Microsoft.Extensions.Options; |
| | | 7 | | using System.Security.Cryptography; |
| | | 8 | | |
| | | 9 | | namespace Anichron.API.Services; |
| | | 10 | | |
| | | 11 | | public interface ITokenService |
| | | 12 | | { |
| | | 13 | | Task<AuthTokens> IssueAsync(User user, CancellationToken ct); |
| | | 14 | | Task<AuthResult<AuthTokens>> RefreshAsync(string rawToken, CancellationToken ct); |
| | | 15 | | Task RevokeAsync(string rawToken, CancellationToken ct); |
| | | 16 | | Task MarkAllSessionsRevokedAsync(Guid userId, Instant revokedAt, CancellationToken ct); |
| | | 17 | | } |
| | | 18 | | |
| | 15 | 19 | | public sealed class TokenService( |
| | 15 | 20 | | IRefreshTokenRepository tokens, |
| | 15 | 21 | | IUnitOfWork unitOfWork, |
| | 15 | 22 | | IClock clock, |
| | 15 | 23 | | IGuidFactory guidFactory, |
| | 15 | 24 | | IOptions<JwtSettings> options, |
| | 15 | 25 | | IJwtFactory jwtFactory) : ITokenService |
| | | 26 | | { |
| | 15 | 27 | | private readonly JwtSettings settings = options.Value; |
| | | 28 | | |
| | | 29 | | public async Task<AuthTokens> IssueAsync(User user, CancellationToken ct) |
| | 6 | 30 | | { |
| | 6 | 31 | | var rawToken = GenerateRefreshToken(); |
| | 6 | 32 | | var now = clock.GetCurrentInstant(); |
| | | 33 | | |
| | 6 | 34 | | tokens.Add(new RefreshToken |
| | 6 | 35 | | { |
| | 6 | 36 | | Id = guidFactory.NewGuid(), |
| | 6 | 37 | | UserId = user.Id, |
| | 6 | 38 | | TokenHash = HashToken(rawToken), |
| | 6 | 39 | | CreatedAt = now, |
| | 6 | 40 | | ExpiresAt = now.Plus(Duration.FromDays(settings.RefreshTokenDays)), |
| | 6 | 41 | | }); |
| | | 42 | | |
| | 6 | 43 | | await unitOfWork.SaveChangesAsync(ct); |
| | | 44 | | |
| | 5 | 45 | | return new AuthTokens(jwtFactory.Create(user), rawToken); |
| | 5 | 46 | | } |
| | | 47 | | |
| | | 48 | | public async Task<AuthResult<AuthTokens>> RefreshAsync(string rawToken, CancellationToken ct) |
| | 7 | 49 | | { |
| | 7 | 50 | | var tokenHash = HashToken(rawToken); |
| | 7 | 51 | | var now = clock.GetCurrentInstant(); |
| | | 52 | | |
| | 7 | 53 | | var stored = await tokens.FindByHashWithUserAsync(tokenHash, ct); |
| | | 54 | | |
| | 7 | 55 | | if (stored is null) |
| | 1 | 56 | | return AuthResult.Fail<AuthTokens>(AuthError.TokenInvalid); |
| | | 57 | | |
| | 6 | 58 | | if (stored.RevokedAt.HasValue) |
| | 1 | 59 | | { |
| | | 60 | | // Revoked token replayed — possible theft, wipe all sessions |
| | 1 | 61 | | await tokens.RevokeAllActiveByUserIdAsync(stored.UserId, now, ct); |
| | 1 | 62 | | return AuthResult.Fail<AuthTokens>(AuthError.TokenInvalid); |
| | | 63 | | } |
| | | 64 | | |
| | 5 | 65 | | if (stored.ExpiresAt <= now) |
| | 1 | 66 | | return AuthResult.Fail<AuthTokens>(AuthError.TokenInvalid); |
| | | 67 | | |
| | 4 | 68 | | if (stored.User.IsDisabled) |
| | 1 | 69 | | return AuthResult.Fail<AuthTokens>(AuthError.AccountDisabled); |
| | | 70 | | |
| | 3 | 71 | | if (stored.User.LockedUntil is { } lockedUntil && lockedUntil > now) |
| | 1 | 72 | | { |
| | 1 | 73 | | return AuthResult.Locked<AuthTokens>( |
| | 1 | 74 | | Math.Max(1, (int)Math.Ceiling((lockedUntil - now).TotalSeconds))); |
| | | 75 | | } |
| | | 76 | | |
| | | 77 | | // Mark old token revoked; IssueAsync's SaveChangesAsync persists both |
| | 2 | 78 | | stored.RevokedAt = now; |
| | | 79 | | |
| | 2 | 80 | | return AuthResult.Ok(await IssueAsync(stored.User, ct)); |
| | 6 | 81 | | } |
| | | 82 | | |
| | | 83 | | public async Task RevokeAsync(string rawToken, CancellationToken ct) |
| | 3 | 84 | | { |
| | 3 | 85 | | var tokenHash = HashToken(rawToken); |
| | 3 | 86 | | var stored = await tokens.FindByHashAsync(tokenHash, ct); |
| | 3 | 87 | | if (stored is null) |
| | 1 | 88 | | return; |
| | 2 | 89 | | if (stored.RevokedAt.HasValue) |
| | 1 | 90 | | return; |
| | | 91 | | |
| | 1 | 92 | | stored.RevokedAt = clock.GetCurrentInstant(); |
| | 1 | 93 | | await unitOfWork.SaveChangesAsync(ct); |
| | 3 | 94 | | } |
| | | 95 | | |
| | | 96 | | public Task MarkAllSessionsRevokedAsync(Guid userId, Instant revokedAt, CancellationToken ct) |
| | 1 | 97 | | => tokens.RevokeAllActiveByUserIdAsync(userId, revokedAt, ct); |
| | | 98 | | |
| | | 99 | | private static string GenerateRefreshToken() |
| | 6 | 100 | | => Convert.ToBase64String(RandomNumberGenerator.GetBytes(64)); |
| | | 101 | | |
| | | 102 | | private static string HashToken(string rawToken) |
| | 16 | 103 | | => Convert.ToBase64String(SHA256.HashData(Convert.FromBase64String(rawToken))); |
| | | 104 | | } |