feat: 토큰 갱신 및 로그아웃 API 구현 (#38)
All checks were successful
SPMS_API/pipeline/head This commit looks good

Reviewed-on: https://git.ipstein.myds.me/SPMS/SPMS_API/pulls/39
This commit is contained in:
김선규 2026-02-09 14:17:46 +00:00
commit 5eb3635719
8 changed files with 133 additions and 1 deletions

View File

@ -31,4 +31,36 @@ public class AuthController : ControllerBase
var result = await _authService.LoginAsync(request);
return Ok(ApiResponse<LoginResponseDto>.Success(result));
}
[HttpPost("token/refresh")]
[AllowAnonymous]
[SwaggerOperation(
Summary = "토큰 갱신",
Description = "Refresh Token을 사용하여 새로운 Access Token과 Refresh Token을 발급받습니다.")]
[SwaggerResponse(200, "토큰 갱신 성공", typeof(ApiResponse<TokenRefreshResponseDto>))]
[SwaggerResponse(401, "유효하지 않거나 만료된 Refresh Token")]
public async Task<IActionResult> RefreshTokenAsync([FromBody] TokenRefreshRequestDto request)
{
var result = await _authService.RefreshTokenAsync(request);
return Ok(ApiResponse<TokenRefreshResponseDto>.Success(result));
}
[HttpPost("logout")]
[Authorize]
[SwaggerOperation(
Summary = "로그아웃",
Description = "현재 로그인된 관리자의 Refresh Token을 무효화합니다.")]
[SwaggerResponse(200, "로그아웃 성공")]
[SwaggerResponse(401, "인증되지 않은 요청")]
public async Task<IActionResult> LogoutAsync()
{
var adminIdClaim = User.FindFirst("adminId")?.Value;
if (string.IsNullOrEmpty(adminIdClaim) || !long.TryParse(adminIdClaim, out var adminId))
{
return Unauthorized(ApiResponse<object>.Fail("101", "인증 정보가 유효하지 않습니다."));
}
await _authService.LogoutAsync(adminId);
return Ok(ApiResponse.Success());
}
}

View File

@ -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;
}

View File

@ -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; }
}

View File

@ -5,4 +5,6 @@ namespace SPMS.Application.Interfaces;
public interface IAuthService
{
Task<LoginResponseDto> LoginAsync(LoginRequestDto request);
Task<TokenRefreshResponseDto> RefreshTokenAsync(TokenRefreshRequestDto request);
Task LogoutAsync(long adminId);
}

View File

@ -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<TokenRefreshResponseDto> 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();
}
}

View File

@ -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; }
}

View File

@ -6,5 +6,6 @@ public interface IAdminRepository : IRepository<Admin>
{
Task<Admin?> GetByEmailAsync(string email);
Task<Admin?> GetByAdminCodeAsync(string adminCode);
Task<Admin?> GetByRefreshTokenAsync(string refreshToken);
Task<bool> EmailExistsAsync(string email, long? excludeId = null);
}

View File

@ -22,6 +22,12 @@ public class AdminRepository : Repository<Admin>, IAdminRepository
.FirstOrDefaultAsync(a => a.AdminCode == adminCode && !a.IsDeleted);
}
public async Task<Admin?> GetByRefreshTokenAsync(string refreshToken)
{
return await _dbSet
.FirstOrDefaultAsync(a => a.RefreshToken == refreshToken && !a.IsDeleted);
}
public async Task<bool> EmailExistsAsync(string email, long? excludeId = null)
{
var query = _dbSet.Where(a => a.Email == email && !a.IsDeleted);