토리맘의 한글라이즈 프로젝트 logo 토리맘의 한글라이즈 프로젝트

스프링 시큐리티 공식 레퍼런스를 한글로 번역한 문서입니다.

전체 목차는 여기에 있습니다.

목차:


이번 섹션에선 스프링 시큐리티가 지원하는 테스트 기능을 설명한다.

스프링 시큐리티 테스트 기능을 사용하려면 프로젝트 의존성에 spring-security-test-5.3.2.RELEASE.jar를 추가해야 한다.


19.1. Testing Method Security

이번 섹션은 스프링 시큐리티의 테스트 기능으로 메소드 시큐리티를 테스트하는 방법을 설명한다. 먼저 인증된 사용자만 접근할 수 있는 MessageService를 소개한다.

public class HelloMessageService implements MessageService {

    @PreAuthorize("authenticated")
    public String getMessage() {
        Authentication authentication = SecurityContextHolder.getContext()
            .getAuthentication();
        return "Hello " + authentication;
    }
}

getMessage 결과는 현재 스프링 시큐리티 Authentication에게 “Hello”라고 인사하는 문자열이다. 아래 보이는 건 출력 예시다.

Hello org.springframework.security.authentication.UsernamePasswordAuthenticationToken@ca25360: Principal: org.springframework.security.core.userdetails.User@36ebcb: Username: user; Password: [PROTECTED]; Enabled: true; AccountNonExpired: true; credentialsNonExpired: true; AccountNonLocked: true; Granted Authorities: ROLE_USER; Credentials: [PROTECTED]; Authenticated: true; Details: null; Granted Authorities: ROLE_USER

19.1.1. Security Test Setup

스프링 시큐리티 테스트 기능을 사용하려면 먼저 몇 가지를 설정해야 한다. 예제는 바로 아래에 있다:

@RunWith(SpringJUnit4ClassRunner.class) // (1)
@ContextConfiguration // (2)
public class WithMockUserTests {

이 코드는 스프링 시큐리티 테스트를 세팅하는 기본적인 코드다. 주요 내용은 다음과 같다:

(1) 스프링 테스트 모듈은 @RunWith를 보고 ApplicationContext를 생성한다. 기존 스프링 테스트 지원과 동일하다. 자세한 정보는 스프링 레퍼런스를 참고해라.
(2) @ContextConfiguration은 스프링 테스트 모듈에 ApplicationContext를 생성할 설정을 알려준다. 설정을 명시하지 않았기 때문에 디폴트 설정 위치를 찾는다. 기존 스프링 테스트 지원과 동일하다. 자세한 정보는 스프링 레퍼런스를 참고해라.

스프링 시큐리티는 WithSecurityContextTestExecutionListener로 스프링 테스트와 연결된다. 이 리스너는 테스트를 실행하기 전에 SecurityContextHolder를 채워 올바른 사용자로 테스트를 실행할 수 있게 해준다. 리액티브 메소드 시큐리티를 사용한다면 ReactiveSecurityContextHolder를 채우는 ReactorContextTestExecutionListener도 필요하다. 리스너는 테스트를 완료한 후에 SecurityContextHolder를 비운다. 스프링 시큐리티 관련 기능만 필요하다면 @ContextConfiguration 대신 @SecurityTestExecutionListeners를 사용해도 된다.

HelloMessageService@PreAuthorize 애노테이션을 달았었다는 걸 떠올려보자. 따라서 이 메소드를 호출하려면 사용자를 인증해야 한다. 다음 테스트를 실행하면 통과한다:

@Test(expected = AuthenticationCredentialsNotFoundException.class)
public void getMessageUnauthenticated() {
    messageService.getMessage();
}

19.1.2. @WithMockUser

특정 사용자로 테스트를 실행할 수 있는 가장 쉬운 방법은 뭘까? 이 질문에 대한 답은 @WithMockUser다. 다음 테스트는 사용자 이름 “user”, 비밀번호 “password”, role “ROLE_USER”를 가진 사용자로 실행된다.

@Test
@WithMockUser
public void getMessageWithMockUser() {
String message = messageService.getMessage();
...
}

이 테스트 코드는 다음과 같은 특징이 있다:

이 예제는 사용할 수 있는 디폴트 값이 많다는 장점이 있다. 다른 사용자 이름으로 테스트를 실행하고 싶었다면? 아래 테스트는 “customUser”라는 이름으로 실행한다. 다시 말하지만, 이 사용자가 실제로 존재해야 하는 건 아니다.

@Test
@WithMockUser("customUsername")
public void getMessageWithMockUserCustomUsername() {
    String message = messageService.getMessage();
...
}

role도 쉽게 커스텀할 수 있다. 예를 들어 이 테스트 코드는 사용자 이름 “admin”과 “ROLE_USER”, “ROLE_ADMIN” role로 실행된다.

@Test
@WithMockUser(username = "admin", roles = { "USER", "ADMIN" })
public void getMessageWithMockUserCustomUser() {
    String message = messageService.getMessage();
    ...
}

자동으로 ROLE_ 프리픽스가 붙는게 싫다면 authorities 속성을 사용하면 된다. 예를 들어 이 코드는 사용자 이름 “admin”과 “USER”, “ADMIN” 권한으로 실행된다.

@Test
@WithMockUser(username = "admin", authorities = { "ADMIN", "USER" })
public void getMessageWithMockUserCustomAuthorities() {
    String message = messageService.getMessage();
    ...
}

물론 테스트 메소드마다 전부 애노테이션을 다는 건 지루한 일이다. 메소드 대신 클래스 레벨에 애노테이션을 달면 모든 테스트에서 지정한 사용자를 사용한다. 예를 들어 다음 코드는 모든 테스트를 사용자 이름 “admin”, 비밀번호 “password”, role “ROLE_USER”, “ROLE_ADMIN”을 가진 사용자로 실행한다.

@RunWith(SpringJUnit4ClassRunner.class)
@ContextConfiguration
@WithMockUser(username = "admin", roles = { "USER", "ADMIN" })
public class WithMockUserTests {

기본적으로 SecurityContextTestExecutionListener.beforeTestMethod 이벤트에서 설정한다. 이 이벤트는 JUnit의 @Before 실행 전에 해당한다. JUnit의 @Before 실행 후, 테스트 메소드 실행 전에 해당하는 TestExecutionListener.beforeTestExecution 이벤트에서 설정하도록 바꿀 수도 있다.

@WithMockUser(setupBefore = TestExecutionEvent.TEST_EXECUTION)

19.1.3. @WithAnonymousUser

@WithAnonymousUser를 사용하면 익명 사용자로 테스트를 실행할 수 있다. 테스트 대부분을 지정한 사용자로 실행하고, 일부만 익명 사용자로 실행하고 싶을 때 특히 편리하다. 예를 들어 다음 코드는 withMockUser1(), withMockUser2() 메소드는 @WithMockUser로, anonymous() 메소드는 익명 사용자로 실행한다.

@RunWith(SpringJUnit4ClassRunner.class)
@WithMockUser
public class WithUserClassLevelAuthenticationTests {

    @Test
    public void withMockUser1() {
    }

    @Test
    public void withMockUser2() {
    }

    @Test
    @WithAnonymousUser
    public void anonymous() throws Exception {
        // override default to run as anonymous user
    }
}

기본적으로 SecurityContextTestExecutionListener.beforeTestMethod 이벤트에서 설정한다. 이 이벤트는 JUnit의 @Before 실행 전에 해당한다. JUnit의 @Before 실행 후, 테스트 메소드 실행 전에 해당하는 TestExecutionListener.beforeTestExecution 이벤트에서 설정하도록 바꿀 수도 있다.

@WithAnonymousUser(setupBefore = TestExecutionEvent.TEST_EXECUTION)

19.1.4. @WithUserDetails

처음 시작할 땐 @WithMockUser가 편리하지만, 모든 인스턴스에서 동작하지는 않는다. 예를 들어 흔히들 Authentication principal을 특정 인스턴스 타입으로 사용한다. 이렇게 하면 어플리케이션에선 principal을 커스텀 타입으로 참조할 수 있으며, 스프링 시큐리티와의 결합도도 줄일 수 있다.

커스텀 principal은 보통 커스텀 UserDetailsServiceUserDetails와 커스텀 타입을 모두 구현한 객체를 반환한다. 이런 상황에선 커스텀 UserDetailsService로 테스트 사용자를 만들 수 있어야 한다. 이게 바로 @WithUserDetails가 하는 일이다.

UserDetailsService를 빈으로 등록했다고 가정하면, 아래 테스트는 UsernamePasswordAuthenticationToken 타입 AuthenticationUserDetailsService가 리턴한 principal, 사용자 이름 “user”로 실행된다.

@Test
@WithUserDetails
public void getMessageWithUserDetails() {
    String message = messageService.getMessage();
    ...
}

UserDetailsService에서 사용자를 찾을 때 쓸 사용자 이름도 커스텀할 수 있다. 예를 들어 이 테스트는 UserDetailsService에서 리턴한 principal과 사용자 이름 “customUsername”으로 실행된다.

@Test
@WithUserDetails("customUsername")
public void getMessageWithUserDetailsCustomUsername() {
    String message = messageService.getMessage();
    ...
}

UserDetailsService를 찾을 때 사용할 빈 이름을 명시하는 것도 가능하다. 예를 들어 이 테스트는 “myUserDetailsService”란 이름을 가진 UserDetailsService 빈을 사용해서 사용자 이름 “customUsername”을 찾는다.

@Test
@WithUserDetails(value="customUsername", userDetailsServiceBeanName="myUserDetailsService")
public void getMessageWithUserDetailsServiceBeanName() {
    String message = messageService.getMessage();
    ...
}

@WithMockUser처럼 클래스 레벨에 애노테이션을 달면 모든 테스트에서 같은 사용자를 사용한다. 하지만 @WithUserDetails@WithMockUser를 사용할 때와는 달리 실제로 사용자가 있어야 한다.

기본적으로 SecurityContextTestExecutionListener.beforeTestMethod 이벤트에서 설정한다. 이 이벤트는 JUnit의 @Before 실행 전에 해당한다. JUnit의 @Before 실행 후, 테스트 메소드 실행 전에 해당하는 TestExecutionListener.beforeTestExecution 이벤트에서 설정하도록 바꿀 수도 있다.

@WithUserDetails(setupBefore = TestExecutionEvent.TEST_EXECUTION)

19.1.5. @WithSecurityContext

Authentication principal을 커스텀하지 않으면 @WithMockUser를 사용하는 게 더 낫다는 것을 확인했다. 다음으로 @WithUserDetails는 커스텀 UserDetailsService를 사용해서 Authentication principal을 만들 수 있지만, 해당 사용자가 실제로 있어야 한다는 것을 알아봤다. 이제부터 살펴볼 옵션은 가장 유연하게 사용할 수 있는 옵션이다.

이제 원하는 SecurityContext를 생성해 주는 @WithSecurityContext를 사용해서 자체 애노테이션을 만들 수 있다. 예를 들어 아래와 같은 @WithMockCustomUser 애노테이션을 만들 수 있다.

@Retention(RetentionPolicy.RUNTIME)
@WithSecurityContext(factory = WithMockCustomUserSecurityContextFactory.class)
public @interface WithMockCustomUser {

    String username() default "rob";

    String name() default "Rob Winch";
}

보이는 바와 같이 @WithMockCustomUser@WithSecurityContext 애노테이션이 선언돼 있다. 스프링 시큐리티 테스트 기능에선 이를 SecurityContext를 생성해서 테스트를 실행하라는 신호로 받아들인다. @WithSecurityContext 애노테이션엔, @WithMockCustomUser 애노테이션을 선언했을 때 새 SecurityContext를 만들 SecurityContextFactory를 지정해야 한다. WithMockCustomUserSecurityContextFactory 구현은 다음과 같다:

public class WithMockCustomUserSecurityContextFactory
    implements WithSecurityContextFactory<WithMockCustomUser> {
    @Override
    public SecurityContext createSecurityContext(WithMockCustomUser customUser) {
        SecurityContext context = SecurityContextHolder.createEmptyContext();

        CustomUserDetails principal =
            new CustomUserDetails(customUser.name(), customUser.username());
        Authentication auth =
            new UsernamePasswordAuthenticationToken(principal, "password", principal.getAuthorities());
        context.setAuthentication(auth);
        return context;
    }
}

이제 테스트 클래스나 메소드에 이 새 애노테이션을 선언하면 스프링 시큐리티의 WithSecurityContextTestExecutionListener가 적절한 SecurityContext를 채워준다.

자체 WithSecurityContextFactory 구현체를 만든다면, 표준 스프링 애노테이션을 선언할 수 있다는 점을 알아두면 좋다. 예를 들어 WithUserDetailsSecurityContextFactory에선 @Autowired 애노테이션을 사용해서 UserDetailsService를 주입받는다:

final class WithUserDetailsSecurityContextFactory
    implements WithSecurityContextFactory<WithUserDetails> {

    private UserDetailsService userDetailsService;

    @Autowired
    public WithUserDetailsSecurityContextFactory(UserDetailsService userDetailsService) {
        this.userDetailsService = userDetailsService;
    }

    public SecurityContext createSecurityContext(WithUserDetails withUser) {
        String username = withUser.value();
        Assert.hasLength(username, "value() must be non-empty String");
        UserDetails principal = userDetailsService.loadUserByUsername(username);
        Authentication authentication = new UsernamePasswordAuthenticationToken(principal, principal.getPassword(), principal.getAuthorities());
        SecurityContext context = SecurityContextHolder.createEmptyContext();
        context.setAuthentication(authentication);
        return context;
    }
}

기본적으로 SecurityContextTestExecutionListener.beforeTestMethod 이벤트에서 설정한다. 이 이벤트는 JUnit의 @Before 실행 전에 해당한다. JUnit의 @Before 실행 후, 테스트 메소드 실행 전에 해당하는 TestExecutionListener.beforeTestExecution 이벤트에서 설정하도록 바꿀 수도 있다.

@WithSecurityContext(setupBefore = TestExecutionEvent.TEST_EXECUTION)

19.1.6. Test Meta Annotations

여러 테스트에서 같은 사용자를 재사용한다면, 반복해서 속성을 지정하는 건 좋지 않다. 예를 들어 사용자 이름 “admin”과 ROLE_USER, ROLE_ADMIN role을 가진 관리자 사용자를 사용하는 테스트가 많다면 매번 다음 코드를 작성할 거다:

@WithMockUser(username = "admin", roles = { "USER", "ADMIN" })

이 코드를 여기저기 반복하는 대신 메타 애노테이션을 활용할 수 있다. 예를 들어 WithMockAdmin이란 메타 애노테이션을 만들 수 있다:

@Retention(RetentionPolicy.RUNTIME)
@WithMockUser(value="rob",roles="ADMIN")
public @interface WithMockAdmin { }

이제 장황했던 @WithMockUser 대신 @WithMockAdmin을 똑같은 방법으로 사용할 수 있다.

메타 애노테이션은 위에서 설명한 모든 테스트 애노테이션에 적용할 수 있다. 예를 들어 @WithUserDetails("admin")이란 메타 애노테이션을 만들 수도 있다.


19.2. Spring MVC Test Integration

스프링 시큐리티는 스프링 MVC 테스트와 종합적으로 통합된다.

19.2.1. Setting Up MockMvc and Spring Security

스프링 시큐리티를 스프링 MVC 테스트와 함께 사용하려면 스프링 시큐리티 FilterChainProxyFilter로 추가해야 한다. 또한 스프링 시큐리티의 TestSecurityContextHolderPostProcessor를 추가해야 애노테이션으로 사용자를 지정해 스프링 MVC 테스트를 실행할 수 있다. 이땐 스프링 시큐리티의 SecurityMockMvcConfigurers.springSecurity()를 사용하면 된다. 예를 들어:

스프링 시큐리티의 테스트 기능은 spring-test-4.1.3.RELEASE나 그 이상이 필요하다.

import static org.springframework.security.test.web.servlet.setup.SecurityMockMvcConfigurers.*;

@RunWith(SpringJUnit4ClassRunner.class)
@ContextConfiguration(classes = SecurityConfig.class)
@WebAppConfiguration
public class CsrfShowcaseTests {

    @Autowired
    private WebApplicationContext context;

    private MockMvc mvc;

    @Before
    public void setup() {
        mvc = MockMvcBuilders
                .webAppContextSetup(context)
                .apply(springSecurity()) // (1)
                .build();
    }

...

(1) SecurityMockMvcConfigurers.springSecurity()는 스프링 시큐리티를 스프링 MVC 테스트와 통합할 때 필요한 모든 초기 세팅을 수행한다.

19.2.2. SecurityMockMvcRequestPostProcessors

스프링 MVC 테스트는 요청을 수정할 수 있는 편리한 인터페이스 RequestPostProcessor를 제공한다. 스프링 시큐리티는 테스트를 쉽게 만들어줄 다양한 RequestPostProcessor 구현체를 제공한다. 스프링 시큐리티의 RequestPostProcessor 구현체를 사용하려면 아래 있는 스태틱 임포트를 사용해야 한다:

import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.*;

Testing with CSRF Protection

스프링 시큐리티의 CSRF 방어를 사용 중인 unsafe HTTP 메소드를 테스트하려면 요청에 유효한 CSRF 토큰을 추가해야 한다. 요청 파라미터에 유효한 CSRF 토큰을 지정하려면 다음 코드를 사용하면 된다:

mvc
    .perform(post("/").with(csrf()))

원한다면 CSRF 토큰을 헤더에 넣을 수도 있다:

mvc
    .perform(post("/").with(csrf().asHeader()))

아래 코드로 유효하지 않은 CSRF 토큰으로 테스트를 실행할 수도 있다:

mvc
    .perform(post("/").with(csrf().useInvalidToken()))

Running a Test as a User in Spring MVC Test

테스트는 특정 사용자로 실행해야 할 때가 많다. 간단하게 사용자를 지정하는 방법은 두 가지가 있다:

Running as a User in Spring MVC Test with RequestPostProcessor

사용자를 현재 HttpServletRequest로 연결하는 옵션은 다양하다. 예를 들어 다음 코드는 사용자 이름 “user”, 비밀번호 “password”, role “ROLE_USER” 사용자로 (실제로는 없어도 되는 사용자) 실행한다:

사용자를 HttpServletRequest로 연결하는 방식으로 테스트를 지원한다. 요청을 SecurityContextHolder에 연계하려면 SecurityContextPersistenceFilterMockMvc와 연결시켜야 한다. 다음과 같은 방법으로 필터를 연결할 수 있다:

  • apply(springSecurity()) 실행
  • MockMvc에 스프링 시큐리티의 FilterChainProxy 추가
  • MockMvcBuilders.standaloneSetup을 사용할 땐 수동으로 MockMvc 인스턴스에 SecurityContextPersistenceFilter를 추가하는 것도 괜찮다
mvc
    .perform(get("/").with(user("user")))

커스텀도 쉽게 할 수 있다. 예를 들어 다음 코드는 사용자 이름 “admin”, 비밀번호 “pass”, role “ROLE_USER”, “ROLE_ADMIN”을 가진 사용자로 (실제로는 없어도 되는 사용자) 실행한다.

mvc
    .perform(get("/admin").with(user("admin").password("pass").roles("USER","ADMIN")))

커스텀 UserDetails를 사용하고 싶다면 함께 명시하면 된다. 예를 들어 다음 코드는 지정한 UserDetails를 사용해서 (실제로는 없어도 되는), 여기에 있는 principal을 가진 UsernamePasswordAuthenticationToken으로 테스트를 실행한다:

mvc
    .perform(get("/").with(user(userDetails)))

익명 사용자는 다음 코드로 실행할 수 있다:

mvc
    .perform(get("/").with(anonymous()))

테스트 대부분을 지정한 사용자로 실행하고, 일부만 익명 사용자로 실행하고 싶을 때 특히 편리하다.

커스텀 Authentication을 사용하고 싶다면 (실제로는 없어도 되는) 다음 코드를 사용해라:

mvc
    .perform(get("/").with(authentication(authentication)))

다음 코드를 사용하면 SecurityContext도 커스텀할 수 있다:

mvc
    .perform(get("/").with(securityContext(securityContext)))

MockMvcBuilders의 디폴트 요청을 설정하면 모든 요청을 지정한 사용자로 실행할 수도 있다. 예를 들어 다음 코드는 사용자 이름 “admin”, 비밀번호 “password”, role “ROLE_ADMIN”을 가진 사용자로 (실제로는 없어도 되는) 실행한다:

mvc = MockMvcBuilders
        .webAppContextSetup(context)
        .defaultRequest(get("/").with(user("user").roles("ADMIN")))
        .apply(springSecurity())
        .build();

여러 테스트에서 같은 사용자를 사용한다면, 사용자 정의를 메소드로 분리하는 게 좋다. 예를 들어 CustomSecurityMockMvcRequestPostProcessors 클래스에 다음 코드를 정의할 수 있다:

public static RequestPostProcessor rob() {
    return user("rob").roles("ADMIN");
}

이제 SecurityMockMvcRequestPostProcessors 메소드를 스태틱으로 임포트해서 테스트에 사용할 수 있다:

import static sample.CustomSecurityMockMvcRequestPostProcessors.*;

...

mvc
    .perform(get("/").with(rob()))
Running as a User in Spring MVC Test with Annotations

RequestPostProcessor로 사용자를 만드는 대신 메소드 시큐리티 테스트에서 설명한 애노테이션을 사용해도 된다. 예를 들어 다음 코드는 사용자 이름 “user”, 비밀번호 “password”, role “ROLE_USER”를 가진 사용자로 테스트를 실행한다:

@Test
@WithMockUser
public void requestProtectedUrlWithUser() throws Exception {
mvc
        .perform(get("/"))
        ...
}

또는 다음 코드로 사용자 이름 “user”, 비밀번호 “password”, role “ROLE_ADMIN”을 가진 사용자로 테스트를 실행할 수도 있다:

@Test
@WithMockUser(roles="ADMIN")
public void requestProtectedUrlWithUser() throws Exception {
mvc
        .perform(get("/"))
        ...
}

Testing HTTP Basic Authentication

지금까지도 HTTP 기본 인증을 실행할 순 있었지만, 헤더 이름과 포맷을 기억해두고 값을 인코딩하는 건 지루한 일이었다. 이제는 스프링 시큐리티의 httpBasic RequestPostProcessor를 사용할 수 있다. 예를 들어 아래 코드는:

mvc
    .perform(get("/").with(httpBasic("user","password")))

HTTP 요청에 아래 헤더를 추가해서 사용자 이름 “user”, 비밀번호 “password”를 가진 사용자로 HTTP 기본 인증을 수행한다:

Authorization: Basic dXNlcjpwYXNzd29yZA==

Testing OAuth 2.0

OAuth 2.0에 관해서라면, 이전에 다룬 원칙이 그대로 적용된다: 궁극적으로 테스트할 메소드가 SecurityContextHolder 안에서 무엇을 사용하냐에 달렸다.

예를 들어 다음과 같은 컨트롤러가 있다면:

@GetMapping("/endpoint")
public String foo(Principal user) {
    return user.getName();
}

OAuth2에 특화된 코드가 없기 때문에 예상한대로 단순히 @WithMockUser를 사용하면 된다.

하지만 다음처럼 테스트하려는 컨트롤러가 스프링 시큐리티의 OAuth 2.0 기능을 사용한다면:

@GetMapping("/endpoint")
public String foo(@AuthenticationPrincipal OidcUser user) {
    return user.getIdToken().getSubject();
}

이럴땐 스프링 시큐리티의 테스트 기능이 유용하다.

Testing OIDC Login

스프링 MVC 테스트로 위 메소드를 테스트하려면 인가 서버로 일종의 권한 부여 플로우를 시뮬레이션해야 한다. 시뮬레이션은 확실히 벅찬 일이다. 스프링 시큐리티는 이런 보일러플레이트 없이도 테스트할 수 있도록 지원한다.

예를 들어 아래처럼 SecurityMockMvcRequestPostProcessors#oidcLogin 메소드를 사용해서 스프링 시큐리티에 디폴트 OidcUser를 추가할 수 있다:

mvc
    .perform(get("/endpoint").with(oidcLogin()));

이렇게 하면 관련 MockHttpServletRequest에, 간단한 OidcIdTokenOidcUserInfo, 부여받은 권한 Collection을 가지고 있는 OidcUser를 설정한다.

특히, OidcIdTokenuser라는 sub 클레임을 추가해 준다:

assertThat(user.getIdToken().getClaim("sub")).isEqualTo("user");

클레임 셋이 없는 OidcUserInfo도 추가되며:

assertThat(user.getUserInfo().getClaims()).isEmpty();

SCOPE_read 하나를 가지고 있는 권한 Collection도 설정된다:

assertThat(user.getAuthorities()).hasSize(1);
assertThat(user.getAuthorities()).containsExactly(new SimpleGrantedAuthority("SCOPE_read"));

스프링 시큐리티는 OidcUser 인스턴스를 @AuthenticationPrincipal 애노테이션에서 사용할 수 있도록 필요한 일을 해준다.

게다가 OidcUserHttpSessionOAuth2AuthorizedClientRepository에 보관하는 간단한 OAuth2AuthorizedClient 인스턴스로 연결해 준다. @RegisteredOAuth2AuthorizedClient 애노테이션을 사용해서 테스트할 때 유용하다.

Configuring Authorities

많은 경우에 메소드를 필터나 메소드 시큐리티로 보호하고 있으며, 요청을 허용하려면 Authentication에 특정 권한을 부여해야 한다.

이럴땐 authorities() 메소드로 필요한 권한을 부여할 수 있다:

mvc
    .perform(get("/endpoint")
        .with(oidcLogin()
            .authorities(new SimpleGrantedAuthority("SCOPE_message:read"))
        )
    );
Configuring Claims

권한 부여는 스프링 시큐리티에서 전반적으로 다루고 있지만, OAuth 2.0에는 클레임이란 개념도 있다.

예를 들어 시스템 내 사용자의 ID를 나타내는 user_id 클레임이 있다고 해보자. 컨트롤러에서는 다음과 같이 클레임에 접근할 수 있다:

@GetMapping("/endpoint")
public String foo(@AuthenticationPrincipal OidcUser oidcUser) {
    String userId = oidcUser.getIdToken().getClaim("user_id");
    // ...
}

이럴땐 idToken() 메소드로 클레임을 지정할 수 있다:

mvc
    .perform(get("/endpoint")
        .with(oidcLogin()
                .idToken(token -> token.claim("user_id", "1234"))
        )
    );

이렇게 하면 OidcUserOidcIdToken에서 클레임을 수집할 수 있다.

Additional Configurations

다른 메소드로도 인증 정보를 설정할 수 있다. 컨트롤러에서 사용하는 데이터에 따라 필요한 메소드를 사용하면 된다.

마지막 메소드는 1. OidcUser 자체 구현체를 쓰거나, 2. name 속성을 바꿔야 할 때 유용하다.

예를 들어 인가 서버에서 principal 이름을 sub 클레임이 아닌 user_name 클레임으로 전송한다고 가정해보자. 이럴땐 직접 만든 OidcUser를 설정할 수 있다:

OidcUser oidcUser = new DefaultOidcUser(
        AuthorityUtils.createAuthorityList("SCOPE_message:read"),
        Collections.singletonMap("user_name", "foo_user"),
        "user_name");

mvc
    .perform(get("/endpoint")
        .with(oidcLogin().oidcUser(oidcUser))
    );

Testing OAuth 2.0 Login

OIDC 로그인 테스트와 마찬가지로, OAuth 2.0 로그인에서도 유사하게 권한 부여 플로우를 모킹해야 한다. 꽤나 까다로운 일이기 때문에, 스프링 시큐리티는 OIDC 외에 다른 테스트도 지원한다.

로그인한 사용자 정보를 OAuth2User로 받는 컨트롤러가 있다고 가정해보자:

@GetMapping("/endpoint")
public String foo(@AuthenticationPrincipal OAuth2User oauth2User) {
    return oauth2User.getAttribute("sub");
}

이럴땐 아래처럼 SecurityMockMvcRequestPostProcessors#oauth2User를 사용해서 스프링 시큐리티에 디폴트 OAuth2User를 추가할 수 있다:

mvc
    .perform(get("/endpoint").with(oauth2Login()));

이렇게 하면 관련 MockHttpServletRequest에, 간단한 속성 Map과 부여받은 권한 Collection을 가지고 있는 OAuth2User를 설정한다.

특히, Map에 키/값 sub/user를 추가해 준다:

assertThat((String) user.getAttribute("sub")).isEqualTo("user");

SCOPE_read 하나를 가지고 있는 권한 Collection도 설정된다:

assertThat(user.getAuthorities()).hasSize(1);
assertThat(user.getAuthorities()).containsExactly(new SimpleGrantedAuthority("SCOPE_read"));

스프링 시큐리티는 OAuth2User 인스턴스를 @AuthenticationPrincipal 애노테이션에서 사용할 수 있도록 필요한 일을 해준다.

게다가 OAuth2UserHttpSessionOAuth2AuthorizedClientRepository에 보관하는 간단한 OAuth2AuthorizedClient 인스턴스로 연결해 준다. @RegisteredOAuth2AuthorizedClient 애노테이션을 사용해서 테스트할 때 유용하다.

Configuring Authorities

많은 경우에 메소드를 필터나 메소드 시큐리티로 보호하고 있으며, 요청을 허용하려면 Authentication에 특정 권한을 부여해야 한다.

이럴땐 authorities() 메소드로 필요한 권한을 부여할 수 있다:

mvc
    .perform(get("/endpoint")
        .with(oauth2Login()
            .authorities(new SimpleGrantedAuthority("SCOPE_message:read"))
        )
    );
Configuring Claims

권한 부여는 스프링 시큐리티에서 전반적으로 다루고 있지만, OAuth 2.0에는 클레임이란 개념도 있다.

예를 들어 시스템 내 사용자의 ID를 나타내는 user_id 속성이 있다고 해보자. 컨트롤러에서는 다음과 같이 속성에 접근할 수 있다:

@GetMapping("/endpoint")
public String foo(@AuthenticationPrincipal OAuth2User oauth2User) {
    String userId = oauth2User.getAttribute("user_id");
    // ...
}

이럴땐 attributes() 메소드로 속성을 지정할 수 있다:

mvc
    .perform(get("/endpoint")
        .with(oauth2Login()
                .attributes(attrs -> attrs.put("user_id", "1234"))
        )
    );
Additional Configurations

다른 메소드로도 인증 정보를 설정할 수 있다. 컨트롤러에서 사용하는 데이터에 따라 필요한 메소드를 사용하면 된다.

마지막 메소드는 1. OAuth2User 자체 구현체를 쓰거나, 2. name 속성을 바꿔야 할 때 유용하다.

예를 들어 인가 서버에서 principal 이름을 sub 클레임이 아닌 user_name 클레임으로 전송한다고 가정해보자. 이럴땐 직접 만든 OAuth2User를 설정할 수 있다:

OAuth2User oauth2User = new DefaultOAuth2User(
        AuthorityUtils.createAuthorityList("SCOPE_message:read"),
        Collections.singletonMap("user_name", "foo_user"),
        "user_name");

mvc
    .perform(get("/endpoint")
        .with(oauth2Login().oauth2User(oauth2User))
    );

Testing OAuth 2.0 Clients

사용자를 인증하는 방법과는 상관 없이, 테스트하고 싶은 요청에서 다른 토큰과 클라이언트 등록 정보를 사용할 수도 있다. 예를 들어 컨트롤러에서 클라이언트에 부여한 credential로 사용자 아무런 연관이 없는 토큰을 가져올 수 있다:

@GetMapping("/endpoint")
public String foo(@RegisteredOAuth2AuthorizedClient("my-app") OAuth2AuthorizedClient authorizedClient) {
    return this.webClient.get()
        .attributes(oauth2AuthorizedClient(authorizedClient))
        .retrieve()
        .bodyToMono(String.class)
        .block();
}

인가 서버와의 핸드셰이킹을 시뮬레이션하긴 번거롭다. 대신에 SecurityMockMvcRequestPostProcessor#oauth2Client를 사용해서 HttpSessionOAuth2AuthorizedClientRepositoryOAuth2AuthorizedClient를 추가할 수 있다:

mvc
    .perform(get("/endpoint").with(oauth2Client("my-app")));

어플리케이션에서 사용 중인 HttpSessionOAuth2AuthorizedClientRepository가 없다면 @TestConfiguration에 하나를 등록할 수 있다:

@TestConfiguration
static class AuthorizedClientConfig {
    @Bean
    OAuth2AuthorizedClientRepository authorizedClientRepository() {
        return new HttpSessionOAuth2AuthorizedClientRepository();
    }
}

이렇게 하면 간단한 ClientRegistration, OAuth2AccessToken, 리소스 소유자 이름을 가지고 있는 OAuth2AuthorizedClient를 생성한다.

특히 ClientRegistration에 클라이언트 ID “test-client”, 클라이언트 secret “test-secret”을 가지고 있는 ClientRegistration을 추가해 준다:

assertThat(authorizedClient.getClientRegistration().getClientId()).isEqualTo("test-client");
assertThat(authorizedClient.getClientRegistration().getClientSecret()).isEqualTo("test-secret");

리소스 소유자 이름 “user”도 추가되며:

assertThat(authorizedClient.getPrincipalName()).isEqualTo("user");

read 스코프 하나를 가지고 있는 OAuth2AccessToken도 설정된다:

assertThat(authorizedClient.getAccessToken().getScopes()).hasSize(1);
assertThat(authorizedClient.getAccessToken().getScopes()).containsExactly("read");

스프링 시큐리티는 OAuth2AuthorizedClient 인스턴스를 관련 HttpSession에서 사용할 수 있도록 필요한 일을 해준다. 덕분에 HttpSessionOAuth2AuthorizedClientRepository에서 OAuth2AuthorizedClient를 조회할 수 있다.

Configuring Scopes

OAuth 2.0 액세스 토큰은 흔히 스코프 셋을 함께 제공한다. 컨트롤러에선 다음과 같이 스코프를 참조할 수 있다:

@GetMapping("/endpoint")
public String foo(@RegisteredOAuth2AuthorizedClient("my-app") OAuth2AuthorizedClient authorizedClient) {
    Set<String> scopes = authorizedClient.getAccessToken().getScopes();
    if (scopes.contains("message:read")) {
        return this.webClient.get()
            .attributes(oauth2AuthorizedClient(authorizedClient))
            .retrieve()
            .bodyToMono(String.class)
            .block();
    }
    // ...
}

스코프는 accessToken() 메소드로 설정할 수 있다:

mvc
    .perform(get("/endpoint")
        .with(oauth2Client("my-app")
            .accessToken(new OAuth2AccessToken(BEARER, "token", null, null, Collections.singleton("message:read"))))
        )
    );
Additional Configurations

다른 메소드로도 인증 정보를 설정할 수 있다. 컨트롤러에서 사용하는 데이터에 따라 필요한 메소드를 사용하면 된다.

마지막 메소드는 실제 ClientRegistration을 사용하고 싶을 때 유용하다.

예를 들어 application.yml에 있는 어플리케이션의 ClientRegistration 정의 중 하나를 사용하고 싶다고 해보자.

이럴땐 테스트 코드에 ClientRegistrationRepository를 주입하면 필요한 빈을 찾아준다:

@Autowired
ClientRegistrationRepository clientRegistrationRepository;

// ...

mvc
    .perform(get("/endpoint")
        .with(oauth2Client()
            .clientRegistration(this.clientRegistrationRepository.findByRegistrationId("facebook"))));

Testing JWT Authentication

리소스 서버에 권한을 부여받은 요청을 만들려면 bearer 토큰이 필요하다.

리소스 서버를 JWT로 설정했다면 bearer 토큰을 서명한 다음 JWT 스펙에 따라 인코딩해야 한다. 이 모든 일은 꽤나 벅찬 일이며, 특히나 테스트하려는 핵심 로직이 아닐 땐 더 그렇다.

다행히도 테스트 코드에선 bearer 토큰 표현 대신 간단히 인가 로직에만 집중할 수 있는 방법이 많이 있다. 여기서는 두 가지 방법을 살펴보겠다.

jwt() RequestPostProcessor

첫 번째 방법은 RequestPostProcessor를 사용하는 방법이다. 가장 간단하게는 아래처럼 사용할 수 있다:

mvc
    .perform(get("/endpoint").with(jwt()));

이렇게 하면 관련 목 Jwt를 생성해서 인증 API로 넘겨주기 때문에, 인가 메커니즘 검증에 활용할 수 있다.

디폴트 JWT는 다음과 같이 생성한다:

{
  "headers" : { "alg" : "none" },
  "claims" : {
    "sub" : "user",
    "scope" : "read"
  }
}

테스트를 실행하면 결과적으로 다음과 같은 Jwt를 전달한다:

assertThat(jwt.getTokenValue()).isEqualTo("token");
assertThat(jwt.getHeaders().get("alg")).isEqualTo("none");
assertThat(jwt.getSubject()).isEqualTo("sub");
GrantedAuthority authority = jwt.getAuthorities().iterator().next();
assertThat(authority.getAuthority()).isEqualTo("read");

물론 이 값들도 설정할 수 있다.

헤더나 클레임은 전용 메소드를 사용해 설정한다:

mvc
    .perform(get("/endpoint")
        .with(jwt().jwt(jwt -> jwt.header("kid", "one").claim("iss", "https://idp.example.org"))));
mvc
    .perform(get("/endpoint")
        .with(jwt().jwt(jwt -> jwt.claims(claims -> claims.remove("scope")))));

여기선 일반 bearer 토큰 요청과 동일하게 scope, scp 클레임을 처리한다. 하지만 테스트에 필요한 GrantedAuthority 인스턴스 목록을 제공해서 간단히 재정의할 수 있다:

mvc
    .perform(get("/endpoint")
        .with(jwt().authorities(new SimpleGrantedAuthority("SCOPE_messages"))));

또는 JwtCollection<GrantedAuthority>로 변환하는 커스텀 컨버터로도 권한을 생성할 수 있다:

mvc
    .perform(get("/endpoint")
        .with(jwt().authorities(new MyConverter())));

Jwt.Builder를 사용해서 Jwt를 직접 만들 수도 있다:

Jwt jwt = Jwt.withTokenValue("token")
    .header("alg", "none")
    .claim("sub", "user")
    .claim("scope", "read")
    .build();

mvc
    .perform(get("/endpoint")
        .with(jwt().jwt(jwt)));
authentication() RequestPostProcessor

두 번째 방법은 authentication() RequestPostProcessor를 사용하는 방법이다. 기본적으로 아래 코드처럼 직접 만든 JwtAuthenticationToken 인스턴스를 테스트에 제공할 수 있다:

Jwt jwt = Jwt.withTokenValue("token")
    .header("alg", "none")
    .claim("sub", "user")
    .build();
Collection<GrantedAuthority> authorities = AuthorityUtils.createAuthorityList("SCOPE_read");
JwtAuthenticationToken token = new JwtAuthenticationToken(jwt, authorities);

mvc
    .perform(get("/endpoint")
        .with(authentication(token)));

이 두 가지 방법 외에도 @MockBean 애노테이션으로 JwtDecoder 빈 자체를 모킹하는 방법도 있다.

Testing Opaque Token Authentication

opaque 토큰도 JWT와 유사하게 유효성 검증을 위해서는 인가 서버가 필요하므로, 테스트하기가 더 까다롭다. 이를 위해 스프링 시큐리티는 opaque 토큰을 위한 테스트 기능을 지원한다.

BearerTokenAuthentication으로 인증 정보를 가져오는 컨트롤러가 있다고 가정해보자:

@GetMapping("/endpoint")
public String foo(BearerTokenAuthentication authentication) {
    return (String) authentication.getTokenAttributes("sub");
}

이럴땐 아래처럼 SecurityMockMvcRequestPostProcessors#opaqueToken 메소드를 사용해서 스프링 시큐리티에 디폴트 BearerTokenAuthentication을 추가할 수 있다:

mvc
    .perform(get("/endpoint").with(opaqueToken()));

이렇게 하면 관련 MockHttpServletRequest에, 간단한 OAuth2AuthenticatedPrincipal과, 속성 Map, 부여받은 권한 Collection을 가지고 있는 BearerTokenAuthentication을 설정한다.

특히, Map에 키/값 sub/user를 추가해 준다:

assertThat((String) token.getTokenAttributes().get("sub")).isEqualTo("user");

SCOPE_read 하나를 가지고 있는 권한 Collection도 설정된다:

assertThat(token.getAuthorities()).hasSize(1);
assertThat(token.getAuthorities()).containsExactly(new SimpleGrantedAuthority("SCOPE_read"));

스프링 시큐리티는 BearerTokenAuthentication 인스턴스를 컨트롤러 메소드에서 사용할 수 있도록 필요한 일을 해준다.

Configuring Authorities

많은 경우에 메소드를 필터나 메소드 시큐리티로 보호하고 있으며, 요청을 허용하려면 Authentication에 특정 권한을 부여해야 한다.

이럴땐 authorities() 메소드로 필요한 권한을 부여할 수 있다:

mvc
    .perform(get("/endpoint")
        .with(opaqueToken()
            .authorities(new SimpleGrantedAuthority("SCOPE_message:read"))
        )
    );
Configuring Claims

권한 부여는 스프링 시큐리티에서 전반적으로 다루고 있지만, OAuth 2.0에는 속성이란 개념도 있다.

예를 들어 시스템 내 사용자의 ID를 나타내는 user_id 속성이 있다고 해보자. 컨트롤러에서는 다음과 같이 속성에 접근할 수 있다:

@GetMapping("/endpoint")
public String foo(BearerTokenAuthentication authentication) {
    String userId = (String) authentication.getTokenAttributes().get("user_id");
    // ...
}

이럴땐 attributes() 메소드로 속성을 지정할 수 있다:

mvc
    .perform(get("/endpoint")
        .with(opaqueToken()
                .attributes(attrs -> attrs.put("user_id", "1234"))
        )
    );
Additional Configurations

다른 메소드로도 인증 정보를 설정할 수 있다. 컨트롤러에서 사용하는 데이터에 따라 필요한 메소드를 사용하면 된다.

한 가지 메소드는 BearerTokenAuthentication에 넣을 OAuth2AuthenticatedPrincipal 인스턴스를 직접 설정할 수 있는 principal(OAuth2AuthenticatedPrincipal)이다.

이 메소드는 1. OAuth2AuthenticatedPrincipal 자체 구현체를 쓰거나, 2. principal 이름을 바꿔야 할 때 유용하다.

예를 들어 인가 서버에서 principal 이름을 sub 속성이 아닌 user_name 속성으로 전송한다고 가정해보자. 이럴땐 직접 만든 OAuth2AuthenticatedPrincipal을 설정할 수 있다:

Map<String, Object> attributes = Collections.singletonMap("user_name", "foo_user");
OAuth2AuthenticatedPrincipal principal = new DefaultOAuth2AuthenticatedPrincipal(
        (String) attributes.get("user_name"),
        attributes,
        AuthorityUtils.createAuthorityList("SCOPE_message:read"));

mvc
    .perform(get("/endpoint")
        .with(opaqueToken().principal(principal))
    );

opaqueToken()을 사용하는 방법 외에도 @MockBean 애노테이션으로 OpaqueTokenIntrospector 빈 자체를 모킹하는 방법도 있다.

19.2.3. SecurityMockMvcRequestBuilders

스프링 MVC 테스트는 테스트에 사용할 MockHttpServletRequest 생성하는 RequestBuilder 인터페이스도 지원한다. 스프링 시큐리티는 더 편리한 RequestBuilder 구현체를 몇 가지 제공한다. 스프링 시큐리티의 RequestBuilder 구현체를 사용하려면 아래 있는 스태틱 임포트를 사용해야 한다:

import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestBuilders.*;

Testing Form Based Authentication

스프링 시큐리티의 테스트 기능을 사용하면 쉽게 폼 기반 인증 테스트 요청을 만들 수 있다. 예를 들어 다음 코드는 사용자 이름 “user”, 비밀번호 “password”와 유효한 CSRF 토큰으로 “/login”에 POST 요청을 제출한다:

mvc
    .perform(formLogin())

요청을 커스텀하는 것도 간단하다. 예를 들어 다음 코드는 사용자 이름 “admin”, 비밀번호 “pass”와 유효한 CSRF 토큰으로 “/auth”에 POST 요청을 제출한다:

mvc
    .perform(formLogin("/auth").user("admin").password("pass"))

사용자 이름과 비밀번호를 넣을 파라미터 이름도 커스텀할 수 있다. 예를 들어 다음은 위에 있는 요청을 HTTP 파라미터 “u”에 사용자 이름을, HTTP 파라미터 “p”에 비밀번호를 추가하도록 수정한 코드다:

mvc
    .perform(formLogin("/auth").user("u","admin").password("p","pass"))

Testing Logout

간단하게 표준 스프링 MVC 테스트를 사용할 수도 있지만, 스프링 시큐리티 테스트 기능을 사용하면 로그아웃 테스트를 더 쉽게 구현할 수 있다. 예를 들어 다음 코드는 유효한 CSRF 토큰으로 “/logout”에 POST 요청을 보낸다:

mvc
    .perform(logout())

요청을 보낼 URL을 커스텀할 수도 있다. 예를 들어 아래 코드는 유효한 CSRF 토큰으로 “/signout”에 POST 요청을 보낸다:

mvc
    .perform(logout("/signout"))

19.2.4. SecurityMockMvcResultMatchers

때로는 보안 로직을 검증해야 할 때도 있다. 이를 위해 스프링 시큐리티 테스트에선 스프링 MVC 테스트의 ResultMatcher 인터페이스 구현체를 제공한다. 스프링 시큐리티의 ResultMatcher 구현체를 사용하려면 아래 있는 스태틱 임포트를 사용해야 한다:

import static org.springframework.security.test.web.servlet.response.SecurityMockMvcResultMatchers.*;

Unauthenticated Assertion

MockMvc를 호출했을 때 인증된 사용자가 없었음을 확인해야할 때가 있다. 예를 들어 유효하지 않은 사용자 이름과 비밀번호를 제출했을 땐 인증된 사용자가 없었다는 것을 검증해야 한다. 다음과 같이 스프링 시큐리티의 테스트 기능을 사용하면 쉽게 구현할 수 있다:

mvc
    .perform(formLogin().password("invalid"))
    .andExpect(unauthenticated());

Authenticated Assertion

반대로 인증된 사용자가 있음을 확인해야 할 때도 있다. 예를 들어 인증에 성공했는지 검증해야 할 수 있다. 다음 코드를 사용하면 폼 기반 로그인에 성공했는지 검증할 수 있다:

mvc
    .perform(formLogin())
    .andExpect(authenticated());

사용자의 role을 확인하고 싶다면 아래처럼 코드를 수정하면 된다:

mvc
    .perform(formLogin().user("admin"))
    .andExpect(authenticated().withRoles("USER","ADMIN"));

아니면 사용자 이름을 검증할 수도 있다:

mvc
    .perform(formLogin().user("admin"))
    .andExpect(authenticated().withUsername("admin"));

여러 가지 검증 조건을 조합하는 것도 가능하다:

mvc
    .perform(formLogin().user("admin").roles("USER","ADMIN"))
    .andExpect(authenticated().withUsername("admin"));

인증에 대한 임의의 검증 조건을 추가할 수도 있다:

mvc
    .perform(formLogin())
    .andExpect(authenticated().withAuthentication(auth ->
        assertThat(auth).isInstanceOf(UsernamePasswordAuthenticationToken.class)));

전체 목차는 여기에 있습니다.

<< >>