들어가기 전
@AuthenticationPrincipal을 통해 UserDetails를 받아 실제 DB에 저장된 User를 받아오는 방식을 @LoginUser라는 어노테이션을 통해 쉽게 접근한 경험이 있었습니다
access Token을 Filter를 SecurityContext에 저장하고 저장된 유저를 쉽게 얻어오는 방식을 정리해보고자 합니다.
해당 작업을 포함한 프로젝트 주소입니다.
https://github.com/Findy-org/FINDY_BE
Spring Security에서 Access Token을 활용한 인증?
여러 방법이 있겠지만, 저는 이번 글에서 Spring Security에서 accessToken
을 이용해 사용자를 인증하는 전체 흐름을 Filter부터 Controller까지 정리해 보겠습니다.
또한, @AuthenticationPrincipal
어노테이션을 통해 Security Context에서 사용자 정보를 어떻게 불러오는지도 정리해보겠습니다.
Access Token 발급 및 클라이언트 요청
사용자 로그인과 Access Token 발급
클라이언트는 사용자가 로그인할 때 서버에서 발급받은 accessToken을 사용하여 인증을 요청합니다.
이 토큰은 사용자의 인증 정보를 담고 있으며, JWT 형식입니다.
GET /api/users
Authorization: Bearer {accessToken}
클라이언트는 이와 같이 Authorization
헤더에 토큰을 포함하여 서버에 요청을 보냅니다.
TokenAuthenticationFilter에서 Access Token 처리
TokenAuthenticationFilter은 OncePerRequestFilter를 상속 받은 Filter입니다. 이는 JWT 를 통해 토큰을 검증하고, 사용자 정보를 SecurityContext
에 저장하는 역할을 합니다.
TokenAuthenticationFilter에서는 doFilterInternal메서드를 구현하게 됩니다.
OncePerRequestFilter를 상속받는 이유
스프링에서 디스패처 서블릿은 모든 요청을 해당 컨트롤러로 전달합니다. 각 요청마다 서블릿 객체가 생성되고, 동일한 클라이언트의 요청이 들어오면 기존의 서블릿 객체를 재활용합니다.
하지만 만약 요청이 다른 서블릿으로 디스패치된다면, 필터 체인을 한 번 더 거치게 됩니다. 이로 인해 동일한 보안 필터가 여러 번 적용되면서 인증 과정이 중복될 수 있습니다.
따라서, 중복 인증을 방지하고 효율성을 높이기 위해 OncePerRequestFilter를 상속받아 AuthenticationFilter를 구현했습니다.
Filter의 역할
클라이언트 요청이 서버에 도달하면, Spring Security의 TokenAuthenticationFilter
가 요청을 가로채서 토큰을 검증하는 과정을 거칩니다. 이 필터는 요청에 담긴 accessToken을 추출하고, 유효한지 확인한 후 사용자 정보를 SecurityContext
에 저장합니다.
String tokenStr = HeaderUtil.getAccessToken(request);
AuthToken token = tokenProvider.convertAuthToken(tokenStr);
if (token.validate()) {
Authentication authentication = tokenProvider.getAuthentication(token);
SecurityContextHolder.getContext().setAuthentication(authentication);
}
토큰 검증 및 SecurityContext 설정
- 요청에서
accessToken
을 추출한 후,token.validate()
를 통해 토큰의 유효성을 검증합니다. - 토큰이 유효하다면,
Authentication
객체를 생성하여SecurityContextHolder
에 설정합니다. - 이후 요청은 이 인증된 사용자 정보로 처리됩니다. (OncePerRequestFilter를 사용한 효과입니다.)
Controller에서 @AuthenticationPrincipal로 사용자 정보 접근
인증 후 로그인 객체를 가져오는 방법
SecurityContextHolder에서 직접 가져오는 방법
Object principal = SecurityContextHolder.getContext().getAuthentication().getPrincipal();
UserDetails userDetails = (UserDetails) principal;
String username = userDetails.getUsername();
String password = userDetails.getPassword();
가장 기본적인 방식은 SecurityContextHolder
를 통해 현재 인증된 사용자의 principal
객체를 직접 가져오는 것입니다. 이 방식은 명시적이지만, 여러 곳에서 중복된 코드가 발생할 수 있습니다.
Controller에서 Principal 객체를 가져오는 방법
@Controller
public class SecurityController {
@GetMapping("/username")
@ResponseBody
public String currentUserName(Principal principal) {
return principal.getName();
}
}
Principal
객체를 직접 컨트롤러에서 인자로 받아 사용자의 이름을 가져오는 방식입니다. 하지만 Principal
객체는 기본적인 사용자 정보만 제공하며, 세부적인 정보는 접근하기 어렵다는 단점이 있습니다.
@AuthenticationPrincipal 어노테이션을 사용하는 방법
@AuthenticationPrincipal의 동작 원리
이 어노테이션은 내부적으로 AuthenticationPrincipalArgumentResolver
라는 클래스에서 동작합니다. 이 클래스는 HandlerMethodArgumentResolver
를 구현하여 다음과 같은 흐름으로 작동합니다:
- SecurityContextHolder에서 Authentication 객체 가져오기
현재 보안 컨텍스트에서Authentication
객체를 가져옵니다. - Principal 객체를 추출
Authentication.getPrincipal()
을 통해 인증된 사용자의principal
을 가져옵니다. - Annotation 처리
만약 어노테이션에 특정 속성이 설정되어 있으면 해당 속성에 따라 처리가 됩니다. - Principal 반환
최종적으로principal
객체가 메서드 인자로 주입됩니다.
@AuthenticationPrincipal과 @LoginUser
컨트롤러에서 사용자 정보를 쉽게 사용할 수 있도록, Spring Security는 @AuthenticationPrincipal
어노테이션을 제공합니다.
저는 이를 확장한 @LoginUser
어노테이션을 사용하여 인증된 사용자 정보를 컨트롤러 메서드로 전달받았습니다.
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.PARAMETER)
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : username")
public @interface LoginUser {
}
사용 예시
@GetMapping
public String getUser(@LoginUser User user) {
return user.getProfileImageUrl();
}
@AuthenticationPrincipal(expression)로 Security Context에서 사용자 정보 불러오기
@AuthenticationPrincipal
은 expression
속성을 통해 사용자 정보를 추출합니다.
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : username")
이 표현식은 SecurityContext
에서 Authentication
객체를 확인하여, 만약 anonymousUser (로그인하지 않은 사용자)라면 null
을 반환하고, 그렇지 않다면 인증된 사용자의 username
을 반환합니다.
anonymousUser를 설정하는 이유는 로그인하지 않은 사용자의 요청이 문제가 될 가능성이 있기 때문입니다.
LoginUserArgumentResolver
@LoginUser
어노테이션은 내부적으로 LoginUserArgumentResolver
에 의해 동작하도록 설계했습니다.
이 Resolver는 Filter에서 저장했던 인증된 사용자 정보를 SecurityContextHolder
에서 추출하고, UserService를 통해 데이터베이스에서 실제 사용자 정보를 조회하여 컨트롤러 메서드의 파라미터로 전달합니다.
@AuthenticationPrincipal의 동작 방식을 차용하여 AuthenticationPrincipalArgumentResolver이 HandlerMethodArgumentResolver를 상속받은 것처럼 LoginUserArgumentResolver 역시 HandlerMethodArgumentResolver를 상속 어노테이션의 결과값을 UserService에 의존성을 더해 처리했습니다.
public Object resolveArgument(MethodParameter parameter,
ModelAndViewContainer mavContainer,
NativeWebRequest webRequest,
WebDataBinderFactory binderFactory) throws Exception {
UserDetails principal = (UserDetails) SecurityContextHolder.getContext().getAuthentication().getPrincipal();
return userService.findUser(principal.getUsername());
}
Reference
'Develop > Spring' 카테고리의 다른 글
saveAll() vs Batch Insert(with JPA, 쓰기 지연) 성능 비교: 영속성 context 관리와 트랜잭션 관리 관점에서 (5) | 2024.10.31 |
---|---|
Spring | 🔥 Spring 프레임워크의 주요 어노테이션 (1) | 2024.09.24 |
Spring | Spring MVC 패턴 - HTTP 요청을 받고 응답하기까지의 전 과정(2) (1) | 2024.09.05 |
Spring | Spring MVC 패턴 - MVC와 서블릿 (1) (0) | 2024.09.05 |
Spring | Spring AOP의 동작원리와 JDK Dynamic Proxy vs CGLIB Proxy 비교 및 Spring AOP와 AspectJ 비교 (0) | 2024.09.05 |