Notice
Recent Posts
Recent Comments
Link
| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 1 | 2 | 3 | 4 | 5 | 6 | |
| 7 | 8 | 9 | 10 | 11 | 12 | 13 |
| 14 | 15 | 16 | 17 | 18 | 19 | 20 |
| 21 | 22 | 23 | 24 | 25 | 26 | 27 |
| 28 | 29 | 30 | 31 |
Tags
- RabbitMQ Exchange
- 컨테이너오케스트레이션
- docker
- 고가용성
- ApacheBench
- 서비스 설계
- rabbitmq
- 메시징 패턴
- 프로덕션 운영
- 서비스 메시
- 세션저장소
- 이벤트 스트리밍
- Python
- 메시지 브로커
- 분산 모니터링
- 클라우드
- infrastructureascode
- 분산 시스템
- 모노리스 분해
- 마이크로서비스 통신
- 클러스터
- 보안
- 마이크로서비스
- 마이크로서비스 운영
- devops
- 모니터링
- 인메모리데이터베이스
- Kafka 클러스터
- CI/CD
- kubernetes
Archives
- Today
- Total
hobokai 님의 블로그
OAuth2 & JWT 고급 활용 가이드 본문
실무에서 바로 써먹는 OAuth2와 JWT 완전 정복
기본 인증을 넘어선 고급 보안 패턴과 실전 구현 전략을 다룹니다.
🔐 OAuth2 고급 패턴
Authorization Code Flow with PKCE
PKCE(Proof Key for Code Exchange)는 SPA와 모바일 앱의 보안을 강화하는 OAuth2 확장입니다.
// 프론트엔드 - PKCE 구현
class OAuth2PKCEClient {
constructor(clientId, redirectUri, authUrl, tokenUrl) {
this.clientId = clientId;
this.redirectUri = redirectUri;
this.authUrl = authUrl;
this.tokenUrl = tokenUrl;
}
// PKCE Code Verifier 생성
generateCodeVerifier() {
const array = new Uint8Array(32);
crypto.getRandomValues(array);
return this.base64URLEncode(array);
}
// Code Challenge 생성
async generateCodeChallenge(verifier) {
const encoder = new TextEncoder();
const data = encoder.encode(verifier);
const digest = await crypto.subtle.digest('SHA-256', data);
return this.base64URLEncode(new Uint8Array(digest));
}
base64URLEncode(array) {
return btoa(String.fromCharCode(...array))
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=/g, '');
}
// 인증 URL 생성
async buildAuthUrl() {
const codeVerifier = this.generateCodeVerifier();
const codeChallenge = await this.generateCodeChallenge(codeVerifier);
// 세션 스토리지에 code_verifier 저장
sessionStorage.setItem('pkce_code_verifier', codeVerifier);
const params = new URLSearchParams({
response_type: 'code',
client_id: this.clientId,
redirect_uri: this.redirectUri,
scope: 'openid profile email',
state: this.generateState(),
code_challenge: codeChallenge,
code_challenge_method: 'S256'
});
return `${this.authUrl}?${params.toString()}`;
}
// 토큰 교환
async exchangeCodeForToken(code) {
const codeVerifier = sessionStorage.getItem('pkce_code_verifier');
const response = await fetch(this.tokenUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: new URLSearchParams({
grant_type: 'authorization_code',
client_id: this.clientId,
code: code,
redirect_uri: this.redirectUri,
code_verifier: codeVerifier
})
});
if (!response.ok) {
throw new Error('Token exchange failed');
}
return await response.json();
}
generateState() {
return Math.random().toString(36).substring(2, 15);
}
}
OAuth2 Resource Server 구현 (Spring Boot)
@Configuration
@EnableResourceServer
public class OAuth2ResourceServerConfig {
@Bean
public JwtDecoder jwtDecoder() {
// JWT 서명 검증을 위한 RSA 공개키 설정
RSAKey rsaKey = loadRSAKey();
return NimbusJwtDecoder.withPublicKey(rsaKey.toRSAPublicKey()).build();
}
@Bean
public JwtAuthenticationConverter jwtAuthenticationConverter() {
JwtGrantedAuthoritiesConverter authoritiesConverter =
new JwtGrantedAuthoritiesConverter();
authoritiesConverter.setAuthorityPrefix("ROLE_");
authoritiesConverter.setAuthoritiesClaimName("roles");
JwtAuthenticationConverter jwtConverter = new JwtAuthenticationConverter();
jwtConverter.setJwtGrantedAuthoritiesConverter(authoritiesConverter);
jwtConverter.setPrincipalClaimName("sub");
return jwtConverter;
}
@Bean
public SecurityFilterChain resourceServerFilterChain(HttpSecurity http) throws Exception {
return http
.oauth2ResourceServer(oauth2 -> oauth2
.jwt(jwt -> jwt
.decoder(jwtDecoder())
.jwtAuthenticationConverter(jwtAuthenticationConverter())
)
)
.authorizeHttpRequests(authz -> authz
.requestMatchers("/api/public/**").permitAll()
.requestMatchers(HttpMethod.GET, "/api/users/me").hasRole("USER")
.requestMatchers(HttpMethod.POST, "/api/admin/**").hasRole("ADMIN")
.anyRequest().authenticated()
)
.build();
}
}
다중 테넌트 OAuth2 구현
@Component
public class MultiTenantJwtDecoder implements JwtDecoder {
private final Map<String, JwtDecoder> jwtDecoders = new ConcurrentHashMap<>();
private final TenantResolver tenantResolver;
public MultiTenantJwtDecoder(TenantResolver tenantResolver) {
this.tenantResolver = tenantResolver;
}
@Override
public Jwt decode(String token) throws JwtException {
String tenantId = tenantResolver.resolve(token);
JwtDecoder decoder = jwtDecoders.computeIfAbsent(tenantId, this::createJwtDecoder);
return decoder.decode(token);
}
private JwtDecoder createJwtDecoder(String tenantId) {
TenantConfig config = getTenantConfig(tenantId);
return NimbusJwtDecoder.withJwkSetUri(config.getJwksUri())
.cache(Duration.ofMinutes(5))
.build();
}
private TenantConfig getTenantConfig(String tenantId) {
// 테넌트별 설정 조회 (데이터베이스, 캐시 등)
return tenantConfigRepository.findByTenantId(tenantId)
.orElseThrow(() -> new IllegalArgumentException("Unknown tenant: " + tenantId));
}
}
@Component
public class TenantResolver {
public String resolve(String token) {
try {
// JWT 헤더에서 테넌트 정보 추출
String[] chunks = token.split("\\.");
Base64.Decoder decoder = Base64.getUrlDecoder();
String header = new String(decoder.decode(chunks[0]));
JsonNode headerNode = objectMapper.readTree(header);
return headerNode.get("tenant").asText();
} catch (Exception e) {
throw new IllegalArgumentException("Invalid token format", e);
}
}
}
🎫 JWT 고급 활용 패턴
JWT Refresh Token 로테이션
보안을 강화하기 위해 Refresh Token을 한 번 사용하면 새로운 토큰으로 교체하는 패턴입니다.
@Service
@RequiredArgsConstructor
public class RefreshTokenService {
private final RefreshTokenRepository refreshTokenRepository;
private final JwtTokenProvider jwtTokenProvider;
@Transactional
public TokenResponse refreshTokens(String refreshToken) {
// 기존 Refresh Token 검증
RefreshToken existingToken = refreshTokenRepository
.findByToken(refreshToken)
.filter(token -> !token.isExpired())
.filter(token -> !token.isRevoked())
.orElseThrow(() -> new InvalidTokenException("Invalid refresh token"));
// 기존 토큰 무효화
existingToken.revoke();
// 토큰 재사용 감지 (Refresh Token Rotation)
if (existingToken.isUsed()) {
// 보안 침해 가능성 - 해당 사용자의 모든 토큰 무효화
refreshTokenRepository.revokeAllByUserId(existingToken.getUserId());
throw new SecurityException("Refresh token reuse detected");
}
existingToken.markAsUsed();
// 새로운 토큰 쌍 생성
String newAccessToken = jwtTokenProvider.createAccessToken(existingToken.getUserId());
String newRefreshToken = jwtTokenProvider.createRefreshToken(existingToken.getUserId());
// 새로운 Refresh Token 저장
RefreshToken newRefreshTokenEntity = RefreshToken.builder()
.token(newRefreshToken)
.userId(existingToken.getUserId())
.expiresAt(LocalDateTime.now().plusDays(30))
.build();
refreshTokenRepository.save(newRefreshTokenEntity);
return TokenResponse.builder()
.accessToken(newAccessToken)
.refreshToken(newRefreshToken)
.tokenType("Bearer")
.expiresIn(3600L)
.build();
}
@Scheduled(fixedRate = 3600000) // 1시간마다 실행
public void cleanupExpiredTokens() {
refreshTokenRepository.deleteAllExpired();
}
}
JWT Claims 기반 동적 권한 관리
@Component
public class DynamicJwtTokenProvider extends JwtTokenProvider {
private final UserPermissionService permissionService;
private final RoleHierarchyService roleHierarchyService;
@Override
public String createAccessToken(String userId) {
User user = userService.findById(userId);
// 동적 권한 조회
Set<Permission> permissions = permissionService.getUserPermissions(userId);
Set<Role> roles = roleHierarchyService.expandRoles(user.getRoles());
// 컨텍스트 기반 클레임 추가
Map<String, Object> claims = new HashMap<>();
claims.put("sub", userId);
claims.put("email", user.getEmail());
claims.put("roles", roles.stream().map(Role::getName).collect(Collectors.toList()));
claims.put("permissions", permissions.stream().map(Permission::getName).collect(Collectors.toList()));
// 조직 및 부서 정보
if (user.getOrganization() != null) {
claims.put("org_id", user.getOrganization().getId());
claims.put("dept_id", user.getDepartment().getId());
}
// 지역 제한 (Multi-region)
claims.put("allowed_regions", user.getAllowedRegions());
// 데이터 접근 레벨
claims.put("data_classification_level", user.getDataClassificationLevel());
return Jwts.builder()
.setClaims(claims)
.setIssuedAt(new Date())
.setExpiration(new Date(System.currentTimeMillis() + ACCESS_TOKEN_VALIDITY))
.signWith(SignatureAlgorithm.RS256, privateKey)
.compact();
}
}
@Component
public class ClaimsBasedSecurityExpressionRoot {
public boolean hasDataAccess(Authentication auth, String dataClassification) {
if (!(auth.getPrincipal() instanceof JwtAuthenticationToken)) {
return false;
}
JwtAuthenticationToken jwt = (JwtAuthenticationToken) auth.getPrincipal();
String userLevel = jwt.getToken().getClaimAsString("data_classification_level");
return isAuthorizedForDataLevel(userLevel, dataClassification);
}
public boolean inRegion(Authentication auth, String regionCode) {
if (!(auth.getPrincipal() instanceof JwtAuthenticationToken)) {
return false;
}
JwtAuthenticationToken jwt = (JwtAuthenticationToken) auth.getPrincipal();
List<String> allowedRegions = jwt.getToken().getClaimAsStringList("allowed_regions");
return allowedRegions.contains(regionCode);
}
private boolean isAuthorizedForDataLevel(String userLevel, String requiredLevel) {
// PUBLIC < INTERNAL < CONFIDENTIAL < RESTRICTED
Map<String, Integer> levels = Map.of(
"PUBLIC", 1,
"INTERNAL", 2,
"CONFIDENTIAL", 3,
"RESTRICTED", 4
);
return levels.getOrDefault(userLevel, 0) >= levels.getOrDefault(requiredLevel, 999);
}
}
// 사용 예시
@RestController
public class DataController {
@GetMapping("/api/financial-reports/{id}")
@PreAuthorize("hasDataAccess(authentication, 'CONFIDENTIAL') and inRegion(authentication, 'KR')")
public FinancialReport getFinancialReport(@PathVariable String id) {
return financialReportService.findById(id);
}
}
JWT 토큰 블랙리스트 관리
@Service
@RequiredArgsConstructor
public class JwtBlacklistService {
private final RedisTemplate<String, String> redisTemplate;
private final JwtTokenProvider jwtTokenProvider;
private static final String BLACKLIST_KEY_PREFIX = "jwt:blacklist:";
public void blacklistToken(String token) {
try {
Claims claims = jwtTokenProvider.parseClaims(token);
String jti = claims.getId(); // JWT ID
Date expiration = claims.getExpiration();
if (jti != null && expiration.after(new Date())) {
String key = BLACKLIST_KEY_PREFIX + jti;
long ttl = expiration.getTime() - System.currentTimeMillis();
// Redis에 블랙리스트 등록 (만료시간까지만)
redisTemplate.opsForValue().set(key, token, Duration.ofMillis(ttl));
}
} catch (Exception e) {
log.error("Failed to blacklist token", e);
}
}
public boolean isBlacklisted(String token) {
try {
Claims claims = jwtTokenProvider.parseClaims(token);
String jti = claims.getId();
if (jti == null) return false;
String key = BLACKLIST_KEY_PREFIX + jti;
return redisTemplate.hasKey(key);
} catch (Exception e) {
// 파싱 실패한 토큰은 유효하지 않은 것으로 간주
return true;
}
}
// 사용자의 모든 토큰 무효화
public void blacklistAllUserTokens(String userId) {
// 활성 세션에서 해당 사용자의 토큰들을 찾아서 블랙리스트에 추가
Set<String> activeTokens = getActiveTokensByUser(userId);
activeTokens.forEach(this::blacklistToken);
}
private Set<String> getActiveTokensByUser(String userId) {
// Redis나 데이터베이스에서 활성 토큰 조회
String pattern = "user:session:" + userId + ":*";
return redisTemplate.keys(pattern).stream()
.map(key -> redisTemplate.opsForValue().get(key))
.collect(Collectors.toSet());
}
}
// JWT 검증 필터에서 블랙리스트 체크
@Component
public class JwtBlacklistFilter extends OncePerRequestFilter {
private final JwtBlacklistService blacklistService;
private final JwtTokenProvider jwtTokenProvider;
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
String token = jwtTokenProvider.resolveToken(request);
if (token != null && blacklistService.isBlacklisted(token)) {
response.setStatus(HttpStatus.UNAUTHORIZED.value());
response.getWriter().write("{\"error\":\"Token has been revoked\"}");
return;
}
filterChain.doFilter(request, response);
}
}
🔒 고급 보안 패턴
JWT 암호화 (JWE) 구현
민감한 정보를 포함한 JWT는 암호화해야 합니다.
@Service
public class JWETokenService {
private final RSAPrivateKey privateKey;
private final RSAPublicKey publicKey;
public String createEncryptedToken(Map<String, Object> claims) {
try {
// JWE 헤더 생성
JWEHeader header = new JWEHeader.Builder(JWEAlgorithm.RSA_OAEP_256, EncryptionMethod.A256GCM)
.contentType("JWT")
.build();
// JWT Claims 생성
JWTClaimsSet claimsSet = new JWTClaimsSet.Builder()
.subject((String) claims.get("sub"))
.issuer("your-issuer")
.audience("your-audience")
.expirationTime(new Date(System.currentTimeMillis() + 3600000))
.issueTime(new Date())
.jwtID(UUID.randomUUID().toString())
.build();
// 추가 클레임 설정
claims.forEach((key, value) -> {
if (!"sub".equals(key)) {
claimsSet.setClaim(key, value);
}
});
// JWE 객체 생성
JWEObject jweObject = new JWEObject(header, new Payload(claimsSet.toJSONObject()));
// RSA 암호화
RSAEncrypter encrypter = new RSAEncrypter(publicKey);
jweObject.encrypt(encrypter);
return jweObject.serialize();
} catch (Exception e) {
throw new RuntimeException("Failed to create encrypted token", e);
}
}
public JWTClaimsSet decryptToken(String encryptedToken) {
try {
// JWE 파싱
JWEObject jweObject = JWEObject.parse(encryptedToken);
// RSA 복호화
RSADecrypter decrypter = new RSADecrypter(privateKey);
jweObject.decrypt(decrypter);
// Claims 추출
return JWTClaimsSet.parse(jweObject.getPayload().toJSONObject());
} catch (Exception e) {
throw new RuntimeException("Failed to decrypt token", e);
}
}
}
토큰 바인딩 (Token Binding)
토큰을 특정 클라이언트나 세션에 바인딩하여 보안을 강화합니다.
@Service
public class TokenBindingService {
public String createBoundToken(String userId, HttpServletRequest request) {
// 클라이언트 지문 생성
String clientFingerprint = generateClientFingerprint(request);
Map<String, Object> claims = new HashMap<>();
claims.put("sub", userId);
claims.put("client_fp", clientFingerprint);
claims.put("ip", getClientIP(request));
claims.put("user_agent_hash", hashUserAgent(request.getHeader("User-Agent")));
return jwtTokenProvider.createToken(claims);
}
public boolean validateTokenBinding(String token, HttpServletRequest request) {
try {
Claims claims = jwtTokenProvider.parseClaims(token);
String tokenFingerprint = claims.get("client_fp", String.class);
String tokenIP = claims.get("ip", String.class);
String tokenUserAgentHash = claims.get("user_agent_hash", String.class);
String currentFingerprint = generateClientFingerprint(request);
String currentIP = getClientIP(request);
String currentUserAgentHash = hashUserAgent(request.getHeader("User-Agent"));
return Objects.equals(tokenFingerprint, currentFingerprint) &&
Objects.equals(tokenIP, currentIP) &&
Objects.equals(tokenUserAgentHash, currentUserAgentHash);
} catch (Exception e) {
return false;
}
}
private String generateClientFingerprint(HttpServletRequest request) {
StringBuilder fingerprint = new StringBuilder();
fingerprint.append(request.getHeader("User-Agent"));
fingerprint.append(request.getHeader("Accept-Language"));
fingerprint.append(request.getHeader("Accept-Encoding"));
fingerprint.append(getClientIP(request));
return DigestUtils.sha256Hex(fingerprint.toString());
}
private String getClientIP(HttpServletRequest request) {
String xForwardedFor = request.getHeader("X-Forwarded-For");
if (xForwardedFor != null) {
return xForwardedFor.split(",")[0].trim();
}
return request.getRemoteAddr();
}
private String hashUserAgent(String userAgent) {
return userAgent != null ? DigestUtils.sha256Hex(userAgent) : "";
}
}
적응형 인증 (Adaptive Authentication)
@Service
@RequiredArgsConstructor
public class AdaptiveAuthService {
private final UserBehaviorService behaviorService;
private final RiskAssessmentService riskService;
private final MFAService mfaService;
public AuthenticationDecision authenticate(LoginRequest request, HttpServletRequest httpRequest) {
// 위험도 평가
RiskScore riskScore = riskService.calculateRisk(request, httpRequest);
AuthenticationDecision.Builder decision = AuthenticationDecision.builder()
.userId(request.getUserId())
.riskScore(riskScore);
if (riskScore.getScore() < 30) {
// 낮은 위험도 - 일반 인증
return decision
.authenticationLevel(AuthLevel.BASIC)
.requiresMFA(false)
.tokenValidity(Duration.ofHours(8))
.build();
} else if (riskScore.getScore() < 70) {
// 중간 위험도 - 추가 인증 필요
return decision
.authenticationLevel(AuthLevel.ENHANCED)
.requiresMFA(true)
.mfaMethod(MFAMethod.SMS_OR_APP)
.tokenValidity(Duration.ofHours(2))
.build();
} else {
// 높은 위험도 - 강화된 인증
return decision
.authenticationLevel(AuthLevel.HIGH_ASSURANCE)
.requiresMFA(true)
.mfaMethod(MFAMethod.HARDWARE_TOKEN)
.requiresDeviceVerification(true)
.tokenValidity(Duration.ofMinutes(30))
.additionalVerification(List.of("EMAIL_VERIFICATION", "SECURITY_QUESTIONS"))
.build();
}
}
}
@Service
public class RiskAssessmentService {
public RiskScore calculateRisk(LoginRequest request, HttpServletRequest httpRequest) {
RiskCalculator calculator = new RiskCalculator();
// 지리적 위치 위험도
calculator.addFactor("location", assessLocationRisk(request.getUserId(), httpRequest));
// 디바이스 위험도
calculator.addFactor("device", assessDeviceRisk(request.getUserId(), httpRequest));
// 시간 패턴 위험도
calculator.addFactor("time_pattern", assessTimePatternRisk(request.getUserId()));
// 행동 패턴 위험도
calculator.addFactor("behavior", assessBehaviorRisk(request.getUserId(), request));
// 네트워크 위험도
calculator.addFactor("network", assessNetworkRisk(httpRequest));
return calculator.calculate();
}
private int assessLocationRisk(String userId, HttpServletRequest request) {
String clientIP = getClientIP(request);
Location currentLocation = geoLocationService.getLocation(clientIP);
List<Location> knownLocations = userLocationService.getKnownLocations(userId);
if (knownLocations.isEmpty()) {
return 30; // 새 사용자
}
double minDistance = knownLocations.stream()
.mapToDouble(loc -> calculateDistance(currentLocation, loc))
.min()
.orElse(Double.MAX_VALUE);
if (minDistance < 50) return 0; // 50km 이내
if (minDistance < 500) return 20; // 500km 이내
if (minDistance < 2000) return 40; // 2000km 이내
return 80; // 매우 먼 거리
}
private int assessDeviceRisk(String userId, HttpServletRequest request) {
String deviceFingerprint = deviceFingerprintService.generate(request);
boolean isKnownDevice = deviceService.isKnownDevice(userId, deviceFingerprint);
if (isKnownDevice) {
return 0;
} else {
// 새로운 디바이스
return 50;
}
}
}
🔄 토큰 생명주기 관리
토큰 갱신 전략
@Service
public class TokenLifecycleService {
@Scheduled(fixedRate = 300000) // 5분마다
public void refreshActiveTokens() {
List<ActiveSession> expiringSessions = sessionService
.findSessionsExpiringWithin(Duration.ofMinutes(10));
expiringSessions.parallelStream().forEach(session -> {
try {
if (shouldRefreshToken(session)) {
refreshSessionToken(session);
}
} catch (Exception e) {
log.error("Failed to refresh token for session: {}", session.getId(), e);
}
});
}
private boolean shouldRefreshToken(ActiveSession session) {
// 토큰 갱신 조건
return session.getLastActivity().isAfter(LocalDateTime.now().minusMinutes(30)) &&
session.isActive() &&
!session.isHighRisk();
}
private void refreshSessionToken(ActiveSession session) {
String newAccessToken = jwtTokenProvider.createAccessToken(
session.getUserId(),
session.getAuthenticationLevel()
);
session.updateAccessToken(newAccessToken);
sessionService.save(session);
// 클라이언트에 새 토큰 푸시 (WebSocket, Server-Sent Events 등)
tokenPushService.pushNewToken(session.getSessionId(), newAccessToken);
}
}
@Component
public class TokenValidityAdjuster {
public Duration adjustTokenValidity(String userId, AuthenticationContext context) {
UserProfile profile = userService.getUserProfile(userId);
Duration baseValidity = Duration.ofHours(1);
// 사용자 역할에 따른 조정
if (profile.hasRole("ADMIN")) {
baseValidity = Duration.ofMinutes(30); // 관리자는 짧게
}
// 접근하는 리소스에 따른 조정
if (context.isHighSensitiveResource()) {
baseValidity = Duration.ofMinutes(15);
}
// 네트워크 환경에 따른 조정
if (context.isFromPublicNetwork()) {
baseValidity = baseValidity.dividedBy(2);
}
// 사용자 행동 패턴에 따른 조정
if (behaviorService.isRegularUser(userId)) {
baseValidity = baseValidity.multipliedBy(2); // 신뢰할 수 있는 사용자는 길게
}
return baseValidity;
}
}
🚀 성능 최적화
JWT 캐싱 전략
@Service
public class OptimizedJwtValidationService {
private final RedisTemplate<String, String> redisTemplate;
private final LoadingCache<String, ValidationResult> validationCache;
public OptimizedJwtValidationService(RedisTemplate<String, String> redisTemplate) {
this.redisTemplate = redisTemplate;
this.validationCache = Caffeine.newBuilder()
.maximumSize(10000)
.expireAfterWrite(5, TimeUnit.MINUTES)
.build(this::validateTokenFromSource);
}
public ValidationResult validateToken(String token) {
try {
return validationCache.get(token);
} catch (Exception e) {
log.error("Token validation failed", e);
return ValidationResult.invalid("Validation error");
}
}
private ValidationResult validateTokenFromSource(String token) {
// 1. 기본 JWT 구조 검증
if (!isValidJwtStructure(token)) {
return ValidationResult.invalid("Invalid JWT structure");
}
// 2. 블랙리스트 체크 (Redis)
if (isBlacklisted(token)) {
return ValidationResult.invalid("Token is blacklisted");
}
// 3. 서명 검증
if (!verifySignature(token)) {
return ValidationResult.invalid("Invalid signature");
}
// 4. 클레임 검증
Claims claims = extractClaims(token);
if (claims.getExpiration().before(new Date())) {
return ValidationResult.invalid("Token expired");
}
return ValidationResult.valid(claims);
}
// 토큰 무효화 시 캐시 삭제
public void invalidateToken(String token) {
validationCache.invalidate(token);
blacklistService.blacklistToken(token);
}
}
@Component
public class JwtCompressionService {
public String compressToken(String token) {
try {
byte[] compressed = compress(token.getBytes(StandardCharsets.UTF_8));
return Base64.getUrlEncoder().withoutPadding().encodeToString(compressed);
} catch (Exception e) {
throw new RuntimeException("Failed to compress token", e);
}
}
public String decompressToken(String compressedToken) {
try {
byte[] compressed = Base64.getUrlDecoder().decode(compressedToken);
byte[] decompressed = decompress(compressed);
return new String(decompressed, StandardCharsets.UTF_8);
} catch (Exception e) {
throw new RuntimeException("Failed to decompress token", e);
}
}
private byte[] compress(byte[] data) throws IOException {
ByteArrayOutputStream bos = new ByteArrayOutputStream();
try (GZIPOutputStream gzip = new GZIPOutputStream(bos)) {
gzip.write(data);
}
return bos.toByteArray();
}
private byte[] decompress(byte[] compressed) throws IOException {
ByteArrayInputStream bis = new ByteArrayInputStream(compressed);
ByteArrayOutputStream bos = new ByteArrayOutputStream();
try (GZIPInputStream gzip = new GZIPInputStream(bis)) {
byte[] buffer = new byte[1024];
int len;
while ((len = gzip.read(buffer)) != -1) {
bos.write(buffer, 0, len);
}
}
return bos.toByteArray();
}
}
🎯 실무 적용 가이드
마이크로서비스에서의 JWT 전파
@Component
public class JwtPropagationInterceptor implements ClientHttpRequestInterceptor {
@Override
public ClientHttpResponse intercept(
HttpRequest request,
byte[] body,
ClientHttpRequestExecution execution) throws IOException {
// 현재 요청의 JWT 토큰 획득
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
if (authentication instanceof JwtAuthenticationToken) {
JwtAuthenticationToken jwtAuth = (JwtAuthenticationToken) authentication;
String token = jwtAuth.getToken().getTokenValue();
// 다운스트림 서비스 호출 시 JWT 전달
request.getHeaders().setBearerAuth(token);
}
return execution.execute(request, body);
}
}
// Feign Client에서 JWT 전파
@Component
public class FeignJwtInterceptor implements RequestInterceptor {
@Override
public void apply(RequestTemplate template) {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
if (authentication instanceof JwtAuthenticationToken) {
JwtAuthenticationToken jwtAuth = (JwtAuthenticationToken) authentication;
String token = jwtAuth.getToken().getTokenValue();
template.header(HttpHeaders.AUTHORIZATION, "Bearer " + token);
}
}
}
API Gateway에서의 JWT 처리
@Component
public class JwtGatewayFilter implements GlobalFilter, Ordered {
private final JwtTokenProvider jwtTokenProvider;
private final RouteValidator routeValidator;
@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
ServerHttpRequest request = exchange.getRequest();
if (routeValidator.isSecured(request)) {
if (!request.getHeaders().containsKey(HttpHeaders.AUTHORIZATION)) {
return onError(exchange, "Missing authorization header", HttpStatus.UNAUTHORIZED);
}
String authHeader = request.getHeaders().getOrEmpty(HttpHeaders.AUTHORIZATION).get(0);
if (!authHeader.startsWith("Bearer ")) {
return onError(exchange, "Invalid authorization header", HttpStatus.UNAUTHORIZED);
}
String token = authHeader.substring(7);
return validateTokenAsync(token)
.flatMap(isValid -> {
if (!isValid) {
return onError(exchange, "Invalid token", HttpStatus.UNAUTHORIZED);
}
// JWT 클레임을 헤더에 추가하여 다운스트림으로 전달
return addJwtClaimsToRequest(exchange, token, chain);
})
.onErrorResume(throwable ->
onError(exchange, "Token validation failed", HttpStatus.UNAUTHORIZED)
);
}
return chain.filter(exchange);
}
private Mono<Void> addJwtClaimsToRequest(ServerWebExchange exchange, String token, GatewayFilterChain chain) {
try {
Claims claims = jwtTokenProvider.parseClaims(token);
ServerHttpRequest modifiedRequest = exchange.getRequest()
.mutate()
.header("X-User-Id", claims.getSubject())
.header("X-User-Roles", String.join(",", getUserRoles(claims)))
.header("X-User-Email", claims.get("email", String.class))
.build();
ServerWebExchange modifiedExchange = exchange.mutate()
.request(modifiedRequest)
.build();
return chain.filter(modifiedExchange);
} catch (Exception e) {
return onError(exchange, "Failed to process token", HttpStatus.INTERNAL_SERVER_ERROR);
}
}
private Mono<Boolean> validateTokenAsync(String token) {
return Mono.fromCallable(() -> jwtTokenProvider.validateToken(token))
.subscribeOn(Schedulers.boundedElastic());
}
private Mono<Void> onError(ServerWebExchange exchange, String message, HttpStatus status) {
ServerHttpResponse response = exchange.getResponse();
response.setStatusCode(status);
response.getHeaders().add("Content-Type", "application/json");
String errorResponse = String.format("{\"error\":\"%s\"}", message);
DataBuffer buffer = response.bufferFactory().wrap(errorResponse.getBytes());
return response.writeWith(Mono.just(buffer));
}
@Override
public int getOrder() {
return -100; // 높은 우선순위
}
}
🛡️ 보안 모니터링
토큰 이상 행위 탐지
@Service
@RequiredArgsConstructor
public class TokenAnomalyDetectionService {
private final RedisTemplate<String, String> redisTemplate;
private final AlertService alertService;
@EventListener
public void handleTokenUsage(TokenUsageEvent event) {
// 토큰 사용 패턴 분석
analyzeTokenUsagePattern(event);
// 지리적 이상 탐지
detectGeographicalAnomaly(event);
// 시간 패턴 이상 탐지
detectTimePatternAnomaly(event);
// 동시 접속 탐지
detectConcurrentAccess(event);
}
private void analyzeTokenUsagePattern(TokenUsageEvent event) {
String key = "token:usage:" + event.getUserId();
String currentHour = String.valueOf(System.currentTimeMillis() / 3600000);
Long requestCount = redisTemplate.opsForHash()
.increment(key, currentHour, 1);
redisTemplate.expire(key, Duration.ofDays(1));
// 비정상적으로 많은 요청 탐지
if (requestCount > 1000) { // 시간당 1000회 초과
SecurityAlert alert = SecurityAlert.builder()
.type(AlertType.EXCESSIVE_TOKEN_USAGE)
.userId(event.getUserId())
.details(Map.of(
"requestCount", requestCount,
"timeWindow", currentHour,
"token", maskToken(event.getToken())
))
.severity(AlertSeverity.HIGH)
.build();
alertService.sendAlert(alert);
}
}
private void detectConcurrentAccess(TokenUsageEvent event) {
String key = "token:concurrent:" + event.getTokenId();
String sessionInfo = event.getSessionId() + ":" + event.getClientIP();
// 현재 활성 세션들 추가
redisTemplate.opsForSet().add(key, sessionInfo);
redisTemplate.expire(key, Duration.ofMinutes(5));
// 동시 세션 수 확인
Long concurrentSessions = redisTemplate.opsForSet().size(key);
if (concurrentSessions > 3) { // 3개 이상 동시 접속
SecurityAlert alert = SecurityAlert.builder()
.type(AlertType.CONCURRENT_TOKEN_USAGE)
.userId(event.getUserId())
.details(Map.of(
"concurrentSessions", concurrentSessions,
"sessions", redisTemplate.opsForSet().members(key)
))
.severity(AlertSeverity.MEDIUM)
.build();
alertService.sendAlert(alert);
}
}
@Scheduled(fixedRate = 3600000) // 1시간마다
public void generateSecurityReport() {
SecurityReport report = SecurityReport.builder()
.reportTime(LocalDateTime.now())
.activeTokens(getActiveTokenCount())
.blacklistedTokens(getBlacklistedTokenCount())
.anomalousActivities(getAnomalousActivities())
.build();
securityReportService.save(report);
}
}
📋 체크리스트 & 베스트 프랙티스
보안 체크리스트
- JWT 서명 알고리즘: RS256 또는 ES256 사용 (HS256 지양)
- 토큰 만료 시간: 적절한 만료 시간 설정 (보안 vs 사용성)
- Refresh Token: 안전한 저장 및 로테이션 구현
- 토큰 블랙리스트: 로그아웃 시 토큰 무효화
- HTTPS 전용: 토큰 전송은 반드시 HTTPS
- 민감 정보 제외: JWT에 비밀번호 등 민감 정보 포함 금지
- 토큰 바인딩: 클라이언트/세션 바인딩으로 보안 강화
- 이상 행위 모니터링: 토큰 남용 탐지 시스템 구축
성능 최적화 체크리스트
- 토큰 캐싱: 검증 결과 캐시로 성능 향상
- 토큰 압축: 큰 페이로드의 경우 압축 적용
- 비동기 검증: 블랙리스트 체크 등 비동기 처리
- Connection Pool: 외부 검증 서비스 연결 풀 최적화
- 지연 로딩: 필요시에만 추가 사용자 정보 조회
이 가이드를 통해 OAuth2와 JWT를 실무에서 안전하고 효율적으로 활용할 수 있을 것입니다! 🚀
'Backend' 카테고리의 다른 글
| gRPC 강점 (0) | 2025.09.04 |
|---|---|
| Spring Boot 완전 가이드 Part 5: 운영과 모니터링 (1) | 2025.08.06 |
| Spring Boot 완전 가이드 Part 4: 데이터 액세스와 배치 (2) | 2025.08.06 |
| Spring Boot 완전 가이드 Part 3: 웹 개발과 REST API (3) | 2025.08.06 |
| Spring Boot 완전 가이드 Part 2: 설정과 자동구성 (2) | 2025.08.06 |