👉[Java] JWT 검증방식 Interceptor 구현
기존에 Java/Spring-boot 활용한 백엔드 코드를 정리하던 중 불편한 코드를 발견했다.
지금 돌이켜 보니 문제점이 많아보여, 공부도 할겸 리팩토링을 시도해본 과정이다.
Spring 진영의 경우 Request에 대해 검증하는 방식을 크게 Filter와 Interceptor 두가지로 볼 수 있다.
모두 중간에서 값을 하이재킹하여 검증 로직을 구현한다.
아래 그림처럼 말이다.
기존의 코드는 Filter 인터페이스의 구현체가 중간에서 URL 매칭을 통해 검증하는 구조이다.
두 가지 접근 방식에는 각각 장단점이 있다:
- Interceptor: Spring의 Interceptor는 Controller로 들어오는 요청을 가로채서 처리할 수 있는 기능을 제공. Interceptor는 Spring의 ApplicationContext에 등록되어 동작하며, Spring의 빈을 주입받아 사용할 수 있다. 따라서 Spring의 기능을 최대한 활용하고자 할 때 유용하다. 그러나 Interceptor는 DispatcherServlet이 작동한 후에만 동작하기 때문에, 일부 요청은 인증 처리 없이 Controller까지 도달할 수 있다.
- Filter: Servlet Filter는 DispatcherServlet이 작동하기 전에 요청과 응답을 가로챌 수 있다. 따라서 모든 요청에 대해 인증 처리를 할 수 있으므로, 보안 측면에서는 Filter가 더 안전하다. 그러나 Filter는 Spring의 ApplicationContext 밖에서 동작하므로, Spring의 빈 주입 등의 기능을 사용할 수 없다.
일반적으로는 인증과 같은 보안 관련 로직은 가능한 일찍, 모든 요청에 대해 처리되어야 하므로 Filter를 사용하는 것이 올바른 방향일 것이라 생각했다. 이 때, Interceptor가 도달하지 못하는 케이스는 아래와 같다고 한다.
1. 정적 자원에 대한 요청: HTML, CSS, JavaScript, 이미지 등의 정적 자원은 일반적으로 Spring MVC의 컨트롤러를 통해 제공되지 않는다. 이런 자원에 대한 요청은 DispatcherServlet을 거치지 않으므로 Interceptor에서 처리할 수 없다.
2. DispatcherServlet을 거치지 않는 요청: 일부 특수한 설정에서는 요청이 DispatcherServlet을 거치지 않고 직접 서블릿이나 필터에 의해 처리되는 경우가 있을 수 있다.
인증/인가 자체는 보안의 개념이기 때문에 모든 리퀘스트에 대해서 검증을 해야한다고 생각했다.
따라서 Interceptor가 아닌 Filter로 JWT 검증을 처리하기 위해 Spring Security의 OncePerRequestFilter를 상속받아 JWT 검증 로직을 구현하는 방식으로 구현했다.
해당 코드는 과거의 내가 급하게 만들었던 코드이다.
OncePerRequestFilter 상속받은 JwtRequestFilter 클래스 내에 doFilterInternal함수에서 URL 단위로 아래와 같이 확인한다.
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
throws ServletException, IOException {
String path = request.getRequestURI();
// No Filtering URL
if (path.startsWith("/name/check")) {
filterChain.doFilter(request, response);
return;
}
if (path.startsWith("/adm/")){
filterChain.doFilter(request, response);
return;
}
...
final String authorizationHeader = request.getHeader("Authorization");
String token = null;
...
path 값에 대해서 내가 원하는 URL 패턴에 대해 매칭을 시도하고 검증이 필요한 로직이면 따로 처리를 진행한다.
공식 문서 방식
Spring Security 공식 문서에 따르면 아래와 같은 방식으로 권장하고 있다.
이를 통해 doFilterInternal 를 재정의하는 방식보다는 훨씬 깔끔해보인다.
protected void configure(HttpSecurity http) throws Exception {
http
// ...
.authorizeRequests(authorize -> authorize
.mvcMatchers("/resources/**", "/signup", "/about").permitAll()
.mvcMatchers("/admin/**").hasRole("ADMIN")
.mvcMatchers("/db/**").access("hasRole('ADMIN') and hasRole('DBA')")
.anyRequest().denyAll()
);
}
그러나 두가지 문제점이 보였다.
1. 방대해지는 코드 : URL이 추가될 때마다, mvcMatchers 구문을 계속해서 추가해줘야 한다. 지금은 작아서 그렇지, 10개 100개가 된다면 configure 함수가 매우 무거워질 것이다.
2. 코드 직관성 저하 : 특정 API가 JWT 검증 로직이 도는지 확인하기 위해서 매번 configure 함수를 체크해야 한다. 주석 없는 코드가 가장 깔끔한 것처럼, 설명이나 기타 요소 없이 API 코드를 읽는 것만으로도 코드 이해도를 높일 수 있어야 한다.
자주 쓰던 Flask 진영에서는 데코레이터를 활용해서 라우터 단위로 토큰의 검증 Y/N 처리를 할 수 있다.
아래와 같이 말이다.
@bp.route("/test")
@jwt_required()
def get_test():
# 로직 처리
return ...
@jwt_required() 라는 어노테이션(파이썬은 데코레이터) 을 활용하여 해당 코드가 JWT 검증 로직이 도는지 안 도는지 한눈에 확인할 수 있다.
해당 방식으로는 위의 문제점 두가지를 모두 해결할 수 있을 것이라고 보았다. 구현해보자.
@JwtRequired 구현
위 파이썬 사례처럼 custom annotation 인 JwtRequired를 만들어서 filtering에 적용해보고자 했다.
커스텀 어노테이션에 대한 선언을 작성 해주자.
@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
public @interface JwtRequired {
}
그리고 JwtRequired 에 대한 로직을 Filter 에서 구현해주자.
public class JwtAuthenticationFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
HandlerMethod handlerMethod = (HandlerMethod) request.getAttribute(HandlerMapping.BEST_MATCHING_HANDLER_ATTRIBUTE);
Method method = handlerMethod.getMethod();
if (method.isAnnotationPresent(JwtRequired.class) || handlerMethod.getBeanType().isAnnotationPresent(JwtRequired.class)) {
String token = request.getHeader("Authorization");
// JWT 토큰 검증 로직을 수행하고, 토큰이 유효하지 않다면 예외를 발생시킵니다.
}
filterChain.doFilter(request, response);
}
}
기존의 URL 패턴 매칭보다 훨씬 코드 복잡도가 낮아졌다.
하지만 실제로 실행시키면 아래와 같은 에러가 발생했다.
java.lang.NullPointerException: Cannot invoke "org.springframework.web.method.HandlerMethod.getMethod()" because "handlerMethod" is null
handlerMethod가 doFilterInternal 에서 null로 나타난 것이다.
왜 그럴까?
관련해서 찾아보니 딱히 이유가 나오지 않았다. 추측컨대 아래와 같은 이유가 아닐까 싶다.
Filter에서 HandlerMethod를 얻으려면, DispatcherServlet가 요청을 처리하는 과정 중에서야 가능하다. 이는 일반적으로 Filter의 책임 범위를 벗어나는 동작이다. 왜냐하면 Filter 인터페이스는 DispatcherServlet 이전에 존재하기 때문이다. 따라서 이런 문제를 해결하려면 Filter 대신 HandlerInterceptor를 사용하는 것이 적절하다. HandlerInterceptor는 DispatcherServlet이 요청을 처리하는 과정에서 동작하므로, 컨트롤러 메소드에 대한 정보를 바로 얻을 수 있을 것이다.
HandlerInterceptor 를 활용해서 다시 작성해보자!
public class JwtInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
HandlerMethod handlerMethod = (HandlerMethod) handler;
Method method = handlerMethod.getMethod();
if (method.isAnnotationPresent(JwtRequired.class) || handlerMethod.getBeanType().isAnnotationPresent(JwtRequired.class)) {
String token = request.getHeader("Authorization");
// JWT 토큰 검증 로직을 수행하고, 토큰이 유효하지 않다면 예외를 발생시킵니다.
if (token == null){
return false;
}
}
return true;
}
}
이제 위 Interceptor로 인해서 annotation을 검증하여 token 값을 처리할 수 있다. 실제 토큰을 발행하고 복호화하여 검증 로직을 추가하기만 하면 된다.
정리
코드를 다시 비교해보자. 훨씬 깔끔해졌다.
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
throws ServletException, IOException {
String path = request.getRequestURI();
// No Filtering URL
if (path.startsWith("/name/check")) {
filterChain.doFilter(request, response);
return;
}
if (path.startsWith("/adm/")){
filterChain.doFilter(request, response);
return;
}
.
.
.
리팩토링 이전
public class JwtInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
HandlerMethod handlerMethod = (HandlerMethod) handler;
Method method = handlerMethod.getMethod();
if (method.isAnnotationPresent(JwtRequired.class) || handlerMethod.getBeanType().isAnnotationPresent(JwtRequired.class)) {
String token = request.getHeader("Authorization");
// JWT 토큰 검증 로직을 수행하고, 토큰이 유효하지 않다면 예외를 발생시킵니다.
if (token == null){
return false;
}
}
return true;
}
}
리팩토링 이후
이제는 라우터에 단순히 아래와 같이 어노테이션을 추가하면 JWT 검증 로직을 구현할 수 있다.
@JwtRequired
@GetMapping("/user")
public ResponseEntity<?> getUser() {
// 로직 생략
return ResponseEntity.ok().body("");
}
아까 말한 코드 직관정 저하의 문제도 해결되었다.
파이썬만 하다가 자바 쓰면 복잡하긴 하지만, 명시적이라는 특징이 마음에 든다. 명료하다.
그럼 20000!