📘JWT 사용하여 로그아웃 기능 구현
JWT 기능 구현

[필터 흐름]
0. HTTP 요청 / doFilterInternal 진입
1. 인증이 필요 없는 URI인지 확인 → 필요 없으면 바로 다음 필터로 이동
2. 요청 헤더에서 Access Token 추출
3. 추출한 토큰으로 로그인 상태 확인
4. 화이트리스트 조건 검사 → 조건 불충족 시 종료 (컨트롤러로 미진입)
5. 로그인 상태에 따라 SecurityContext에 인증 정보 설정 또는 클리어
6. 다음 필터로 넘김 → 컨트롤러로 진입
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final JwtTokenProvider jwtTokenProvider;
private final WhiteListManager whiteListManager;
private final TokenExtractor tokenExtractor;
private final JwtAuthenticationProvider jwtAuthenticationProvider;
@Override
protected void doFilterInternal(
HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain
) throws ServletException, IOException {
//토큰 검증 무시 리스트 확인
if(whiteListManager.isNoAuthRequiredUris(request)) {
filterChain.doFilter(request,response);
return;
}
//헤더에서 토큰 생성
String accessToken = tokenExtractor.
extractAccessTokenFromHeader(request).orElse(null);
//로그인 상태 확인
boolean isLoggedIn = jwtTokenProvider.isLoggedIn(accessToken);
//로그인 상태와 화이트리스트 판별하여 조건에 따라 예외처리.
if(!whiteListManager.isWhiteList(isLoggedIn, request, response)) return;
//로그인 상태라면 access 토큰 security 저장. 로그아웃 상태라면 clear
jwtAuthenticationProvider
.setTokenToSecurityContextOrClear(accessToken, isLoggedIn, response);
// 다음 필터로 넘김
filterChain.doFilter(request,response);
}
}
OncePerRequestFilter를 상속한다.//토큰 유효성 검사 무시하는 URI
private static final String[] NO_AUTH_REQUIRED_URIS = {
"/api/reissue"
};
//입력 받은 URI 와 인증 필요없는 URI 판별
public boolean isNoAuthRequiredUri(HttpServletRequest request) {
String requestUri = request.getRequestURI();
return org.springframework.util
.PatternMatchUtils
.simpleMatch(NO_AUTH_REQUIRED_URIS, requestUri);
}
Access 토큰의 유효성 검증이 필요없으므로 해당 URI 일 경우 doFilter() 생성 후 리턴한다. (다음 필터로 넘긴다)// [Headear 예시]
// Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6...
// Header 에서 access token 값을 가져온다.
public Optional<String> extractAccessTokenFromHeader(HttpServletRequest request) {
return Optional.ofNullable(request.getHeader("Authorization")) // Authorization 헤더의 값을 Optional로 감싼다.
.filter(header -> header.startsWith("Bearer ")) // 값이 "Bearer "로 시작하는지 확인 - Bearer : 토큰 종류
.map(header -> header.substring(7)); // "Bearer " 길이만큼 잘라서 리턴
}
"Authorization" 값을 가져온다."Bearer "로 시작하는지 확인한다."Bearer " 부분을 제외한 나머지 토큰 값만 추출한다.Null 방지를 위해 Optional 타입으로 반환한다.//로그인 상태 확인
public boolean isLoggedIn(String accessToken) {
if(accessToken == null) return false; // null 체크
if(!jwtTokenUtils.isValidToken(accessToken)) return false; // 유효성 검사
return !tokenBlacklistService.isTokenInBlackList(accessToken); // 블랙리스트 판별
}
//토큰 유효성 검증(key, 만료, 유효 시작, 형식)
//보안상 예외 처리는 최소화
public boolean isValidToken(String token) {
try {
Jwts.parserBuilder()
.setSigningKey(key)
.build()
.parseClaimsJws(token);
return true;
} catch (JwtException | IllegalArgumentException e) {
return false;
}
}
//토큰 블랙리스트인지 확인
public boolean isTokenInBlackList(String accessToken){
return tokenBlackListRepository.existsByAccessToken(accessToken);
}
Access 토큰의 유효성 여부와 블랙리스트 판별로 확인한다.tokenBlackListRepository 에서 해당 토큰이 블랙리스트 인지 확인한다.//Get 방식일때만 로그아웃 허용 -> URI 추가 고려하여 배열로 생성
private static final String[] ONLY_GET_PUBLIC_URI = {
"/api/posts"
};
//로그아웃 상태 진입 URI - 게시글 보기는 로그아웃 상태에서도 진입 가능
private static final String[] PUBLIC_URIS = {
"/api/users/signup",
"/api/login"
};
//로그인 상태에서 진입 불가 URI - 회원가입, 로그인 기능은 로그아웃 상태에서만 진입 가능
private static final String[] LOGOUT_ONLY_URIS = {
"/api/users/signup",
"/api/login"
};
//화이트 리스트 판단
public boolean isWhiteList(boolean isLoggedIn, HttpServletRequest request, HttpServletResponse response) throws IOException {
String requestUri = request.getRequestURI();
String requestMethod = request.getMethod();
boolean isAllowed;
if (isLoggedIn) {
isAllowed = handleLoggedInUser(requestUri);
} else {
isAllowed = handleGuestUser(requestUri, requestMethod);
}
if (!isAllowed) {
filterException.writeExceptionResponse(response);
}
return isAllowed;
}
// 로그인 상태 처리
private boolean handleLoggedInUser(String requestUri) {
return !isLogoutOnlyUris(requestUri);
}
// 비로그인 상태 처리
private boolean handleGuestUser(String requestUri, String requestMethod) {
return isPublicUris(requestUri) || isPublicGetUris(requestUri, requestMethod);
}
//로그아웃 상태에서만 집입가능한 uri
private boolean isLogoutOnlyUris(String requestUri){
return isWhitelistedUri(LOGOUT_ONLY_URIS, requestUri);
}
//로그인 없이 진입가능한 uri -> 추후 사용
private boolean isPublicUris(String requestUri){
return isWhitelistedUri(PUBLIC_URIS, requestUri);
}
//로그인 없이 진입가능한 Get uri (post)
private boolean isPublicGetUris(String requestUri,String requestMethod ){
boolean isWhiteList = isStartsWithWhitelistedUri(ONLY_GET_PUBLIC_URI, requestUri);
return isWhiteList && "GET".equalsIgnoreCase(requestMethod);
}
//화이트 리스트 인지 확인
private boolean isWhitelistedUri(String[] URLS_LIST, String requestURI) {
return PatternMatchUtils.simpleMatch(URLS_LIST, requestURI);
}
//하위 경로 포함하여 화이트 리스트 확인
private boolean isStartsWithWhitelistedUri (String[] URLS_LIST, String requestURI){
return Arrays.stream(URLS_LIST)
.anyMatch(requestURI::startsWith);
}
/api/posts)public void setTokenToSecurityContextOrClear(String accessToken, boolean isLoggedIn) throws IOException {
//로그인 상태가 아닐경우 ContextHolder 초기화
if(!isLoggedIn) {
SecurityContextHolder.clearContext();
}
//토큰 유효성 검증 후 SecurityContext 저장 (Access token)
else{
saveAuthenticationFromToken(accessToken);
}
}
//토큰에서 인증 정보 설정
public void saveAuthenticationFromToken(String accessToken){
// 토큰으로부터 유저 정보 받기
Authentication authentication = getAuthentication(accessToken);
// SecurityContext 에 Authentication 객체를 저장
SecurityContextHolder.getContext().setAuthentication(authentication);
}
SecurityContext에 담는다.@Configuration
@RequiredArgsConstructor
public class SecurityConfig {
private final JwtTokenProvider jwtTokenProvider;
private final WhiteListManager whiteListManager;
private final TokenExtractor tokenExtractor;
private final JwtAuthenticationProvider jwtAuthenticationProvider;
@Bean
public JwtAuthenticationFilter jwtAuthenticationFilter() {
return new JwtAuthenticationFilter(jwtTokenProvider,whiteListManager,tokenExtractor,jwtAuthenticationProvider);
}
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.csrf(csrf -> csrf.disable()) //보호 기능 삭제
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) //세션 사용하지 않음
.authorizeHttpRequests(auth -> auth
.requestMatchers("/**").permitAll() //특정 경로는 접근 허용
.anyRequest().authenticated() // 나머지 요청은 인증 필요
)
// JwtAuthenticationFilter를 UsernamePasswordAuthenticationFilter 보다 먼저 실행되도록 등록
.addFilterBefore(jwtAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class);
return http.build(); //필터 객체 반환
}
// 로그인 등 인증 처리 시 필요한 AuthenticationManager 빈 등록
@Bean
public AuthenticationManager authenticationManager(AuthenticationConfiguration config) throws Exception {
return config.getAuthenticationManager();
}
}
JwtAuthenticationFilter를 통해 JWT 토큰 기반 인증을 처리하고 SecurityContext에 유저 정보를 저장한다.permitAll로 허용하고, 필터 내부의 화이트리스트 로직으로 접근 제어한다.