본문 바로가기
Develop/Spring

Spring Boot에서 Access Token으로 인증 구현하기: Filter, SecurityContext와 @AuthenticationPrincipal활용

by Hoya324 2024. 10. 6.

들어가기 전

@AuthenticationPrincipal을 통해 UserDetails를 받아 실제 DB에 저장된 User를 받아오는 방식을 @LoginUser라는 어노테이션을 통해 쉽게 접근한 경험이 있었습니다
access Token을 Filter를 SecurityContext에 저장하고 저장된 유저를 쉽게 얻어오는 방식을 정리해보고자 합니다.

 

해당 작업을 포함한 프로젝트 주소입니다.

https://github.com/Findy-org/FINDY_BE

 

GitHub - Findy-org/FINDY_BE

Contribute to Findy-org/FINDY_BE development by creating an account on GitHub.

github.com

 

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 처리

TokenAuthenticationFilterOncePerRequestFilter를 상속 받은 Filter입니다. 이는 JWT 를 통해 토큰을 검증하고, 사용자 정보를 SecurityContext에 저장하는 역할을 합니다.

 

TokenAuthenticationFilter에서는 doFilterInternal메서드를 구현하게 됩니다.

OncePerRequestFilter
TokenAuthenticationFilter

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를 구현하여 다음과 같은 흐름으로 작동합니다:

  1. SecurityContextHolder에서 Authentication 객체 가져오기
    현재 보안 컨텍스트에서 Authentication 객체를 가져옵니다.
  2. Principal 객체를 추출
    Authentication.getPrincipal()을 통해 인증된 사용자의 principal을 가져옵니다.
  3. Annotation 처리
    만약 어노테이션에 특정 속성이 설정되어 있으면 해당 속성에 따라 처리가 됩니다.
  4. 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에서 사용자 정보 불러오기

@AuthenticationPrincipalexpression 속성을 통해 사용자 정보를 추출합니다.

@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