런닝 코스 공유 서비스

[런닝 코스 공유 서비스] - 16. AccessToken 블랙리스트

sson-coding 2026. 1. 11. 15:26

🏅오늘의 목표

  • Access Token 블랙리스트 구현

진행한 작업

  • Access Token 블랙리스트 구현

📃 개발내용

AccessToken 블랙리스트

블랙리스트란?

  • 로그아웃 이후에도 유효 기간이 남은 AccessToken 을 서버에서 차단하는 기능

왜 필요할까?

  • JWT 는 stateless 구조라 서버는 토큰을 기억하지 않음
  • 로그아웃을 해도 AccessToken 은 유효 기간이 끝날 때까지 계속 사용 가능해서 문제 발생
  • 예시
    • 로그인 → Access Token 탈취
    • 로그아웃 요청
    • Refresh Token 만료 → 유효시간 남아있는 Access Token 악용 가능

프론트에서 AccessToken 삭제가 불충분한 이유

  • 프론트에서 제거 → 내 브라우저에서는 안 씀 → 서버에선 여전히 쓸 수 있는 토큰
  • 서버에서 차단하지 않으면 보안적으로 충분하지 않음

구현

Access Token 에 jti(UUID) 추가

블랙리스트의 key 를 토큰 전체 문자열로 저장하지 않고, 짧고 명확한 식별자로 관리하기 위해 jti 를 추가

private String createJWT(JWTUserDto user, Long expiration) {
        String jti = UUID.randomUUID().toString();
        Date now = new Date();
        Date expirationDate = new Date(now.getTime() + expiration);

        String token = Jwts.builder()
            .id(jti)
            .subject(String.valueOf(user.userUuid()))
            .claim("email", user.email())
            .claim("name", user.name())
            .claim("role", user.role().name())
            .issuedAt(now)
            .expiration(expirationDate)
            .signWith(secretKey)
            .compact();

        log.info("JWT 토큰 생성 완료 - email: {} , name: {}, role: {}, expirationDate: {}",
            user.email(), user.name(), user.role(), expirationDate);

        return token;
    }
  • jti
    • 토큰 문자열 전체를 Redis Key 로 사용하지 않기 위해
    • 블랙리스트 key 를 짧고 명확하게 관리

블랙리스트 저장/조회 서비스 - JwtBlacklistService

@Service
@RequiredArgsConstructor
@Slf4j
public class JwtBlacklistService {

    private final JWTUtil jwtUtil;
    private final RedisTemplate<String, String> redisTemplate;
    private final String PREFIX = "blacklist:access:";

    // 블랙리스트에 등록
    public void blacklist(String accessToken,long expirationMillis){
        String jti = jwtUtil.getJti(accessToken);

        redisTemplate.opsForValue().set(
            PREFIX+jti,"logout",expirationMillis, TimeUnit.MILLISECONDS
        );
    }

    // 블랙리스트에 있는지 확인
    public boolean isBlacklisted(String accessToken){
        try{
            String jti = jwtUtil.getJti(accessToken);
            return Boolean.TRUE.equals(redisTemplate.hasKey(PREFIX + jti));
        }catch(Exception e){
            log.warn("만료/위조 토큰이여서 블랙리스트 체크 불가 : {}", e.getMessage());
            return false;
        }
    }
}

blacklist() - 블랙리스트 등록

  1. AccessToken 에서 jti 추출
  2. Redis 에 블랙리스트 등록
    1. key : blacklist:access:{jti}
    2. value : logout 의미 있는 값이 아니라 존재 여부만 중요
    3. TTL : Access Token 의 남은 유효 시간

isBlacklisted() - 블랙리스트에 있는지 확인

  1. AccessToken 에서 jti 추출
  2. Redis 에 블랙리스트 key 존재 여부 확인
    1. hasKey 는 null 가능성 방어
  3. jwtUtil.getJti() 내부
    1. 만료토큰, 위조토큰 → 블랙리스트 책임 아님

로그아웃 - AuthController

// 로그아웃
    @PostMapping("/logout")
    public ResponseEntity<ApiResponse<Void>> logout(
        @AuthenticationPrincipal CustomUserDetails customUserDetails,
        HttpServletRequest request
    ){
        // 리프레시 토큰 삭제
        jwtRefreshTokenService.delete(customUserDetails.getUserUuid());

        // 액세스 토큰 추출
        String header = request.getHeader("Authorization");

        if (header != null && header.startsWith("Bearer ")) {
            String accessToken = header.substring(7);
            try {
                long remainingTime = jwtUtil.getRemainingExpiration(accessToken);
                if (remainingTime > 0) {
                    jwtBlacklistService.blacklist(accessToken, remainingTime);
                }
            } catch (Exception e) {
                log.debug("logout accessToken 처리 중 예외: {}", e.getMessage());
            }
        }

        return ResponseEntity.ok(new ApiResponse<>("로그아웃 성공"));
    }

흐름

  1. Refresh Token 삭제
    1. 재발급 경로 차단
  2. Authorization 헤더에서 Access Token 추출
  3. Bearer 형식 검증
  4. 남은 만료 시간 계산
    1. 남은 유효 시간 동안만 블랙리스트 유지
  5. 유효한 경우에만 블랙리스트 등록
  6. 예외 발생
    1. Access Token 이 이미 만료 됨
    2. 토큰이 위조/깨짐
    3. 잘못된 형식
  7. 예외를 던지지 않는 이유
    1. Refresh Token 삭제 : 로그아웃의 핵심 목적
    2. Access Token 문제 있어도 재발급 불가

getRemainingExpiration() , getJti() - JwtUtil

public String getJti(String token) {
        return getClaims(token).getId();
    }

    public long getRemainingExpiration(String token) {
        Date expiration = getClaims(token).getExpiration();
        return expiration.getTime() - System.currentTimeMillis();
    }
  • Jti 추출 메서드
  • 남은 만료 시간 계산 메서드

블랙리스트 확인 - JwtAuthenticationFilter

@Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
        FilterChain filterChain) throws ServletException, IOException {

        // Authorization 헤더에서 JWT 토큰 추출    
        String authorization = request.getHeader("Authorization");

        //Authorization 헤더 검증 : JWT 헤더가 없을 경우 다음 필터로 넘김
        if (authorization == null || !authorization.startsWith("Bearer ")) {
            log.debug("JWT Token 을 request headres 에서 찾을 수 없음");
            filterChain.doFilter(request, response);
            return;
        }

        // Bearer 접두사 제거해 토큰 값 추출
        String token = authorization.substring(7);

        // Token 이 blacklist 인지 검증
        if(jwtBlacklistService.isBlacklisted(token)) {
            log.debug("JWT Token 이 Blacklist");
            sendErrorResponse(response,"JWT Token 이 Blacklist");
            return;
        }

        try{
            // JWT 유효성 검증
            if (!jwtUtil.validateToken(token)) {
                log.warn("JWT token 유효성 검증 실패");
                sendErrorResponse(response, "JWT token 유효성 실패");
                return;
            }

            // JWT 유효성 검증 성공 후
            Claims claims = jwtUtil.getClaims(token);
            String email = jwtUtil.getEmail(claims);

            log.debug("JWT token 인증 유저 : {}",email);

            // 데이터베이스에서 사용자 정보 조회
            UserDetails userDetails = customUserDetailsService.loadUserByUsername(email);

            // 사용자가 존재 하지 않는 경우
            if (userDetails == null) {
                log.warn("사용자가 존재하지 않음 : {}", email);
                sendErrorResponse(response, "사용자가 존재하지 않음");
                return;
            }

            // security 인증 토큰 생성
            Authentication authToken = new UsernamePasswordAuthenticationToken(userDetails,null, userDetails.getAuthorities());

            // 세션에 사용자 등록
            SecurityContextHolder.getContext().setAuthentication(authToken);

        }catch (Exception e) {
            log.error("JWT Authentication 실패 : {}",e.getMessage());
            sendErrorResponse(response, "Authentication 실패");
            return;
        }
        filterChain.doFilter(request, response);
    }

흐름

  1. Authorization 헤더에서 토큰 추출
  2. 헤더가 없거나 Bearer 형식이 아니면 필터 패스
    1. 모든 요청이 JWT 를 필요로 하지 않음
  3. Bearer 접두사 제거 후 토큰 추출
  4. 블랙리스트 여부 체크
    1. 블랙리스트에 등록된 토큰은 차단해야 함
    2. JWT 파싱, DB 조회 같은 비용을 쓰기 전에 차단
  5. JWT 유효성 검증
    1. 검증 : 서명, 만료, 형식
  6. Claims 파싱 후 사용자 식별 정보 추출
    1. 토큰이 유효하면 클레임 꺼낼 수 있음
  7. DB 에서 사용자 정보 조회
    1. JWT 가 유효하더라도 실제 사용자 상태가 바뀌었을 수 있음
    2. 사용자 탈퇴,차단 or 권한 변경 등
  8. 토큰은 유효하지만 사용자가 없다면, 401 처리 후 종료
  9. Authentication 생성 후 SecurityContext 에 등록
  10. 인증 과정 실패 시 401 후 종료
  11. 모든 검증을 통과하면 다음 필터로 진행

📝 테스트


참고자료

https://ddururiiiiiii.tistory.com/615