Backend/Spring | Spring Boot

Spring Security 테스트

호_두씨 2025. 6. 10. 09:40

목적 : 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 테스트 컨텍스트를 활성화
  1. 인증 없이 테스트
    1. 로그인된 사용자가 조회할 수 있는 API이다.
    2. 그렇기 때문에 인증된 사용자가 아니면 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());
}
  1. @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