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