[트러블슈팅] 스프링 시큐리티와 OSIV

종미(미아)·2023년 11월 27일
0

🌱 Spring

목록 보기
1/9

이번 트러블슈팅은 '해결'보다는 '왜 발생했는가?'에 집중했다.
더 큰 문제를 예방하기 위해서!

문제 인식

스프링 스터디 토이프로젝트 과제 중 게시글 작성자가 게시글을 수정하는 Patch API를 구현하고 있었다.
@AuthenticationPrincipal로 주입 받은 인증 객체(Member)와 게시글 작성자 객체가 같은지 확인하는 로직이 필요했다.

// Post.java
  public boolean hasPermission(final Member member) {
    if(writer.equals(member)) {
      return true;
    }
    return false;
  }

게시글(Post) 엔티티 클래스 내부에 위 메서드를 구현하고

// PostService.java
  public void update(final Long postId, final Member member, final PostUpdateRequest request) throws Exception {
    Post post = postRepository.findById(postId).orElseThrow(()->new NotFoundException("게시물을 찾을 수 없습니다."));
    if(!post.hasPermission(member)) {
      throw new AccessDeniedException("게시글을 삭제할 권한이 없습니다.");
    }
    post.update(request.getTitle(), request.getPrice(),
        request.getIsAuction(), request.getDescription(), request.getAddress());
  }
  
// PostController.java
  @PatchMapping("/post/{postId}")
  public ResponseEntity<Void> updatePost(@AuthenticationPrincipal final Member member,
      @PathVariable final Long postId, @RequestBody final PostUpdateRequest request) throws Exception {
    postService.update(postId, member, request);
    return ResponseEntity.status(HttpStatus.OK).build();
  }

이렇게 Patch 기능을 구현하였다.
그런데 테스트를 해보니 같은 유저임에도 삭제할 권한이 없다는 AccessDeniedException 가 발생했다.

원인

로그를 찍어 확인해보니 두 객체를 비교(equals())할 때 인스턴스 주소로 비교하고 있었다. 영속 상태의 두 엔티티 객체는 id로 비교해야 하는데 일까?

답은 @AuthenticationPrincipal으로 주입 받은 UserDetails 객체가 준영속 상태이기 때문이다.

하지만 스프링부트의 OSIV(Open Session In View)는 기본 설정 값이 enable이다. 즉, 세션을 Controller Layer에서도 사용할 수 있다. Controller에서도 영속성 컨텍스트가 생존한다.

정답은 OSIV가 구현되는 클래스에 있다. OpenEntityManagerInViewInterceptor를 인터셉터로 등록하여 OSIV를 구현한다.

...
@ConditionalOnProperty(prefix = "spring.jpa", name = "open-in-view", havingValue = "true", matchIfMissing = true) // default가 true
protected static class JpaWebConfiguration {
    ...

    @Bean
    public OpenEntityManagerInViewInterceptor openEntityManagerInViewInterceptor() {
        ...
        return new OpenEntityManagerInViewInterceptor();
    }

    @Bean
    public WebMvcConfigurer openEntityManagerInViewInterceptorConfigurer(OpenEntityManagerInViewInterceptor interceptor) {
        return new WebMvcConfigurer() {
            @Override
            public void addInterceptors(InterceptorRegistry registry) {
                registry.addWebRequestInterceptor(interceptor);
            }
        };
    }
}

스프링의 구조를 보면, 인터셉터 앞단에 필터가 있다. 스프링 시큐리티는 필터로 구현한다.

나는 JWT 토큰 인증 방식을 사용했기 때문에 JwtTokenProvider에서 아래와 같이 인증 객체를 얻는다.

    public Authentication getAuthentication(String token) {
        UserDetails userDetails = customUserDetailService.loadUserByUsername(this.getUserPk(token));
        return new UsernamePasswordAuthenticationToken(userDetails, "", userDetails.getAuthorities());
    }

또한 SecurityFilterChain에서 jwtAuthenticationFilterUsernamePasswordAuthenticationFilter 앞에 등록한다.

	@Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        ...

        http.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);

        return http.build();
    }

jwtAuthenticationFilter는 아래와 같이 구현했다. JwtTokenProvider에서 얻은 인증 객체를 SecurityContextHolder에 설정한다.

    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) {
        try {
            String token = jwtTokenProvider.resolveToken((HttpServletRequest)request);

            if (token != null && jwtTokenProvider.validateToken(token)) {
                Authentication authentication = jwtTokenProvider.getAuthentication(token);
                SecurityContextHolder.getContext().setAuthentication(authentication);
            }
            chain.doFilter(request, response);
        } catch (Exception e) {
            setErrorResponse(response, e.getMessage());
        }
    }

@AuthenticationPrincipal argument resolver이다. 이렇게 UserDetails를 가져온다.

public final class AuthenticationPrincipalArgumentResolver implements HandlerMethodArgumentResolver {
	...
	@Override
	public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer,
			NativeWebRequest webRequest, WebDataBinderFactory binderFactory) {
         // Authentication 얻기
		Authentication authentication = this.securityContextHolderStrategy.getContext().getAuthentication();
		if (authentication == null) {
			return null;
		}
        // UserDetails 얻기
		Object principal = authentication.getPrincipal();
        // UserDetails 구현한 클래스 이름 얻기
		AuthenticationPrincipal annotation = findMethodAnnotation(AuthenticationPrincipal.class, parameter);
		String expressionToParse = annotation.expression();
        // UserDetails 인스턴스 생성하기
		if (StringUtils.hasLength(expressionToParse)) {
			StandardEvaluationContext context = new StandardEvaluationContext();
			context.setRootObject(principal);
			context.setVariable("this", principal);
			context.setBeanResolver(this.beanResolver);
			Expression expression = this.parser.parseExpression(expressionToParse);
			principal = expression.getValue(context);
		}
		if (principal != null && !ClassUtils.isAssignable(parameter.getParameterType(), principal.getClass())) {
			if (annotation.errorOnInvalidType()) {
				throw new ClassCastException(principal + " is not assignable to " + parameter.getParameterType());
			}
			return null;
		}
		return principal;
	}
}

따라서 OSIV가 작동해도 준영속 상태의 객체가 주입되었다.

해결하기

단순히 두 유저가 같은지 비교하면 되므로 첫 번째 방법을 사용했지만, 두 번째 방법을 안다면 OSIV과 스프링 시큐리티를 함께 사용할 수 있다.

방법1. equals()를 오버라이딩하기

두 영속 상태의 객체를 비교할 때 id로 비교하므로, 단순히 equals()에서 id를 비교해주면 된다.

  @Override
  public boolean equals(Object member) {
    if(((Member)member).getId().equals(id)) {
      return true;
    }
    return false;
  }

방법2. OSIV 관련 인터셉터의 우선순위 변경하기

@Component
@Configuration
public class OpenEntityManagerConfig {
    @Bean
    public FilterRegistrationBean<OpenEntityManagerInViewFilter> openEntityManagerInViewFilter() {
        FilterRegistrationBean<OpenEntityManagerInViewFilter> filterFilterRegistrationBean = new FilterRegistrationBean<>();
        filterFilterRegistrationBean.setFilter(new OpenEntityManagerInViewFilter()); // (1)
        filterFilterRegistrationBean.setOrder(Integer.MIN_VALUE); // (2)  --  예시를 위해 최우선 순위로 Filter 등록
        return filterFilterRegistrationBean;
    }
}

출처: Entity Lifecycle을 고려해 코드를 작성하자 2편
(1) OpenEntityManager를 인터셉터가 아니라 필터로 동작하게 한다.
(2) 해당 필터의 우선순위를 스프링 시큐리티 필터보다 앞으로 설정한다.

소감

스프링부트로 프로젝트를 하면서 @AuthenticationPrincipal로 주입 받은 인증 객체는 영속 상태가 아니라는 것은 알고 있었다. 하지만 왜인지 몰랐기에 금방 잊었고 문제가 생기고 나서야 떠올랐다. 왜 영속 상태가 아닐지 찾아보다가 스프링의 OSIV 구현 방식을 알게 되었고, 스프링 코드를 뜯어 보고 유저 인증 플로우를 쫓다 보니 스프링 시큐리티의 동작 과정을 세세히 이해할 수 있었다. 문제 상황과 해결 방식은 간단했지만 왜 이런 문제가 발생했는지 계속 찾으니 깊은 공부를 할 수 있었다. 재밌었다!

profile
BE 개발자 지망생 🪐

0개의 댓글