현재 구현하고 있는 스프링 기반 웹 프로젝트에서 사용자 인증 방식으로 Spring Security + JWT
인증 방식을 사용하고 있다. 사용자 인증이 필요한 기능들과 사용자 인증이 없어도 사용 가능한 기능들을 구분해서 구현하고 있기 때문에 프론트 단에서 Local Storage
의 토큰의 유무에 따라
기능을 열어주거나 강제로 로그인 페이지로 리다이렉트 시킨다.
여기서 문제는 로그아웃하면 Local Storage
에 저장된 토큰을 삭제함으로써 이후 요청들에 대해 정상적인 처리가 가능하지만, 토큰의 기한이 만료된 경우 더이상 인증 절차를 밟을 수 없는 토큰인데도 불구하고 Local Storage
에는 남아있다.
현재는 서버에서 JWT
인증 과정 절차를 밟던 중 예외가 발생해 사용자 인증을 하지 못하면 AuthenticationEntryPoint
에 의해 401 상태
를 리턴하도록 되있었다. 토큰 만료
토큰 정보 불일치
서명 불일치
에 대한 예외 모두 로그에만 내용을 남기지 프론트로 내려줄때 메시지는 "Unauthorization"
으로 동일하게 처리했었다. 그래서 401 오류
를 응답 코드로 받더라도 프론트 입장에서는 권한이 없음
정도만 알 수 있지 JWT 토큰이 잘못 된건지
사용자 인증을 위한 정보를 잘못 입력한건지
에 대한 자세한 정보를 알 수 없었다. 토큰이 만료됐으면 로그인 페이지로 리다이렉트 시키고 싶은데 이런 처리를 할 수 없는 상황이다. 토큰이 만료 됐다
정도는 알아야 Local Storage
에서 토큰을 삭제를 하던 재발급 요청을 하던 할텐데 말이다. 그래서 서버에서 JWT
토큰 인증을 처리하는 과정에서 예외가 발생하면 예외를 처리해서 해당되는 오류 메시지를 프론트에 응답으로 보낼 수 있도록 수정했다.
우리가 원하는 시나리오를 구현하기위해 이런 해결 방향으로 방법을 찾아보던 중.. 과연 프론트에 이렇게 오류 정보에 대한 메시지를 자세하게 내려주는게 맞나? 라는 생각이 들었다. 오류에 대한 정보가 친절하면 친절할 수록 공격자에게는 이득일테니 말이다. Refresh Token을 이용해서 만료된 토큰을 관리할 수 있도록 하는 방법으로 수정해야겠다.
기존에는 사용자 인증과 관련된 처리를 AuthenticationEntryPoint
구현체에서 하도록 했다. WebSecurityConfigurerAdapter
에 인증에 관한 예외 처리를 AuthenticationEntryPoint
에서 다루도록 설정해놓았고, 허용된 URL 요청 외에 요청들은 Filter
를 거쳐서 JWT
토큰의 유효성을 검사하도록 설정했다.
@Configuration
@RequiredArgsConstructor
@EnableWebSecurity
@EnableGlobalMethodSecurity(securedEnabled = true)
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
private final JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint;
private final JwtAuthenticationFilter jwtAuthenticationFilter;
@Override
protected void configure(HttpSecurity http) throws Exception {
http.csrf().disable();
http.headers().frameOptions().disable();
http.authorizeRequests()
.antMatchers("인증 없이도 허용할 API").permitAll()
...
.anyRequest().authenticated().and()
...
.exceptionHandling().authenticationEntryPoint(jwtAuthenticationEntryPoint).and()
... ;
http.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
}
...
}
@Component
public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint {
@Override
public void commence(HttpServletRequest request, HttpServletResponse response,
AuthenticationException authException) throws IOException {
response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Unauthorized");
}
}
@RequiredArgsConstructor
@Component
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final UserDetailsService userDetailsService;
private final JwtTokenUtil jwtTokenUtil;
String HEADER_STRING = "Authorization";
String TOKEN_PREFIX = "Bearer ";
@Override
protected void doFilterInternal(HttpServletRequest req, HttpServletResponse res, FilterChain chain) throws IOException, ServletException {
String header = req.getHeader(HEADER_STRING);
String username = null;
String authToken = null;
if (header != null && header.startsWith(TOKEN_PREFIX)) {
authToken = header.replace(TOKEN_PREFIX,"");
try {
username = jwtTokenUtil.getUsernameFromToken(authToken);
} catch (IllegalArgumentException e) {
logger.error("an error occured during getting username from token", e);
} catch (ExpiredJwtException e) {
logger.warn("the token is expired and not valid anymore", e);
} catch(SignatureException e){
logger.error("Authentication Failed. Username or Password not valid.");
}
} else {
logger.warn("couldn't find bearer string, will ignore the header");
}
...
chain.doFilter(req, res);
}
}
이 글을 쓰기에 앞서 스프링에서 예외 처리하는 방법에 대해 간단히 정리했었다. 처음에는 JwtAuthenticationFilter
에서 예외를 던져주면 Exception Handler
에서 처리하면 되지 않을까.. 라는 행복회로를 돌렸다. 정신나간 생각을 잠깐 하며 Filter
는 아직 애플리케이션에 들어가지 못했다는 것을 깨달았다. Filter
는 Dispatcher Servlet
보다 앞단에 존재하며 Handler Intercepter
는 뒷단에 존재하기 때문에 Filter
에서 보낸 예외는 Exception Handler
로 처리를 못한다.
구글에서 삽질하던 중 spring boot filter exception handler
키워드로 검색해보니 현재 필터보다 앞단에 예외 처리를 위한 필터를 하나 더 두고 FilterChain.chain
으로 원래의 JWT
유효성 검사를 하던 필터로 요청을 넘겨주는 방법이 있었다. 필터 구성을 이런식으로 해두면 다음 차례 필터의 로직 수행 중 던져진 예외가 앞서 거쳤던 필터로 넘어가서 처리가 가능하게 되나보다😱
즉, 원래는 요청 ➡️ JwtAuthenticationFilter
의 형태였다면, 요청 ➡️ JwtExceptionFilter ➡️ JwtAuthenticationFilter
로 필터를 구성해서 JwtAuthenticationFilter
에서 던진 예외를 JwtExceptionFilter
가 처리할 수 있도록 했다.
@Configuration
@RequiredArgsConstructor
@EnableWebSecurity
@EnableGlobalMethodSecurity(securedEnabled = true)
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
private final JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint;
private final JwtAuthenticationFilter jwtAuthenticationFilter;
private final JwtExceptionFilter jwtExceptionFilter;
@Override
protected void configure(HttpSecurity http) throws Exception {
...
http.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
// JwtAuthenticationFilter 앞단에 JwtExceptionFilter를 위치시키겠다는 설정
http.addFilterBefore(jwtExceptionFilter, JwtAuthenticationFilter.class);
}
...
@Component
public class JwtExceptionFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest req, HttpServletResponse res, FilterChain chain) throws ServletException, IOException {
try {
chain.doFilter(req, res); // go to 'JwtAuthenticationFilter'
} catch (JwtException ex) {
setErrorResponse(HttpStatus.UNAUTHORIZED, res, ex);
}
}
public void setErrorResponse(HttpStatus status, HttpServletResponse res, Throwable ex) throws IOException {
res.setStatus(status.value());
res.setContentType("application/json; charset=UTF-8");
JwtExceptionResponse jwtExceptionResponse = new JwtExceptionResponse(ex.getMessage(), HttpStatus.UNAUTHORIZED);
res.getWriter().write(jwtExceptionResponse.convertToJson());
}
}
...
@Override
protected void doFilterInternal(HttpServletRequest req, HttpServletResponse res, FilterChain chain) throws IOException, ServletException {
String header = req.getHeader(HEADER_STRING);
String username = null;
String authToken = null;
if (header != null && header.startsWith(TOKEN_PREFIX)) {
authToken = header.replace(TOKEN_PREFIX,"");
try {
username = jwtTokenUtil.getUsernameFromToken(authToken);
} catch (IllegalArgumentException e) {
logger.error("an error occured during getting username from token", e);
// JwtException (custom exception) 예외 발생시키기
throw new JwtException("유효하지 않은 토큰");
} catch (ExpiredJwtException e) {
logger.warn("the token is expired and not valid anymore", e);
throw new JwtException("토큰 기한 만료");
} catch(SignatureException e){
logger.error("Authentication Failed. Username or Password not valid.");
throw new JwtException("사용자 인증 실패");
}
} else {
logger.warn("couldn't find bearer string, will ignore the header");
}
...
📌 linked2ev. "13. 스프링부트 MVC - Filter 설정", 연어 좋아하는 개발자, 15 Sep 2019.
📌 이상혁 (Sang Hyuk Lee). "인터셉터(Interceptor) & 필터(Filter)", Hyuk의 기술블로그
, 27 February 2020.
🖼 코동이. "Filter vs Interceptor 차이 (Spring)", 너도 나도 함께 성장하자, 13 Aug 2021
감사합니다!