회원가입 후 유저의 닉네임과, MBTI를 추가로 입력해주는 로직을 만들면서

Access Token으로 유저의 정보를 가져올 필요가 있었기 때문에 

Access Token을 헤더에 넣어준 후 다음과 같이 @Authentication으로 유저의 정보를 가져오려고 했었다.

 

	@Operation(summary = "엑세스 토큰을 이용해 유저 정보를 업데이트합니다.")
	@PatchMapping
	public ResponseEntity<Boolean> signup(@Valid @RequestBody UserUpdateDto updateDto,
		@AuthenticationPrincipal UserPrincipal principal) {
		return ResponseEntity.ok(userService.updateUser(updateDto, principal.getUsername()));
	}

 

 

 

그러나 계속 유저정보를 가져올 수 없다는 에러가 발생했다. :(

 

 

 

에러를 정확히 트레킹하기 위해 먼저 @Authentication 어노테이션의 동작 방식부터 파헤쳐보았다.

 

 

UserDetailsService를 구현한 CustomUserDetailsService의 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 어노테이션을 뜯어보면

 

이 어노테이션이 붙은 파라미터에 값을 주입해주는 ArgumentResolver의 이름이 AuthenticationPrincipalArgumentResolver라고 한다.

 

 

그럼 이 AuthenticationPrincipalArgumentResolver의 안으로 들어가보면,

 

이 어노테이션에서 중점적으로 봐야 할 곳은 두군데이다 .

1. supportParameter 메서드를 통해 @AuthenticationPrincipal이라는 어노테이션이 있는지 체크
2. 위의 supportParameter의 값이 true라면 resolveArgument에서 파라미터에 값을 주입
 
위의 그림에도 볼 수 있듯이, resolveArgument에서는 SecurityContextHolder.getContext().getAuthentication()의 값을 가져와서 파라미터에 주입해 주는 걸 알 수 있다.

즉 CustomUserDetailsService의 loadUserByUsername의 반환값과는 다른 값을 제공해 주는 것이다


 
이제 다시 SecurityContextHolder에 인증 정보를 넣어주는 로직을 찾아가보자. 

 

 

 

일단 첫번째 문제를 발견했다.

public class JwtVerificationFilter extends OncePerRequestFilter {
	private final AuthTokenProvider authTokenProvider;
	// TODO : Redis 추가?

	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에 authentication을 설정해주는 부분이 빠져있었기 때문에

SecurityContextHolder에서 애초에 유저의 정보를 읽어올 수가 없었다.......

 

 

해당부분을 추가해 준 후

이제 SecurityContextHolder에 잘 넣어줬으니 될까? 라고 생각했으나,, 애석하게도 여전히 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 메서드를 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 객체가 저장된다.

 

 

해당부분까지 수정해주고 나니 드디어 Access Token으로 principal정보를 잘 가져오는 것을 확인할 수 있었고

 

스웨거로 유저 정보를 변경하는 테스트도 잘 동작하였따 ㅠㅠㅠ

 

 

 

 

 

이 문제로 정말 며칠을 고생했는데,, 덕분에 유저 인증 로직을 좀 더 자세히 알 수 있었던 것 같다.

 

 

 

 

도움이 많이 되었던 자료

https://devjem.tistory.com/70

복사했습니다!