프로젝트를 진행하면서 회원가입 후 유저의 닉네임과, 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()
의 값을 가져와 파라미터에 주입해주게 된다.
그러나 내가 구현한 CustomUserDetailsService
의 loadUserByUsername
메서드의 반환값과는 다른 값을 제공주는 걸 알 수 있다..!
⠀
그리고 또 다른 문제를 발견했는데,
아래는 내가 구현한 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