diff --git a/src/Application/Common/Exceptions/SecurityException.cs b/src/Application/Common/Exceptions/SecurityException.cs new file mode 100644 index 0000000..c4d7898 --- /dev/null +++ b/src/Application/Common/Exceptions/SecurityException.cs @@ -0,0 +1,16 @@ +namespace Application.Common.Exceptions; + +/// +/// Exception thrown when security violations are detected +/// Used for token assassination and other security events +/// +public sealed class SecurityException : Exception +{ + public SecurityException(string message) : base(message) + { + } + + public SecurityException(string message, Exception innerException) : base(message, innerException) + { + } +} diff --git a/src/Application/Features/Auth/Login/LoginCommand.cs b/src/Application/Features/Auth/Login/LoginCommand.cs index 9209120..a370ea0 100644 --- a/src/Application/Features/Auth/Login/LoginCommand.cs +++ b/src/Application/Features/Auth/Login/LoginCommand.cs @@ -31,7 +31,7 @@ public sealed record LoginCommand : ICommand> public sealed record LoginResponse { /// - /// Access token + /// Access token (JWT) /// public required string AccessToken { get; init; } @@ -41,9 +41,14 @@ public sealed record LoginResponse public required string RefreshToken { get; init; } /// - /// Token expiration time + /// Access token expiration time (always short-lived) /// - public required DateTime ExpiresAt { get; init; } + public required DateTimeOffset AccessTokenExpiresAt { get; init; } + + /// + /// Refresh token expiration time (depends on RememberMe) + /// + public required DateTimeOffset RefreshTokenExpiresAt { get; init; } /// /// User information diff --git a/src/Application/Features/Auth/Login/LoginCommandHandler.cs b/src/Application/Features/Auth/Login/LoginCommandHandler.cs index c20ef91..362653f 100644 --- a/src/Application/Features/Auth/Login/LoginCommandHandler.cs +++ b/src/Application/Features/Auth/Login/LoginCommandHandler.cs @@ -4,6 +4,8 @@ using Application.Interfaces.Repositories; using Application.Interfaces.Services.Auth; using Domain.Common; +using Domain.Constants; +using Domain.Entities; namespace Application.Features.Auth.Login; @@ -13,7 +15,8 @@ namespace Application.Features.Auth.Login; public sealed class LoginCommandHandler( IUserRepository userRepository, IAuthService authService, - ITokenService tokenService) : ICommandHandler> + ITokenService tokenService, + IRefreshTokenRepository refreshTokenRepository) : ICommandHandler> { public async Task> Handle(LoginCommand request, CancellationToken cancellationToken) { @@ -34,7 +37,12 @@ public async Task> Handle(LoginCommand request, Cancellati throw new UnauthorizedException("Invalid email or password."); } - // Generate tokens + // Calculate token expirations based on RememberMe flag + var refreshTokenExpiry = request.RememberMe + ? DateTimeOffset.UtcNow.Add(TokenConstants.RefreshToken.RememberMeExpiration) // 30 days + : DateTimeOffset.UtcNow.Add(TokenConstants.RefreshToken.NormalExpiration); // 1 day + + // Generate user info var nameParts = user.FullName.Split(' ', 2); var firstName = nameParts.Length > 0 ? nameParts[0] : user.FullName; var lastName = nameParts.Length > 1 ? nameParts[1] : string.Empty; @@ -49,14 +57,25 @@ public async Task> Handle(LoginCommand request, Cancellati AvatarUrl = user.AvatarUrl }; - var accessToken = tokenService.GenerateAccessToken(userInfo); - var refreshToken = tokenService.GenerateRefreshToken(); + var (accessToken, accessTokenExpiresAt) = tokenService.GenerateAccessToken(userInfo); + var refreshTokenValue = tokenService.GenerateRefreshToken(); + + // Store refresh token in database + var refreshTokenEntity = RefreshToken.Create( + userId: user.Id, + token: refreshTokenValue, + expiresAt: refreshTokenExpiry, + isPersistent: request.RememberMe + ); + + await refreshTokenRepository.AddAsync(refreshTokenEntity, cancellationToken); var response = new LoginResponse { AccessToken = accessToken, - RefreshToken = refreshToken, - ExpiresAt = DateTime.UtcNow.AddHours(1), + RefreshToken = refreshTokenValue, + AccessTokenExpiresAt = accessTokenExpiresAt, + RefreshTokenExpiresAt = refreshTokenExpiry, User = userInfo }; diff --git a/src/Application/Features/Auth/LoginExternal/LoginExternalCommand.cs b/src/Application/Features/Auth/LoginExternal/LoginExternalCommand.cs index 6f2f7ca..acd6aff 100644 --- a/src/Application/Features/Auth/LoginExternal/LoginExternalCommand.cs +++ b/src/Application/Features/Auth/LoginExternal/LoginExternalCommand.cs @@ -39,4 +39,9 @@ public sealed record LoginExternalCommand : ICommand public string? AvatarUrl { get; init; } + + /// + /// Remember me flag + /// + public bool RememberMe { get; init; } = true; } diff --git a/src/Application/Features/Auth/LoginExternal/LoginExternalCommandHandler.cs b/src/Application/Features/Auth/LoginExternal/LoginExternalCommandHandler.cs index 4007f37..b11a2a5 100644 --- a/src/Application/Features/Auth/LoginExternal/LoginExternalCommandHandler.cs +++ b/src/Application/Features/Auth/LoginExternal/LoginExternalCommandHandler.cs @@ -5,6 +5,7 @@ using Application.Interfaces.Services.Auth; using Domain.Common; using Domain.Constants; +using Domain.Entities; using Domain.Events.User; using TblUser = Domain.Entities.User; using TblUserExternalLogin = Domain.Entities.UserExternalLogin; @@ -20,9 +21,10 @@ namespace Application.Features.Auth.LoginExternal; /// public sealed class LoginExternalCommandHandler( IUserRepository userRepository, - IExternalLoginRepository externalLoginRepository, IAuthService authService, ITokenService tokenService, + IRefreshTokenRepository refreshTokenRepository, + IExternalLoginRepository externalLoginRepository, IUnitOfWork unitOfWork) : ICommandHandler> { public async Task> Handle(LoginExternalCommand request, CancellationToken cancellationToken) @@ -75,7 +77,7 @@ public async Task> Handle(LoginExternalCommand req } // Generate tokens - return await GenerateTokenResponse(existingUser, request.Provider.ToString(), isNewUser: false); + return await GenerateTokenResponse(existingUser, request.Provider.ToString(), isNewUser: false, rememberMe: request.RememberMe, cancellationToken); } // Step 2: Check if user with this email already exists (registered via email/password) @@ -108,7 +110,7 @@ public async Task> Handle(LoginExternalCommand req await unitOfWork.SaveChangesAsync(cancellationToken); // Generate tokens for existing user - return await GenerateTokenResponse(userByEmail, request.Provider.ToString(), isNewUser: false); + return await GenerateTokenResponse(userByEmail, request.Provider.ToString(), isNewUser: false, rememberMe: request.RememberMe, cancellationToken); } // Scenario 1: Completely new user - create domain user, identity user, and external login @@ -169,13 +171,18 @@ public async Task> Handle(LoginExternalCommand req await unitOfWork.SaveChangesAsync(cancellationToken); // Generate tokens for new user - return await GenerateTokenResponse(createdUser, request.Provider.ToString(), isNewUser: true); + return await GenerateTokenResponse(createdUser, request.Provider.ToString(), isNewUser: true, rememberMe: request.RememberMe, cancellationToken); } /// /// Helper method to generate token response /// - private Task> GenerateTokenResponse(TblUser user, string provider, bool isNewUser) + private async Task> GenerateTokenResponse( + TblUser user, + string provider, + bool isNewUser, + bool rememberMe, + CancellationToken cancellationToken) { // Parse FullName to FirstName and LastName var nameParts = user.FullName.Split(' ', 2); @@ -192,19 +199,30 @@ private Task> GenerateTokenResponse(TblUser user, AvatarUrl = user.AvatarUrl }; - var accessToken = tokenService.GenerateAccessToken(userInfo); + var (accessToken, accessTokenExpiresAt) = tokenService.GenerateAccessToken(userInfo); var refreshToken = tokenService.GenerateRefreshToken(); + // Store refresh token in database + var refreshTokenEntity = RefreshToken.Create( + userId: user.Id, + token: refreshToken, + expiresAt: rememberMe + ? DateTimeOffset.UtcNow.Add(TokenConstants.RefreshToken.RememberMeExpiration) + : DateTimeOffset.UtcNow.Add(TokenConstants.RefreshToken.NormalExpiration), + isPersistent: rememberMe + ); + await refreshTokenRepository.AddAsync(refreshTokenEntity, cancellationToken); + var response = new LoginExternalResponse { AccessToken = accessToken, RefreshToken = refreshToken, - ExpiresAt = DateTime.UtcNow.AddHours(1), + AccessTokenExpiresAt = accessTokenExpiresAt, User = userInfo, IsNewUser = isNewUser, Provider = provider }; - return Task.FromResult(Result.Success(response)); + return Result.Success(response); } } diff --git a/src/Application/Features/Auth/LoginExternal/LoginExternalResponse.cs b/src/Application/Features/Auth/LoginExternal/LoginExternalResponse.cs index 4e9439f..15e8d79 100644 --- a/src/Application/Features/Auth/LoginExternal/LoginExternalResponse.cs +++ b/src/Application/Features/Auth/LoginExternal/LoginExternalResponse.cs @@ -18,9 +18,9 @@ public sealed record LoginExternalResponse public required string RefreshToken { get; init; } /// - /// Token expiration time + /// Access token expiration time /// - public required DateTime ExpiresAt { get; init; } + public required DateTimeOffset AccessTokenExpiresAt { get; init; } /// /// User information diff --git a/src/Application/Features/Auth/RefreshAccessToken/RefreshAccessTokenCommand.cs b/src/Application/Features/Auth/RefreshAccessToken/RefreshAccessTokenCommand.cs new file mode 100644 index 0000000..a16ccb8 --- /dev/null +++ b/src/Application/Features/Auth/RefreshAccessToken/RefreshAccessTokenCommand.cs @@ -0,0 +1,48 @@ +using Application.Common; +using Application.Common.Models; +using Domain.Common; + +namespace Application.Features.Auth.RefreshAccessToken; + +/// +/// Command để refresh access token sử dụng refresh token +/// Pattern: Command Pattern (CQRS) +/// +public sealed record RefreshAccessTokenCommand : ICommand> +{ + /// + /// Refresh token để refresh access token + /// + public required string RefreshToken { get; init; } +} + +/// +/// Response sau khi refresh access token thành công +/// +public sealed record RefreshAccessTokenResponse +{ + /// + /// Access token mới + /// + public required string AccessToken { get; init; } + + /// + /// Refresh token mới (optional, for token rotation) + /// + public string? RefreshToken { get; init; } + + /// + /// Access token expiration time + /// + public required DateTimeOffset AccessTokenExpiresAt { get; init; } + + /// + /// Refresh token expiration time (if new refresh token is issued) + /// + public DateTimeOffset? RefreshTokenExpiresAt { get; init; } + + /// + /// User information + /// + public required UserInfo User { get; init; } +} diff --git a/src/Application/Features/Auth/RefreshAccessToken/RefreshAccessTokenCommandHandler.cs b/src/Application/Features/Auth/RefreshAccessToken/RefreshAccessTokenCommandHandler.cs new file mode 100644 index 0000000..fe2dfc0 --- /dev/null +++ b/src/Application/Features/Auth/RefreshAccessToken/RefreshAccessTokenCommandHandler.cs @@ -0,0 +1,146 @@ + + +using Application.Common; +using Application.Common.Exceptions; +using Application.Common.Models; +using System.Collections.Generic; +using Application.Interfaces.Repositories; +using Application.Interfaces.Services.Auth; +using Domain.Common; +using Domain.Constants; + +namespace Application.Features.Auth.RefreshAccessToken; + +/// +/// Handler xử lý RefreshAccessTokenCommand +/// Pattern: Command Handler Pattern +/// +public sealed class RefreshAccessTokenCommandHandler( + IRefreshTokenRepository refreshTokenRepository, + ITokenService tokenService, + IUnitOfWork unitOfWork) : ICommandHandler> +{ + private readonly IRefreshTokenRepository _refreshTokenRepository = refreshTokenRepository + ?? throw new ArgumentNullException(nameof(refreshTokenRepository)); + private readonly ITokenService _tokenService = tokenService + ?? throw new ArgumentNullException(nameof(tokenService)); + private readonly IUnitOfWork _unitOfWork = unitOfWork + ?? throw new ArgumentNullException(nameof(unitOfWork)); + + public async Task> Handle(RefreshAccessTokenCommand request, CancellationToken cancellationToken) + { + var storedRefreshToken = await _refreshTokenRepository.GetByTokenAsync(request.RefreshToken, cancellationToken) + ?? throw new UnauthorizedException("Invalid refresh token."); + + if (storedRefreshToken.RevokedAt.HasValue) + { + // Có người đang cố gắng sử dụng token đã bị revoke + // nên ta sẽ hủy toàn bộ chuỗi token để bảo vệ tài khoản + await RevokeEntireTokenChainAsync(storedRefreshToken, cancellationToken); + await _unitOfWork.SaveChangesAsync(cancellationToken); + + // 2. Throw security exception to alert about potential hack + throw new SecurityException("Security violation detected. Cannot refresh access token."); + } + + // Check if token is expired (but not revoked) + if (storedRefreshToken.IsExpired) + { + throw new UnauthorizedException("Refresh token has expired."); + } + + var user = storedRefreshToken.User + ?? throw new UnauthorizedException("User account not found or deactivated."); + + var nameParts = user.FullName.Split(' ', 2); + var firstName = nameParts.Length > 0 ? nameParts[0] : user.FullName; + var lastName = nameParts.Length > 1 ? nameParts[1] : string.Empty; + + var userInfo = new UserInfo + { + Id = user.Id, + Email = user.Email, + FirstName = firstName, + LastName = lastName, + Roles = [.. user.Roles.Select(r => r.ToString())], + AvatarUrl = user.AvatarUrl + }; + + var (newAccessToken, accessTokenExpiry) = _tokenService.GenerateAccessToken(userInfo); + + var newRefreshToken = _tokenService.GenerateRefreshToken(); + var newRefreshTokenExpiry = storedRefreshToken.IsPersistent + ? DateTimeOffset.UtcNow.Add(TokenConstants.RefreshToken.RememberMeExpiration) + : DateTimeOffset.UtcNow.Add(TokenConstants.RefreshToken.NormalExpiration); + + // Revoke the old refresh token + storedRefreshToken.Revoke( + reason: TokenConstants.RevocationReasons.TokenRotation, + replacedByToken: newRefreshToken); + _refreshTokenRepository.Update(storedRefreshToken); + + var newRefreshTokenEntity = Domain.Entities.RefreshToken.Create( + userId: user.Id, + token: newRefreshToken, + expiresAt: newRefreshTokenExpiry, + isPersistent: storedRefreshToken.IsPersistent + ); + await _refreshTokenRepository.AddAsync(newRefreshTokenEntity, cancellationToken); + + return Result.Success(new RefreshAccessTokenResponse + { + AccessToken = newAccessToken, + RefreshToken = newRefreshToken, + AccessTokenExpiresAt = accessTokenExpiry, + RefreshTokenExpiresAt = newRefreshTokenExpiry, + User = userInfo + }); + } + + /// + /// Revoke entire token chain when compromised token is detected + /// This implements "token assassination" security pattern + /// + private async Task RevokeEntireTokenChainAsync(Domain.Entities.RefreshToken compromisedToken, CancellationToken cancellationToken) + { + var tokensToRevoke = new HashSet(); + var processedTokens = new HashSet(); + + // Start with the compromised token + tokensToRevoke.Add(compromisedToken.Token); + + // Follow the ReplacedBy chain to find all related tokens + var currentToken = compromisedToken; + while (!string.IsNullOrEmpty(currentToken.ReplacedByToken) && !processedTokens.Contains(currentToken.ReplacedByToken)) + { + processedTokens.Add(currentToken.ReplacedByToken); + + // Find the token that replaced this one + var replacementToken = await _refreshTokenRepository.GetByTokenAsync(currentToken.ReplacedByToken, cancellationToken); + if (replacementToken != null) + { + tokensToRevoke.Add(replacementToken.Token); + currentToken = replacementToken; + } + else + { + break; + } + } + + // Revoke all tokens in the chain + foreach (var tokenValue in tokensToRevoke) + { + await _refreshTokenRepository.RevokeAsync( + token: tokenValue, + reason: TokenConstants.RevocationReasons.TokenChainAssassination, + cancellationToken: cancellationToken); + } + + // Also revoke all other active tokens for this user as additional security measure + await _refreshTokenRepository.RevokeAllForUserAsync( + userId: compromisedToken.UserId, + reason: TokenConstants.RevocationReasons.SecurityBreachAllTokensRevoked, + cancellationToken: cancellationToken); + } +} diff --git a/src/Application/Features/Auth/RefreshAccessToken/RefreshAccessTokenCommandValidator.cs b/src/Application/Features/Auth/RefreshAccessToken/RefreshAccessTokenCommandValidator.cs new file mode 100644 index 0000000..ecfae85 --- /dev/null +++ b/src/Application/Features/Auth/RefreshAccessToken/RefreshAccessTokenCommandValidator.cs @@ -0,0 +1,18 @@ +using FluentValidation; + +namespace Application.Features.Auth.RefreshAccessToken; + +/// +/// Validator cho RefreshAccessTokenCommand +/// +public sealed class RefreshAccessTokenCommandValidator : AbstractValidator +{ + public RefreshAccessTokenCommandValidator() + { + RuleFor(x => x.RefreshToken) + .NotEmpty() + .WithMessage("Refresh token is required") + .MinimumLength(10) + .WithMessage("Refresh token must be at least 10 characters"); + } +} diff --git a/src/Application/Features/Auth/Register/RegisterCommandHandler.cs b/src/Application/Features/Auth/Register/RegisterCommandHandler.cs index 13f4d02..2dd884b 100644 --- a/src/Application/Features/Auth/Register/RegisterCommandHandler.cs +++ b/src/Application/Features/Auth/Register/RegisterCommandHandler.cs @@ -16,8 +16,8 @@ namespace Application.Features.Auth.Register; /// public sealed class RegisterCommandHandler( IUserRepository userRepository, - IAuthService authService, - ITokenService tokenService) : ICommandHandler> + IAuthService authService + ) : ICommandHandler> { public async Task> Handle(RegisterCommand request, CancellationToken cancellationToken) { @@ -73,28 +73,14 @@ public async Task> Handle(RegisterCommand request, Canc var firstName = nameParts.Length > 0 ? nameParts[0] : createdUser.FullName; var lastName = nameParts.Length > 1 ? nameParts[1] : string.Empty; - var userInfo = new UserInfo + return Result.Success(new RegisterResponse { - Id = createdUser.Id, + UserId = createdUser.Id, Email = createdUser.Email, FirstName = firstName, LastName = lastName, Roles = createdUser.Roles, AvatarUrl = createdUser.AvatarUrl - }; - - // Generate tokens for auto-login - var accessToken = tokenService.GenerateAccessToken(userInfo); - var refreshToken = tokenService.GenerateRefreshToken(); - - return Result.Success(new RegisterResponse - { - UserId = createdUser.Id, - Email = createdUser.Email, - FirstName = request.FirstName, - LastName = request.LastName, - AccessToken = accessToken, - RefreshToken = refreshToken }); } } diff --git a/src/Application/Features/Auth/Register/RegisterResponse.cs b/src/Application/Features/Auth/Register/RegisterResponse.cs index 59ab740..1ecb91d 100644 --- a/src/Application/Features/Auth/Register/RegisterResponse.cs +++ b/src/Application/Features/Auth/Register/RegisterResponse.cs @@ -31,12 +31,12 @@ public sealed record RegisterResponse public string FullName => $"{FirstName} {LastName}".Trim(); /// - /// Access token (optional - auto login after register) + /// User's roles /// - public string? AccessToken { get; init; } + public required List Roles { get; init; } /// - /// Refresh token (optional - auto login after register) + /// User's avatar URL /// - public string? RefreshToken { get; init; } + public string? AvatarUrl { get; init; } } diff --git a/src/Application/Interfaces/Repositories/IRefreshTokenRepository.cs b/src/Application/Interfaces/Repositories/IRefreshTokenRepository.cs new file mode 100644 index 0000000..124cf59 --- /dev/null +++ b/src/Application/Interfaces/Repositories/IRefreshTokenRepository.cs @@ -0,0 +1,44 @@ +using Domain.Entities; + +namespace Application.Interfaces.Repositories; + +/// +/// Interface for Refresh Token repository to handle refresh token data operations +/// +public interface IRefreshTokenRepository +{ + /// + /// Add a new refresh token + /// + Task AddAsync(RefreshToken refreshToken, CancellationToken cancellationToken = default); + + /// + /// Get refresh token by token value + /// + Task GetByTokenAsync(string token, CancellationToken cancellationToken = default); + + /// + /// Get all active refresh tokens for a user + /// + Task> GetActiveTokensByUserIdAsync(Guid userId, CancellationToken cancellationToken = default); + + /// + /// Update refresh token + /// + void Update(RefreshToken refreshToken); + + /// + /// Revoke refresh token + /// + Task RevokeAsync(string token, string reason, CancellationToken cancellationToken = default); + + /// + /// Revoke all refresh tokens for a user + /// + Task RevokeAllForUserAsync(Guid userId, string reason, CancellationToken cancellationToken = default); + + /// + /// Clean up expired refresh tokens + /// + Task CleanExpiredTokensAsync(CancellationToken cancellationToken = default); +} diff --git a/src/Application/Interfaces/Services/Auth/ITokenService.cs b/src/Application/Interfaces/Services/Auth/ITokenService.cs index ee2bd63..9ca8b52 100644 --- a/src/Application/Interfaces/Services/Auth/ITokenService.cs +++ b/src/Application/Interfaces/Services/Auth/ITokenService.cs @@ -8,20 +8,15 @@ namespace Application.Interfaces.Services.Auth; public interface ITokenService { /// - /// Generate access token + /// Generate access token with default expiration /// - string GenerateAccessToken(UserInfo user); + (string accessToken, DateTimeOffset accessTokenExpiresAt) GenerateAccessToken(UserInfo user); /// /// Generate refresh token /// string GenerateRefreshToken(); - /// - /// Validate access token - /// - Task ValidateAccessTokenAsync(string token); - /// /// Get user claims from token /// diff --git a/src/Domain/Constants/TokenConstants.cs b/src/Domain/Constants/TokenConstants.cs new file mode 100644 index 0000000..74a9e64 --- /dev/null +++ b/src/Domain/Constants/TokenConstants.cs @@ -0,0 +1,54 @@ +namespace Domain.Constants; + +/// +/// Constants for JWT token configuration +/// +public static class TokenConstants +{ + /// + /// Refresh token expiration times + /// + public static class RefreshToken + { + /// + /// Expiration time for refresh tokens when RememberMe is false (1 day) + /// + public static readonly TimeSpan NormalExpiration = TimeSpan.FromDays(1); + + /// + /// Expiration time for refresh tokens when RememberMe is true (30 days) + /// + public static readonly TimeSpan RememberMeExpiration = TimeSpan.FromDays(30); + } + + /// + /// Token revocation reasons + /// + public static class RevocationReasons + { + /// + /// Token rotation during refresh + /// + public const string TokenRotation = "Token rotation - new access token generated"; + + /// + /// Token chain assassination due to security breach + /// + public const string TokenChainAssassination = "Token chain assassination - potential security breach detected"; + + /// + /// All user tokens revoked due to security breach + /// + public const string SecurityBreachAllTokensRevoked = "Security breach detected - all tokens revoked"; + + /// + /// User logout + /// + public const string UserLogout = "User logout"; + + /// + /// Token expired + /// + public const string TokenExpired = "Token expired"; + } +} diff --git a/src/Domain/Entities/RefreshToken.cs b/src/Domain/Entities/RefreshToken.cs new file mode 100644 index 0000000..9e67cb5 --- /dev/null +++ b/src/Domain/Entities/RefreshToken.cs @@ -0,0 +1,82 @@ +using Domain.Common; + +namespace Domain.Entities; + +/// +/// Refresh Token entity for JWT authentication +/// +public sealed class RefreshToken : BaseEntity +{ + /// + /// Token value (hashed) + /// + public required string Token { get; set; } + /// + /// User ID that owns this token + /// + public required Guid UserId { get; set; } + + /// + /// When the token expires + /// + public required DateTimeOffset ExpiresAt { get; set; } + + /// + /// When the token was revoked (if applicable) + /// + public DateTimeOffset? RevokedAt { get; set; } + + /// + /// The token that replaced this one (for token rotation) + /// + public string? ReplacedByToken { get; set; } + + /// + /// User that owns this token + /// + public User User { get; set; } = null!; + + /// + /// Check if token is expired + /// + public bool IsExpired => DateTimeOffset.UtcNow >= ExpiresAt; + + /// + /// Check if token is active (not expired and not revoked) + /// + public bool IsActive => !IsExpired && RevokedAt is null; + + /// + /// Check if token is persistent (RememberMe is true) + /// + public bool IsPersistent { get; set; } + + /// + /// The reason why the token was revoked + /// + public string? RevokedReason { get; set; } + + /// + /// Create a new refresh token + /// + public static RefreshToken Create(Guid userId, string token, DateTimeOffset expiresAt, bool isPersistent = false) + { + return new RefreshToken + { + UserId = userId, + Token = token, + ExpiresAt = expiresAt, + IsPersistent = isPersistent + }; + } + + /// + /// Revoke this token + /// + public void Revoke(string reason, string? replacedByToken = null) + { + RevokedAt = DateTimeOffset.UtcNow; + RevokedReason = reason; + ReplacedByToken = replacedByToken ?? Token; + } +} diff --git a/src/Infrastructure/Data/Configurations/RefreshTokenConfiguration.cs b/src/Infrastructure/Data/Configurations/RefreshTokenConfiguration.cs new file mode 100644 index 0000000..df50d8d --- /dev/null +++ b/src/Infrastructure/Data/Configurations/RefreshTokenConfiguration.cs @@ -0,0 +1,47 @@ +using Domain.Entities; +using Infrastructure.Data.Contexts; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace Infrastructure.Data.Configurations; + +/// +/// Entity configuration for RefreshToken +/// +public sealed class RefreshTokenConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("RefreshTokens", Schemas.Default); + + // Primary Key + builder.HasKey(rt => rt.Id); + + // Properties + builder.Property(rt => rt.Token) + .IsRequired() + .HasMaxLength(500); // Refresh tokens are usually longer + + builder.Property(rt => rt.ExpiresAt) + .IsRequired(); + + builder.Property(rt => rt.RevokedReason) + .HasMaxLength(255); + + // Relationships + builder.HasOne(rt => rt.User) + .WithMany() // User can have many refresh tokens + .HasForeignKey(rt => rt.UserId) + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + // Indexes + builder.HasIndex(rt => rt.UserId); + builder.HasIndex(rt => rt.Token).IsUnique(); + builder.HasIndex(rt => rt.ExpiresAt); + builder.HasIndex(rt => new { rt.UserId, rt.RevokedAt }); // For active tokens query + + builder.HasQueryFilter(rt => + rt.User != null && !rt.User.IsDeleted); + } +} diff --git a/src/Infrastructure/Data/Contexts/DataContext.cs b/src/Infrastructure/Data/Contexts/DataContext.cs index 0f150a6..cc1a89d 100644 --- a/src/Infrastructure/Data/Contexts/DataContext.cs +++ b/src/Infrastructure/Data/Contexts/DataContext.cs @@ -31,6 +31,7 @@ public DataContext(DbContextOptions options) : base(options) public DbSet Conversations => Set(); public DbSet Messages => Set(); public DbSet UserExternalLogins => Set(); + public DbSet RefreshTokens => Set(); protected override void OnModelCreating(ModelBuilder builder) { diff --git a/src/Infrastructure/Data/Migrations/20251121160401_CreateRefreshTokenTable.Designer.cs b/src/Infrastructure/Data/Migrations/20251121160401_CreateRefreshTokenTable.Designer.cs new file mode 100644 index 0000000..5696027 --- /dev/null +++ b/src/Infrastructure/Data/Migrations/20251121160401_CreateRefreshTokenTable.Designer.cs @@ -0,0 +1,663 @@ +// +using System; +using Infrastructure.Data.Contexts; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Infrastructure.Data.Migrations +{ + [DbContext(typeof(DataContext))] + [Migration("20251121160401_CreateRefreshTokenTable")] + partial class CreateRefreshTokenTable + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("public") + .HasAnnotation("ProductVersion", "8.0.21") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Domain.Entities.Conversation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("IsPrivate") + .HasColumnType("boolean"); + + b.Property("IsStarred") + .HasColumnType("boolean"); + + b.Property("OwnerId") + .HasColumnType("uuid"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("Tags") + .IsRequired() + .HasColumnType("text[]"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("CreatedAt"); + + b.HasIndex("IsDeleted"); + + b.HasIndex("OwnerId"); + + b.HasIndex("UserId"); + + b.ToTable("Conversations", "public"); + }); + + modelBuilder.Entity("Domain.Entities.Message", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Content") + .IsRequired() + .HasMaxLength(10000) + .HasColumnType("character varying(10000)"); + + b.Property("ConversationId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("EditedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("IsEdited") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("Metadata") + .HasMaxLength(5000) + .HasColumnType("character varying(5000)"); + + b.Property("Role") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasDefaultValue("user"); + + b.Property("SenderId") + .HasColumnType("uuid"); + + b.Property("Type") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("text") + .HasDefaultValue("Text"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("ConversationId"); + + b.HasIndex("CreatedAt"); + + b.HasIndex("Role"); + + b.HasIndex("SenderId"); + + b.ToTable("Messages", "public"); + }); + + modelBuilder.Entity("Domain.Entities.RefreshToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("IsPersistent") + .HasColumnType("boolean"); + + b.Property("ReplacedByToken") + .HasColumnType("text"); + + b.Property("RevokedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("RevokedReason") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("Token") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ExpiresAt"); + + b.HasIndex("Token") + .IsUnique(); + + b.HasIndex("UserId"); + + b.HasIndex("UserId", "RevokedAt"); + + b.ToTable("RefreshTokens", "public"); + }); + + modelBuilder.Entity("Domain.Entities.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AvatarUrl") + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("FullName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("PhoneNumber") + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("Roles") + .IsRequired() + .HasColumnType("text"); + + b.Property("UpdatedAt") + .IsRequired() + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("Email") + .IsUnique(); + + b.HasIndex("IsDeleted"); + + b.ToTable("Users", "public"); + }); + + modelBuilder.Entity("Domain.Entities.UserExternalLogin", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AvatarUrl") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("FirstName") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("LastName") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Provider") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("ProviderKey") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId") + .HasDatabaseName("IX_UserExternalLogins_UserId"); + + b.HasIndex("Provider", "ProviderKey") + .IsUnique() + .HasDatabaseName("IX_UserExternalLogins_Provider_ProviderKey"); + + b.ToTable("UserExternalLogins", "public"); + }); + + modelBuilder.Entity("Infrastructure.Identity.ApplicationRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("Roles", "identity"); + }); + + modelBuilder.Entity("Infrastructure.Identity.ApplicationUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AccessFailedCount") + .HasColumnType("integer"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("boolean"); + + b.Property("FirstName") + .IsRequired() + .HasColumnType("text"); + + b.Property("FullName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("LastName") + .IsRequired() + .HasColumnType("text"); + + b.Property("LockoutEnabled") + .HasColumnType("boolean"); + + b.Property("LockoutEnd") + .HasColumnType("timestamp with time zone"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PasswordHash") + .HasColumnType("text"); + + b.Property("PhoneNumber") + .HasColumnType("text"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("boolean"); + + b.Property("SecurityStamp") + .HasColumnType("text"); + + b.Property("TwoFactorEnabled") + .HasColumnType("boolean"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("Users", "identity"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("RoleClaims", "identity"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("UserClaims", "identity"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("ProviderKey") + .HasColumnType("text"); + + b.Property("ProviderDisplayName") + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("UserLogins", "identity"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("UserRoles", "identity"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("Value") + .HasColumnType("text"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("UserTokens", "identity"); + }); + + modelBuilder.Entity("Domain.Entities.Conversation", b => + { + b.HasOne("Domain.Entities.User", "Owner") + .WithMany() + .HasForeignKey("OwnerId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Domain.Entities.User", null) + .WithMany("OwnedConversations") + .HasForeignKey("UserId"); + + b.Navigation("Owner"); + }); + + modelBuilder.Entity("Domain.Entities.Message", b => + { + b.HasOne("Domain.Entities.Conversation", "Conversation") + .WithMany("Messages") + .HasForeignKey("ConversationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Domain.Entities.User", "Sender") + .WithMany() + .HasForeignKey("SenderId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Conversation"); + + b.Navigation("Sender"); + }); + + modelBuilder.Entity("Domain.Entities.RefreshToken", b => + { + b.HasOne("Domain.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Domain.Entities.UserExternalLogin", b => + { + b.HasOne("Domain.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Infrastructure.Identity.ApplicationRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("Infrastructure.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("Infrastructure.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("Infrastructure.Identity.ApplicationRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Infrastructure.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("Infrastructure.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Domain.Entities.Conversation", b => + { + b.Navigation("Messages"); + }); + + modelBuilder.Entity("Domain.Entities.User", b => + { + b.Navigation("OwnedConversations"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Infrastructure/Data/Migrations/20251121160401_CreateRefreshTokenTable.cs b/src/Infrastructure/Data/Migrations/20251121160401_CreateRefreshTokenTable.cs new file mode 100644 index 0000000..06ded7d --- /dev/null +++ b/src/Infrastructure/Data/Migrations/20251121160401_CreateRefreshTokenTable.cs @@ -0,0 +1,78 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Infrastructure.Data.Migrations +{ + /// + public partial class CreateRefreshTokenTable : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "RefreshTokens", + schema: "public", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + Token = table.Column(type: "character varying(500)", maxLength: 500, nullable: false), + UserId = table.Column(type: "uuid", nullable: false), + ExpiresAt = table.Column(type: "timestamp with time zone", nullable: false), + RevokedAt = table.Column(type: "timestamp with time zone", nullable: true), + ReplacedByToken = table.Column(type: "text", nullable: true), + IsPersistent = table.Column(type: "boolean", nullable: false), + RevokedReason = table.Column(type: "character varying(255)", maxLength: 255, nullable: true), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), + UpdatedAt = table.Column(type: "timestamp with time zone", nullable: true), + IsDeleted = table.Column(type: "boolean", nullable: false), + DeletedAt = table.Column(type: "timestamp with time zone", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_RefreshTokens", x => x.Id); + table.ForeignKey( + name: "FK_RefreshTokens_Users_UserId", + column: x => x.UserId, + principalSchema: "public", + principalTable: "Users", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_RefreshTokens_ExpiresAt", + schema: "public", + table: "RefreshTokens", + column: "ExpiresAt"); + + migrationBuilder.CreateIndex( + name: "IX_RefreshTokens_Token", + schema: "public", + table: "RefreshTokens", + column: "Token", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_RefreshTokens_UserId", + schema: "public", + table: "RefreshTokens", + column: "UserId"); + + migrationBuilder.CreateIndex( + name: "IX_RefreshTokens_UserId_RevokedAt", + schema: "public", + table: "RefreshTokens", + columns: new[] { "UserId", "RevokedAt" }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "RefreshTokens", + schema: "public"); + } + } +} diff --git a/src/Infrastructure/Data/Migrations/DataContextModelSnapshot.cs b/src/Infrastructure/Data/Migrations/DataContextModelSnapshot.cs index c2079d5..bdf2867 100644 --- a/src/Infrastructure/Data/Migrations/DataContextModelSnapshot.cs +++ b/src/Infrastructure/Data/Migrations/DataContextModelSnapshot.cs @@ -156,12 +156,6 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("CreatedAt") .HasColumnType("timestamp with time zone"); - b.Property("CreatedByIp") - .HasColumnType("text"); - - b.Property("CreatedByUserAgent") - .HasColumnType("text"); - b.Property("DeletedAt") .HasColumnType("timestamp with time zone"); @@ -171,21 +165,23 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("IsDeleted") .HasColumnType("boolean"); + b.Property("IsPersistent") + .HasColumnType("boolean"); + b.Property("ReplacedByToken") .HasColumnType("text"); b.Property("RevokedAt") .HasColumnType("timestamp with time zone"); - b.Property("RevokedByIp") - .HasColumnType("text"); - b.Property("RevokedReason") - .HasColumnType("text"); + .HasMaxLength(255) + .HasColumnType("character varying(255)"); b.Property("Token") .IsRequired() - .HasColumnType("text"); + .HasMaxLength(500) + .HasColumnType("character varying(500)"); b.Property("UpdatedAt") .HasColumnType("timestamp with time zone"); @@ -195,8 +191,15 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasKey("Id"); + b.HasIndex("ExpiresAt"); + + b.HasIndex("Token") + .IsUnique(); + b.HasIndex("UserId"); + b.HasIndex("UserId", "RevokedAt"); + b.ToTable("RefreshTokens", "public"); }); diff --git a/src/Infrastructure/Extensions/ServiceCollectionExtensions.cs b/src/Infrastructure/Extensions/ServiceCollectionExtensions.cs index c8f9a84..ac7bbea 100644 --- a/src/Infrastructure/Extensions/ServiceCollectionExtensions.cs +++ b/src/Infrastructure/Extensions/ServiceCollectionExtensions.cs @@ -85,6 +85,7 @@ private static IServiceCollection AddRepositories(this IServiceCollection servic services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); // Identity authentication service services.AddScoped(); diff --git a/src/Infrastructure/Repositories/RefreshTokenRepository.cs b/src/Infrastructure/Repositories/RefreshTokenRepository.cs new file mode 100644 index 0000000..ceb6b7c --- /dev/null +++ b/src/Infrastructure/Repositories/RefreshTokenRepository.cs @@ -0,0 +1,77 @@ +using Application.Interfaces.Repositories; +using Domain.Entities; +using Infrastructure.Data.Contexts; +using Microsoft.EntityFrameworkCore; + +namespace Infrastructure.Repositories; + +/// +/// Implementation of the Refresh Token repository +/// +public class RefreshTokenRepository : IRefreshTokenRepository +{ + private readonly DataContext _context; + + public RefreshTokenRepository(DataContext context) + { + _context = context ?? throw new ArgumentNullException(nameof(context)); + } + + public async Task AddAsync(RefreshToken refreshToken, CancellationToken cancellationToken = default) + { + await _context.RefreshTokens.AddAsync(refreshToken, cancellationToken); + } + + public async Task GetByTokenAsync(string token, CancellationToken cancellationToken = default) + { + return await _context.RefreshTokens + .Include(rt => rt.User) + .FirstOrDefaultAsync(rt => rt.Token == token, cancellationToken); + } + + public async Task> GetActiveTokensByUserIdAsync(Guid userId, CancellationToken cancellationToken = default) + { + return await _context.RefreshTokens + .Where(rt => rt.UserId == userId && rt.RevokedAt == null && rt.ExpiresAt > DateTimeOffset.UtcNow && rt.RevokedReason == null) + .ToListAsync(cancellationToken); + } + + public void Update(RefreshToken refreshToken) + { + _context.RefreshTokens.Update(refreshToken); + } + + public async Task RevokeAsync(string token, string reason, CancellationToken cancellationToken = default) + { + var refreshToken = await GetByTokenAsync(token, cancellationToken); + if (refreshToken is not null && !refreshToken.IsExpired && refreshToken.RevokedAt is null) + { + refreshToken.Revoke(reason); + Update(refreshToken); + } + } + + public async Task RevokeAllForUserAsync(Guid userId, string reason, CancellationToken cancellationToken = default) + { + var activeTokens = await GetActiveTokensByUserIdAsync(userId, cancellationToken); + foreach (var token in activeTokens) + { + token.Revoke(reason); + Update(token); + } + } + + public async Task CleanExpiredTokensAsync(CancellationToken cancellationToken = default) + { + var expiredTokens = await _context.RefreshTokens + .Where(rt => rt.ExpiresAt < DateTimeOffset.UtcNow || rt.RevokedAt != null) + .ToListAsync(cancellationToken); + + if (expiredTokens.Any()) + { + _context.RefreshTokens.RemoveRange(expiredTokens); + } + + return expiredTokens.Count; + } +} diff --git a/src/Infrastructure/Services/TokenService.cs b/src/Infrastructure/Services/TokenService.cs index e02629a..0a157e2 100644 --- a/src/Infrastructure/Services/TokenService.cs +++ b/src/Infrastructure/Services/TokenService.cs @@ -25,47 +25,15 @@ public TokenService(IConfiguration configuration) } /// - /// Generate access token (JWT) + /// Generate access token (JWT) with default expiration /// - public string GenerateAccessToken(UserInfo user) + public (string accessToken, DateTimeOffset accessTokenExpiresAt) GenerateAccessToken(UserInfo user) { var jwtSettings = _configuration.GetSection("JwtSettings"); - var secretKey = jwtSettings["SecretKey"] ?? "YourSuperSecretKeyThatIsAtLeast32Characters!!"; - var issuer = jwtSettings["Issuer"] ?? "LegalAssistant"; - var audience = jwtSettings["Audience"] ?? "LegalAssistantUsers"; var expirationHours = int.Parse(jwtSettings["ExpirationHours"] ?? "1", CultureInfo.InvariantCulture); - var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(secretKey)); - var credentials = new SigningCredentials(key, SecurityAlgorithms.HmacSha256); - - var claims = new List - { - new(ClaimTypes.NameIdentifier, user.Id.ToString()), - new(ClaimTypes.Email, user.Email), - new(ClaimTypes.Name, user.FullName), - new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()), - new(JwtRegisteredClaimNames.Iat, - DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString(CultureInfo.InvariantCulture), - ClaimValueTypes.Integer64) - }; - - // Add roles - foreach (var role in user.Roles) - { - claims.Add(new Claim(ClaimTypes.Role, role)); - } - - var tokenDescriptor = new SecurityTokenDescriptor - { - Subject = new ClaimsIdentity(claims), - Expires = DateTime.UtcNow.AddHours(expirationHours), - Issuer = issuer, - Audience = audience, - SigningCredentials = credentials - }; - - var token = _tokenHandler.CreateToken(tokenDescriptor); - return _tokenHandler.WriteToken(token); + var accessTokenExpiresAt = DateTime.UtcNow.AddHours(expirationHours); + return (GenerateAccessToken(user, accessTokenExpiresAt), accessTokenExpiresAt); } /// @@ -84,11 +52,6 @@ public string GenerateRefreshToken() throw new NotImplementedException(); } - public Task ValidateAccessTokenAsync(string token) - { - throw new NotImplementedException(); - } - /// /// Validate token and extract claims /// @@ -123,4 +86,47 @@ public Task ValidateAccessTokenAsync(string token) return null; } } + + /// + /// Generate access token (JWT) with custom expiration + /// + private string GenerateAccessToken(UserInfo user, DateTime expiresAt) + { + var jwtSettings = _configuration.GetSection("JwtSettings"); + var secretKey = jwtSettings["SecretKey"] ?? "YourSuperSecretKeyThatIsAtLeast32Characters!!"; + var issuer = jwtSettings["Issuer"] ?? "LegalAssistant"; + var audience = jwtSettings["Audience"] ?? "LegalAssistantUsers"; + + var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(secretKey)); + var credentials = new SigningCredentials(key, SecurityAlgorithms.HmacSha256); + + var claims = new List + { + new(ClaimTypes.NameIdentifier, user.Id.ToString()), + new(ClaimTypes.Email, user.Email), + new(ClaimTypes.Name, user.FullName), + new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()), + new(JwtRegisteredClaimNames.Iat, + DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString(CultureInfo.InvariantCulture), + ClaimValueTypes.Integer64) + }; + + // Add roles + foreach (var role in user.Roles) + { + claims.Add(new Claim(ClaimTypes.Role, role)); + } + + var tokenDescriptor = new SecurityTokenDescriptor + { + Subject = new ClaimsIdentity(claims), + Expires = expiresAt, + Issuer = issuer, + Audience = audience, + SigningCredentials = credentials + }; + + var token = _tokenHandler.CreateToken(tokenDescriptor); + return _tokenHandler.WriteToken(token); + } } diff --git a/src/Web.Api/Controllers/V1/AuthController.cs b/src/Web.Api/Controllers/V1/AuthController.cs index c4fac00..91d217b 100644 --- a/src/Web.Api/Controllers/V1/AuthController.cs +++ b/src/Web.Api/Controllers/V1/AuthController.cs @@ -1,9 +1,11 @@ using Application.Features.Auth.Login; using Application.Features.Auth.LoginExternal; +using Application.Features.Auth.RefreshAccessToken; using Application.Features.Auth.Register; using Application.Features.Auth.VerifyEmail; using Domain.Common; using MediatR; +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Web.Api.Models.Responses; @@ -16,10 +18,11 @@ namespace Web.Api.Controllers.V1; public sealed class AuthController(IMediator mediator) : BaseController { /// - /// User login + /// User login with JWT authentication + /// Access token: Always 15 minutes, Refresh token: 1 day (normal) or 30 days (remember me) /// - /// Login credentials - /// Login result with tokens + /// Login credentials with RememberMe flag + /// Login result with access token, refresh token and their expiration times [HttpPost("login")] [ProducesResponseType(typeof(LoginResponse), StatusCodes.Status200OK)] [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status400BadRequest)] @@ -113,4 +116,28 @@ public async Task VerifyEmail([FromQuery] Guid userId, [FromQuery _ => BadRequest(ApiResponse.CreateFailure(result.Error.Description)) }; } + + /// + /// Refresh access token using refresh token + /// Returns new access token and optionally new refresh token (token rotation) + /// + /// Refresh token command + /// New access token and refresh token + [HttpPost("refresh-access-token")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status400BadRequest)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status401Unauthorized)] + public async Task RefreshToken([FromBody] RefreshAccessTokenCommand command) + { + var result = await mediator.Send(command); + + if (result.IsSuccess) + { + return Ok(ApiResponse.CreateSuccess( + result.Value!, + "Tokens refreshed successfully")); + } + + return BadRequest(ApiResponse.CreateFailure(result.Error!.Description)); + } } diff --git a/src/Web.Api/Middleware/GlobalExceptionMiddleware.cs b/src/Web.Api/Middleware/GlobalExceptionMiddleware.cs index 867496f..49b29a7 100644 --- a/src/Web.Api/Middleware/GlobalExceptionMiddleware.cs +++ b/src/Web.Api/Middleware/GlobalExceptionMiddleware.cs @@ -1,6 +1,7 @@ using System.Net; using System.Text.Json; using Application.Common.Exceptions; +using Microsoft.AspNetCore.Http; namespace Web.Api.Middleware; @@ -98,6 +99,16 @@ private static (int StatusCode, string Message, object ErrorDetails) GetExceptio } ), + SecurityException securityEx => ( + (int)HttpStatusCode.Unauthorized, + securityEx.Message, + new + { + Type = "SecurityError", + Details = securityEx.InnerException?.Message ?? securityEx.Message + } + ), + NotFoundException notFoundEx => ( (int)HttpStatusCode.NotFound, notFoundEx.Message,