using System.IdentityModel.Tokens.Jwt; using System.Security.Claims; using System.Security.Cryptography; using System.Text; using LctMonolith.Application.Options; using LctMonolith.Database.UnitOfWork; using LctMonolith.Models; using LctMonolith.Services.Contracts; using LctMonolith.Services.Models; using Microsoft.AspNetCore.Identity; using Microsoft.Extensions.Options; using Microsoft.IdentityModel.Tokens; using Serilog; namespace LctMonolith.Services; /// /// Issues and refreshes JWT + refresh tokens. /// public class TokenService : ITokenService { private readonly IUnitOfWork _uow; private readonly UserManager _userManager; private readonly JwtOptions _options; private readonly SigningCredentials _creds; public TokenService(IUnitOfWork uow, UserManager userManager, IOptions options) { _uow = uow; _userManager = userManager; _options = options.Value; var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_options.Key)); _creds = new SigningCredentials(key, SecurityAlgorithms.HmacSha256); } public async Task IssueAsync(AppUser user, CancellationToken ct = default) { var now = DateTime.UtcNow; var accessExp = now.AddMinutes(_options.AccessTokenMinutes); var refreshExp = now.AddDays(_options.RefreshTokenDays); var claims = new List { new(JwtRegisteredClaimNames.Sub, user.Id.ToString()), new(ClaimTypes.NameIdentifier, user.Id.ToString()), new(JwtRegisteredClaimNames.Email, user.Email ?? string.Empty), new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()), }; var jwt = new JwtSecurityToken( issuer: _options.Issuer, audience: _options.Audience, claims: claims, notBefore: now, expires: accessExp, signingCredentials: _creds); var accessToken = new JwtSecurityTokenHandler().WriteToken(jwt); var refreshToken = GenerateSecureToken(); var rt = new RefreshToken { Token = refreshToken, UserId = user.Id, ExpiresAt = refreshExp }; await _uow.RefreshTokens.AddAsync(rt, ct); await _uow.SaveChangesAsync(ct); return new TokenPair(accessToken, accessExp, refreshToken, refreshExp); } public async Task RefreshAsync(string refreshToken, CancellationToken ct = default) { var token = await _uow.RefreshTokens.Query(r => r.Token == refreshToken).FirstOrDefaultAsync(ct); if (token == null || token.IsRevoked || token.ExpiresAt < DateTime.UtcNow) throw new SecurityTokenException("Invalid refresh token"); var user = await _userManager.FindByIdAsync(token.UserId.ToString()) ?? throw new SecurityTokenException("User not found"); token.IsRevoked = true; // rotate await _uow.SaveChangesAsync(ct); return await IssueAsync(user, ct); } public async Task RevokeAsync(string refreshToken, CancellationToken ct = default) { var token = await _uow.RefreshTokens.Query(r => r.Token == refreshToken).FirstOrDefaultAsync(ct); if (token == null) return; // idempotent token.IsRevoked = true; await _uow.SaveChangesAsync(ct); } private static string GenerateSecureToken() { Span bytes = stackalloc byte[64]; RandomNumberGenerator.Fill(bytes); return Convert.ToBase64String(bytes); } } internal static class EfAsyncExtensions { public static Task FirstOrDefaultAsync(this IQueryable query, CancellationToken ct = default) => Microsoft.EntityFrameworkCore.EntityFrameworkQueryableExtensions.FirstOrDefaultAsync(query, ct); }