Spring Security는 손쉽게 사용할 수 있는 기본 LogoutFilter를 제공한다. 기능을 기본 LogoutFilter 스펙에 맞춰서 구현했다면 아무런 문제가 없겠지만 그렇지 않을 경우(예를 들어 JWT로 인증/인가를 구현) 로그아웃을 했을 때 알 수 없는 오류가 터지기 시작한다.
이럴 땐 내가 원하는 기능에 맞춰 로그아웃 기능을 커스터마이징이 필요한데, 그 기능을 정확히 이해하지 않으면 또 다른 문제가 발생할 수 있다. 지금부터 LogoutFilter의 개념과 구현 과정 중 문제 어떻게 해결할 수 있는지 알아보자.
Spring Securiry Logout
스프링 시큐리티는 로그아웃 기능을 제공한다. Config 파일에서 아무런 설정을 하지 않아도 기본적으로 제공되는 로그아웃 기능이 동작한다. 기본 로그아웃의 특징은 아래와 같다.
- /logout Path로 GET이나 POST 요청
- ServerCsrfTokenRepository와 ServerSecurityContextRepository를 비워준다.
- RememberMe Authentication을 지운다.
- 저장되어 있는 모든 CSRF token을 지운다.
- LogoutSuccessEventPublishingLogoutHandler를 통해 LogoutSuccessEvent라는 이벤트를 발행한다.
위 로직이 수행되기 위해서는 Spring Security의 Filter Chain 중 LogoutFilter를 거쳐야 한다.
LogoutFilter
Spring Security는 Filter Chain을 사용해 애플리케이션의 엔드 포인트 요청에 도달하기 전에 요청을 가로채 인증/인가 로직을 수행한다. 특정 요건(URI)이 충족되면 로직을 수행한다.
Spring Security의 Filter Chain은 아래 순서대로 진행된다. Logout Filter에 설정되어 있는 URI, Method와 일치하는 요청이 들어오면 LogoutFilter에서 해당 요청을 채간다.
LogoutFilter에 요청이 도달했을 때의 실행과정은 아래와 같다.
- LogoutFilter에 요청이 도착하면 AntPathRequestMatcher를 통해 URI가 일치하는지 확인한다. 만약 일치하면 LogoutFilter의 로직이 실행된다.
- Security Context에서 Authentication 객체를 찾고 그것을 LogoutHandler로 넘겨준다.
- LogoutHandler에서 세션 무효화, 쿠키 삭제, SecurityContext 삭제, 로그인 페이지 리다이렉트 등의 필요한 작업을 수행한다.
Logout 커스터마이징
하지만 Spring Security에서 기본적으로 제공하는 로그아웃 기능만으로는 요구사항을 충족하지 못하는 경우가 생길 수 있다. 예를 들어, 인증/인가를 위해 세션이 아닌 JWT를 사용한다거나, 로그아웃 Path를 다르게 하고 싶을 수 있다. 이런 경우 Logout을 커스터마이징하면 된다. Security에서 HttpSecurity 객체의 체이닝 메소드 중 logout 메소드를 사용해 로그아웃을 커스터마이징할 수 있다.
public final class HttpSecurity extends AbstractConfiguredSecurityBuilder<DefaultSecurityFilterChain, HttpSecurity>
implements SecurityBuilder<DefaultSecurityFilterChain>, HttpSecurityBuilder<HttpSecurity> {
...
public HttpSecurity logout(Customizer<LogoutConfigurer<HttpSecurity>> logoutCustomizer) throws Exception {
logoutCustomizer.customize(getOrApply(new LogoutConfigurer<>()));
return HttpSecurity.this;
}
...
}
logout 체이닝 메소드는 LogoutConfigurer를 전달 받는데, LogoutConfigurer를 통해 로그아웃 기능을 핸들링할 수 있다. 아래는 LogoutConfigurer에서 제공하는 핵심 메소드이다.
- logoutUrl(String logoutUrl)
- logoutSuccessHandler(LogoutSuccessHandler logoutSuccessHandler)
- addLogoutHandler(LogoutHandler logoutHandler)
- deleteCookies(String... cookieNamesToClear)
- logoutRequestMatcher(RequestMatcher logoutRequestMatcher)
1. URI 커스텀
- LogoutConfigurer의 logoutUrl(String logoutUrl)를 사용하여 로그아웃 시 사용할 URI를 지정할 수 있다.
- 아래와 같이 logoutUrl 메소드에 스트링 형식의 path를 전달하면 된다.
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.logout((logout) -> logout.logoutUrl("/api/v1/logout"));
}
}
2. Clean Up Handler Custom
세션이 아닌 쿠키를 사용해 인증/인가 작업을 진행하고 로그아웃 로직을 만들고 싶다면 LogoutConfigurer의 addLogoutHandler를 사용해 Clean Up Handler를 커스터마이징해야한다.
아래 예시는 스프링 시큐리티에서 기본적으로 제공하는 쿠키 로그아웃 핸들러인 CookieClearingLogoutHandler를 활용한 코드 예시이다. 생성자는 CookieClearingLogoutHandler(String... cookiesToClear)이다. 제거하고 싶은 쿠키를 전달하고 싶은만큼 전달하면 된다.
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {
...
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.logout((logout) -> logout.addLogoutHandler(new CookieClearingLogoutHandler("accessToken", "refreshToken")));
}
}
CookieClearingLogoutHandler 대신 LogoutConfigurer의 deleteCookies(String... cookieNamesToClear) 메소드를 활용해도 동일하게 동작한다.
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {
...
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.logout((logout) -> logout.deleteCookies("accessToken", "refreshToken"));
}
}
3. Success Handler Custom
LogoutConfigurer의 logoutSuccessHandler(LogoutSuccessHandler logoutSuccessHandler) 메소드를 사용해 로그아웃이 성공했을 때의 동작을 제어할 수 있는 핸들러를 지정할 수 있다. 아래는 코드 예시이다.
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {
...
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.logout((logout) -> logout.logoutSuccessHandler(new HttpStatusReturningLogoutSuccessHandler()));
}
}
LogoutFilter를 사용할 때 발생하는 문제들
1. 로그아웃 Success Handling
로그아웃 성공 핸들러를 따로 설정하지 않으면 로그아웃이 성공했을 때, LogoutConfigurer는 createDefaultSuccessHandler()를 사용해 기본 LogoutSuccessHandler를 반환한다.
public final class LogoutConfigurer<H extends HttpSecurityBuilder<H>>
extends AbstractHttpConfigurer<LogoutConfigurer<H>, H> {
...
public LogoutSuccessHandler getLogoutSuccessHandler() {
LogoutSuccessHandler handler = this.logoutSuccessHandler;
if (handler == null) {
handler = createDefaultSuccessHandler();
this.logoutSuccessHandler = handler;
}
return handler;
}
private LogoutSuccessHandler createDefaultSuccessHandler() {
SimpleUrlLogoutSuccessHandler urlLogoutHandler = new SimpleUrlLogoutSuccessHandler();
urlLogoutHandler.setDefaultTargetUrl(this.logoutSuccessUrl);
if (this.defaultLogoutSuccessHandlerMappings.isEmpty()) {
return urlLogoutHandler;
}
DelegatingLogoutSuccessHandler successHandler = new DelegatingLogoutSuccessHandler(
this.defaultLogoutSuccessHandlerMappings);
successHandler.setDefaultLogoutSuccessHandler(urlLogoutHandler);
return successHandler;
}
...
}
기본 핸들러인 SimpleUrlLogoutSuccessHandler는 /login으로 리다이렉트를하는 동작을 수행한다. 이 때 문제가 발생할 수 있다. /login 자원에 아무것도 할당되어 있지 않다면 로그아웃을 성공했는데도 아래와 같이 404 에러가 발생할 수 있다.
2. 쿠키 삭제의 문제
위에서 확인했다시피 스프링에서 deleteCookies 와 같은 메소드나 CookieClearingLogoutHandler 와 같은 클래스를 사용해 쿠키를 비워주는 로직을 별다른 코드 작성 없이 실행할 수 있었다. 하지만 여기에는 치명적인 단점이 존재하는데, Cookie 객체를 사용해 HttpServletResponse에 addCookie 메소드로 담은 쿠키가 아니면 사용할 수 없다.
아래는 Spring Security에서 제공하는 CookieClearingLogoutHandler 의 코드이다.
public final class CookieClearingLogoutHandler implements LogoutHandler {
private final List<Function<HttpServletRequest, Cookie>> cookiesToClear;
public CookieClearingLogoutHandler(String... cookiesToClear) {
Assert.notNull(cookiesToClear, "List of cookies cannot be null");
List<Function<HttpServletRequest, Cookie>> cookieList = new ArrayList<>();
for (String cookieName : cookiesToClear) {
cookieList.add((request) -> {
Cookie cookie = new Cookie(cookieName, null);
String contextPath = request.getContextPath();
String cookiePath = StringUtils.hasText(contextPath) ? contextPath : "/";
cookie.setPath(cookiePath);
cookie.setMaxAge(0);
cookie.setSecure(request.isSecure());
return cookie;
});
}
this.cookiesToClear = cookieList;
}
/**
* @param cookiesToClear - One or more Cookie objects that must have maxAge of 0
* @since 5.2
*/
public CookieClearingLogoutHandler(Cookie... cookiesToClear) {
Assert.notNull(cookiesToClear, "List of cookies cannot be null");
List<Function<HttpServletRequest, Cookie>> cookieList = new ArrayList<>();
for (Cookie cookie : cookiesToClear) {
Assert.isTrue(cookie.getMaxAge() == 0, "Cookie maxAge must be 0");
cookieList.add((request) -> cookie);
}
this.cookiesToClear = cookieList;
}
@Override
public void logout(HttpServletRequest request, HttpServletResponse response, Authentication authentication) {
this.cookiesToClear.forEach((f) -> response.addCookie(f.apply(request)));
}
}
ResponseCookie 객체를 사용해 쿠키를 발행하는 방식은 Cookie 객체를 사용하는 것과는 다르게 same site 설정을 할 수 있다는 장점이 있는데 same site 설정을 활용하기 위해 ResponseCookie를 활용했다고 가정해보자.
@RequiredArgsConstructor
public class CustomAuthenticationSuccessHandler implements AuthenticationSuccessHandler {
private final TokenService tokenService;
@Override
public void onAuthenticationSuccess(
HttpServletRequest request, HttpServletResponse response, Authentication authentication)
throws IOException, ServletException {
String accessToken = tokenService.generateAccessToken(authentication);
response.setHeader(HttpHeaders.SET_COOKIE, makeTokenCookie(ACCESS_TOKEN, accessToken).toString());
sendResponse(authentication, response);
}
private ResponseCookie makeTokenCookie(String key, String token) {
return ResponseCookie.from(key, token)
.httpOnly(true)
.sameSite("None")
.secure(true)
.maxAge(TOKEN_EXPIRE_PERIOD)
.path("/")
.build();
}
}
ResponseCookie 객체를 사용해 쿠키를 생성하고 HttpServletResponse의 setHeader 메소드를 사용해 쿠키를 설정해주면 Cookie 객체가 Response에 들어가지 않는다. 이 상태에서 Logout 로직을 실행하면 CookieClearingLogoutHandler 에서는 아래의 에러를 터트린다.
11:23:31.752 [http-nio-5555-exec-10] ERROR org.apache.catalina.core.ContainerBase.[Tomcat].[localhost].[/].[dispatcherServlet] - Servlet.service() for servlet [dispatcherServlet] in context with path [] threw exception [Filter execution threw an exception] with root cause
java.lang.NoSuchMethodError: 'java.lang.String jakarta.servlet.http.Cookie.getAttribute(java.lang.String)
위 에러를 해결하기 위해서는 시큐리티에서 기본 제공하는 쿠키 제거 방식이 아닌 헤더에서 쿠키를 제거해주는 Custom Clean Up Handler를 만들어 적용해줘야 한다.
Logout 로직 만들어보기
아래의 전제를 바탕으로 Logout 로직을 작성해봤다. 위에서 제시한 문제점을 어떻게 해결할 수 있는지 살펴보자.
- JWT 방식의 인증/인가를 사용하며 ResponseCookie를 통해 쿠키를 구성하고 HttpServletResponse의 setHeader 메소드를 사용해 쿠키를 할당한다.
- 로그아웃를 하기 위한 경로는 /api/v1/logout 이다.
- 로그아웃이 성공했을 때, /login으로 리다이렉트되는 것이 아닌 200 OK를 주길 원한다.
1. LogoutSuccessHandler 작성하기
문서의 예시처럼 HttpStatusReturningLogoutSuccessHandler 객체를 할당해주면 /login으로 강제로 리다이렉트되는 문제는 해결할 수 있지만 다른 로직을 추가할 수 없다는 단점이 있다. 로직을 추가하고 싶다면 LogoutSuccessHandler 인터페이스의 구현체를 만들어 코드를 작성하면 된다.
@Component
public class CustomLogoutSuccessHandler implements LogoutSuccessHandler {
@Override
public void onLogoutSuccess(
HttpServletRequest request, HttpServletResponse response, Authentication authentication)
throws IOException, ServletException {
...
추가로직 작성
...
response.setStatus(HttpStatus.OK.value());
}
}
2. Clean Up Handler 작성하기
ResponseCookie를 사용해 생성과 동시에 만료되는 쿠키를 만들고 HttpServletResponse의 setHeader를 사용해 할당해주면 쿠키가 만료 처리되어 제거된다.
@Component
public class CustomCookieLogoutHandler implements LogoutHandler {
@Override
public void logout(
HttpServletRequest request, HttpServletResponse response, Authentication authentication) {
ResponseCookie access = makeExpiredTokenCookie(TokenProvider.ACCESS_TOKEN, null);
ResponseCookie refresh = makeExpiredTokenCookie(TokenProvider.REFRESH_TOKEN, null);
response.addHeader(HttpHeaders.SET_COOKIE, access.toString());
response.addHeader(HttpHeaders.SET_COOKIE, refresh.toString());
}
private ResponseCookie makeExpiredTokenCookie(String key, String token) {
return ResponseCookie.from(key, token)
.httpOnly(true)
.sameSite("None")
.secure(true)
.maxAge(0)
.path("/")
.build();
}
}
3. SecurityConfig에서 LogoutFilter 설정하기
구현한 LogoutHandler와 LogoutSuccessHandler를 주입받아 LogoutFilter에 설정해주면 핸들러가 적용된다.
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {
private final LogoutHandler logoutHandler;
private final LogoutSuccessHandler logoutSuccessHandler;
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
return http.
...
.logout(
logout ->
logout
.logoutUrl("/api/v1/logout")
.logoutSuccessHandler(logoutSuccessHandler)
.addLogoutHandler(logoutHandler))
...
.build();
}
글맺음
JWT 기능을 사용한 인증/인가에서 로그아웃을 할 수 있는 간단한 예제를 작성해봤다.
로그인을 어떻게 구현했느냐에 따라서 로그아웃의 커스터마이징도 완전히 달라질 수 있다는 것을 명심해야 한다. 본인이 만든 로그인과 LogoutFilter의 정확한 스펙을 확인해 로그아웃 기능을 만들 수 있다면 좋을 것이다.