반응형
JWT 활용 및 예제 코드
오늘은 JWT(JSON Web Token) 토큰 인증을 좀 더 확장하여 작성한 예시코드를 살펴보겠습니다.
Refresh Token 재사용 탐지 로직, Redis TTL 구조 설계, 스케일러빌리티를 적용하여 살펴보겠습니다.
1. Refresh Token 재사용 탐지 로직
문제: 공격자가 Refresh Token을 탈취한 경우, 정상 사용자가 갱신 후 이미 무효화된 Refresh를 또 제출할 수 있음.
해결책:
- Refresh 발급 시 토큰 ID(jti)를 생성
- 서버(예: Redis)에
refresh:{tokenId}저장 - 갱신 시:
- Redis에 존재 여부 확인
- 있으면 Access/Refresh 재발급 + Redis 갱신
- 없으면 → 재사용 감지 이벤트 (탈취 가능성) → 모든 세션 무효화
if (redis.exists("refresh:" + tokenId)) {
// 정상 처리: 새 Access + Refresh 발급
redis.delete("refresh:" + tokenId);
redis.set("refresh:" + newTokenId, userId, ttl);
} else {
// 재사용 감지: 탈취 가능 → 사용자 전체 로그아웃 처리
securityService.invalidateAllSessions(userId);
throw new SecurityException("Refresh token reuse detected!");
}
2. Redis TTL 구조 설계
권장 키 구조 예시:
refresh:{tokenId} -> userId, deviceId, ip, exp
- TTL(Time To Live) = Refresh 만료 시간과 동일
- Redis가 자동으로 만료시켜 불필요한 키 정리
- 장점: 로그아웃/만료 관리 단순화
3. 스케일러빌리티 (여러 인스턴스 공유)
- 서버 인스턴스가 여러 개여도 Redis가 중앙 집중 저장소 역할을 함
- Access Token 검증은 각 인스턴스가 자체 수행 (서명 키 공유)
- Refresh 검증 및 회전 로직은 Redis에서 원자적으로 처리
분산 환경 권장 설계
- JWT 서명 키는 모든 인스턴스에서 동일하게 관리 (환경변수/시크릿 매니저)
- Refresh Token 저장소는 Redis Cluster 사용
- 재사용 탐지 로직은 Redis 원자적 연산으로 구현 (예:
SETNX)
통합 예제
프로젝트 구조
jwt-redis-rotation/
├─ build.gradle
├─ src/main/java/com/example/auth/
│ ├─ config/
│ │ ├─ SecurityConfig.java
│ │ ├─ RedisConfig.java
│ ├─ controller/
│ │ └─ AuthController.java
│ ├─ domain/
│ │ ├─ LoginRequest.java
│ │ ├─ TokenResponse.java
│ │ ├─ RefreshSession.java
│ ├─ security/
│ │ ├─ JwtUtil.java
│ │ ├─ JwtAuthenticationFilter.java
│ │ ├─ CookieUtil.java
│ ├─ service/
│ │ ├─ AuthService.java
│ │ └─ RefreshTokenStore.java // Redis 연동
│ ├─ exception/
│ │ ├─ GlobalExceptionHandler.java
│ │ └─ TokenReuseDetectedException.java
│ └─ JwtRedisRotationApplication.java
└─ src/main/resources/
└─ application.yml1. build.gradle (핵심 의존성)
plugins {
id 'java'
id 'org.springframework.boot' version '3.3.2'
id 'io.spring.dependency-management' version '1.1.5'
}
java { toolchain { languageVersion = JavaLanguageVersion.of(17) } }
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'io.jsonwebtoken:jjwt-api:0.11.5'
runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.5'
runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.11.5'
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testImplementation 'org.testcontainers:junit-jupiter'
testImplementation 'org.testcontainers:redis'
}2. application.yml
server:
port: 8080
spring:
data:
redis:
host: localhost
port: 6379
jwt:
issuer: "my-service"
access-exp-minutes: 15
refresh-exp-days: 14
secret: "CHANGE_ME_TO_A_LONG_RANDOM_SECRET" # HS256
# RS256를 원하면 public/private key 설정으로 교체
security:
require-ssl: false # 실제 운영은 true + https(ingress/elb) 필수3. RedisConfig.java
@Configuration
public class RedisConfig {
@Bean
public RedisConnectionFactory redisConnectionFactory() {
return new LettuceConnectionFactory();
}
@Bean
public StringRedisTemplate stringRedisTemplate(RedisConnectionFactory cf) {
return new StringRedisTemplate(cf);
}
}4. JwtUtil.java (Access/Refresh 발급·검증)
@Component
public class JwtUtil {
@Value("${jwt.secret}") private String secret;
@Value("${jwt.issuer}") private String issuer;
@Value("${jwt.access-exp-minutes}") private long accessExpMin;
@Value("${jwt.refresh-exp-days}") private long refreshExpDays;
private Key key() { return Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8)); }
public String generateAccess(String userId, Map<String,Object> claims) {
Instant now = Instant.now();
return Jwts.builder()
.setIssuer(issuer)
.setSubject(userId)
.addClaims(claims)
.setIssuedAt(Date.from(now))
.setExpiration(Date.from(now.plus(accessExpMin, ChronoUnit.MINUTES)))
.signWith(key(), SignatureAlgorithm.HS256)
.compact();
}
public String generateRefresh(String userId, String jti) {
Instant now = Instant.now();
return Jwts.builder()
.setIssuer(issuer)
.setSubject(userId)
.setId(jti) // jti 필수(재사용 탐지)
.setIssuedAt(Date.from(now))
.setExpiration(Date.from(now.plus(refreshExpDays, ChronoUnit.DAYS)))
.signWith(key(), SignatureAlgorithm.HS256)
.compact();
}
public Claims parse(String token) {
return Jwts.parserBuilder().setSigningKey(key()).build()
.parseClaimsJws(token).getBody();
}
}5. SecurityConfig.java (보호 경로 + JWT 필터)
@Configuration
@EnableMethodSecurity
public class SecurityConfig {
private final JwtAuthenticationFilter jwtFilter;
public SecurityConfig(JwtAuthenticationFilter jwtFilter) {
this.jwtFilter = jwtFilter;
}
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http.csrf(csrf -> csrf.disable())
.sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(reg -> reg
.requestMatchers("/auth/**", "/public/**").permitAll()
.anyRequest().authenticated()
)
.addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class);
return http.build();
}
}6. JwtAuthenticationFilter.java (매 요청 Access 검증)
@Component
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final JwtUtil jwt;
public JwtAuthenticationFilter(JwtUtil jwt) { this.jwt = jwt; }
@Override
protected void doFilterInternal(HttpServletRequest req, HttpServletResponse res, FilterChain chain)
throws ServletException, IOException {
String header = req.getHeader("Authorization");
if (header != null && header.startsWith("Bearer ")) {
String token = header.substring(7);
try {
Claims c = jwt.parse(token); // 서명/만료 검증
String userId = c.getSubject();
// (옵션) roles = c.get("roles", List.class);
UsernamePasswordAuthenticationToken auth =
new UsernamePasswordAuthenticationToken(userId, null, List.of());
SecurityContextHolder.getContext().setAuthentication(auth);
} catch (ExpiredJwtException e) {
res.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
return;
} catch (JwtException e) {
res.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
return;
}
}
chain.doFilter(req, res);
}
}7. RefreshSession.java (서버 저장용)
public record RefreshSession(
String jti, String userId, String deviceId, long expEpochSec
) {}8. RefreshTokenStore.java (Redis 연동 + 회전/재사용 탐지)
@Service
public class RefreshTokenStore {
private final StringRedisTemplate redis;
private static final String PREFIX = "refresh:"; // key: refresh:{jti}
public RefreshTokenStore(StringRedisTemplate redis) { this.redis = redis; }
public void save(RefreshSession s, Duration ttl) {
String key = PREFIX + s.jti();
Map<String,String> map = Map.of(
"userId", s.userId(),
"deviceId", s.deviceId(),
"exp", String.valueOf(s.expEpochSec())
);
redis.opsForHash().putAll(key, map);
redis.expire(key, ttl);
}
public RefreshSession get(String jti) {
String key = PREFIX + jti;
Map<Object,Object> m = redis.opsForHash().entries(key);
if (m == null || m.isEmpty()) return null;
return new RefreshSession(
jti, (String)m.get("userId"), (String)m.get("deviceId"),
Long.parseLong((String)m.get("exp"))
);
}
/** 원자적 회전: 기존 jti 삭제 + 새 jti 저장 */
@Transactional
public void rotate(String oldJti, RefreshSession newSession, Duration ttl) {
String oldKey = PREFIX + oldJti;
redis.delete(oldKey);
save(newSession, ttl);
}
/** 재사용 탐지: 제출된 jti가 이미 삭제되어 있으면 null 반환 -> 재사용 의심 */
public boolean exists(String jti) {
return Boolean.TRUE.equals(redis.hasKey(PREFIX + jti));
}
public void delete(String jti) { redis.delete(PREFIX + jti); }
}+) 강화(선택): 재사용 탐지의 원자성 보장을 위해 Lua 스크립트로 GETDEL 유사 동작 구현(존재 확인과 삭제를 한 번에), 또는 SETNX로 락 키 사용.
9. CookieUtil.java (Refresh를 HttpOnly 쿠키로)
@Component
public class CookieUtil {
public void writeRefresh(HttpServletResponse res, String value, int maxAgeSec) {
Cookie c = new Cookie("refreshToken", value);
c.setHttpOnly(true);
c.setSecure(true); // 운영은 반드시 true (HTTPS)
c.setPath("/");
c.setMaxAge(maxAgeSec);
res.addCookie(c);
}
public Optional<String> readRefresh(HttpServletRequest req) {
if (req.getCookies()==null) return Optional.empty();
return Arrays.stream(req.getCookies())
.filter(c -> "refreshToken".equals(c.getName()))
.map(Cookie::getValue).findFirst();
}
public void expireRefresh(HttpServletResponse res) {
Cookie c = new Cookie("refreshToken", "");
c.setHttpOnly(true);
c.setSecure(true);
c.setPath("/");
c.setMaxAge(0);
res.addCookie(c);
}
}10. AuthService.java (로그인/리프레시/로그아웃)
@Service
public class AuthService {
private final JwtUtil jwt;
private final RefreshTokenStore store;
@Value("${jwt.refresh-exp-days}") private long refreshDays;
public AuthService(JwtUtil jwt, RefreshTokenStore store) {
this.jwt = jwt; this.store = store;
}
public TokenResponse login(String userId, String deviceId) {
Map<String,Object> claims = Map.of("roles", List.of("USER"));
String access = jwt.generateAccess(userId, claims);
String jti = UUID.randomUUID().toString();
String refresh = jwt.generateRefresh(userId, jti);
Instant exp = Instant.now().plus(refreshDays, ChronoUnit.DAYS);
store.save(new RefreshSession(jti, userId, deviceId, exp.getEpochSecond()),
Duration.ofDays(refreshDays));
return new TokenResponse(access, refresh);
}
/** 회전 + 재사용 탐지 */
public TokenResponse refresh(String refreshToken, String deviceId) {
Claims claims;
try { claims = jwt.parse(refreshToken); }
catch (ExpiredJwtException e) { throw new ResponseStatusException(HttpStatus.UNAUTHORIZED,"refresh expired"); }
catch (JwtException e) { throw new ResponseStatusException(HttpStatus.UNAUTHORIZED,"refresh invalid"); }
String jti = claims.getId();
String userId = claims.getSubject();
// 재사용 탐지: jti가 존재하지 않으면 이미 회전되어 삭제된 것 → 도난 의심
if (!store.exists(jti)) {
// 모든 세션 무효화/알림 등 추가 조치 가능
throw new TokenReuseDetectedException(userId, jti);
}
// 정상 회전
Map<String,Object> newClaims = Map.of("roles", List.of("USER"));
String newAccess = jwt.generateAccess(userId, newClaims);
String newJti = UUID.randomUUID().toString();
String newRefresh = jwt.generateRefresh(userId, newJti);
Instant exp = Instant.now().plus(refreshDays, ChronoUnit.DAYS);
store.rotate(jti, new RefreshSession(newJti, userId, deviceId, exp.getEpochSecond()),
Duration.ofDays(refreshDays));
return new TokenResponse(newAccess, newRefresh);
}
public void logout(String refreshToken) {
try {
Claims c = jwt.parse(refreshToken);
store.delete(c.getId());
} catch (JwtException ignored) {}
}
}11. 컨트롤러 & DTO
11-1. LoginRequest.java / TokenResponse.java
public record LoginRequest(String userId, String password) {}
public record TokenResponse(String accessToken, String refreshToken) {}11-2. AuthController.java
@RestController
@RequestMapping("/auth")
public class AuthController {
private final AuthService auth;
private final CookieUtil cookie;
public AuthController(AuthService auth, CookieUtil cookie) {
this.auth = auth; this.cookie = cookie;
}
@PostMapping("/login")
public ResponseEntity<TokenResponse> login(@RequestBody LoginRequest req, HttpServletResponse res) {
// TODO: 비밀번호 검증(생략)
String deviceId = "web"; // UA/IP 기반 식별자 바인딩 가능
TokenResponse t = auth.login(req.userId(), deviceId);
cookie.writeRefresh(res, t.refreshToken(), (int)Duration.ofDays(14).getSeconds());
// 클라이언트에는 access만 반환
return ResponseEntity.ok(new TokenResponse(t.accessToken(), null));
}
@PostMapping("/refresh")
public ResponseEntity<TokenResponse> refresh(HttpServletRequest req, HttpServletResponse res) {
String deviceId = "web";
String refresh = cookie.readRefresh(req).orElseThrow(
() -> new ResponseStatusException(HttpStatus.UNAUTHORIZED,"no refresh cookie"));
TokenResponse t = auth.refresh(refresh, deviceId);
cookie.writeRefresh(res, t.refreshToken(), (int)Duration.ofDays(14).getSeconds());
return ResponseEntity.ok(new TokenResponse(t.accessToken(), null));
}
@PostMapping("/logout")
public ResponseEntity<Void> logout(HttpServletRequest req, HttpServletResponse res) {
cookie.readRefresh(req).ifPresent(auth::logout);
cookie.expireRefresh(res);
return ResponseEntity.noContent().build();
}
}12. GlobalExceptionHandler.java
12-1. GlobalExceptionHandler.java
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(TokenReuseDetectedException.class)
public ResponseEntity<Map<String,Object>> reuse(TokenReuseDetectedException e) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
.body(Map.of("error","refresh_reuse_detected","userId", e.getUserId()));
}
@ExceptionHandler(ResponseStatusException.class)
public ResponseEntity<Map<String,Object>> rse(ResponseStatusException e) {
return ResponseEntity.status(e.getStatusCode())
.body(Map.of("error", e.getReason()));
}
}12-2. TokenReuseDetectedException.java
public class TokenReuseDetectedException extends RuntimeException {
private final String userId; private final String jti;
public TokenReuseDetectedException(String userId, String jti) {
super("Refresh reuse detected");
this.userId = userId; this.jti = jti;
}
public String getUserId() { return userId; }
public String getJti() { return jti; }
}반응형
'Backend > Study' 카테고리의 다른 글
| [Study] 웹소켓 vs SSE(Server-Sent Events) 차이와 활용법 (0) | 2025.10.13 |
|---|---|
| [Study] Docker에서 MySQL 연결 안 될 때 해결법 (0) | 2025.10.10 |
| [Study] JWT 동작방식 깊게 살펴보기 (0) | 2025.10.02 |
| [Study] JWT 토큰 인증 방식과 보안 고려사항 (0) | 2025.10.01 |
| [Study] 서버 다운 없이 배포하는 방법 (무중단 배포) (0) | 2025.09.30 |