@AuthenticationPrincipal 사용 시, principal이 null로 들어오는 문제

현주·2024년 3월 25일
0

Trouble Shooting

목록 보기
29/32

🔥 문제

프로젝트를 진행하면서 회원가입 후 유저의 닉네임과, MBTI를 추가로 받아 회원 정보를 수정하는 로직을 만드는 중이었다.

Controller를 통해 Access Token으로 유저의 정보를 가져와야 했어서

아래처럼 @AuthenticationPrincipal으로 유저의 정보를 가져오려고 했었다.

그런데 계속 저 principal이 null로 들어와 유저의 정보를 가져올 수 없다는 에러가 발생했다 !

📌 Srping Security와 JWT에 대한 자세한 개념들과 구현 방식은 아래 포스팅을 참고해주세요.


☘️ 원인 & 해결

에러를 정확히 트레킹하기 위해 먼저 @AuthenticationPrincipal 애너테이션의 동작 방식을 살펴보자.

이 애너테이션은 직접 구현한 CustomUserDetailService 클래스의 loadUserByUsername 메서드에서 반환해준 값을 파라미터로 직접 받아, 인증된 유저의 정보를 사용할 수 있게 해주는 애너테이션이다.

@Service
public class CustomUserDetailService implements UserDetailsService {
   private final JpaUserRepository userRepository;
⠀ ⠀
   public CustomUserDetailService(JpaUserRepository userRepository) {
      this.userRepository = userRepository;
   }
⠀ ⠀
   @Override
   public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
      User user = userRepository.findByEmail(username)
         .orElseThrow(() -> new CustomLogicException(ExceptionCode.USER_NONE));
      return UserPrincipal.create(user);
   }
}

loadUserByUsername 메서드에서 반환해주는 UserPrincipal 객체를 살펴보면,

@Getter
@Setter
@Slf4j
public class UserPrincipal extends User implements UserDetails, OAuth2User {
   private Map<String, Object> attributes;
⠀ ⠀
   public UserPrincipal(User user) {
      setEmail(user.getEmail());
      setPassword(user.getPassword());
      setRoles(user.getRoles());
      setProviderType(user.getProviderType());
   }
⠀ ⠀
   public static UserPrincipal create(User user) {
      return new UserPrincipal(user);
   }
⠀ ⠀
   public static UserPrincipal create(User user, Map<String, Object> attribues) {
      UserPrincipal userPrincipal = create(user);
      userPrincipal.setAttributes(attribues);
⠀ ⠀
      return userPrincipal;
   }
⠀ ⠀
   @Override
   public Collection<? extends GrantedAuthority> getAuthorities() {
      return AuthoritiesUtils.getAuthoritiesByEntity(getRoles());
   }
	...
}

UserDetails와 Oauth2로 인증하여 가져오는 Oauth2 유저를 동시에 관리하기 위해 둘을 implement하고 있고,

문제가 발생하는 @AuthenticationPrincipal 애너테이션을 살펴보면 아래와 같다.

이 애너테이션을 사용하게 되면, 저 AuthenticationPrincipalArgumentResolver가 이 애너테이션이 붙은 파라미터에 값을 주입해주는 것이다.

AuthenticationPrincipalArgumentResolver를 살펴보면 아래와 같다.

위 동작 방식은

1. supportParameter 메서드를 통해 @AuthenticationPrincipal이라는 애너테이션이이 있는지 체크

2. supportParameter의 값이 true라면(@AuthenticationPrincipal가 존재한다면),
resolveArgument에서 파라미터에 값을 주입해줌

이렇게 진행되는데

resolveArgument에서는 SecurityContextHolder.getContext().getAuthentication()의 값을 가져와 파라미터에 주입해주게 된다.

그러나 내가 구현한 CustomUserDetailsServiceloadUserByUsername 메서드의 반환값과는 다른 값을 제공주는 걸 알 수 있다..!


그리고 또 다른 문제를 발견했는데,

아래는 내가 구현한 JWT 인증 토큰의 검증을 담당하는 필터 JwtVerificationFilter 클래스다.

public class JwtVerificationFilter extends OncePerRequestFilter {
	private final AuthTokenProvider authTokenProvider;
⠀ ⠀
	public JwtVerificationFilter(AuthTokenProvider authTokenProvider) {
		this.authTokenProvider = authTokenProvider;
	}
⠀ ⠀
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
   FilterChain filterChain) throws ServletException, IOException {
⠀ ⠀⠀ ⠀
   filterChain.doFilter(request, response);
}

그런데 이 로직에서 SecurityContextHolder에 유저의 principal을 저장해주는 로직을 구현했어야 했는데

그 부분을 빠뜨려서 SecurityContextHolder에서 애초에 유저의 정보를 읽어올 수가 없었다....... ㅎ

public class JwtVerificationFilter extends OncePerRequestFilter {
	private final AuthTokenProvider authTokenProvider;
⠀ ⠀
	public JwtVerificationFilter(AuthTokenProvider authTokenProvider) {
		this.authTokenProvider = authTokenProvider;
	}
⠀ ⠀
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
   FilterChain filterChain) throws ServletException, IOException {
⠀ ⠀
   String tokenStr = HeaderUtils.getAccessToken(request);
   AuthToken token = authTokenProvider.convertAuthToken(tokenStr);
⠀ ⠀
   if (token.isTokenValid()) {
      Authentication authentication = authTokenProvider.getAuthentication(token);
⠀ ⠀
      SecurityContextHolder.getContext().setAuthentication(authentication);
⠀ ⠀
   }
⠀ ⠀⠀ ⠀
   filterChain.doFilter(request, response);
}

그래서 위와 같이 contextHolder에 유저 정보를 넣어주는 로직을 구현하고 다시 한번 테스트 해보았다.

그러나 여전히 principal 객체는 null을 반환했다..!

그래서 위 로직에서 사용되는 tokenProvider의 getAuthentication 메서드를 통해 어떤 객체가 생성되는지를 확인해보았다.

public Authentication getAuthentication(AuthToken authToken) {
   if (authToken.isTokenValid()) {
      Claims claims = authToken.getValidTokenClaims();
      Collection<? extends GrantedAuthority> authorities = getAuthorities((List)claims.get(AUTHORITIES_KEY));
⠀ 
      log.debug("claims subject := [{}]", claims.getSubject());//스프링 시큐리티 내부 인증용으로 사용하는 principal 객체
      User principal = new User(claims.getSubject(), "", authorities);return new UsernamePasswordAuthenticationToken(principal, authToken, authorities);
   } else {
      throw new CustomLogicException(ExceptionCode.USER_NONE);
   }
}

위의 principal 객체를 보면,

인증 객체를 저장 하는 과정에서 DB에 접근해 User 정보를 가져오는 대신, 토큰에서 추출한 정보만으로 인증 객체를 만들었다.

여기서 우리는 UsernamePasswordAuthenticationToken에 인자로 들어가는 principal은 loadUserByUsername의 반환 타입인 UserPrincipal과 내부 데이터도, 타입 자체도 다르다는 것을 알 수 있다 !

그래서 getAuthentication 메서드에서 UsernamePasswordAuthenticationToken에 loadByUsername메서드를 이용해서 만든 userDeatils가 들어갈 수 있도록

아래와 같이 코드를 수정해주었다.

public Authentication getAuthentication(AuthToken authToken) {
   if (authToken.isTokenValid()) {
      Claims claims = authToken.getValidTokenClaims();
      log.debug("claims subject := [{}]", claims.getSubject());
⠀ ⠀
      UserDetails userDetails = customUserDetailService.loadUserByUsername(
         authToken.getValidTokenClaims().getSubject());
⠀ ⠀
      return new UsernamePasswordAuthenticationToken(userDetails, authToken, userDetails.getAuthorities());
   } else {
      throw new CustomLogicException(ExceptionCode.USER_NONE);
   }
}

이제 SecurityContextHolder 내부에는 CustomUserDetailsService의 loadUserByUsername 메서드가 반환한 UserPrincipal 객체가 저장된다.

여기까지 수정해주고 테스트해보았는데,

드디어 유저의 principal 정보를 잘 가져와 잘 실행되었다 !!!

💡 참고한 블로그
https://devjem.tistory.com/70

0개의 댓글