Spring Security에서, 현재 로그인한 엔티티를 바로 불러오자 ( + null )

HwangDo·2024년 2월 5일
0

SpringBoot

목록 보기
14/14
post-thumbnail
post-custom-banner

Spring Security를 쓸 때, 현재 로그인한 유저 엔티티가 필요한 상황은 많다.
당장 로그인 자체를 구현하지 않았다면, 해당 기능부터 구현해보자.

UserDetails principal = (UserDetails)SecurityContextHolder.getContext().getAuthentication().getPrincipal()
username = principal.getUsername()
User usr = repository.findByUsername(username);

엔티티를 로드하기 위해서는 항상 위 같은 코드를 쓸 수 있는데, 모든 권한이 필요한 작업을 할 때 마다 반복적으로 위 코드를 호출하는것은 꽤나 마음에 들지 않는다. 추가로, username을 기반으로 다시 DB에 쿼리를 날려서 객체를 가져오는 것 또한 불필요한 작업이다.

이를 위해 @AuthenticationPrincipal을 많이 사용하는데, 이부터 확인해보자.

1. @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보다 더 짧은 이름을 짓고싶었던 마음도 있었다.

2. Null Check도 하는 커스텀 어노테이션 선언

먼저, 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 검사 또한 할 필요가 없이, 깔끔하게 접근 할 수 있다.

profile
제가 배워가는 내용과, 실수한 부분을 정리합니다
post-custom-banner

0개의 댓글