본문 바로가기

Backend/Study

[Study] JWT 동작방식 깊게 살펴보기

반응형

오늘은 어제 알아본 JWT(JSON Web Token) 토큰 인증에 대해 조금 더 살펴보겠습니다.


1. Token 검증 및 요청방식, 처리 방법

  • 클라이언트는 매 요청마다 Access Token을 보냅니다.
    • 일반적으로 Authorization 헤더: Authorization: Bearer <access_token>.
  • Refresh Token은 매 요청마다 보내지지 않습니다.
    • 오직 Access 만료 시(또는 재발급 필요 시)에만 사용.
  • Refresh Token 전송 방식
    • 웹 브라우저: HttpOnly, Secure, SameSite 쿠키 권장(자동 전송).
    • 모바일/네이티브: 안전한 기기 저장소(키체인 등)에 보관하고, 갱신 시 API 호출으로 전송.
  • 서버는 보호된(인증/인가 필요한) 모든 API에서 Access Token을 검증해야 합니다.
    • 중앙 인증 미들웨어(필터/인터셉터)에서 서명·만료·권한(claims) 검증.
  • 만료된 Access 처리 흐름
    1. 클라이언트가 API 호출 → 서버가 Access 만료 응답(401) 또는 명시 코드 반환.
    2. 클라이언트는 Refresh 엔드포인트 호출(Refresh Token 자동 쿠키 전송 또는 바디 포함).
    3. 서버는 Refresh Token 서버측 저장소(예: Redis/DB)에서 조회/검증 → 유효하면 새 Access(및 필요하면 새 Refresh) 발급.
    4. 서버는 이전 Refresh를 무효화(rotate)하거나 블랙리스트 처리.
  • Refresh 토큰 탈취(재사용) 방지
    • Refresh 토큰은 회전(Rotation): 재발급 시 기존 토큰 즉시 무효화.
    • 재사용(Replay)이 감지되면 해당 세션/모든 세션 강제 로그아웃.
    • 토큰에 jti(id)나 device id, 발급 IP 등을 바인딩해 이상 징후 탐지.
  • 로그아웃/강제 무효화
    • 클라이언트 로그아웃 → 서버에서 Refresh 토큰 삭제/블랙리스트 등록.
    • Access는 만료될 때까지 쓸 수 있지만 단시간이므로 허용(혹은 즉시 블랙리스트 처리).

2. 인증 시나리오

  • Access: JWT (짧은 만료), 클라이언트가 매 요청에 Authorization 헤더로 보냄
  • Refresh: 길게 만료, 서버에 저장(UID→tokenId 맵, Redis), 쿠키(HttpOnly)로 전달 → 회전 전략 사용

3. 구현 예시

3-1. 시나리오 흐름

  1. 로그인 → 서버: Access JWT + Refresh Token 절차(Refresh는 DB/Redis에 저장)
  2. 클라이언트 → 모든 보호 API 요청 시 Authorization: Bearer <access> 전송
  3. 서버 인증 필터 → 서명·만료·권한 검증
    • 유효 → 정상 처리
    • 만료 → 401 응답 (또는 특정 error code)
  4. 클라이언트는 /auth/refresh 호출(브라우저면 쿠키가 자동 포함)
  5. 서버는 Refresh 토큰 검증(서버 저장소 조회) → 유효하면
    • 새 Access JWT 발급
    • (권장) 새 Refresh 발급 → 저장소에 새 토큰 저장 & 이전 토큰 삭제(회전)
    • 응답: 새 Access(+Set-Cookie 새 Refresh) 반환
  6. 클라이언트는 새 Access로 재시도

3-2. Spring Boot: Access 검증 필터 예시

// 스프링 시큐리티 필터 체인에 이 필터를 추가
// JwtAuthenticationFilter: Authorization 헤더 검증 -> SecurityContext 세팅
public class JwtAuthenticationFilter extends OncePerRequestFilter {

    private final JwtUtil jwtUtil;

    @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 claims = jwtUtil.validateAccessToken(token); // 서명/만료 체크
                String userId = claims.getSubject();
                List<GrantedAuthority> auth = // claims에서 권한 추출
                UsernamePasswordAuthenticationToken authToken =
                    new UsernamePasswordAuthenticationToken(userId, null, auth);
                SecurityContextHolder.getContext().setAuthentication(authToken);
            } catch (ExpiredJwtException e) {
                // 토큰 만료: SecurityContext에 아무 것도 안 넣고 다음으로 (컨트롤러/예외핸들러에서 401 처리)
                req.setAttribute("expiredToken", e.getClaims());
            } catch (JwtException e) {
                // 서명 오류 등: 바로 401
                res.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
                return;
            }
        }
        chain.doFilter(req, res);
    }
}

3-3. Refresh 엔드포인트(회전 + 서버저장 예제)

기본 아이디어: Refresh는 임의 토큰 문자열(UUID 등)을 발급하고 Redis에 refresh:{tokenId} -> userId, deviceId, expiry 형태로 저장. Refresh 요청 시 저장소 확인 후 새 Access 발급하고, 새 Refresh 생성→저장→이전 토큰 삭제.

@RestController
@RequestMapping("/auth")
public class AuthController {
    private final JwtUtil jwtUtil;
    private final RedisService redis; // Redis에 tokenId -> 세션정보 저장

    @PostMapping("/refresh")
    public ResponseEntity<?> refresh(HttpServletRequest req, HttpServletResponse res) {
        // 1) 브라우저: HttpOnly cookie에서 refresh token 꺼내기
        String refreshToken = CookieUtil.getCookie(req, "refreshToken");

        if (refreshToken == null) {
            return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body("no refresh token");
        }

        // 2) Redis에서 조회 (tokenId로 저장했다고 가정)
        RefreshSession session = redis.getRefreshSession(refreshToken);
        if (session == null || session.isExpired()) {
            // 재사용/탈취 의심 -> 전체 세션 로그아웃 등 추가 조치 가능
            return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body("invalid refresh");
        }

        // 3) 발급: 새 Access JWT
        String newAccess = jwtUtil.generateAccessToken(session.getUserId());

        // 4) Rotate refresh token: 새 토큰 생성, DB에 저장, 이전 토큰 제거
        String newRefreshToken = UUID.randomUUID().toString();
        redis.saveRefreshSession(newRefreshToken, session.getUserId(), /*expiry*/);
        redis.deleteRefreshSession(refreshToken);

        // 5) Set HttpOnly Secure Cookie에 새 refreshToken 설정
        Cookie cookie = new Cookie("refreshToken", newRefreshToken);
        cookie.setHttpOnly(true);
        cookie.setSecure(true);
        cookie.setPath("/");
        cookie.setMaxAge((int) Duration.ofDays(14).getSeconds());
        res.addCookie(cookie);

        // 6) 응답: 새 Access 반환(클라이언트는 Authorization 헤더에 설정)
        return ResponseEntity.ok(Map.of("accessToken", newAccess));
    }
}

주의사항/확장:

  • 토큰 재사용(이전 refresh가 이미 삭제되어 있는데 누군가 제출하면) 감지 시 해당 사용자 전체 세션 무효화(보안 사고 대응).
  • deviceId/ip/userAgent 바인딩으로 이상 징후 탐지.

3-3. 탈취 대비 — 표준 대응 패턴

  • Refresh 탈취 의심(같은 토큰이 다른 IP/디바이스에서 사용) → 즉시 토큰 모두 무효화 + 사용자 알림 + 강제 로그아웃
  • Refresh 회전 전략을 쓰면 탈취자는 기존 토큰을 재사용 못함(재발급하면 기존 것은 삭제됨). 재사용이 감지되면 공격 시도 처리.

4. 추가 팁

  • 토큰 저장 권장 정리
    • Browser SPA: Access → 메모리(또는 short-lived in-memory), Refresh → HttpOnly Secure Cookie
    • Native App: Access/Refresh 모두 OS 보안 저장소 사용(키체인 등)
  • Refresh 토큰 회전(Rotating Refresh Tokens)
    • 매번 재발급하면서 이전 토큰 즉시 무효화 → 재사용(replay) 시 탐지.
  • 서버측 저장 + 블랙리스트
    • Access는 JWT로 stateless 인증하되, 필요시(강제 로그아웃 등) 블랙리스트로 차단(보통 짧은 기간에만 유지).
    • Refresh는 반드시 서버 저장(토큰ID + 유저 + 디바이스 정보).
  • 단계적 검증(Defense in Depth)
    • JWT 서명·만료 검증
    • jti / tokenId를 서버 저장소에서 검증(특히 Refresh)
    • device fingerprint / IP / UA 체크로 의심 세션 차단
  • Rate-limit / Brute-force 방지
    • /auth/refresh, /auth/login 등 민감 엔드포인트에 대해 rate-limit 적용
  • 모니터링과 탐지
    • Refresh 재사용, 비정상적 발급 빈도, 지역 변경 등 이벤트를 모니터링해 자동 차단 또는 알림.
  • 로그아웃 처리
    • 클라이언트 로그아웃 → 서버에서 Refresh 삭제 + 응답으로 refresh cookie 만료 처리. (Access는 곧 만료)
  • 만료 정책 권장값(예시)
    • Access: 5–15분
    • Refresh: 7–30일 (서비스 성격에 따라)
    • 너무 길면 탈취 위험↑, 너무 짧으면 UX↓.
반응형