이전에 스프링 시큐리티를 활용해 프로젝트를 진행하면서 컨트롤러 메서드가 추가될 때마다 매번 엔드포인트와 해당 엔드포인트에 대한 권한 설정을 SecurityConfig에 추가적으로 진행해줘야 했던 부분이 불편하다 생각했다.

 

무엇보다 컨트롤러 메서드와 관련된 로직이 컨트롤러가 아니라SecurityConfig클래스에 동떨어져 있어 매번 메서드가 추가될 때마다 설정 클래스를 같이 수정해 주어야 했다.

...
    http.authorizeRequests()
        .antMatchers(HttpMethod.POST, "/api/v1/products").hasAnyRole("USER")
        .antMatchers(HttpMethod.POST, "/api/v1/bidding").hasAnyRole("USER")
        .antMatchers(HttpMethod.POST, "/api/v1/reports/users/{userId}").hasAnyRole("USER")
        .antMatchers(HttpMethod.POST, "/api/v1/reports/products/{productId}").hasAnyRole("USER")
        .antMatchers(HttpMethod.POST, "/api/v1/reports/comments/{commentId}").hasAnyRole("USER")
        .antMatchers(HttpMethod.POST, "/api/v1/comments").hasAnyRole("USER")
        .antMatchers(HttpMethod.GET, "/api/v1/products/{productId}/result").hasAnyRole("USER")
        .antMatchers(HttpMethod.GET, "/api/v1/biddings/products/{productId}").hasAnyRole("USER")
        .antMatchers(HttpMethod.GET, "/api/v1/chatRooms").hasAnyRole("USER")
        .antMatchers(HttpMethod.GET, "/api/v1/chatRooms/**").hasAnyRole("USER")
...

🤔 컨트롤러 메서드에 다음과 같이 간단하게 어노테이션을 붙여서 권한 관련 설정을 응집도 있게 구현할 수는 없을까?

@Slf4j
@RestController
@RequiredArgsConstructor
public class TestController {

  @Auth(role = USER)
  @GetMapping("posts")
  public String getPosts() {
    ...
  }

}

✅ 요구사항

  • 헤더로 전달되는 JWT 토큰을 통해 인증, 인가를 진행하고 있고, 토큰 내에 권한(Role)이 들어있는 상태다.
  • 컨트롤러의 메서드에 도달하기전 토큰에 들어있는 권한을 확인하고 어노테이션의 권한과 일치하지 않으면 예외를 발생시킨다.
  • 권한과 일치하는 경우 해당 요청의 컨텍스트에 토큰 내 유저 정보(userId, role)를 ThreadLocal에 저장한다.
  • 스프링 시큐리티는 사용하지 않는다.

💻 구현

우선 컨트롤러의 메서드에 요청이 도달하기 전에 헤더의 토큰을 확인하고 인가를 진행해야 한다. 즉 컨트롤러 메서드 앞단에서 인가와 관련된 부가기능을 부여해 주어야 하는데 이때 사용할 수 있는 대표적인 방법이 Filter, Interceptor, AOP가 있다.

  • Filter의 경우 디스패처 서블릿 앞단에서 수행되므로 매핑할 핸들러 메서드에 대한 정보를 얻을 수 없다.
  • AOP를 활용해 부가기능을 적용하기 위해서는 어디에 부가기능을 적용할지 결정하는 포인트 컷을 작성해 주어야 한다. 컨트롤러는 리턴 타입과 파라미터가 일정하지 않아 포인트 컷의 작성이 어렵다.
  • Interceptor의 경우 스프링 컨텍스트에서 관리하기 때문에 빈을 주입받을 수 있고, 엔드포인트와 메서드에 따른 적절한 핸들러를 매핑한 이후에 동작하기 때문에 핸들러에 대한 정보를 얻을 수 있다. 즉 어노테이션 정보를 얻을 수 있다.

위와 같은 이유로 Interceptor를 통해 인가 기능을 구현하였다. Interceptor를 구현하기 위해선 HandlerInterceptor를 구현해야 한다. HandlerInterceptor는 다음의 3가지 메서드를 제공한다.

  • preHandle(..): 핸들러 동작전 실행할 로직을 정의
  • postHandle(..): 핸들러 동작후 실행할 로직을 정의
  • afterCompletion(..): 요청이 끝난 후 실행할 로직을 정의

핸들러 동작전 요청에 대한 인가를 진행해야 하므로 preHandle() 메서드에 관련 로직을 구현하였다.

@Override
public boolean preHandle(
    HttpServletRequest request,
    HttpServletResponse response,
    Object handler
) {
  //1. 형변환 가능한지 확인
  if (!(handler instanceof HandlerMethod handlerMethod)) {
    return true;
  }

  //2. 권한이 필요한 메서드인지 확인(Auth어노테이션이 붙어있는지 확인)
  if (isNeedsAuthorization(handlerMethod)) {
    return true;
  }

  //3. 헤더에서 토큰 가져오기
  String token = extractTokenFromHeader(request);

  //4. 토큰 확인
  if (!authenticationTokenResolver.validateToken(token)) {
    throw new AuthenticationException();
  }

  //5. 인가 가능 여부 체크
  Authentication authentication = authenticationTokenResolver.getAuthentication(token);
  Role clientRole = authentication.role();
  Role handlerRole = getMethodRole(handlerMethod);

  if (!clientRole.hasAuthority(handlerRole)) {
    throw new AuthenticationException();
  }

  //6.
  AuthenticationContextHolder.setAuthentication(authentication);

  return true;
}
  1. HandlerMethod 인스턴스인지 확인한다
  2. 해당 메서드의 어노테이션에 @Auth가 있는지 확인한다.
  3. 헤더로부터 토큰을 가져온다.
  4. 토큰 위변조 및 만료 여부를 검증한다.
  5. 토큰의 권한이 핸들러의 권한보다 높은지 확인한다.
  6. 토큰을 통해 얻은 정보(Authentication)해당 요청 사이클에서 활용할 수 있도록 AuthenticationContextHolder에 넣는다.

handlerMethod의 getMethodAnnotation을 통해 어노테이션에 지정된 권한을 확인하였다.

private Role getMethodRole(HandlerMethod handlerMethod) {
  return Objects.requireNonNull(handlerMethod.getMethodAnnotation(Auth.class))
                .role();
}

AuthenticationContextHolder
SpringSecurityContextHolder를 참고하여 작성하였다.

public class AuthenticationContextHolder {

  private static final ThreadLocal<Authentication> authenticationHolder = new ThreadLocal<>();

  public static void clearContext() {
    authenticationHolder.remove();
  }

  public static Authentication getAuthentication() {
    Authentication authentication = authenticationHolder.get();
    if (authentication == null) {
      authentication = Authentication.createEmptyAuthentication();
      authenticationHolder.set(authentication);
    }
    return authentication;
  }

  public static void setAuthentication(Authentication authentication) {
    authenticationHolder.set(authentication);
  }

}

스프링 부트의 WAS(톰캣)은 각 요청마다 Thread Pool에서 Thread를 할당받아 사용하고 응답이 끝나면 Thread를 ThreadPool에 반환한다. 이때 ThreadLocal을 통해 저장한 권한 정보를 제거하지 않으면 다음번 요청에 해당 스레드를 재사용하는 경우 다른 사용자가 해당 스레드의 권한 정보를 얻게 되는 문제가 발생할 수 있다.

 

이러한 문제를 방지하기 위해서 응답이 종료되면 반드시 ThreadLocal.remove()를 통해 ThreadLocal의 값을 제거해 주어야 한다. 그래서 핸들러가 요청을 처리하고 return하면 ThreadLocal의 값을 제거하도록 인터셉터의 postHandler() 로직을 다음과 같이 구현하였다.

...
@Override
public void postHandle(
    HttpServletRequest request,
    HttpServletResponse response,
    Object handler,
    ModelAndView modelAndView
) {
  AuthenticationContextHolder.clearContext();
}
...

인터셉터 등록
WebMvcConfigure를 구현하여 작성한 인터셉터를 등록하였다.

@Configuration
@RequiredArgsConstructor
public class WebConfig implements WebMvcConfigurer {

  private final AuthenticationInterceptor authenticationInterceptor;

  @Override
  public void addInterceptors(InterceptorRegistry registry) {
    registry.addInterceptor(authenticationInterceptor);
  }

}

📸 동작 확인

테스트 핸들러를 작성하고 동작을 확인하였다.

@Slf4j
@RestController
@RequiredArgsConstructor
public class TestController {

  @Auth(role = USER)
  @GetMapping(“login”)
  public String resolveToken() {
    Authentication heldAuthentication = AuthenticationContextHolder.getAuthentication();
    log.info(“held authentication={}”, heldAuthentication);

    return “success”;
  }

}

로그

2022-11-30 10:02:19.231  INFO 21571 --- [nio-8081-exec-7] c.waterfogsw.board.user.TestController   : held authentication=Authentication[userId=1, role=USER]

image

🤦🏻 문제점

위와같이 인터셉터를 구현하고 컨트롤러 메서드를 구현하면서 다음과 같은 문제점들이 있음을 느꼈다.

  • @Auth가 메서드에 붙어있지 않으면 토큰을 Resolve 하여 AuthenticationContextHolder에 정보를 저장하는 인증 과정이 진행되지 않는다.
    • 토큰을 전달했음에도 불구하고 컨텍스트에서 유저 정보를 조회할 수 없는 상황이 발생할 수 있음.
  • Token Resolve를 하는 과정이 인터셉터에서 진행되어 Spring Web MVC에 종속적이다.
  • 해당 인터셉터 클래스의 책임이 2가지 이상이다.
    • 토큰을 Resolve하여 회원 정보를 컨텍스트에 저장하는 책임
    • 권한을 확인하여 메서드 실행 인가를 하는 책임

 

필터에서 인증과정을 진행하자


우선 위와 같은 문제를 해결하기 위해서 Spring Security를 참고했다. Spring Securtiy는 서블릿 필터를 기반으로 인증과정을 진행하기 때문에 Spring Web MVC와 같은 의존성에 종속적이지 않다는 장점이 있다.

 

@Auth어노테이션을 통해 인가를 진행하는 과정은 핸들러 메서드를 기반으로 하기 때문에 인터셉터로 구현할 필요성이 있지만, 인증을 진행하는 과정까지 인터셉터로 구현하여 Web MVC에 종속적으로 구현할 필요는 없다고 판단했다.

 

필터는 서블릿 컨텍스트에서 동작하기 때문에 빈을 주입받을 수 없다고 알고 있었지만, 간단하게 @Component어노테이션을 통해 컴포넌트 스캔의 대상으로 하여 빈으로 등록할 수 있다. 스프링 부트는 임베디드 서블릿 컨테이너를 사용하기 때문에 필터와 서블릿도 빈으로 등록하여 사용할 수 있다. (참고 : 8.1.3. Embedded Servlet Container Support)

 

따라서 다음과 같이 인증을 진행하는 로직을 필터로 분리하였다.

@Slf4j
@Component
@RequiredArgsConstructor
public class AuthenticationFilter extends OncePerRequestFilter {

  private static final String TOKEN_HEADER = "Authorization";
  private final AuthenticationTokenResolver tokenResolver;

  @Override
  protected void doFilterInternal(
      HttpServletRequest request,
      HttpServletResponse response,
      FilterChain filterChain
  ) throws ServletException, IOException {

    try {
      String token = extractTokenFromHeader(request);
      if (!tokenResolver.isTokenExpired(token)) {
        throw new JwtException("Invalid token exception");
      }
      Authentication authentication = tokenResolver.getAuthentication(token);
      AuthenticationContextHolder.setAuthentication(authentication);
    } catch (Exception e) {
      log.debug(e.getMessage());
    }

    doFilter(request, response, filterChain);
    AuthenticationContextHolder.clearContext();
  }
...

기존 인터셉터의 책임은 인가로 축소하였다.

@Component
@RequiredArgsConstructor
public class AuthenticationInterceptor implements HandlerInterceptor {

  @Override
  public boolean preHandle(
      HttpServletRequest request,
      HttpServletResponse response,
      Object handler
  ) {
    if (!(handler instanceof HandlerMethod handlerMethod)) {
      return true;
    }

    if (isNeedsAuthorization(handlerMethod)) {
      return true;
    }

    Authentication authentication = AuthenticationContextHolder.getAuthentication();
    Role clientRole = authentication.role();
    Role handlerRole = getMethodRole(handlerMethod);

    if (!clientRole.hasAuthority(handlerRole)) {
      throw new AuthenticationException();
    }

    AuthenticationContextHolder.setAuthentication(authentication);

    return true;
  }

🔠 코드

https://github.com/waterfogSW/Spring-Board/blob/main/board-api-server/src/main/java/com/waterfogsw/board/common/auth/AuthenticationInterceptor.java

Reference

+ Recent posts