improvement: 로그아웃 시 Access Token 즉시 무효화 (#169) #201
|
|
@ -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());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user