![[Spring-Security] @AuthenticationPrincipal 객체에 null이 저장되는 문제](https://img1.daumcdn.net/thumb/R750x0/?scode=mtistory2&fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbDihrv%2FbtsIAZStsRl%2FjOyy0vWTKcNHVXbZ9JH7Mk%2Fimg.png)
1. 오류 발생 흐름
1-1. 사용자가 로그인을 한다.
로그인이 완료된 사용자의 인증 정보가 SecurityContext에 저장된다.
1-2. 인증된 사용자가 HTTP 요청을 한다.
인증이 완료된 사용자는 페이지를 이동하면서 HTTP 요청을 보낸다. 게시물 작성 페이지로 이동하기 위해서는 인증이 완료된 사용자이어야 하며, 더불어 작성 완료하기 위해서 또한 인증이 완료된 사용자이어야 한다.
1-3. HTTP 요청 시에 인증된 사용자 정보를 가져올 수 없다.
사용자가 게시물 작성 버튼을 누르면 인증 정보를 가져올 수 없다는 에러가 발생한다.
아래 코드를 보면 createPost()의 매개변수에 @AuthenticationPrincipal 애너테이션을 사용하여 인증된 사용자 정보를 주입받고자 한다. 그러나 HTTP 요청이 들어오는 시점에 user 객체에 null이 저장된다.
@Controller
@RequestMapping("/posts")
class PostController(
private val postService: PostService
) {
@PostMapping
fun createPost(
@AuthenticationPrincipal user: User?,
@ModelAttribute request: CreatePostRequestDto,
): String {
postService.createPost(user, request)
return "redirect:/"
}
@GetMapping("/form")
fun postFormPage(): String {
return "/post-form"
}
}
SecurityContext에는 이미 인증된 사용자가 저장되어 있음에도 @AuthenticationPrincipal을 사용하여 인증 객체를 주입받지 못하고 있다.
2. @AuthenticationPrincipal의 흐름
기본적으로 @AuthenticationPrincipal을 사용하여 인증 객체를 주입받기 위해서는 아래 과정을 거친다.
- UserDetailsService를 구현한 CustomUserDetailsService가 호출된다.
- CustomUserDetailsService의 loadUserByUsername()을 호출한다.
- 호출 결과로 UserDetails를 반환하고, 이 객체를 @AuthenticationPrincipal를 사용하는 매개변수에 삽입한다.
@RequiredArgsConstructor
@Service
class CustomUserDetailsService(
private val userRepository: UserRepository
) : UserDetailsService {
override fun loadUserByUsername(username: String?): UserDetails {
val user =
userRepository.findUserByEmail(username) ?: fail("사용자를 조회할 수 없습니다. email = ${username}")
return CustomUserDetails(
user.id,
user.email,
user.password,
user.nickName,
mutableListOf(SimpleGrantedAuthority("ROLE_${user.role.name}"))
)
}
}
3. 근데 UserDetails 객체를 주입받지 못한다?
2번에서 설명했듯이, User 객체가 @AuthenticationPrincipal를 사용하여 인증된 객체를 주입 받고자 했으나 null이 저장된다. null이 저장된 것이 맞는지 의심이 된다면 위의 그림1을 보자.
@PostMapping
fun createPost(
@AuthenticationPrincipal user: User?,
@ModelAttribute request: CreatePostRequestDto,
): String {
val authenticationPrincipal = SecurityContextHolder.getContext().authentication
println(authenticationPrincipal)
postService.createPost(user, request)
return "redirect:/"
}
4. @AuthenticationPrincipal 구조 분석하기
이 애너테이션은 AuthenticationPrincipalArgumentResolver을 사용함을 알 수 있다.
createPost()의 매개변수로 @AuthenticationPrincipal user: User? 가 사용되기 때문에 1번은 true를 반환한다. 1번이 true를 반환하기 resolveArgument() 메서드가 호출된다.
resolveArgument() 메서드가 호출되면서 가장 먼저 SecurityContext에 담긴 모든 인증 객체를 가져온다. 따라서 autentication 객체에는 null이 저장되지 않기 때문에 if문을 거치지 않고 else문을 거친다.
그림4와 그림5를 보면 parameter와 principal의 클래스 타입이 다른 것을 알 수 있다.
- parameter 클래스 타입 : User
- princpal 클래스 타입 : CustomUserDetails
principal이 null이 아니면서 두 객체의 타입이 다르기 때문에 if문에 접근하게 된다. if문 내부에 한 번 더 if문이 존재하는데, 기본적으로 errorOnInvalidType()은 false를 반환하므로 else문에 접근하게 되고, else문은 null을 반환한다.
AuthenticationPrincipalArgumentResolver가 최종적으로 null을 반환하기 때문에 @AuthenticationPrincipal을 사용하는 User 객체에 null이 저장된다. ⇒ 인증된 사용자 정보를 불러올 수 없다.
@Controller
@RequestMapping("/posts")
class PostController(
private val postService: PostService
) {
@PostMapping
fun createPost(
@AuthenticationPrincipal user: User?,
@ModelAttribute request: CreatePostRequestDto,
): String {
postService.createPost(user, request)
return "redirect:/"
}
@GetMapping("/form")
fun postFormPage(): String {
return "/post-form"
}
}
5. 오류를 해결해보자.
5-1. 커스텀 Argument Resolver를 정의한다.
스프링 시큐리티는 기본적으로 @AuthenticationPrincipal 애너테이션의 경우 AuthenticationPrincipalArgumentResolver 를 사용하여 UserDetails 인터페이스를 구현한 객체를 주입한다. 현재 프로젝트의 경우 UserDetails가 아닌 CustomUserDetails를 구현하였으므로 커스텀된 Argument Resolver를 사용해야 한다.
- CustomUserDetailsService는 UserDetails를 구현한 CustomUserDetails 객체를 반환한다.
@RequiredArgsConstructor
@Service
class CustomUserDetailsService(
private val userRepository: UserRepository
) : UserDetailsService {
override fun loadUserByUsername(username: String?): UserDetails {
val user =
userRepository.findUserByEmail(username) ?: fail("사용자를 조회할 수 없습니다. email = ${username}")
return CustomUserDetails(
user.id,
user.email,
user.password,
user.nickName,
mutableListOf(SimpleGrantedAuthority("ROLE_${user.role.name}"))
)
}
}
- CustomUserDetails는 UserDetails를 오버라이딩한다.
class CustomUserDetails(
val id: Long?,
val email: String,
val pwd: String,
val nickName: String,
val roles: MutableCollection<GrantedAuthority>
) : UserDetails {
override fun getAuthorities(): MutableCollection<out GrantedAuthority> {
return roles
}
override fun getPassword(): String {
return pwd
}
override fun getUsername(): String {
return email
}
override fun isAccountNonExpired(): Boolean {
return true
}
override fun isAccountNonLocked(): Boolean {
return true
}
override fun isCredentialsNonExpired(): Boolean {
return true
}
override fun isEnabled(): Boolean {
return true
}
}
결론적으로 UserDetails를 구현한 CustomUserDetails의 타입과 @AuthenticationPrincipal의 타입이 다르기 때문에 null을 주입받게 된다. 이를 해결하기 위해서 HandlerMethodArgumentResolver를 구현하는 CustomArgumentResolver 클래스를 정의한다.
- supportsParameter() : @AuthenticationPrincipal을 사용하는 매개변수의 타입이 User 객체인지 확인한다.
- resolveArgument() : 기본적으로 Authentication객체의 principal에는 UserDetails를 구현한 CustomUserDetails가 저장되어 있다. @AuthenticationPrincipal를 사용하는 매개변수의 타입은 User이므로 CustomUserDetails에 저장된 사용자 정보를 기반으로 User 객체를 조회하여 반환한다.
@Component
class CustomArgumentResolver(
private val userRepository: UserRepository
) : HandlerMethodArgumentResolver {
override fun supportsParameter(parameter: MethodParameter): Boolean {
return parameter.parameterType == User::class.java
}
override fun resolveArgument(
parameter: MethodParameter,
mavContainer: ModelAndViewContainer?,
webRequest: NativeWebRequest,
binderFactory: WebDataBinderFactory?
): Any? {
val authentication = SecurityContextHolder.getContext().authentication
if (authentication != null && authentication.principal is CustomUserDetails) {
val userDetails = authentication.principal as CustomUserDetails
return userRepository.findUserByEmail(userDetails.email)
}
return null
}
}
5-2. CustomArgumentResolver를 WebConfig에 등록한다.
@Configuration
class WebConfig(
private val customArgumentResolver: CustomArgumentResolver
) : WebMvcConfigurer {
override fun addArgumentResolvers(resolvers: MutableList<HandlerMethodArgumentResolver>) {
resolvers.add(customArgumentResolver)
}
}
CustomArgumentResolver를 구현하고 이를 WebConfig에 등록하면 AuthenticationPrincipalArgumentResolver가 아닌 CustomArgumentResolver을 사용하여 @AuthenticationPrincipal을 사용하는 매개변수에 인증 객체가 저장된다.