목적 : Spring Boot3로 변경하면서 Spring security도 6으로 변경됨에 따라 많은 것이 변경되어 테스트가 필요했습니다.
- WebSecurityConfigurerAdapter 클래스 deprecated
- 테스트하고자 하는 것
- 인증된 사용자가 접근가능한지
- 인증되지 않은 사용자가 접근 불가능한지
관련 어노테이션
- @Autowired MockMvc mvc : Spring Framework에서 제공하는 웹 애플리케이션 테스트용 라이브러리로, 실제 서버를 띄우지 않고도 Spring MVC의 웹 계층(주로 Controller)을 테스트할 수 있도록 해주는 도구이다.
- DispatcherServlet을 호출하고, 실제 서버 없이도 Spring MVC의 전체 요청 처리 과정을 복제한다.
- @WithMockUser : Spring Security Test의 어노테이션은 사용자 이름 user와 권한 기본값인 ROLE_USER로 로그인하는 사용자를 시뮬레이션한다.
- @ExtendWith Spring 테스트 컨텍스트를 활성화
- 인증 없이 테스트
- 로그인된 사용자가 조회할 수 있는 API이다.
- 그렇기 때문에 인증된 사용자가 아니면 401 Unauthorized 에러 발생
@Operation(summary = "내 정보 조회")
@GetMapping("/v1/users/me")
public ResUserInfo getUserInfo(
@Parameter(hidden = true) @SessionUser Long userSeq
) {
User user = userService.getUserBySeq(userSeq);
return ResUserInfo.of(user);
}
@Test
void 인증된_사용자가_아니면_UNAUTHORIZED_STATUS() throws Exception{
mvc
.perform(get("/api/v1/users/me"))
.andExpect(status().isUnauthorized());
}
- @WithMockUser로 인증된 사용자 테스트
가장 쉽게 인증된 사용자로 테스트하는 방법은 @WithMockUser 어노테이션을 사용하는 것이다.
USER와 ADMIN 사용자가 나뉘었기 때문에 roles로 나눠서 테스트했다.
@Test
@WithMockUser(username = "test@naver.com",roles = "USER")
void 인증된_사용자이면_OK_STATUS() throws Exception{
mvc
.perform(get("/api/v1/boards"))
.andExpect(status().isOk());
}
@Test
@WithMockUser(username = "test@naver.com", roles = "ADMIN")
void admin_ROLE로_admin_API_요청하면_OK_STATUS() throws Exception{
mvc
.perform(get("/api/admin/boards"))
.andExpect(status().isOk());
}
- 이 어노테이션은 실제로 존재하는 사용자가 없어도, username이 "test@naver.com"이고 role이 "USER"인 Mock 사용자를 만들어준다.
- username, roles, authorities 등 다양한 속성을 지정할 수 있다.
User 가 Admin만 사용할 수 있는 API에 접근할 수 없기 때문에(접근한다면 403 Forbidden) 이 경우도 체크한다.
그 반대인 Admin이 User만 사용할 수 있는 API로 접근할때도 확인한다.
@WithMockUser(username = "test@naver.com", roles = "ADMIN")
void admin_ROLE로_user_API_요청하면_FORBIDDEN_STATUS() throws Exception{
mvc
.perform(get("/api/v1/boards"))
.andExpect(status().isForbidden());
}
@Test
@WithMockUser(username = "test@naver.com", roles = "USER")
void user_ROLE로_admin_API_요청하면_FORBIDDEN_STATUS() throws Exception{
mvc
.perform(get("/api/admin/boards"))
.andExpect(status().isForbidden());
}
예시
- 사용자명만 변경: @WithMockUser("customUsername")
- 역할 지정: @WithMockUser(username="admin", roles={"USER","ADMIN"})
- 권한 직접 지정: @WithMockUser(username="admin", authorities={"ADMIN", "USER"})
- 클래스 레벨에 붙이면 모든 테스트에 적용됨
@SpringBootTest
@AutoConfigureMockMvc
@ExtendWith(SpringExtension.class)
public class SecurityBasedTest {
@Autowired
MockMvc mvc;
@Test
void 인증된_사용자가_아니면_UNAUTHORIZED_STATUS() throws Exception{
mvc
.perform(get("/api/v1/users/me"))
.andExpect(status().isUnauthorized());
}
@Test
@WithMockUser(username = "test@naver.com",roles = "USER")
void 인증된_사용자이면_OK_STATUS() throws Exception{
mvc
.perform(get("/api/v1/boards"))
.andExpect(status().isOk());
}
@Test
@WithMockUser(username = "test@naver.com", roles = "ADMIN")
void admin_ROLE로_user_API_요청하면_FORBIDDEN_STATUS() throws Exception{
mvc
.perform(get("/api/v1/boards"))
.andExpect(status().isForbidden());
}
@Test
@WithMockUser(username = "test@naver.com", roles = "ADMIN")
void admin_ROLE로_admin_API_요청하면_OK_STATUS() throws Exception{
mvc
.perform(get("/api/admin/boards"))
.andExpect(status().isOk());
}
@Test
@WithMockUser(username = "test@naver.com", roles = "USER")
void user_ROLE로_admin_API_요청하면_FORBIDDEN_STATUS() throws Exception{
mvc
.perform(get("/api/admin/boards"))
.andExpect(status().isForbidden());
}
}
Spring Security의 User를 상속해서 커스텀하는 경우
하지만 @WithMochUser를 보면 만들어지는 Uesr가 org.springframework.security.core.userdetails.User 인 스프링 시큐리티에서 만든 것이기 때문에 UserDetails를 커스텀하는 경우는 다르게 테스트해야한다.
프로젝트 내에서 User를 상속하고 있는 ResUserSignIn 클래스
@Getter
public class ResUserSignIn extends org.springframework.security.core.userdetails.User {
//생략
}
WithMockUserSecurityContextFactory.java
import org.springframework.security.core.userdetails.User;
public SecurityContext createSecurityContext(WithMockUser withUser) {
String username = StringUtils.hasLength(withUser.username()) ? withUser.username() : withUser.value();
Assert.notNull(username, () -> {
return "" + withUser + " cannot have null username on both username and value properties";
});
List<GrantedAuthority> grantedAuthorities = new ArrayList();
String[] var4 = withUser.authorities();
int var5 = var4.length;
int var6;
String role;
for(var6 = 0; var6 < var5; ++var6) {
role = var4[var6];
grantedAuthorities.add(new SimpleGrantedAuthority(role));
}
if (grantedAuthorities.isEmpty()) {
var4 = withUser.roles();
var5 = var4.length;
for(var6 = 0; var6 < var5; ++var6) {
role = var4[var6];
Assert.isTrue(!role.startsWith("ROLE_"), () -> {
return "roles cannot start with ROLE_ Got " + role;
});
grantedAuthorities.add(new SimpleGrantedAuthority("ROLE_" + role));
}
} else if (withUser.roles().length != 1 || !"USER".equals(withUser.roles()[0])) {
List var10002 = Arrays.asList(withUser.roles());
throw new IllegalStateException("You cannot define roles attribute " + var10002 + " with authorities attribute " + Arrays.asList(withUser.authorities()));
}
User principal = new User(username, withUser.password(), true, true, true, true, grantedAuthorities);
Authentication authentication = UsernamePasswordAuthenticationToken.authenticated(principal, principal.getPassword(), principal.getAuthorities());
SecurityContext context = this.securityContextHolderStrategy.createEmptyContext();
context.setAuthentication(authentication);
return context;
}
@WithUserDetails 활용 (UserDetailsService 연동)
장점: 실제 환경과 유사한 테스트 가능
단점: DB에 실제 사용자 데이터 필요
- @WithUserDetails
실제 UserDetailsService에서 유저 정보를 불러와서 인증 정보를 생성한다. 커스텀 principal 타입이 필요한 경우 사용합니다.
WithUserDetails.class
@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Inherited
@Documented
@WithSecurityContext(
factory = WithUserDetailsSecurityContextFactory.class
)
public @interface WithUserDetails {
String value() default "user";
String userDetailsServiceBeanName() default "";
@AliasFor(
annotation = WithSecurityContext.class
)
TestExecutionEvent setupBefore() default TestExecutionEvent.TEST_METHOD;
}
WithUserDetailsSecurityContextFactory.class
final class WithUserDetailsSecurityContextFactory implements WithSecurityContextFactory<WithUserDetails> {
//...중략
public SecurityContext createSecurityContext(WithUserDetails withUser) {
String beanName = withUser.userDetailsServiceBeanName();
UserDetailsService userDetailsService = this.findUserDetailsService(beanName);
String username = withUser.value();
Assert.hasLength(username, "value() must be non empty String");
UserDetails principal = userDetailsService.loadUserByUsername(username);
Authentication authentication = UsernamePasswordAuthenticationToken.authenticated(principal, principal.getPassword(), principal.getAuthorities());
SecurityContext context = this.securityContextHolderStrategy.createEmptyContext();
context.setAuthentication(authentication);
return context;
}
//...중략
}
withUser어노테이션에서 값을 가져와서 UserDetailsService에서 SecurityContext를 만들고 있다.
프로젝트 코드 내에 UserDetailsService를 구현하고 있는 클래스가 있기 때문에 @WithUserDetails 의 userDetailsServiceBeanName 속성으로 빈을 지정한다.
프로젝트 내에서 UserDetailsService를 구현하고 있는 UserEmailSignInService 클래스
@Service
@RequiredArgsConstructor
public class UserEmailSignInService implements UserDetailsService {
private final UserAuthRepository userAuthRepository;
....
}
테스트 코드
username에 해당하는 UserAuth가 DB에 있어야 하기 때문에 “user@naver.com”은 실제로 디비에 저장되어있으며 user Role을 가지고 있는 사용자다
@Test
@WithUserDetails(value = "user@naver.com" , userDetailsServiceBeanName ="userEmailSignInService" ) // username에 해당하는 UserAuth가 DB에 있어야 함
void 인증된_사용자이면_OK_STATUS() throws Exception{
mvc
.perform(get("/api/v1/boards"))
.andExpect(status().isOk());
}
@Test
@WithUserDetails(value = "user@gmail.com" , userDetailsServiceBeanName ="userEmailSignInService" )
void user_ROLE로_admin_API_요청하면_FORBIDDEN_STATUS() throws Exception{
mvc
.perform(get("/api/admin/boards"))
.andExpect(status().isForbidden());
}
참고
https://docs.spring.io/spring-security/reference/servlet/test/method.htm
'Backend > Spring | Spring Boot' 카테고리의 다른 글
[예외 처리] 사용자 정의 예외 처리 (0) | 2024.06.07 |
---|---|
[Spring] Filter와 OncePerRequestFilter (0) | 2024.03.13 |
[SpringBoot] @PostConstructd와 @PreDestroy (0) | 2024.02.22 |
[Error] TransactionRequiredException: Executing an update/delete query 에러 해결 방법 (1) | 2023.01.27 |
[spring-data-jpa] @Transactional 의 readonly 옵션과 성능 향상 (0) | 2023.01.27 |