feat: 토큰 갱신 및 로그아웃 API 구현 (#38)
All checks were successful
SPMS_API/pipeline/head This commit looks good
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:
commit
5eb3635719
|
|
@ -31,4 +31,36 @@ public class AuthController : ControllerBase
|
||||||
var result = await _authService.LoginAsync(request);
|
var result = await _authService.LoginAsync(request);
|
||||||
return Ok(ApiResponse<LoginResponseDto>.Success(result));
|
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());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
11
SPMS.Application/DTOs/Auth/TokenRefreshRequestDto.cs
Normal file
11
SPMS.Application/DTOs/Auth/TokenRefreshRequestDto.cs
Normal 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;
|
||||||
|
}
|
||||||
15
SPMS.Application/DTOs/Auth/TokenRefreshResponseDto.cs
Normal file
15
SPMS.Application/DTOs/Auth/TokenRefreshResponseDto.cs
Normal 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; }
|
||||||
|
}
|
||||||
|
|
@ -5,4 +5,6 @@ namespace SPMS.Application.Interfaces;
|
||||||
public interface IAuthService
|
public interface IAuthService
|
||||||
{
|
{
|
||||||
Task<LoginResponseDto> LoginAsync(LoginRequestDto request);
|
Task<LoginResponseDto> LoginAsync(LoginRequestDto request);
|
||||||
|
Task<TokenRefreshResponseDto> RefreshTokenAsync(TokenRefreshRequestDto request);
|
||||||
|
Task LogoutAsync(long adminId);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -63,7 +63,9 @@ public class AuthService : IAuthService
|
||||||
admin.Role.ToString());
|
admin.Role.ToString());
|
||||||
var refreshToken = _jwtService.GenerateRefreshToken();
|
var refreshToken = _jwtService.GenerateRefreshToken();
|
||||||
|
|
||||||
// 5. 최종 로그인 시간 업데이트
|
// 5. Refresh Token 및 최종 로그인 시간 저장
|
||||||
|
admin.RefreshToken = refreshToken;
|
||||||
|
admin.RefreshTokenExpiresAt = DateTime.UtcNow.AddDays(_jwtSettings.RefreshTokenExpiryDays);
|
||||||
admin.LastLoginAt = DateTime.UtcNow;
|
admin.LastLoginAt = DateTime.UtcNow;
|
||||||
_adminRepository.Update(admin);
|
_adminRepository.Update(admin);
|
||||||
await _unitOfWork.SaveChangesAsync();
|
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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,8 @@ public class Admin : BaseEntity
|
||||||
public DateTime? EmailVerifiedAt { get; set; }
|
public DateTime? EmailVerifiedAt { get; set; }
|
||||||
public DateTime CreatedAt { get; set; }
|
public DateTime CreatedAt { get; set; }
|
||||||
public DateTime? LastLoginAt { get; set; }
|
public DateTime? LastLoginAt { get; set; }
|
||||||
|
public string? RefreshToken { get; set; }
|
||||||
|
public DateTime? RefreshTokenExpiresAt { get; set; }
|
||||||
public bool IsDeleted { get; set; }
|
public bool IsDeleted { get; set; }
|
||||||
public DateTime? DeletedAt { get; set; }
|
public DateTime? DeletedAt { get; set; }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,5 +6,6 @@ public interface IAdminRepository : IRepository<Admin>
|
||||||
{
|
{
|
||||||
Task<Admin?> GetByEmailAsync(string email);
|
Task<Admin?> GetByEmailAsync(string email);
|
||||||
Task<Admin?> GetByAdminCodeAsync(string adminCode);
|
Task<Admin?> GetByAdminCodeAsync(string adminCode);
|
||||||
|
Task<Admin?> GetByRefreshTokenAsync(string refreshToken);
|
||||||
Task<bool> EmailExistsAsync(string email, long? excludeId = null);
|
Task<bool> EmailExistsAsync(string email, long? excludeId = null);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,12 @@ public class AdminRepository : Repository<Admin>, IAdminRepository
|
||||||
.FirstOrDefaultAsync(a => a.AdminCode == adminCode && !a.IsDeleted);
|
.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)
|
public async Task<bool> EmailExistsAsync(string email, long? excludeId = null)
|
||||||
{
|
{
|
||||||
var query = _dbSet.Where(a => a.Email == email && !a.IsDeleted);
|
var query = _dbSet.Where(a => a.Email == email && !a.IsDeleted);
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user