improvement: 로그아웃 시 Access Token 즉시 무효화 (#169)
All checks were successful
SPMS_API/pipeline/head This commit looks good

Reviewed-on: https://git.ipstein.myds.me/SPMS/SPMS_API/pulls/201
This commit is contained in:
김선규 2026-02-24 08:35:01 +00:00
commit 10460b40c3
6 changed files with 41 additions and 4 deletions

View File

@ -77,7 +77,7 @@ public class AuthController : ControllerBase
[Authorize] [Authorize]
[SwaggerOperation( [SwaggerOperation(
Summary = "로그아웃", Summary = "로그아웃",
Description = "현재 로그인된 관리자의 Refresh Token을 무효화합니다.")] Description = "현재 로그인된 관리자의 토큰을 무효화합니다. Refresh Token은 DB에서 삭제되고, Access Token은 Redis 블랙리스트에 등록되어 즉시 사용 불가합니다.")]
[SwaggerResponse(200, "로그아웃 성공")] [SwaggerResponse(200, "로그아웃 성공")]
[SwaggerResponse(401, "인증되지 않은 요청")] [SwaggerResponse(401, "인증되지 않은 요청")]
public async Task<IActionResult> LogoutAsync() public async Task<IActionResult> LogoutAsync()
@ -86,7 +86,10 @@ public class AuthController : ControllerBase
if (string.IsNullOrEmpty(adminIdClaim) || !long.TryParse(adminIdClaim, out var adminId)) if (string.IsNullOrEmpty(adminIdClaim) || !long.TryParse(adminIdClaim, out var adminId))
throw SpmsException.Unauthorized("인증 정보가 유효하지 않습니다."); throw SpmsException.Unauthorized("인증 정보가 유효하지 않습니다.");
await _authService.LogoutAsync(adminId); var accessToken = HttpContext.Request.Headers["Authorization"]
.ToString().Replace("Bearer ", "");
await _authService.LogoutAsync(adminId, accessToken);
return Ok(ApiResponse.Success()); return Ok(ApiResponse.Success());
} }

View File

@ -1,6 +1,7 @@
using System.Text; using System.Text;
using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.IdentityModel.Tokens; using Microsoft.IdentityModel.Tokens;
using SPMS.Application.Interfaces;
using SPMS.Application.Settings; using SPMS.Application.Settings;
namespace SPMS.API.Extensions; namespace SPMS.API.Extensions;
@ -34,6 +35,22 @@ public static class AuthenticationExtensions
Encoding.UTF8.GetBytes(jwtSettings.SecretKey)), Encoding.UTF8.GetBytes(jwtSettings.SecretKey)),
ClockSkew = TimeSpan.Zero ClockSkew = TimeSpan.Zero
}; };
options.Events = new JwtBearerEvents
{
OnTokenValidated = async context =>
{
var tokenStore = context.HttpContext.RequestServices
.GetRequiredService<ITokenStore>();
var jti = context.Principal?.FindFirst("jti")?.Value;
if (!string.IsNullOrEmpty(jti))
{
var blacklisted = await tokenStore.GetAsync($"blacklist:{jti}");
if (blacklisted != null)
context.Fail("토큰이 무효화되었습니다.");
}
}
};
}); });
return services; return services;

View File

@ -8,7 +8,7 @@ public interface IAuthService
Task<SignupResponseDto> SignupAsync(SignupRequestDto request); Task<SignupResponseDto> SignupAsync(SignupRequestDto request);
Task<LoginResponseDto> LoginAsync(LoginRequestDto request); Task<LoginResponseDto> LoginAsync(LoginRequestDto request);
Task<TokenRefreshResponseDto> RefreshTokenAsync(TokenRefreshRequestDto request); Task<TokenRefreshResponseDto> RefreshTokenAsync(TokenRefreshRequestDto request);
Task LogoutAsync(long adminId); Task LogoutAsync(long adminId, string accessToken);
Task ChangePasswordAsync(long adminId, ChangePasswordRequestDto request); Task ChangePasswordAsync(long adminId, ChangePasswordRequestDto request);
Task<EmailCheckResponseDto> CheckEmailAsync(EmailCheckRequestDto request); Task<EmailCheckResponseDto> CheckEmailAsync(EmailCheckRequestDto request);
Task VerifyEmailAsync(EmailVerifyRequestDto request); Task VerifyEmailAsync(EmailVerifyRequestDto request);

View File

@ -7,4 +7,5 @@ public interface IJwtService
string GenerateAccessToken(long adminId, string role, string? serviceCode = null); string GenerateAccessToken(long adminId, string role, string? serviceCode = null);
ClaimsPrincipal? ValidateAccessToken(string token); ClaimsPrincipal? ValidateAccessToken(string token);
string GenerateRefreshToken(); string GenerateRefreshToken();
(string? Jti, DateTime ValidTo) GetTokenInfo(string token);
} }

View File

@ -185,7 +185,7 @@ public class AuthService : IAuthService
}; };
} }
public async Task LogoutAsync(long adminId) public async Task LogoutAsync(long adminId, string accessToken)
{ {
// 1. 관리자 조회 // 1. 관리자 조회
var admin = await _adminRepository.GetByIdAsync(adminId); var admin = await _adminRepository.GetByIdAsync(adminId);
@ -202,6 +202,15 @@ public class AuthService : IAuthService
admin.RefreshTokenExpiresAt = null; admin.RefreshTokenExpiresAt = null;
_adminRepository.Update(admin); _adminRepository.Update(admin);
await _unitOfWork.SaveChangesAsync(); await _unitOfWork.SaveChangesAsync();
// 3. Access Token 블랙리스트 (JTI 기반, TTL = 남은 만료시간)
var (jti, validTo) = _jwtService.GetTokenInfo(accessToken);
if (!string.IsNullOrEmpty(jti))
{
var remaining = validTo - DateTime.UtcNow;
if (remaining > TimeSpan.Zero)
await _tokenStore.StoreAsync($"blacklist:{jti}", "revoked", remaining);
}
} }
public async Task ChangePasswordAsync(long adminId, ChangePasswordRequestDto request) public async Task ChangePasswordAsync(long adminId, ChangePasswordRequestDto request)

View File

@ -77,4 +77,11 @@ public class JwtService : IJwtService
rng.GetBytes(randomBytes); rng.GetBytes(randomBytes);
return Convert.ToBase64String(randomBytes); return Convert.ToBase64String(randomBytes);
} }
public (string? Jti, DateTime ValidTo) GetTokenInfo(string token)
{
var handler = new JwtSecurityTokenHandler();
var jwtToken = handler.ReadJwtToken(token);
return (jwtToken.Id, jwtToken.ValidTo);
}
} }