이번 트러블슈팅은 '해결'보다는 '왜 발생했는가?'에 집중했다.
더 큰 문제를 예방하기 위해서!
스프링 스터디 토이프로젝트 과제 중 게시글 작성자가 게시글을 수정하는 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
에서 jwtAuthenticationFilter
를 UsernamePasswordAuthenticationFilter
앞에 등록한다.
@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과 스프링 시큐리티를 함께 사용할 수 있다.
equals()
를 오버라이딩하기두 영속 상태의 객체를 비교할 때 id로 비교하므로, 단순히 equals()
에서 id를 비교해주면 된다.
@Override
public boolean equals(Object member) {
if(((Member)member).getId().equals(id)) {
return true;
}
return false;
}
@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 구현 방식을 알게 되었고, 스프링 코드를 뜯어 보고 유저 인증 플로우를 쫓다 보니 스프링 시큐리티의 동작 과정을 세세히 이해할 수 있었다. 문제 상황과 해결 방식은 간단했지만 왜 이런 문제가 발생했는지 계속 찾으니 깊은 공부를 할 수 있었다. 재밌었다!