Programming/Spring Boot

@AuthenticationPrincipal 동작 원리와 사용 예시

Jan92 2023. 11. 11. 14:59

Spring Security @AuthenticationPrincipal 동작 원리와 사용 예시

 

@AuthenticationPrincipal

해당 포스팅은 스프링 시큐리티 환경에서 인증 후 로그인 객체를 가져오는 방법 중 '@AuthenticationPrincipal 어노테이션을 사용하는 방법과 동작 원리, 사용 예시'를 중점으로 정리한 내용입니다.

 

(코드가 너무 긴 부분은 내용상 필요한 부분만 올려놓았으며, 전체가 궁금하신 경우 포스팅 맨 하단 github 소스코드를 참고하시면 좋을 것 같습니다.)


인증 후 로그인 객체를 가져오는 방법

 

1. SecurityContextHolder에서 직접 가져오는 방법

Object principal = SecurityContextHolder.getContext().getAuthentication().getPrincipal(); 
UserDetails userDetails = (UserDetails)principal; 
String username = principal.getUsername(); 
String password = principal.getPassword();

 

첫 번째는 가장 직접적인 방법으로 'SecurityContextHolder'에 직접 접근하여 로그인 객체를 가져오는 방법이 있습니다.

해당 코드를 static 메서드로 만들어서 사용할 수도 있지만, 로그인 객체가 필요한 요청마다 해당 메서드를 호출하는 코드가 반복적으로 사용된다는 비효율적인 부분이 발생합니다.

 

 

 

2. Controller에서 Principal 객체를 가져오는 방법

@Controller 
public class SecurityController { 

    @GetMapping("/username") 
    @ResponseBody 
    public String currentUserName(Principal principal) { 
        return principal.getName(); 
    } 
}

 

두 번째 방법으로는 위 코드와 같이 Controller 단에서 'Principal' 객체를 가져오는 방법이 있습니다.

하지만 Principal은 Spring Security에서 제공되는 객체가 아니라 Java에 정의되어 있는 객체이기 때문에 사용할만한 메서드가 예시에 있는 getName() 밖에 없다는 단점이 있습니다.

 

(Principal 객체를 가져오는 원리가 궁금하시다면 ServletRequestMethodArgumentResolver 클래스를 참고하시면 됩니다.)

 

 

 

3. @AuthenticationPrincipal 어노테이션을 사용하는 방법

 

위 내용처럼 SecurityContextHolder에서 로그인 객체를 직접 가져오는 방법과 Controller에서 Principal 객체를 가져오는 방법은 다소 비효율적이거나 기능적인 부분에서의 아쉬운 점이 존재하는데요.

 

Spring Security 3.2부터는 '@AuthenticationPrincipal' 어노테이션을 통해 Custom 로그인 객체를 가져올 수 있습니다.

해당 기능의 경우 어노테이션을 사용하기 때문에 반복되는 코드가 줄어들며, 커스텀 로그인 객체를 가져올 수 있기 때문에 기능적으로 활용도가 높아지게 되는데요.

@AuthenticationPrincipal에 대해서는 아래 동작 원리와 사용 예시를 통해 더 자세한 내용 살펴보도록 하겠습니다.

 

 


@AuthenticationPrincipal 동작 원리와 JWT 환경에서의 코드

 

1. @AuthenticationPrincipal 어노테이션

@AuthenticationPrincipal 어노테이션

먼저 @AuthenticationPrincipal 어노테이션은 'Annotation that is used to resolve Authentication.getPrincipal() to a method argument.'라는 설명처럼 Authentication.getPrincipal() 메서드의 인수를 사용하기 위한 어노테이션입니다.

 

해당 어노테이션의 동작 과정은 HandlerMethodArgumentResolver를 구현한 구현체인 'AuthenticationPrincipalArgumentResolver'에서 확인할 수 있는데요.

 

 

 

2. AuthenticationPrincipalArgumentResolver

AuthenticationPrincipalArgumentResolver resolveArgument() method

AuthenticationPrincipalArgumentResolver 클래스의 resolveArgument() 메서드의 큰 흐름을 살펴보면 다음과 같습니다.

 

1. SecurityContextHolder로부터 Authentication 객체를 가져오고, 해당 객체에서 Object principal 객체를 가져옵니다.

2. @AuthenticationPrincipal 어노테이션이 적용된 파라미터를 확인합니다.

3. 해당 부분은 AuthenticationPrincipal 어노테이션의 속성 값에 따른 처리 부분으로 default false인 상태에서는 적용되지 않습니다.

4. principal 인스턴스를 반환합니다.

 

결국 @AuthenticationPrincipal 어노테이션도 SecurityContextHolder에 저장된 인증 객체의 principal을 가져와서 사용하는 것인데요.

가져오는 principal의 타입이 Object라는 것을 살펴보며 UserDetailsService 구현체의 loadUserByUsername() 메서드 부분을 살펴보겠습니다.

 

 

 

3. CustomUserDetils && CustomUserDetailsService

@Getter
public class CustomUserDetails implements UserDetails {

    private Long idx;
    private String email;
    private String password;
    private List<String> roles;

    public CustomUserDetails(Long idx, String email, String password, List<String> roles) {
        this.idx = idx;
        this.email = email;
        this.password = password;
        this.roles = roles;
    }

    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        return this.getRoles().stream()
                .map(SimpleGrantedAuthority::new)
                .collect(Collectors.toList());
    }

    @Override
    public String getUsername() {
        return email;
    }

    //나머지 override method 코드 생략 (전부 return true;)
}

 

먼저 Custom 로그인 객체로 사용할 CustomUserDetails입니다.

아래 UserDetailsService 구현체의 loadUserByUsername() method의 반환 타입이 UserDetails 이기 때문에 해당 인터페이스를 상속받도록 만들었으며, 로그인 객체를 통해 사용할 데이터들을 선언하였습니다.

 

 

@Service
@RequiredArgsConstructor
public class CustomUserDetailsService implements UserDetailsService {

    private final UsersRepository usersRepository;

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        // 해당하는 User 의 데이터가 존재한다면 UserDetails 객체로 만들어서 리턴
        Users users = usersRepository.findByEmail(username)
                .orElseThrow(() -> new UsernameNotFoundException("해당하는 유저를 찾을 수 없습니다."));

        return new CustomUserDetails(users.getIdx(), users.getEmail(), users.getPassword(), users.getRoles());
    }
}

 

그리고 UserDetailsService 구현 클래스에서는 loadUserByUsername() 메서드를 통해 로그인 객체에서 필요한 user 정보를 담은 CustomUserDetails 클래스를 반환하도록 하였습니다.

 

 

 

4. login method 동작 부분

(해당 내용부터는 jwt를 사용하는 환경에서의 예시라는 점 참고 부탁드립니다.)

// 2. 실제 검증 (사용자 비밀번호 체크)이 이루어지는 부분
// authenticate 매서드가 실행될 때 CustomUserDetailsService 에서 만든 loadUserByUsername 메서드가 실행
Authentication authentication = authenticationManagerBuilder.getObject().authenticate(authenticationToken);

// 3. 인증 정보를 기반으로 JWT 토큰 생성
UserResponseDto.TokenInfo tokenInfo = jwtTokenProvider.generateToken(authentication);

(UserService login method 코드 중 일부)

 

이어서 Service 단의 login() method 부분을 살펴보면 위 예시 코드의 2번 과정에서 loadUserByUsername() method가 실행되고, 결국 2번 과정에서 반환되는 Authentication의 principal은 loadUserByUsername()에서 반환한 CustomUserDetails 객체가 됩니다.

 

 

 

5. JwtTokenProvider generateToken() && getAuthentication() method

    public UserResponseDto.TokenInfo generateToken(Authentication authentication) {
        //getPrincipal
        CustomUserDetails customUserDetails = (CustomUserDetails) authentication.getPrincipal();

        // 권한 가져오기
        String authorities = customUserDetails.getAuthorities().stream()
                .map(GrantedAuthority::getAuthority)
                .collect(Collectors.joining(","));

        long now = (new Date()).getTime();
        // Access Token 생성
        Date accessTokenExpiresIn = new Date(now + ACCESS_TOKEN_EXPIRE_TIME);
        String accessToken = Jwts.builder()
                .setSubject(customUserDetails.getIdx().toString())
                .claim(AUTHORITIES_KEY, authorities)
                .claim(EMAIL_KEY, customUserDetails.getEmail())
                .setExpiration(accessTokenExpiresIn)
                .signWith(key, SignatureAlgorithm.HS256)
                .compact();

        return UserResponseDto.TokenInfo.builder()
                .grantType(BEARER_TYPE)
                .accessToken(accessToken)
                .build();
    }

(JwtTokenProvider generateToken method)

 

먼저 토큰을 생성하는 generateToken() method입니다.

앞선 내용을 통해 이해할 수 있는 것처럼 authentication에서 가져온 principal은 CustomUserDetails 객체가 되는데요.

다음과 같이 CustomUserDetails 로그인 객체 정보를 통해 jwt를 생성합니다.

 

 

    public Authentication getAuthentication(String accessToken) {
        // 토큰 복호화
        Claims claims = parseClaims(accessToken);

        if (claims.get(AUTHORITIES_KEY) == null) {
            throw new RuntimeException("권한 정보가 없는 토큰입니다.");
        }

        // 클레임에서 권한 정보 가져오기
        List<SimpleGrantedAuthority> authorities =
                Arrays.stream(claims.get(AUTHORITIES_KEY).toString().split(","))
                .map(SimpleGrantedAuthority::new)
                .collect(Collectors.toList());
        List<String> roles = new ArrayList<>(Arrays.asList(claims.get(AUTHORITIES_KEY, String.class).split(",")));

        // CustomUserDetails 객체를 만들어서 Authentication 리턴
        CustomUserDetails principal = new CustomUserDetails(Long.valueOf(claims.getSubject()), claims.get(EMAIL_KEY, String.class), null, roles);

        return new UsernamePasswordAuthenticationToken(principal, "", authorities);
    }

(JwtTokenProvider getAuthentication method)

 

그리고 이후 해당 토큰으로 요청이 들어왔을 때, 토큰 유효성 검사 이후 해당 getAuthentication() 메서드를 통해 토큰에 들어있는 CustomUserDetails 정보를 다시 가져와 로그인에 사용될 CustomUserDetails 객체(= principal)를 만들고 이를 다시 UsernamePasswordAuthenticationToken 객체의 principal로 주입하여 반환합니다.

 

 

// 2. validateToken 으로 토큰 유효성 검사
if (token != null && jwtTokenProvider.validateToken(token)) {
    // 토큰이 유효할 경우 토큰에서 Authentication 객체를 가지고 와서 SecurityContext 에 저장
    Authentication authentication = jwtTokenProvider.getAuthentication(token);
    SecurityContextHolder.getContext().setAuthentication(authentication);
}

(JwtAuthenticationFilter doFilter method)

 

이렇게 반환된 Authentication(= UsernamePasswordAuthenticationToken)은 SecurityContextHolder에 저장되는 것이고, 이후 과정은 실제 인증 객체가 필요한 요청이 들어왔을 때 앞서 살펴본 @AuthenticationPrincipal의 동작 과정을 통해 SecurityContextHolder에서 principal을 가져오게 되는 것입니다.

 

 


문제가 될 수 있는 부분

위와 같은 과정을 통해 @AuthenticationPrincipal 어노테이션을 사용하여 Custom 로그인 객체를 사용할 수 있게 되는데요.

여기에서는 문제가 될 수 있는 부분이 하나 있습니다.

@AuthenticationPrincipal 어노테이션을 사용하여 로그인 객체를 가져온다는 것은 일반적으로 로그인(인증) 이후에 동작되는 부분이라고 생각할 수 있습니다.

 

하지만 만약 security 설정으로 인해 jwtFilter를 거치지 않는, 즉 인증이 필요하지 않은 요청에서 개발자의 실수로 @AuthenticationPrincipal 어노테이션으로 로그인 객체를 가지고 와서 사용하려는 경우 로그인 객체 자체가 null로 들어오기 때문에 해당 객체를 사용하려고 할 때 NullPointerException이 발생하게 되는데요.

 

그렇다고 해서 해당 어노테이션을 사용하는 모든 부분에서 Custom 로그인 객체에 대해 null 체크를 하는 것은 매우 비효율적인 방식입니다.

 

이러한 문제를 해결하기 위해 아래와 같이 @AuthenticationPrincipal과 동일한 Custom Annotation을 만들고 해당 어노테이션이 동작하는 CustomAuthenticationPrincipalArgumentResolver를 만들어 resolverArgument method 내부에서 principal이 null인 경우를 체크하여 예외 처리를 하는 방식으로 추가적인 작업을 할 수 있습니다.

 

 

@Target({ElementType.PARAMETER, ElementType.ANNOTATION_TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface AuthUser {

    boolean errorOnInvalidType() default true;
}

(@AuthUser Custom Annotation)

 

Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
if (authentication == null) {
    // Custom Exception 을 통한 예외 처리
}
Object principal = authentication.getPrincipal();
AuthUser annotation = findMethodAnnotation(AuthUser.class, parameter);
if (principal == "anonymousUser") {
    // Custom Exception 을 통한 예외 처리
}

(CustomAuthenticationPrincipalArgumentResolver 클래스의 resolveArgument 메서드 코드 중 일부)

 

어노테이션의 expression() 속성은 사용하지 않을 거라 구현하지 않았는데, 필요한 경우 기존의 코드와 동일하게 가져와서 사용하면 될 것 같습니다.

 

 

@Configuration
public class WebMvcConfig implements WebMvcConfigurer {

    @Override
    public void addArgumentResolvers(List<HandlerMethodArgumentResolver> argumentResolvers) {
        argumentResolvers.add(new CustomAuthenticationPrincipalArgumentResolver());
    }
}

 

마지막으로 WebMvcConfigurer 인터페이스를 상속하여 오버라이딩 한 addArgumentResolvers 메서드에 CustomResolver 클래스를 추가하면 정상적으로 작동하게 됩니다.

 

 

여기까지 @AuthenticationPrincipal 어노테이션의 동작 원리와 사용 예시 그리고 문제가 될 수 있는 부분까지 살펴봤는데요.

내용 중 잘못된 부분이나 궁금하신 부분은 댓글 남겨주시면 확인하겠습니다. 감사합니다.

 

 

 

< github 주소 >
https://github.com/JianChoi-Kor/AuthenticationPrincipal-Test


< 참고 자료 >
https://codevang.tistory.com/273
https://velog.io/@pjh612/AuthenticationPrincipal-%EC%BB%A4%EC%8A%A4%ED%85%80%ED%95%B4%EC%84%9C-%EC%82%AC%EC%9A%A9%ED%95%98%EA%B8%B0