🎉 개요
로그인 기능을 구현하고 테스트 하던 중, Access Token을 새로 발급 받았는데도 발급 전의 Access Token으로 여전히 접근이 가능하다는 것을 알게 되었다. 이는 이전 토큰의 만료 기간이 남았기 때문에 해당 토큰이 유효하다고 인식하여 발생한 일이었다.
만약 이전 토큰이 탈취된 경우라면, 새 토큰이 발급되었음에도 탈취한 토큰으로 계속 접근이 가능하므로 보안상 문제가 생길 수 있다. 따라서 이전의 토큰을 무효화하여 접근할 수 없도록 해야 한다.
이 문제를 해결하기 위해 이전 토큰들을 블랙리스트에 넣어서 관리하는 방법을 선택했다. 스프링 시큐리티 관련 코드는 이전에 정리했으니, 블랙 리스트에 넣는 과정만 기록하려고 한다.
[Spring] Spring Security JWT 로그인 구현하기(with Redis)
📝 개요앞에서 JWT에 대해 대략적으로 알아봤으니, 이제는 JWT를 사용한 Spring Security 로그인을 구현하려고 한다. 이때, Refresh Token을 저장하기 위해 Redis를 사용하려고 한다. [Spring] JWT(JSON Web Token)
guswls28.tistory.com
✒️ 토큰 블랙리스트 서비스
우선 토큰을 블랙리스트에 등록하는 서비스를 생성한다. 이때, Refresh Token을 Redis에 저장하는 것과 마찬가지로 블랙리스트에 등록될 토큰도 Redis에 저장한다. (∵ TTL & In-Memory)
블랙리스트 키는 blacklist:AccessToken 형식으로 저장한다.
private final JwtTokenProvider jwtTokenProvider;
private final RedisDao redisDao;
private static final String BLACKLIST_PREFIX = "blacklist:";
addBlackList
이전의 토큰도 만료 시간이 지나면 유효한 토큰이 아니다. 따라서 오랫동안 저장할 필요 없이, 남은 만료 시간을 구한 뒤 그 시간만큼만 블랙리스트로 등록하면 된다.
또한, 블랙리스트에 해당 key가 존재하는지 여부만 확인할 것이므로 value는 중요하지 않다. 따라서 value에는 빈 문자열을 저장하면 된다.
public void addBlacklist(String token) {
String key = BLACKLIST_PREFIX + token;
// key 조회용 => data 중요하지 않음
Claims claims = jwtTokenProvider.parseClaims(token);
Date expiration = claims.getExpiration();
long now = new Date().getTime();
long remainingExpiration = expiration.getTime() - now;
if (remainingExpiration > 0) { // 남은 만료시간만큼 블랙리스트로 등록
redisDao.setValues(key, "", Duration.ofMillis(remainingExpiration));
}
}
isBlacklisted
토큰이 블랙리스트에 존재하는지 확인하기 위한 메서드이다. 블랙리스트에서 key로 찾은 결과가 null이면 해당 토큰은 블랙리스트에 존재하지 않는 것이다. (true: 블랙리스트에 존재, false: 블랙리스트에 존재 X)
public boolean isBlacklisted(String token) {
String key = BLACKLIST_PREFIX + token;
return redisDao.getValues(key) != null;
}
전체 코드
// 토큰 갱신시 이전 토큰 폐기
@Service
@RequiredArgsConstructor
public class TokenBlacklistService {
private final JwtTokenProvider jwtTokenProvider;
private final RedisDao redisDao;
private static final String BLACKLIST_PREFIX = "blacklist:";
public void addBlacklist(String token) {
String key = BLACKLIST_PREFIX + token;
// key 조회용 => data 중요하지 않음
Claims claims = jwtTokenProvider.parseClaims(token);
Date expiration = claims.getExpiration();
long now = new Date().getTime();
long remainingExpiration = expiration.getTime() - now;
if (remainingExpiration > 0) { // 남은 만료시간만큼 블랙리스트로 등록
redisDao.setValues(key, "", Duration.ofMillis(remainingExpiration));
}
}
public boolean isBlacklisted(String token) {
String key = BLACKLIST_PREFIX + token;
return redisDao.getValues(key) != null;
}
}
💾 블랙리스트 저장
로그아웃 시, 해당 토큰을 블랙리스트에 추가하여 더 이상 사용할 수 없도록 처리해야 한다.
// 로그아웃
@Override
public void logout(String accessToken, String email) {
// 저장된 refresh Token 삭제
jwtTokenProvider.deleteRefreshToken(email);
// access token 블랙리스트에 저장
tokenBlacklistService.addBlacklist(accessToken);
}
Refresh Token으로 Access Token을 재발급 받을 때도 해당 토큰을 블랙리스트에 저장하도록 한다.
@Override
public JwtToken checkToken(String accessToken, String refreshToken) {
// Refresh Token이 유효하지 않은 경우
if (refreshToken == null || !jwtTokenProvider.validateRefreshToken(refreshToken))
throw new InvalidTokenException("유효하지 않은 Refresh Token입니다");
// Access Token이 유효하지 않은 경우 (null이거나 블랙리스트에 존재함)
if (accessToken == null || tokenBlacklistService.isBlacklisted(accessToken))
throw new InvalidTokenException("유효하지 않은 Access Token입니다");
// 블랙리스트에 이전 accessToken 넣기
tokenBlacklistService.addBlacklist(accessToken);
// Access Token & Refresh Token 재발급
String userNameFromToken = jwtTokenProvider.getEmailFromToken(refreshToken);
return jwtTokenProvider.generateTokenWithRefreshToken(userNameFromToken);
}
✅ 토큰 유효성 검사
이제 사용자의 JWT를 검사하는 필터에서 해당 토큰이 블랙리스트에 존재하는지만 확인하면 된다. 만약 토큰이 블랙리스트에 존재한다면 다음 필터로 이동하지 못해 서비스에 접근할 수 없다.
RequiredArgsConstructor
public class JwtAuthenticationFilter extends GenericFilter {
private final JwtTokenProvider jwtTokenProvider;
private final TokenBlacklistService tokenBlacklistService;
@Override
public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
// Request Header에서 Access Token 추출
String accessToken = resolveToken((HttpServletRequest) servletRequest);
if (accessToken != null) {
// Access Token이 유효하고 Access Token이 블랙리스트에 존재하는지 확인
if (jwtTokenProvider.validateToken(accessToken) && !tokenBlacklistService.isBlacklisted(accessToken)) {
// 토큰이 유효하면 Authentication 객체를 가지고 와서 SecurityContext에 저장
Authentication authentication = jwtTokenProvider.getAuthentication(accessToken);
SecurityContextHolder.getContext().setAuthentication(authentication);
} else {
// 토큰이 유효하지 않으면 401 (더이상 필터 처리 X)
HttpServletResponse response = (HttpServletResponse) servletResponse;
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
return;
}
}
// 다음 필터로 요청 전달
filterChain.doFilter(servletRequest, servletResponse);
}
...
}
🎯마치며
로그아웃을 하면 아래와 같이 블랙리스트 키에 등록된 것을 확인할 수 있다. 생각보다 간단한 코드였지만 블랙리스트로 관리하는 방법을 배우게 되어 유익했다!

참고
https://velog.io/@boo105/Redis-%EB%A5%BC-%ED%86%B5%ED%95%9C-JWT-Blacklist-%EA%B5%AC%ED%98%84
'Backend' 카테고리의 다른 글
| [Web] 웹 소켓(Web Socket)이란? (1) | 2025.01.29 |
|---|---|
| [Spring] Spring Security JWT 로그인 구현하기(with Redis) (1) | 2025.01.24 |
| [Spring] JWT(JSON Web Token)란? (0) | 2025.01.11 |
| [Spring] 객체 지향 설계와 스프링 (0) | 2024.10.22 |
| [Error] 셧다운 포트가 설정되지 않았습니다. (0) | 2024.04.16 |