Spring Security 기능 활용 #3 (@AuthenticationPrincipal, UserDetails)
<개요>
- @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은 언제 세팅이 되는 걸까요?
- 로그인을 어떻게 구현했느냐와 연관이 있습니다. (이전글 참조)
- 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);
}