Spring Security를 쓸 때, 현재 로그인한 유저 엔티티가 필요한 상황은 많다.
당장 로그인 자체를 구현하지 않았다면, 해당 기능부터 구현해보자.
UserDetails principal = (UserDetails)SecurityContextHolder.getContext().getAuthentication().getPrincipal()
username = principal.getUsername()
User usr = repository.findByUsername(username);
엔티티를 로드하기 위해서는 항상 위 같은 코드를 쓸 수 있는데, 모든 권한이 필요한 작업을 할 때 마다 반복적으로 위 코드를 호출하는것은 꽤나 마음에 들지 않는다. 추가로, username을 기반으로 다시 DB에 쿼리를 날려서 객체를 가져오는 것 또한 불필요한 작업이다.
이를 위해 @AuthenticationPrincipal을 많이 사용하는데, 이부터 확인해보자.
먼저, Security의 흐름은 위와 같다. 여기서 UserDetailsService에서 사용자의 username을 기반으로 객체를 로드하고, 해당 객체와 password를 비교하는 과정을 거친다.
UserDetailsService는 Interface인데, 해당 작업을 위한 메서드는 loadUserByUsername이 존재함을 알 수 있다.
우리는 이 UserDetailsService를 Implement하는 커스텀 클래스를 구현할 것이다.
@Service
@RequiredArgsConstructor
@Slf4j
public class UserDetailsServiceImpl implements UserDetailsService {
private final UserRepository repository;
@Override
public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException {
Optional<User> user = repository.findByEmail(email);
return user.map(SecurityUser::new).orElseThrow(() -> new UsernameNotFoundException(email));
}
}
나는 username 대신 email을 사용했기에, repository에 email을 기반으로 쿼리를 날린다.
Interface를 구현하는 클래스를 만들고, return을 보면 SecurityUser라는 클래스로 매핑한다. 이 또한 내가 구현한, 위에 있는 UserDetails의 구현체다. 해당 코드를 보자.
public class SecurityUser extends User {
private com.tripbros.server.user.domain.User user;
public SecurityUser(com.tripbros.server.user.domain.User user) {
super(user.getEmail(), user.getPassword(), AuthorityUtils.createAuthorityList(user.getRole().toString()));
this.user = user;
}
}
UserDetails의 구현체인, User를 상속한다. 내가 유저의 엔티티 이름을 user로 지었고, Security에서도 user라는 이름이 사용되어 패키지 경로까지 작성 된 점은 양해 바란다.
보면 SecuriyUser는 필드로 내가 만든 User 객체를 필드로 가진다. 이후 생성자에서 this.user = user;로 이를 할당해준다.
이후 Security의 config 파일로 가서, 내가 방금 만든 UserDetailsService를 사용함을 선언하자.
private final UserDetailsServiceImpl userDetailsService; //의존성 주입받음.
...(생략
.userDetailsService(userDetailsService);
이를 거쳐서, 우리는 UserDetails를 재정의 했다.
이제 컨트롤러에서 @AuthenticationPrincipal을 사용하면 되는데...
이 어노테이션은 어떻게 작동할까?
해당 어노테이션을 구현?한 클래스인 AuthenticationPrincipalArgumentResolver 를 보자.
@Override
public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer,
NativeWebRequest webRequest, WebDataBinderFactory binderFactory) {
Authentication authentication = this.securityContextHolderStrategy.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;
}
중간에 parse 과정등은 넘어가면, SecurityContextHolder.getContext().getAuthentication().getPrincipal()의 과정을 대신 처리해주고 return해줌을 알 수 있다.
따라서 ,우리는 컨트롤러에서 @AuthenticationPrincipal 뒤에 Userdetails 타입 객체를 주입받을 수 있다.
@GetMapping
public String test(@AuthenticationPrincipal SecurityUser user) {
if (user == null) throw new SomeException();
return user.getUser().getNickname();
}
이런식으로 접근하면, DB에 추가 쿼리 없이 유저 객체를 바로 접근 할 수 있고, 꺼내오는 과정도 편하다.
그런데, 만약 로그인하지 않고 해당 API를 호출하면 어떻게 될까?
바로 user에 null이 담겨 날아온다. 그러면 NPE를 피하기 위해 항상 if (user != null) 코드를 추가해 줘야 하는데, 난 이 부분도 자동화 하고 싶었다. 추가로, @AuthenticationPrincipal보다 더 짧은 이름을 짓고싶었던 마음도 있었다.
먼저, Null check를 대행할 유틸 클래스를 하나 만들어주자.
public class SecurityUtils {
public static Object checkAuthenticationPrincipal(Object principal) {
if ("anonymousUser".equals(principal)) {
throw new UnauthorizedAccessException("인증에 실패하였습니다.");
}
return principal;
}
}
여기서 갑자기 String으로 "anonymousUser"는 어디서 튀어나온걸까?
만약 인증되지 않았을 경우, 스프링 시큐리티는 principal에 "anonymousUser"라는 String을 담아 보낸다.
@AuthenticationPrincipal에서, UserDetails 클래스는 당연히 저 String과 호환이 안되니까, null이 담겨 오는것이다.
그리고 저기서 던지는 Exception은 내가 지정한 Custom Exception이다.
이를 받아줄 @ControllerAdvice와, @ExcpetionHandler를 이용해 예외를 처리해주는 과정은 생략하겠다.
이제, 유틸 클래스를 만들었으면 어노테이션을 만들자.
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.PARAMETER)
@AuthenticationPrincipal(expression = "T(com.tripbros.server.security.SecurityUtils).checkAuthenticationPrincipal(#this)")
public @interface AuthUser {
}
RetentionPolicy.RUNTIME 은 런타임에도 참조가 가능함을 뜻하고
ElementType.PARAMETER는 사용처가 파라미터임을 뜻한다.
그리고, @AuthenticationPrincipal을 넣어 해당 어노테이션을 감싸는 래퍼 어노테이션처럼 사용 할 것이다.
그런데, expression은 무엇일까?
이는 SpeL 이라고 부르는, 스프링의 문법이다.
잘 정리된 글을 참고해보자. 런타임에 객체를 접근 및 조작 할 수 있는 문법이다.
여기서, 우리가 만든 유틸 클래스의 해당 메소드를 호출한다. 이를 통해, 널 검사가 이루어진다.
@GetMapping
public String test(@AuthUser SecurityUser user) {
return user.getUser().getNickname();
}
이제 우리는 Null 검사 또한 할 필요가 없이, 깔끔하게 접근 할 수 있다.