@AuthenticationPrincipal를 위한 Custom HandlerMethodArgumentResolver

김동훈·2024년 2월 17일
2

reDuck

목록 보기
5/5

우리는 Spring Secutiry의 Authentication을 사용해서 request를 보낸 유저정보를 관리한다. (적어도 reduck은..)

그럼 인증되지 않은 사용자가(이하 비 로그인 유저) request를 보냈을 때, Authentication는 어떻게 될까??

이번 포스팅에서는 JWT 를 인증방식으로 사용하고 있는 reduck에서 겪은 트러블 슈팅과, Authentication Principal에 대해서 적어보려고 한다.


Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
Object principal = authentication.getPrincipal();

UserDetails userDetails = principal.getXXX();

우리는 위 코드처럼 Security Context에 관리되는 principal 이라는 오브젝트로 유저정보를 받아 올 수 있다. ( Authentication을 어떻게 사용하냐에 따라 조금은 다를 것이다.)

만약, 여러 api에서 request를 보낸 사용자 정보가 필요하다면? 못해도 위의 3줄짜리 코드가 계속 쌓인다.
그래서 보통 @AuthenticationPricipal 이라는 어노테이션을 사용해 간단하게 Controller에서 인증된 사용자 정보를 받을 수 있게한다.

@AuthenticationPrincipal 을 가볍게 확인해보자.
이번 트러블 슈팅에 있어 중요한 key point가 담겨 있으니,,

@AuthenticationPrincipal

클래스 주석을 보면, Authentication.getPrincipal() 메소드로 뭔가를 받아오는 것 같다. 그리고 AuthenticationPrincipalArgumentResolver 라는 리졸버를 보라고 한다. 그럼 이 resolver도 가볍게 확인해보자!!

public final class AuthenticationPrincipalArgumentResolver implements HandlerMethodArgumentResolver {

	// another code ...

	@Override
	public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer,
			NativeWebRequest webRequest, WebDataBinderFactory binderFactory) {
		Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
		if (authentication == null) {
			return null;
		}
		Object principal = authentication.getPrincipal();
		AuthenticationPrincipal annotation = findMethodAnnotation(AuthenticationPrincipal.class, parameter);
		String expressionToParse = annotation.expression();
		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;
	}
    
    // another code ...
}

지금은 3곳 정도만 보면된다.
1. HandlerMethodArgumentResolver의 구현체 라는 것.
2. Security Context에서 Authentication객체를 가져오고, Principal를 꺼내오는 것.
3. parameter의 클래스와 Pricipal의 클래스의 일치 여부.

사실 이정도만 알아도 오늘의 트러블 슈팅은 사실 끝났다고 봐도 된다!


다시 처음으로 돌아가서, 비 로그인 유저가 보낸 request경우에는 Authentication Principal은 어떻게 될까??

당연하게도 인증되지 않았으니, NULL 일 것이다.

문제는 서비스 로직에서 Authentication.getPrincipal().getUser() 와 같이 로직을 작성하게 되면 NPE가 터진다는 것이다! 하지만 비 로그인 유저가 request를 요청하더라도 그에 맞는 서비스 로직이 있을 것이고, 올바르게 시스템은 유지되어야한다.

reduck을 예로 들자면, 본인의 게시글을 조회할 경우에 조회수 카운팅을 막고 있다. 또, 비 로그인 유저가 팔로우 목록을 조회하려고 한다면 401 Unauthorized 예외처리를 하고 있다.

이 문제를 CustomHandlerMethodArgumentResolver로 해결해보려고 한다.

사실 우리가 해결하고자 하는 부분은 Authenticaion Principal이 Null인 것이다. 그러면 jwt filter단을 통해서도 가능하지 않을까??

JWT Filter의 책임

reduck에서는 UserDetails를 구현하는 CustomUserDetails라는 구현체를 통해 User 정보를 관리하고 있다.

public class CustomUserDetails implements UserDetails {
	
    private final User user;
    
    public final User getUser() {
        return user;
    }
	
    // another code ...
} 

그리고 jwt filter에서 UserDetailService를 이용해 Authentication Principal에 우리가 만든 CustomUserDetails 객체를 넣어 주고 있다.

if (jwt != null && jwtProvider.validateToken(jwt)) {
  UserDetails customUserDetails = userDetailsService.loadUserByUsername(this.getAccount(token));
  Authentication auth = UsernamePasswordAuthenticationToken(customUserDetails, "", customUserDetails.getAuthorities());
  SecurityContextHolder.getContext().setAuthentication(auth);
}

if ( jwt == null ) {
	  SecurityContextHolder.getContext().setAuthentication({emptyAuthentication});
}

그러면 이 순간에 jwt가 null 인경우 Security Context에 빈 껍데기 UserDetails 를 넣어주면 된다고 볼 수도 있지만, JWT Filter의 책임이 아니라고 생각한다.

jwt filter는 위 코드에서도 볼 수 있듯이, jwt의 유효성을 검증하는데에 있다고 생각한다. 따라서 filter에서 사용자의 인증 여부에 따라 context에 Authentication을 달리 넣는 로직은 범위를 벗어났다고 생각한다

CustomHandlerMethodArgumentResolver

CustomResolver의 구현은 생각보다 간단하다. 그냥 ctrl+c, ctrl+v 이다. 물론 동작과정을 이해가 우선이다.

public class CustomAuthenticationPrincipalArgumentResolver implements HandlerMethodArgumentResolver {

	// another code ...
    
    /**
     * access token이 존재하지 않는 요청이라면, empty CustomUserDetails 객체를 반환한다.
     */
    @Override
    public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer, NativeWebRequest webRequest, WebDataBinderFactory binderFactory) {
		
        // 기존 resolver 와 동일.
        
        if (principal != null && !ClassUtils.isAssignable(parameter.getParameterType(), principal.getClass())) {
            if (annotation.errorOnInvalidType()) {
                throw new ClassCastException(principal + " is not assignable to " + parameter.getParameterType());
            }
            return new CustomUserDetails(null); // empty principal
        }
        return principal;
    }
    
    // another code ...
    
}

이제 이 resolver를 bean으로 등록해주고 사용하면 된다.
실제 Controller에서 @AuthenticationPrincipal를 사용하면 null이 아닌 빈 깡통 Principal을 받을 수 있게된다.

추가로 모두가 디버깅해보면 알겠지만, 내가 만든 @AuthenticaionPrincipal의 resovle과정에 대해 조금 써보겠다.


우선 HandlerMethodArgumentResolver의 구현체인 HandlerMethodArgumentResolverComposite 부터 시작하면 좋을 것 같다.

	@Nullable
	private HandlerMethodArgumentResolver getArgumentResolver(MethodParameter parameter) {
		HandlerMethodArgumentResolver result = this.argumentResolverCache.get(parameter);
		if (result == null) {
			for (HandlerMethodArgumentResolver resolver : this.argumentResolvers) {
				if (resolver.supportsParameter(parameter)) {
					result = resolver;
					this.argumentResolverCache.put(parameter, result);
					break;
				}
			}
		}
		return result;
	}

간단하다. 현재 등록되어있는 모든 resovler중에서 parameter를 지원하는 resolver를 찾아 cache에 넣고 사용하는 것이다. 따라서 우리가 만들었던 Custom Resolver에는 supportsParameter를 구현해야 했고, @AuthenticationPrincipal 어노테이션이 붙어있는 parameter인지 확인해주는 로직이 있으면 된다!

    @Override
    public boolean supportsParameter(MethodParameter parameter) {
        return findMethodAnnotation(AuthenticationPrincipal.class, parameter) != null;
    }

아까 만들었던 우리의 resovler에서 Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
Authentication 객체가 AnonymousAuthenticationToken 이라는 것을 알게된다.


갑자기 AnonymousAuthenticationToken?? 분명 난 이런 로직을 작성한 적이 없으니, 이미 구현되어있는 것 일테다. 그럼 어디서 context에 익명token을 넣고 있는지 찾아가자.


SecurityFilter는 SecurityContextPersistenceFilter 을 먼저 타고간다.(이전에 등록된 filter가 없다면) doFilter()에서 먼저 빈 security context를 생성한다.

그 다음 chain의 doFilter는 reduck경우 jwt filter지만, jwt가 존재하지 않으므로 아무런 일을 하지 않고 다음 chain으로 넘어간다. jwt가 없으니, AnonymousAuthenticationFilter로 타고간다.
이 filter가 바로 AnonymousAuthenticationToken을 넣어주는 역할을 하게 된다.

이제 우리는 AnonymousAuthenticationToken 가 갑자기 어디서 들어갔는지 알게되었다.


Security를 쫓아가다보니, 더 나은 방법이 생각났다. CustomResolver가 아닌 CustomAnonymousAuthenticationFilter이다. 그 이유는, Security의 AnonymousAuthenticationFilter 보면, 실제로 authentication의 null 여부에 따라 Security Context에 Authentication을 넣어주기 때문에 이 로직을 커스텀하여 Empty Authentication을 넣어주어도 상관없을 것 같다..

profile
董訓은 영어로 mentor

0개의 댓글