diff --git a/SPMS.API/Controllers/AuthController.cs b/SPMS.API/Controllers/AuthController.cs index 6620fdb..d90bb27 100644 --- a/SPMS.API/Controllers/AuthController.cs +++ b/SPMS.API/Controllers/AuthController.cs @@ -31,4 +31,36 @@ public class AuthController : ControllerBase var result = await _authService.LoginAsync(request); return Ok(ApiResponse.Success(result)); } + + [HttpPost("token/refresh")] + [AllowAnonymous] + [SwaggerOperation( + Summary = "토큰 갱신", + Description = "Refresh Token을 사용하여 새로운 Access Token과 Refresh Token을 발급받습니다.")] + [SwaggerResponse(200, "토큰 갱신 성공", typeof(ApiResponse))] + [SwaggerResponse(401, "유효하지 않거나 만료된 Refresh Token")] + public async Task RefreshTokenAsync([FromBody] TokenRefreshRequestDto request) + { + var result = await _authService.RefreshTokenAsync(request); + return Ok(ApiResponse.Success(result)); + } + + [HttpPost("logout")] + [Authorize] + [SwaggerOperation( + Summary = "로그아웃", + Description = "현재 로그인된 관리자의 Refresh Token을 무효화합니다.")] + [SwaggerResponse(200, "로그아웃 성공")] + [SwaggerResponse(401, "인증되지 않은 요청")] + public async Task LogoutAsync() + { + var adminIdClaim = User.FindFirst("adminId")?.Value; + if (string.IsNullOrEmpty(adminIdClaim) || !long.TryParse(adminIdClaim, out var adminId)) + { + return Unauthorized(ApiResponse.Fail("101", "인증 정보가 유효하지 않습니다.")); + } + + await _authService.LogoutAsync(adminId); + return Ok(ApiResponse.Success()); + } } diff --git a/SPMS.Application/DTOs/Auth/TokenRefreshRequestDto.cs b/SPMS.Application/DTOs/Auth/TokenRefreshRequestDto.cs new file mode 100644 index 0000000..153fb10 --- /dev/null +++ b/SPMS.Application/DTOs/Auth/TokenRefreshRequestDto.cs @@ -0,0 +1,11 @@ +using System.ComponentModel.DataAnnotations; +using System.Text.Json.Serialization; + +namespace SPMS.Application.DTOs.Auth; + +public class TokenRefreshRequestDto +{ + [Required(ErrorMessage = "Refresh Token은 필수입니다.")] + [JsonPropertyName("refresh_token")] + public string RefreshToken { get; set; } = string.Empty; +} diff --git a/SPMS.Application/DTOs/Auth/TokenRefreshResponseDto.cs b/SPMS.Application/DTOs/Auth/TokenRefreshResponseDto.cs new file mode 100644 index 0000000..e30bdea --- /dev/null +++ b/SPMS.Application/DTOs/Auth/TokenRefreshResponseDto.cs @@ -0,0 +1,15 @@ +using System.Text.Json.Serialization; + +namespace SPMS.Application.DTOs.Auth; + +public class TokenRefreshResponseDto +{ + [JsonPropertyName("access_token")] + public string AccessToken { get; set; } = string.Empty; + + [JsonPropertyName("refresh_token")] + public string RefreshToken { get; set; } = string.Empty; + + [JsonPropertyName("expires_in")] + public int ExpiresIn { get; set; } +} diff --git a/SPMS.Application/Interfaces/IAuthService.cs b/SPMS.Application/Interfaces/IAuthService.cs index e5fe903..880120f 100644 --- a/SPMS.Application/Interfaces/IAuthService.cs +++ b/SPMS.Application/Interfaces/IAuthService.cs @@ -5,4 +5,6 @@ namespace SPMS.Application.Interfaces; public interface IAuthService { Task LoginAsync(LoginRequestDto request); + Task RefreshTokenAsync(TokenRefreshRequestDto request); + Task LogoutAsync(long adminId); } diff --git a/SPMS.Application/Services/AuthService.cs b/SPMS.Application/Services/AuthService.cs index 7a9dc11..0337e21 100644 --- a/SPMS.Application/Services/AuthService.cs +++ b/SPMS.Application/Services/AuthService.cs @@ -63,7 +63,9 @@ public class AuthService : IAuthService admin.Role.ToString()); var refreshToken = _jwtService.GenerateRefreshToken(); - // 5. 최종 로그인 시간 업데이트 + // 5. Refresh Token 및 최종 로그인 시간 저장 + admin.RefreshToken = refreshToken; + admin.RefreshTokenExpiresAt = DateTime.UtcNow.AddDays(_jwtSettings.RefreshTokenExpiryDays); admin.LastLoginAt = DateTime.UtcNow; _adminRepository.Update(admin); await _unitOfWork.SaveChangesAsync(); @@ -83,4 +85,65 @@ public class AuthService : IAuthService } }; } + + public async Task RefreshTokenAsync(TokenRefreshRequestDto request) + { + // 1. Refresh Token으로 관리자 조회 + var admin = await _adminRepository.GetByRefreshTokenAsync(request.RefreshToken); + if (admin is null) + { + throw new SpmsException( + ErrorCodes.Unauthorized, + "유효하지 않은 Refresh Token입니다.", + 401); + } + + // 2. Refresh Token 만료 확인 + if (admin.RefreshTokenExpiresAt < DateTime.UtcNow) + { + throw new SpmsException( + ErrorCodes.Unauthorized, + "Refresh Token이 만료되었습니다. 다시 로그인해주세요.", + 401); + } + + // 3. 새 토큰 생성 + var newAccessToken = _jwtService.GenerateAccessToken( + admin.Id, + admin.Role.ToString()); + var newRefreshToken = _jwtService.GenerateRefreshToken(); + + // 4. 새 Refresh Token 저장 (Rotation) + admin.RefreshToken = newRefreshToken; + admin.RefreshTokenExpiresAt = DateTime.UtcNow.AddDays(_jwtSettings.RefreshTokenExpiryDays); + _adminRepository.Update(admin); + await _unitOfWork.SaveChangesAsync(); + + // 5. 응답 반환 + return new TokenRefreshResponseDto + { + AccessToken = newAccessToken, + RefreshToken = newRefreshToken, + ExpiresIn = _jwtSettings.ExpiryMinutes * 60 + }; + } + + public async Task LogoutAsync(long adminId) + { + // 1. 관리자 조회 + var admin = await _adminRepository.GetByIdAsync(adminId); + if (admin is null) + { + throw new SpmsException( + ErrorCodes.NotFound, + "관리자를 찾을 수 없습니다.", + 404); + } + + // 2. Refresh Token 무효화 + admin.RefreshToken = null; + admin.RefreshTokenExpiresAt = null; + _adminRepository.Update(admin); + await _unitOfWork.SaveChangesAsync(); + } } diff --git a/SPMS.Domain/Entities/Admin.cs b/SPMS.Domain/Entities/Admin.cs index 351243b..16e548e 100644 --- a/SPMS.Domain/Entities/Admin.cs +++ b/SPMS.Domain/Entities/Admin.cs @@ -14,6 +14,8 @@ public class Admin : BaseEntity public DateTime? EmailVerifiedAt { get; set; } public DateTime CreatedAt { get; set; } public DateTime? LastLoginAt { get; set; } + public string? RefreshToken { get; set; } + public DateTime? RefreshTokenExpiresAt { get; set; } public bool IsDeleted { get; set; } public DateTime? DeletedAt { get; set; } } diff --git a/SPMS.Domain/Interfaces/IAdminRepository.cs b/SPMS.Domain/Interfaces/IAdminRepository.cs index efe572b..0a2cf77 100644 --- a/SPMS.Domain/Interfaces/IAdminRepository.cs +++ b/SPMS.Domain/Interfaces/IAdminRepository.cs @@ -6,5 +6,6 @@ public interface IAdminRepository : IRepository { Task GetByEmailAsync(string email); Task GetByAdminCodeAsync(string adminCode); + Task GetByRefreshTokenAsync(string refreshToken); Task EmailExistsAsync(string email, long? excludeId = null); } diff --git a/SPMS.Infrastructure/Persistence/Repositories/AdminRepository.cs b/SPMS.Infrastructure/Persistence/Repositories/AdminRepository.cs index d7a349a..883ac2b 100644 --- a/SPMS.Infrastructure/Persistence/Repositories/AdminRepository.cs +++ b/SPMS.Infrastructure/Persistence/Repositories/AdminRepository.cs @@ -22,6 +22,12 @@ public class AdminRepository : Repository, IAdminRepository .FirstOrDefaultAsync(a => a.AdminCode == adminCode && !a.IsDeleted); } + public async Task GetByRefreshTokenAsync(string refreshToken) + { + return await _dbSet + .FirstOrDefaultAsync(a => a.RefreshToken == refreshToken && !a.IsDeleted); + } + public async Task EmailExistsAsync(string email, long? excludeId = null) { var query = _dbSet.Where(a => a.Email == email && !a.IsDeleted);