프로젝트 전체 코드: https://github.com/kcm02/JWT_OAuth_Login.git
웹 개발에서 로그아웃이란 사용자가 시스템에서 계정을 안전하게 로그아웃하는 과정을 의미한다. 이는 사용자가 더 이상 웹 애플리케이션에 접근하지 못하도록 하여 애플리케이션의 보안을 유지하는 필수적인 기능이다.
JWT는 무상태stateless
인증 방식으로, 사용자가 로그인할 때 서버는 사용자 정보를 포함한 JWT를 생성하여 클라이언트에 전달한다. 이런 방식에서의 로그아웃은 세션 기반 인증과 다르며, 다음과 같은 과정을 거친다.
1) 클라이언트 측 토큰 삭제: 사용자가 로그아웃하면 클라이언트에서 JWT
를 삭제하여 더 이상 인증된 요청을 보낼 수 없게 한다.
2) 서버 측 블랙리스트 처리: 서버는 로그아웃된 JWT
를 블랙리스트에 추가하여 해당 토큰을 무효화한다.
3) 토큰 유효성 검사: 서버는 각 요청 시 블랙리스트를 확인하여 블랙리스트에 있는 JWT
는 접근을 거부한다.
🤔 무상태
stateless
인증 방식이란?
무상태(stateless
)란 시스템이나 애플리케이션이 특정 요청 간에 상태 정보를 저장하지 않는 특성을 의미한다. 무상태 애플리케이션에서는 각 요청이 독립적이며, 서버는 이전 요청의 상태를 기억하지 못한다.
- JWT와 무상태: JWT 안에 필요한 정보가 포함되어 있으므로, 서버는 별도의 상태 정보를 저장하지 않고 클라이언트가 서버에 요청할 때마다 해당 JWT를 검증한다.
여기서 블랙리스트 처리는 Redis
를 이용하여 빠른 읽기/쓰기 속도와 만료 관리를 목표로 할 것이다. Redis
는 메모리 기반 데이터 저장소로, 데이터 구조의 유연성과 TTL(Time To Live)
설정을 통해 만료된 토큰을 자동으로 제거하여 애플리케이션에서 효율적인 JWT 관리를 가능하게 한다.
블랙리스트에 대해서는 JWT 토큰 관리 - 블랙리스트 (feat. Redis)에서 다루었으니 자세한 방식과 주의점 등을 알고 싶다면 이 게시물을 참고 바란다.
com.project.securelogin
├── config
│ └── RedisConfig.java
│ └── SecurityConfig.java
├── controller
│ └── AuthController.java
│ └── UserController.java
├── domain
│ └── CustomUserDetails.java
│ └── User.java
├── dto
│ └── UserRequestDTO.java
│ └── UserResponseDTO.java
├── jwt
│ └── JwtAuthenticationFilter.java
│ └── JwtTokenProvider.java
├── repository
│ └── JwtTokenRedisRepository.java
│ └── UserRepository.java
└── service
└── AuthService.java
└── CustomUserDetailsService.java
└── UserService.java
RedisConfig
: Redis와 관련된 설정을 정의하는 클래스이다. Redis 연결에 필요한 bean
들을 구성할 수 있다.
JwtTokenRedisRepository
: Redis를 사용해 JWT 토큰을 블랙리스트로 관리하는 Repository
클래스이다.
로그인 기능에서 필요한 의존성을 pom.xml
의 dependencies
에 추가한다.
<dependencies>
··· 생략 ···
<!-- Redis 의존성 추가 -->
<dependency>
<groupId>org.springframework.data</groupId>
<artifactId>spring-data-redis</artifactId>
<version>3.3.0</version>
</dependency>
··· 생략 ···
</dependencies>
Spring Data Redis
: 스프링 프레임워크와 Redis
를 통합하여 스프링에서 Redis
데이터베이스에 접근할 수 있도록 기능을 제공하는 라이브러리이다. Redis
는 로그인 후 JWT
를 통해 인증된 사용자의 세션 데이터를 저장한다.application.yml
spring:
data:
redis:
host: localhost
port: 6379
Redis
에 관한 기본적인 설정을 해줘야 한다. host
와 port
는 기본값을 사용했다.
Config
RedisConfig
@Configuration
@EnableRedisRepositories
public class RedisConfig {
@Bean // Lettuce를 이용해 Redis 서버에 연결
public RedisConnectionFactory redisConnectionFactory() {
return new LettuceConnectionFactory();
}
@Bean // Redis 작업을 수행하기 위한 RedisTemplate 빈을 정의
public RedisTemplate<String, String> redisTemplate(RedisConnectionFactory redisConnectionFactory) {
RedisTemplate<String, String> redisTemplate = new RedisTemplate<>();
redisTemplate.setConnectionFactory(redisConnectionFactory);
redisTemplate.setKeySerializer(new StringRedisSerializer());
redisTemplate.setValueSerializer(new StringRedisSerializer());
return redisTemplate;
}
}
RedisConfig
는 스프링 애플리케이션에서 Redis를 사용할 수 있도록 기본적인 설정을 정의한다. redisConnectionFactory()
는 Redis 연결을 설정하고, redisTemplate()
은 Redis 작업을 위한 RedisTemplate을 정의한다. 이 설정들은 Spring Data Redis
를 사용하여 Redis 데이터베이스와 통합할 때 기본적으로 필요한 구성이다.
Repository
JwtTokenRedisRepository
@Component
@RequiredArgsConstructor
public class JwtTokenRedisRepository {
private static final String BLACKLIST_KEY_PREFIX = "jwt:blacklist:";
private final RedisTemplate<String, String> redisTemplate;
// 주어진 JWT 토큰을 블랙리스트에 추가한다. 이미 추가된 경우 false를 반환하고, 성공적으로 추가된 경우 true를 반환한다.
public boolean addTokenToBlacklist(String tokenId, long expireInSeconds) {
try {
String key = BLACKLIST_KEY_PREFIX + tokenId;
Boolean result = redisTemplate.opsForValue().setIfAbsent(key, "true", expireInSeconds, TimeUnit.SECONDS);
return Boolean.TRUE.equals(result);
} catch (Exception e) {
return false;
}
}
// 주어진 JWT 토큰이 블랙리스트에 있는지 여부를 확인한다.
public boolean isTokenBlacklisted(String tokenId) {
if (redisTemplate == null) {
return false;
}
String key = BLACKLIST_KEY_PREFIX + tokenId;
return redisTemplate.hasKey(key);
}
}
JwtTokenRedisRepository
란?이 클래스는 Redis
를 사용해 JWT 토큰을 블랙리스트로 관리하는 Repository
클래스이다. JWT 토큰을 블랙리스트에 추가하거나, 해당 토큰이 블랙리스트에 있는지 확인하는 기능을 제공한다.
BLACKLIST_KEY_PREFIX
상수: jwt:blacklist:
로 시작하는 모든 Redis 키의 접두사이다. 이 접두사는 블랙리스트에 있는 JWT 토큰을 구분하기 위해 사용된다.
redisTemplate
: Redis와의 상호작용을 담당하는 Spring Data Redis의 RedisTemplate
객체이다. 이 객체를 통해 Redis에 데이터를 저장하고 조회할 수 있다.
addTokenToBlacklist(String tokenId, long expireInSeconds)
email
: JWT 토큰에 있는 사용자 이메일expireInSeconds
: 토큰이 Redis에 유지될 유효 기간false
, 성공적으로 추가된 경우 true
를 반환redisTemplate.opsForValue().setIfAbsent(key, "true", expireInSeconds, TimeUnit.SECONDS)
: Redis의 setIfAbsent
명령을 사용하여 키가 없는 경우에만 값("true")을 설정하고, 지정된 시간 후에 만료되도록 설정한다. 이미 키가 존재하면 추가하지 않고 false
를 반환한다.isTokenBlacklisted(String tokenId)
email
: JWT 토큰에 있는 사용자 이메일true
, 없는 경우 false
를 반환redisTemplate.hasKey(key)
: 주어진 키가 Redis에 존재하는지 확인하여 존재하면 true
, 그렇지 않으면 false
를 반환한다. redisTemplate
이 null
인 경우에는 무조건 false
를 반환하도록 예외 처리가 추가되어 있다.JWT
JwtTokenProvider
private final JwtTokenRedisRepository jwtTokenRedisRepository;
private final UserDetailsService userDetailsService;
// 리프레시 토큰 검증
public boolean validateRefreshToken(String refreshToken) {
try {
Jwts.parser().setSigningKey(secretKey).parseClaimsJws(refreshToken);
return true;
} catch (Exception e) {
return false;
}
}
// HttpServletRequest에서 Refresh 토큰을 추출
public String resolveRefreshToken(HttpServletRequest request) {
String refreshToken = request.getHeader("Refresh-Token");
return refreshToken;
}
// Refresh 토큰을 블랙리스트에 추가하고, 성공적으로 추가되면 true를 반환한다.
public boolean blacklistRefreshToken(String refreshToken) {
try {
String tokenId = Jwts.parser().setSigningKey(secretKey).parseClaimsJws(refreshToken).getBody().getId();
if (tokenId == null) {
throw new IllegalArgumentException("토큰에 jti 클레임이 포함되어 있지 않습니다.");
}
return jwtTokenRedisRepository.addTokenToBlacklist(tokenId, refreshTokenValiditySeconds);
} catch (Exception e) {
return false;
}
}
// 블랙리스트에 있는지 확인 (Refresh Token)
public boolean isRefreshTokenBlacklisted(String refreshToken) {
String tokenId = Jwts.parser().setSigningKey(secretKey).parseClaimsJws(refreshToken).getBody().getId();
return jwtTokenRedisRepository.isTokenBlacklisted(tokenId);
}
// Refresh 토큰을 사용하여 새로운 Access 토큰 생성
public String createTokenFromRefreshToken(String refreshToken) {
if (!validateRefreshToken(refreshToken)) {
throw new IllegalArgumentException("Invalid refresh token");
}
String email = getEmail(refreshToken);
CustomUserDetails userDetails = (CustomUserDetails) userDetailsService.loadUserByUsername(email);
return generateToken(new UsernamePasswordAuthenticationToken(userDetails, "", userDetails.getAuthorities()), accessTokenValiditySeconds);
}
validateRefreshToken(String refreshToken)
refreshToken
: 검증할 리프레시 토큰true
, 그렇지 않으면 false
resolveRefreshToken(HttpServletRequest request)
HttpServletRequest
에서 리프레시 토큰을 추출한다.request
: HTTP 요청 객체blacklistRefreshToken(String refreshToken)
refreshToken
: 블랙리스트에 추가할 리프레시 토큰true
, 실패하면 false
isRefreshTokenBlacklisted(String refreshToken)
refreshToken
: 확인할 리프레시 토큰true
, 그렇지 않으면 false
createTokenFromRefreshToken(String refreshToken)
refreshToken
: 새로운 액세스 토큰을 생성하기 위해 사용할 리프레시 토큰이번에 JwtTokenProvider
에서는 리프레시 토큰에 대한 여러 메서드를 생성했다. 리프레시 토큰에 대한 중요도가 커진 만큼 검증, 추출, 블랙리스트 추가, 새로운 액세스 토큰 생성 등의 메서드를 통해 더욱 다양한 활용을 할 수 있을 것이다.
JwtAuthenticationFilter
@Component
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final JwtTokenProvider jwtTokenProvider;
private final CustomUserDetailsService userDetailsService;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
try {
// JWT 토큰 추출
String jwt = jwtTokenProvider.resolveToken(request);
if (jwt != null && jwtTokenProvider.validateToken(jwt)) {
// 토큰에서 사용자 정보 추출
String email = jwtTokenProvider.getEmail(jwt);
// 사용자 정보로부터 UserDetails 로드
CustomUserDetails userDetails = (CustomUserDetails) userDetailsService.loadUserByUsername(email);
// 인증 객체 생성
UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(
userDetails, null, userDetails.getAuthorities());
authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
// SecurityContext에 인증 객체 설정
SecurityContextHolder.getContext().setAuthentication(authentication);
}
} catch (ExpiredJwtException e) {
// 만료된 JWT 처리
handleTokenExpiration(request, response);
return;
} catch (Exception e) {
// 예외 발생 시 SecurityContext 초기화
SecurityContextHolder.clearContext();
}
// 다음 필터로 요청 전달
filterChain.doFilter(request, response);
}
// 토큰 만료 처리 로직
private void handleTokenExpiration(HttpServletRequest request, HttpServletResponse response) throws IOException {
// Refresh Token 추출
String refreshToken = jwtTokenProvider.resolveRefreshToken(request);
if (refreshToken != null && jwtTokenProvider.validateToken(refreshToken)) {
// Refresh Token이 블랙리스트에 없으면 새로운 Access Token 발급
if (!jwtTokenProvider.isRefreshTokenBlacklisted(refreshToken)) {
String jwtToken = jwtTokenProvider.createTokenFromRefreshToken(refreshToken);
response.setHeader("Authorization", "Bearer " + jwtToken);
// 새로 발급받은 Access Token으로 UserDetails 로드
String email = jwtTokenProvider.getEmail(jwtToken);
CustomUserDetails userDetails = (CustomUserDetails) userDetailsService.loadUserByUsername(email);
// 인증 객체 생성 및 SecurityContext에 설정
UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(
userDetails, null, userDetails.getAuthorities());
authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(authentication);
} else {
// Refresh Token이 블랙리스트에 있으면 Unauthorized 에러 반환
response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Refresh token is blacklisted");
}
} else {
// 유효하지 않은 Refresh Token일 경우 SecurityContext 초기화 및 Unauthorized 에러 반환
SecurityContextHolder.clearContext();
response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Invalid refresh token");
}
}
}
JwtAuthenticationFilter
의 바뀐 점액세스 토큰이 만료되었을 때 새롭게 토큰을 생성하는 메서드가 새롭게 추가되었다. 또한 기존 메서드에서 부족했던 예외 처리를 추가함으로 더욱 상세하고 정확한 반환값을 건넬 수 있게 되었다.
JwtTokenProvider
: JWT 토큰을 생성하고 검증하는 도구CustomUserDetailsService
: 사용자 정보를 로드하는 서비스doFilterInternal()
HttpServletRequest request
: HTTP 요청 객체HttpServletResponse response
: HTTP 응답 객체FilterChain filterChain
: 필터 체인CustomUserDetails
객체를 로드하고 인증 객체를 생성하여 SecurityContext
에 설정한다.handleTokenExpiration
메서드를 호출하여 토큰 만료 처리를 수행한다.SecurityContext
를 초기화한다.handleTokenExpiration()
HttpServletRequest request
: HTTP 요청 객체HttpServletResponse response
: HTTP 응답 객체CustomUserDetails
객체를 로드하고 인증 객체를 생성하여 SecurityContext
에 설정한다.Unauthorized
에러를 반환한다.Service
AuthService
// Refresh 토큰을 블랙리스트에 추가하고, 성공적으로 추가되면 true를 반환한다.
public boolean logout(String token) {
return jwtTokenProvider.blacklistRefreshToken(token);
}
JwtTokenProvider
의 블랙 리스트 추가 메서드를 통해 리프레시 토큰을 블랙 리스트에 추가하고, 성공적으로 추가되면 true를 반환한다.
String token
: 블랙리스트에 추가할 리프레시 토큰true
, 실패하면 false
jwtTokenProvider.blacklistRefreshToken(token)
을 호출하여 리프레시 토큰을 블랙리스트에 추가하고, 그 결과를 반환한다.Controller
AuthController
@DeleteMapping("/logout")
public ResponseEntity<Void> logout(@RequestHeader(name = "Refresh-Token") String refreshToken) {
boolean logoutSuccess = authService.logout(refreshToken);
if (logoutSuccess) {
return ResponseEntity.noContent().build(); // 204 No Content
} else {
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build(); // or INTERNAL_SERVER_ERROR
}
}
@DeleteMapping("/logout")
로그아웃은 따로 Body
에 리턴값을 넘겨줄 필요가 없으므로 Delete
API를 사용해 로그아웃 요청을 처리하고 HTTP Status
를 응답한다.
@RequestHeader(name = "Refresh-Token") String refreshToken
: 요청 헤더에서 "Refresh-Token"을 받아 리프레시 토큰으로 사용ResponseEntity<Void>
: 로그아웃 결과에 따른 HTTP 응답authService.logout(refreshToken)
을 호출하여 리프레시 토큰을 블랙리스트에 추가한다.204 No Content
응답을 반환한다.500 Internal Server Error
응답을 반환한다.Test
지금까지의 코드를 빠짐없이 작성했다면 정상적으로 로그아웃 처리가 될 것이다. 여기서 주의해야 할 점이 있다. 로그아웃은 permitAll()
처리가 되어있지 않으므로 반드시 Authorization
을 헤더에 추가해 리프레시 토큰과 함께 넘겨줘야 한다.
if
로그아웃 성공로그아웃을 성공하면 화면과 같이 204 No Content
를 반환한다.
if
로그아웃 실패로그아웃을 실패하면 화면과 같이 500 Internal Server Error
를 반환한다.
이번 로그아웃 과정은 생각보다 힘들었다. 특히 블랙리스트에 관해 고민하는 시간이 많았는데, 결과 나는 RefreshToken
안에 있는 고유 식별자(jti
)를 키로 하여 블랙 리스트에 등록했지만 AccessToken
을 키로 사용하는 케이스도 많으니 프로젝트의 상황에 맞춰 결정하면 될 것 같다.
RefreshToken
를 키로 선택한 이유에 대해서는 JWT 토큰 관리 - 블랙리스트 (feat. Redis)에서 다루었으니 해당 게시물을 참고하자!
다음 게시물에서는 회원 CRUD API에 대해 다룰 것 같다. 어쨌든 JWT라는 큰 산을 넘은 것 같아 뿌듯하다.