From bf8f82e66c44041f42838c4227be5f597fa49b18 Mon Sep 17 00:00:00 2001 From: SEAN Date: Tue, 24 Feb 2026 17:33:37 +0900 Subject: [PATCH] =?UTF-8?q?improvement:=20=EB=A1=9C=EA=B7=B8=EC=95=84?= =?UTF-8?q?=EC=9B=83=20=EC=8B=9C=20Access=20Token=20=EC=A6=89=EC=8B=9C=20?= =?UTF-8?q?=EB=AC=B4=ED=9A=A8=ED=99=94=20(#169)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - IJwtService/JwtService에 GetTokenInfo(JTI, 만료시간 추출) 추가 - LogoutAsync에 Redis 블랙리스트 로직 추가 (key: blacklist:{jti}, TTL: 남은 만료시간) - AuthenticationExtensions OnTokenValidated에서 블랙리스트 체크 - 로그아웃 후 동일 Access Token 재사용 시 401 반환 Closes #169 --- SPMS.API/Controllers/AuthController.cs | 7 +++++-- SPMS.API/Extensions/AuthenticationExtensions.cs | 17 +++++++++++++++++ SPMS.Application/Interfaces/IAuthService.cs | 2 +- SPMS.Application/Interfaces/IJwtService.cs | 1 + SPMS.Application/Services/AuthService.cs | 11 ++++++++++- SPMS.Infrastructure/Auth/JwtService.cs | 7 +++++++ 6 files changed, 41 insertions(+), 4 deletions(-) diff --git a/SPMS.API/Controllers/AuthController.cs b/SPMS.API/Controllers/AuthController.cs index 907079b..b4080d9 100644 --- a/SPMS.API/Controllers/AuthController.cs +++ b/SPMS.API/Controllers/AuthController.cs @@ -77,7 +77,7 @@ public class AuthController : ControllerBase [Authorize] [SwaggerOperation( Summary = "로그아웃", - Description = "현재 로그인된 관리자의 Refresh Token을 무효화합니다.")] + Description = "현재 로그인된 관리자의 토큰을 무효화합니다. Refresh Token은 DB에서 삭제되고, Access Token은 Redis 블랙리스트에 등록되어 즉시 사용 불가합니다.")] [SwaggerResponse(200, "로그아웃 성공")] [SwaggerResponse(401, "인증되지 않은 요청")] public async Task LogoutAsync() @@ -86,7 +86,10 @@ public class AuthController : ControllerBase if (string.IsNullOrEmpty(adminIdClaim) || !long.TryParse(adminIdClaim, out var adminId)) 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()); } diff --git a/SPMS.API/Extensions/AuthenticationExtensions.cs b/SPMS.API/Extensions/AuthenticationExtensions.cs index 8217ae0..5675e65 100644 --- a/SPMS.API/Extensions/AuthenticationExtensions.cs +++ b/SPMS.API/Extensions/AuthenticationExtensions.cs @@ -1,6 +1,7 @@ using System.Text; using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.IdentityModel.Tokens; +using SPMS.Application.Interfaces; using SPMS.Application.Settings; namespace SPMS.API.Extensions; @@ -34,6 +35,22 @@ public static class AuthenticationExtensions Encoding.UTF8.GetBytes(jwtSettings.SecretKey)), ClockSkew = TimeSpan.Zero }; + + options.Events = new JwtBearerEvents + { + OnTokenValidated = async context => + { + var tokenStore = context.HttpContext.RequestServices + .GetRequiredService(); + 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; diff --git a/SPMS.Application/Interfaces/IAuthService.cs b/SPMS.Application/Interfaces/IAuthService.cs index baeee2b..edfbba8 100644 --- a/SPMS.Application/Interfaces/IAuthService.cs +++ b/SPMS.Application/Interfaces/IAuthService.cs @@ -8,7 +8,7 @@ public interface IAuthService Task SignupAsync(SignupRequestDto request); Task LoginAsync(LoginRequestDto request); Task RefreshTokenAsync(TokenRefreshRequestDto request); - Task LogoutAsync(long adminId); + Task LogoutAsync(long adminId, string accessToken); Task ChangePasswordAsync(long adminId, ChangePasswordRequestDto request); Task CheckEmailAsync(EmailCheckRequestDto request); Task VerifyEmailAsync(EmailVerifyRequestDto request); diff --git a/SPMS.Application/Interfaces/IJwtService.cs b/SPMS.Application/Interfaces/IJwtService.cs index 24d74aa..6cfd2cf 100644 --- a/SPMS.Application/Interfaces/IJwtService.cs +++ b/SPMS.Application/Interfaces/IJwtService.cs @@ -7,4 +7,5 @@ public interface IJwtService string GenerateAccessToken(long adminId, string role, string? serviceCode = null); ClaimsPrincipal? ValidateAccessToken(string token); string GenerateRefreshToken(); + (string? Jti, DateTime ValidTo) GetTokenInfo(string token); } diff --git a/SPMS.Application/Services/AuthService.cs b/SPMS.Application/Services/AuthService.cs index 1d20ab6..90d43b8 100644 --- a/SPMS.Application/Services/AuthService.cs +++ b/SPMS.Application/Services/AuthService.cs @@ -185,7 +185,7 @@ public class AuthService : IAuthService }; } - public async Task LogoutAsync(long adminId) + public async Task LogoutAsync(long adminId, string accessToken) { // 1. 관리자 조회 var admin = await _adminRepository.GetByIdAsync(adminId); @@ -202,6 +202,15 @@ public class AuthService : IAuthService admin.RefreshTokenExpiresAt = null; _adminRepository.Update(admin); 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) diff --git a/SPMS.Infrastructure/Auth/JwtService.cs b/SPMS.Infrastructure/Auth/JwtService.cs index fb5229b..4c66531 100644 --- a/SPMS.Infrastructure/Auth/JwtService.cs +++ b/SPMS.Infrastructure/Auth/JwtService.cs @@ -77,4 +77,11 @@ public class JwtService : IJwtService rng.GetBytes(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); + } }