Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions src/Application/Common/Exceptions/SecurityException.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
namespace Application.Common.Exceptions;

/// <summary>
/// Exception thrown when security violations are detected
/// Used for token assassination and other security events
/// </summary>
public sealed class SecurityException : Exception
{
public SecurityException(string message) : base(message)
{
}

public SecurityException(string message, Exception innerException) : base(message, innerException)
{
}
}
11 changes: 8 additions & 3 deletions src/Application/Features/Auth/Login/LoginCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ public sealed record LoginCommand : ICommand<Result<LoginResponse>>
public sealed record LoginResponse
{
/// <summary>
/// Access token
/// Access token (JWT)
/// </summary>
public required string AccessToken { get; init; }

Expand All @@ -41,9 +41,14 @@ public sealed record LoginResponse
public required string RefreshToken { get; init; }

/// <summary>
/// Token expiration time
/// Access token expiration time (always short-lived)
/// </summary>
public required DateTime ExpiresAt { get; init; }
public required DateTimeOffset AccessTokenExpiresAt { get; init; }

/// <summary>
/// Refresh token expiration time (depends on RememberMe)
/// </summary>
public required DateTimeOffset RefreshTokenExpiresAt { get; init; }

/// <summary>
/// User information
Expand Down
31 changes: 25 additions & 6 deletions src/Application/Features/Auth/Login/LoginCommandHandler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -13,7 +15,8 @@ namespace Application.Features.Auth.Login;
public sealed class LoginCommandHandler(
IUserRepository userRepository,
IAuthService authService,
ITokenService tokenService) : ICommandHandler<LoginCommand, Result<LoginResponse>>
ITokenService tokenService,
IRefreshTokenRepository refreshTokenRepository) : ICommandHandler<LoginCommand, Result<LoginResponse>>
{
public async Task<Result<LoginResponse>> Handle(LoginCommand request, CancellationToken cancellationToken)
{
Expand All @@ -34,7 +37,12 @@ public async Task<Result<LoginResponse>> 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;
Expand All @@ -49,14 +57,25 @@ public async Task<Result<LoginResponse>> 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
};

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,4 +39,9 @@ public sealed record LoginExternalCommand : ICommand<Result<LoginExternalRespons
/// Avatar URL from external provider (optional)
/// </summary>
public string? AvatarUrl { get; init; }

/// <summary>
/// Remember me flag
/// </summary>
public bool RememberMe { get; init; } = true;
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -20,9 +21,10 @@ namespace Application.Features.Auth.LoginExternal;
/// </summary>
public sealed class LoginExternalCommandHandler(
IUserRepository userRepository,
IExternalLoginRepository externalLoginRepository,
IAuthService authService,
ITokenService tokenService,
IRefreshTokenRepository refreshTokenRepository,
IExternalLoginRepository externalLoginRepository,
IUnitOfWork unitOfWork) : ICommandHandler<LoginExternalCommand, Result<LoginExternalResponse>>
{
public async Task<Result<LoginExternalResponse>> Handle(LoginExternalCommand request, CancellationToken cancellationToken)
Expand Down Expand Up @@ -75,7 +77,7 @@ public async Task<Result<LoginExternalResponse>> 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)
Expand Down Expand Up @@ -108,7 +110,7 @@ public async Task<Result<LoginExternalResponse>> 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
Expand Down Expand Up @@ -169,13 +171,18 @@ public async Task<Result<LoginExternalResponse>> 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);
}

/// <summary>
/// Helper method to generate token response
/// </summary>
private Task<Result<LoginExternalResponse>> GenerateTokenResponse(TblUser user, string provider, bool isNewUser)
private async Task<Result<LoginExternalResponse>> GenerateTokenResponse(
TblUser user,
string provider,
bool isNewUser,
bool rememberMe,
CancellationToken cancellationToken)
{
// Parse FullName to FirstName and LastName
var nameParts = user.FullName.Split(' ', 2);
Expand All @@ -192,19 +199,30 @@ private Task<Result<LoginExternalResponse>> 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);

Comment thread
taiphanvan2k3 marked this conversation as resolved.
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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,9 @@ public sealed record LoginExternalResponse
public required string RefreshToken { get; init; }

/// <summary>
/// Token expiration time
/// Access token expiration time
/// </summary>
public required DateTime ExpiresAt { get; init; }
public required DateTimeOffset AccessTokenExpiresAt { get; init; }

/// <summary>
/// User information
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
using Application.Common;
using Application.Common.Models;
using Domain.Common;

namespace Application.Features.Auth.RefreshAccessToken;

/// <summary>
/// Command để refresh access token sử dụng refresh token
/// Pattern: Command Pattern (CQRS)
/// </summary>
public sealed record RefreshAccessTokenCommand : ICommand<Result<RefreshAccessTokenResponse>>
{
/// <summary>
/// Refresh token để refresh access token
/// </summary>
public required string RefreshToken { get; init; }
}

/// <summary>
/// Response sau khi refresh access token thành công
/// </summary>
public sealed record RefreshAccessTokenResponse
{
/// <summary>
/// Access token mới
/// </summary>
public required string AccessToken { get; init; }

/// <summary>
/// Refresh token mới (optional, for token rotation)
/// </summary>
public string? RefreshToken { get; init; }

/// <summary>
/// Access token expiration time
/// </summary>
public required DateTimeOffset AccessTokenExpiresAt { get; init; }

/// <summary>
/// Refresh token expiration time (if new refresh token is issued)
/// </summary>
public DateTimeOffset? RefreshTokenExpiresAt { get; init; }

/// <summary>
/// User information
/// </summary>
public required UserInfo User { get; init; }
}
Loading