본문 바로가기

Backend/Study

[Study] JWT 활용 및 예제 코드

반응형

JWT 활용 및 예제 코드

오늘은 JWT(JSON Web Token) 토큰 인증을 좀 더 확장하여 작성한 예시코드를 살펴보겠습니다.
Refresh Token 재사용 탐지 로직, Redis TTL 구조 설계, 스케일러빌리티를 적용하여 살펴보겠습니다.


1. Refresh Token 재사용 탐지 로직

문제: 공격자가 Refresh Token을 탈취한 경우, 정상 사용자가 갱신 후 이미 무효화된 Refresh를 또 제출할 수 있음.

해결책:

  • Refresh 발급 시 토큰 ID(jti)를 생성
  • 서버(예: Redis)에 refresh:{tokenId} 저장
  • 갱신 시:
    1. Redis에 존재 여부 확인
    2. 있으면 Access/Refresh 재발급 + Redis 갱신
    3. 없으면 → 재사용 감지 이벤트 (탈취 가능성) → 모든 세션 무효화
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에서 원자적으로 처리

분산 환경 권장 설계

  1. JWT 서명 키는 모든 인스턴스에서 동일하게 관리 (환경변수/시크릿 매니저)
  2. Refresh Token 저장소는 Redis Cluster 사용
  3. 재사용 탐지 로직은 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.yml

1. 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; }
}
반응형