Java

Spring Security 기능 활용 #3 (@AuthenticationPrincipal, UserDetails)

멋진그이름 2024. 4. 3. 16:34

<개요>

- @AuthenticaionPrincipal 정보확인

- Spring Secuirty를 통한 인증구현에 대한 테스트 코드 작성방법

- @AuthenticaionPrincipal에서 Null 오류가 발생하는 경우

- UserDetials , User 타입확인

 

<내용>

1. Spring Security

- 일반적으로 우리는 Spring을 활용하면서 Security를 통해서 인증/권한에 대한 문제를 쉽게 해결할 수 있습니다.

public @ResponseBody
Page<DataDto> findDataAll (@AuthenticationPrincipal LoginUserDetails loginUserDetails,
                           Pageable pageable)  {

- @AuthenticationPrinciapl 을 활용하면 쉽게 인증정보를 가져올 수 있는데 해당 정보는 SecurityContext내에 세팅되어 있는 정보를 전달받는 것입니다. 아래와 같이 출력해보면 확인할 수 있습니다.

System.out.println(SecurityContextHolder.getContext().getAuthentication().getPrincipal());

- 그렇다면 Authentication 과 Principal은 언제 세팅이 되는 걸까요?

- 로그인을 어떻게 구현했느냐와 연관이 있습니다. (이전글 참조)

https://icthuman.tistory.com/m/entry/Spring-Security-%EA%B8%B0%EB%8A%A5-%ED%99%9C%EC%9A%A9-1-Filter-Chain

 

Spring Security 기능 활용 #1 (Filter Chain)

- Spring Security 의 경우 Filter Chain 의 형태로 기능을 제공하고 있으며 필요에 따라서 추가/수정/삭제가 가능하다. 1. Spring Security - Filter Chain - Spring 에서 모든 호출은 DispatcherServlet을 통과하게 되고

icthuman.tistory.com

- JWT Token을 이용할 경우 일반적으로 Filter에서 해당 토큰을 검증한뒤 필요한 정보를 찾아서 Authentication 인터페이스 를 전달하여 세팅하게 됩니다. 이 Authentication 을 SpringSecurity 에서 일반적인 형태로 구현해놓은 것이 UserNamePasswordAuthenticationToken 입니다. 

public Authentication getAuthentication(String token) throws TimeoutException, ExecutionException, InterruptedException {
    LoginUserDetails loginUserDetails = this.makeUserDetailsFromToken(token);
    return new UsernamePasswordAuthenticationToken(loginUserDetails, "", loginUserDetails.getAuthorities());
}

<Token정보로부터 사용자정보를 얻어낸 뒤 UsernamePasswordAuthenticationToken 생성>

if(jwtTokenProvider.validateToken(token)) {
    Authentication auth = token != null ? jwtTokenProvider.getAuthentication(token) : null;
    SecurityContextHolder.getContext().setAuthentication(auth);
    filterChain.doFilter(req, res);

<생성된 Authentication정보를 SecurityContext에 세팅> 

public class UsernamePasswordAuthenticationToken extends AbstractAuthenticationToken {
    private static final long serialVersionUID = 620L;
    private final Object principal;
    private Object credentials;

<Sring Security에 구현되어 있는 클래스>

 

2. Test Code 작성법

public @ResponseBody
Page<DataDto> findDataAll (@AuthenticationPrincipal LoginUserDetails loginUserDetails,
                           Pageable pageable)  {

- Spring 전체 Context를 기동한다면 가능하겠지만 우리는 일반적으로 Controller Layer를 테스트할때 @WebMvcTest를 사용합니다. (테스트 레이어 분리, 범위 한정 등등의 이유로)

- 문제는 이렇게 진행하면 해당 코드를 테스트할때 그냥 호출하게 되면 loginUserDetails에서는 Null 이 발생하게 됩니다. 

- 1번에서 설명했던 Authentication정보를 Context에 세팅하는 부분 (Filter) 이 호출되지 않기 때문입니다.

- 그래서 아래와 같이 수동으로 UserDatails를 세팅해주는 InMemoryUserDetailManager 사용을 권장합니다.

@Bean
@Primary
public UserDetailsService userDetailsService() {
    List<SimpleGrantedAuthority> userAuth = new ArrayList<>();
    userAuth.add(new SimpleGrantedAuthority("ROLE_USER"));

    List<SimpleGrantedAuthority> adminAuth = new ArrayList<>();
    adminAuth.add(new SimpleGrantedAuthority("ROLE_ADMIN"));

    LoginUserDetails loginUserDetails = new LoginUserDetails(1, "", "user","", userAuth);
    LoginUserDetails loginAdminDetails = new LoginUserDetails(2, "", "admin", "", adminAuth);

    return new InMemoryUserDetailsManager(Arrays.asList(loginUserDetails, loginAdminDetails));
}

- 그리고 테스트 케이스에서는 @WithUserDetails 어노테이션을 사용해서 앞에서 생성했던 UserDetails정보를 통해서 호출할 수 있습니다. (@WithMockUser, @WithAnonymousUser, @WithSecurityContext 등을 적합한 것을 선택해서 사용하면 됩니다.)

@Test
@WithUserDetails("admin")
public void getAllData() throws Exception{
    List<DataDto> datas = new ArrayList<>();

    DataDto dataDto = new DataDto();
    dataDto.setDataId(1);
    String dataName = "ttt";
    dataDto.setDataName(dataName);
    dataDto.setUserId(1);
    datas.add(dataDto);

    Page<DataDto> pages=new PageImpl<>(datas, Pageable.unpaged(), 1);

    //given
    given(dataService.findAllDatas(PageRequest.of(0,1))).willReturn(pages);
    //when
    this.mvc.perform(get("/datas/all?page=0&size=1").header("Authorization", "Bearer " + adminToken))
            .andExpect(jsonPath("content").isNotEmpty())
            .andExpect(jsonPath("content[0].dataId").value(1))
            .andExpect(status().isOk());
}

 

3. NullPointerException이 발생하는 경우

- 일반적으로 구글링을 하면 2번까지는 많이 나옵니다. 테스트도 잘 되지만..

- User 클래스를 상속받아서 별도로 구현한 경우에는 NullPointerException 가 발생하는 경우가 있습니다. (Spring Security 버전에 따라 차이가 있는지는 확인해야 할 것 같습니다.)

public class LoginUserDetails extends User {

    private Integer userId;
    private String userGroup;

    public LoginUserDetails(Integer userId, String password, String userName, String userGroup, Collection<? extends GrantedAuthority> authorities) {
        super(userName, password, authorities);
        this.userId = userId;
        this.userGroup = userGroup;
    }

    .. 	기타 로직구현 메소드

- 이유는 다음과 같습니다. User 클래스를 살펴보면 이 역시 UsenamePasswordAuthenticationToken 처럼 Spring Security에서  UserDetails 인터페이스를 일반적으로 구현해놓은 클래스입니다.

public class User implements UserDetails, CredentialsContainer {
    private static final long serialVersionUID = 620L;
    private static final Log logger = LogFactory.getLog(User.class);
    private String password;
    private final String username;
    private final Set<GrantedAuthority> authorities;
    private final boolean accountNonExpired;
    private final boolean accountNonLocked;
    private final boolean credentialsNonExpired;
    private final boolean enabled;

- 그리고 아까 테스트코드에서 우리가 사용한 InMemoryUserDetailsManager 역시 Spring Security에서 UserDetailsManager를 구현해 놓은 클래스입니다.

public class InMemoryUserDetailsManager implements UserDetailsManager, UserDetailsPasswordService {
    protected final Log logger = LogFactory.getLog(this.getClass());
    private final Map<String, MutableUserDetails> users = new HashMap();
    private SecurityContextHolderStrategy securityContextHolderStrategy = SecurityContextHolder.getContextHolderStrategy();
    private AuthenticationManager authenticationManager;

	... 중략 ...
    
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        UserDetails user = (UserDetails)this.users.get(username.toLowerCase());
        if (user == null) {
            throw new UsernameNotFoundException(username);
        } else {
            return new User(user.getUsername(), user.getPassword(), user.isEnabled(), user.isAccountNonExpired(), user.isCredentialsNonExpired(), user.isAccountNonLocked(), user.getAuthorities());
        }
    }

- Spring Security는 바로 이 loadUserByUsername 을 호출해서 UserDetails를 세팅하게 되는데 User 객체를 받게 됩니다.

- 바로 여기가 문제의 원인입니다.

- 비지니스 사항때문에 User를 상속받아서 별도로 구현한 LoginUserDetails는 User의 Sub type입니다. 

   LoginUserDetails는 User가 될 수 있지만, User는 LoginUserDetails가 될 수 없습니다. 

- 이러한 내용을 Controller에서 좀 더 명확하게 확인하기 위해서 (errorOnInvalidType=true)를 사용해봅니다.

public @ResponseBody
Page<DataDto> findDataAll (@AuthenticationPrincipal(errorOnInvalidType=true) LoginUserDetails loginUserDetails,
                           Pageable pageable)  {

- 아래와 같은 오류메시지를 확인할 수 있습니다.

Caused by: java.lang.ClassCastException: class org.springframework.security.core.userdetails.User cannot be cast to class ...LoginUserDetails (org.springframework.security.core.userdetails.User and ...LoginUserDetails are in unnamed module of loader 'app')

 

4. 해결방법

- 원인을 알게되면 해결하는 방법은 간단합니다.

- SpringSecurity의 Context에 세팅되는 Principal은 UserDetailsManager의 loadUserByUsername이 Return 해주는 객체 입니다. 

- User의 Sub type인 LoginUserDetails를 생성하여 Return해주면 Controller에서 LoginUserDetails 객체를 받아올 때 ClassCastException 이 발생하지 않으면서 정상적으로 사용할 수 있습니다.

class CustomInMemoryUserDetailsManager extends InMemoryUserDetailsManager{
    public CustomInMemoryUserDetailsManager(Collection<UserDetails> users) {
        super(users);
    }

    @Override
    public LoginUserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        UserDetails userDetails =  super.loadUserByUsername(username);

        return new LoginUserDetails(1 ,"", username, "", userDetails.getAuthorities());
    }
}

- 이제 Local에서 TestCase를 수행했을때에도 정상적으로 Customize된 LoginUserDetails객체를 사용할 수 있습니다.

public @ResponseBody
Page<DataDto> findDataAll (@AuthenticationPrincipal LoginUserDetails loginUserDetails,
                           Pageable pageable)  {
    if(loginUserDetails.isAdmin()){
        return dataService.findAllDatas(pageable);
    }
    return new PageImpl(new ArrayList(),Pageable.unpaged(),0);
}