一尘不染

如何使@WebMvcTest与OAuth一起使用?

spring-boot

我只是很难让控制器单元测试正常工作,因为IMO,如果使用OAuth ,Spring文档中的内容还不够。就我而言,它是带有JWT的Oauth2。

我尝试使用@WithMockUser@WithUserDetails甚至定义了自己的注释@WithSecurityContext和一个自定义,UserSecurityContextFactory但是在评估安全性表达式时,无论我在工厂中将测试上下文设置为什么,总是在UserSecurityContext中获得匿名用户。

我提出了我刚想到的解决方案,但是由于我不确定嘲笑TokenService是最有效/干净的方法,请随时提供更好的解决方案。


阅读 242

收藏
2020-05-30

共1个答案

一尘不染

[2019年5月编辑]
以下解决方案特定于spring-security-oauth2,现已弃用。
我编写了一个lib以实现与Spring5相同的目标,其中一些是对spring-
security-test
5.2的贡献。他们选择仅集成JWT流API,因此,如果您需要测试服务(需要使用批注)或使用不透明的令牌自省,则可能需要稍微浏览一下我的存储库…

[2019年7月编辑]
我现在将Spring 5的“ spring-addons”库发布到maven-
central
,这大大提高了可用性。
源代码和自述文件仍在github上

[spring-security-oauth2
的解决方案] 我反复考虑的解决方案是将请求中的虚拟“ Authorization”标头与拦截它的模拟令牌服务结合起来(经过多次尝试,如果您查看编辑堆栈)。

在Github上的一个库中提供了完整的帮助程序源,您可以在那里找到示例OAuth2控制器测试。

简而言之:没有授权标头-> ResourceServerTokenServices不会被触发->
SecurityContext在OAuth堆栈中将是匿名的(无论您尝试使用@WithMockUser还是类似方式将其设置为)。

所以这里有两种情况:

  • 您正在编写集成测试,提供有效的令牌并让真正的令牌服务完成其工作并提供此令牌中包含的身份验证
  • 您正在编写单元测试,我的案例和模拟令牌服务,以便它返回模拟身份验证

我已经描述一种类似的方法,在将头发拉了几天并从头开始构建之后,我已经在这里进行了描述。我只是在针对的模拟Oauth2Authentication配置和工具中走得更远@WebMvcTest

样本用法

由于这篇文章很长,因此公开了涉及很多代码的解决方案,让我们从结果入手,以便您可以决定是否值得阅读;)

@WebMvcTest(MyController.class) // Controller to unit-test
@Import(WebSecurityConfig.class) // your class extending WebSecurityConfigurerAdapter
public class MyControllerTest extends OAuth2ControllerTest {

    @Test
    public void testWithUnauthenticatedClient() throws Exception {
        api.post(payload, "/endpoint")
                .andExpect(...);
    }

    @Test
    @WithMockOAuth2Client
    public void testWithDefaultClient() throws Exception {
        api.get("/endpoint")
                .andExpect(...);
    }

    @Test
    @WithMockOAuth2User
    public void testWithDefaultClientOnBehalfDefaultUser() throws Exception {
            MockHttpServletRequestBuilder req = api.postRequestBuilder(null, "/uaa/refresh")
                .header("refresh_token", JWT_REFRESH_TOKEN);

        api.perform(req)
                .andExpect(status().isOk())
                .andExpect(...)
    }

    @Test
    @WithMockOAuth2User(
        client = @WithMockOAuth2Client(
                clientId = "custom-client",
                scope = {"custom-scope", "other-scope"},
                authorities = {"custom-authority", "ROLE_CUSTOM_CLIENT"}),
        user = @WithMockUser(
                username = "custom-username",
                authorities = {"custom-user-authority"}))
    public void testWithCustomClientOnBehalfCustomUser() throws Exception {
        api.get(MediaType.APPLICATION_ATOM_XML, "/endpoint")
                .andExpect(status().isOk())
                .andExpect(xpath(...));
    }
}

时髦,不是吗?

PS api是本文末尾提供的的实例MockMvcHelper,是我自己的包装MockMvc

@ WithMockOAuth2Client 以模拟仅客户端身份验证(不涉及最终用户)

@Retention(RetentionPolicy.RUNTIME)
@WithSecurityContext(factory = WithMockOAuth2Client.WithMockOAuth2ClientSecurityContextFactory.class)
public @interface WithMockOAuth2Client {

    String clientId() default "web-client";

    String[] scope() default {"openid"};

    String[] authorities() default {};

    boolean approved() default true;

    class WithMockOAuth2ClientSecurityContextFactory implements WithSecurityContextFactory<WithMockOAuth2Client> {

        public static OAuth2Request getOAuth2Request(final WithMockOAuth2Client annotation) {
            final Set<? extends GrantedAuthority> authorities = Stream.of(annotation.authorities())
                    .map(auth -> new SimpleGrantedAuthority(auth))
                    .collect(Collectors.toSet());

            final Set<String> scope = Stream.of(annotation.scope())
                    .collect(Collectors.toSet());

            return new OAuth2Request(
                    null,
                    annotation.clientId(),
                    authorities,
                    annotation.approved(),
                    scope,
                    null,
                    null,
                    null,
                    null);
        }

        @Override
        public SecurityContext createSecurityContext(final WithMockOAuth2Client annotation) {
            final SecurityContext ctx = SecurityContextHolder.createEmptyContext();
            ctx.setAuthentication(new OAuth2Authentication(getOAuth2Request(annotation), null));
            SecurityContextHolder.setContext(ctx);
            return ctx;
        }
    }

}

@ WithMockOAuth2User 代表最终用户模拟客户端身份验证

@Retention(RetentionPolicy.RUNTIME)
@WithSecurityContext(factory = WithMockOAuth2User.WithMockOAuth2UserSecurityContextFactory.class)
public @interface WithMockOAuth2User {

    WithMockOAuth2Client client() default @WithMockOAuth2Client();

    WithMockUser user() default @WithMockUser();

    class WithMockOAuth2UserSecurityContextFactory implements WithSecurityContextFactory<WithMockOAuth2User> {

        /**
         * Sadly, #WithMockUserSecurityContextFactory is not public,
         * so re-implement mock user authentication creation
         *
         * @param user
         * @return an Authentication with provided user details
         */
        public static UsernamePasswordAuthenticationToken getUserAuthentication(final WithMockUser user) {
            final String principal = user.username().isEmpty() ? user.value() : user.username();

            final Stream<String> grants = user.authorities().length == 0 ?
                    Stream.of(user.roles()).map(r -> "ROLE_" + r) :
                    Stream.of(user.authorities());

            final Set<? extends GrantedAuthority> userAuthorities = grants
                    .map(auth -> new SimpleGrantedAuthority(auth))
                    .collect(Collectors.toSet());

            return new UsernamePasswordAuthenticationToken(
                    new User(principal, user.password(), userAuthorities),
                    principal + ":" + user.password(),
                    userAuthorities);
        }

        @Override
        public SecurityContext createSecurityContext(final WithMockOAuth2User annotation) {
            final SecurityContext ctx = SecurityContextHolder.createEmptyContext();
            ctx.setAuthentication(new OAuth2Authentication(
                    WithMockOAuth2Client.WithMockOAuth2ClientSecurityContextFactory.getOAuth2Request(annotation.client()),
                    getUserAuthentication(annotation.user())));
            SecurityContextHolder.setContext(ctx);
            return ctx;
        }
    }
}

OAuth2MockMvcHelper 使用预期的Authorization标头帮助构建测试请求

public class OAuth2MockMvcHelper extends MockMvcHelper {
    public static final String VALID_TEST_TOKEN_VALUE = "test.fake.jwt";

    public OAuth2MockMvcHelper(
            final MockMvc mockMvc,
            final ObjectFactory<HttpMessageConverters> messageConverters,
            final MediaType defaultMediaType) {
        super(mockMvc, messageConverters, defaultMediaType);
    }

    /**
     * Adds OAuth2 support: adds an Authorisation header to all request builders
     * if there is an OAuth2Authentication in test security context.
     * 
     * /!\ Make sure your token services recognize this dummy "VALID_TEST_TOKEN_VALUE" token as valid during your tests /!\
     *
     * @param contentType should be not-null when issuing request with body (POST, PUT, PATCH), null otherwise
     * @param accept      should be not-null when issuing response with body (GET, POST, OPTION), null otherwise
     * @param method
     * @param urlTemplate
     * @param uriVars
     * @return a request builder with minimal info you can tweak further (add headers, cookies, etc.)
     */
    @Override
    public MockHttpServletRequestBuilder requestBuilder(
            Optional<MediaType> contentType,
            Optional<MediaType> accept,
            HttpMethod method,
            String urlTemplate,
            Object... uriVars) {
        final MockHttpServletRequestBuilder builder = super.requestBuilder(contentType, accept, method, urlTemplate, uriVars);
        if (SecurityContextHolder.getContext().getAuthentication() instanceof OAuth2Authentication) {
            builder.header("Authorization", "Bearer " + VALID_TEST_TOKEN_VALUE);
        }
        return builder;
    }
}

OAuth2ControllerTest 控制器的父项单元测试

@RunWith(SpringRunner.class)
@Import(OAuth2MockMvcConfig.class)
public class OAuth2ControllerTest {

    @MockBean
    private ResourceServerTokenServices tokenService;

    @Autowired
    protected OAuth2MockMvcHelper api;

    @Autowired
    protected SerializationHelper conv;

    @Before
    public void setUpTokenService() {
        when(tokenService.loadAuthentication(api.VALID_TEST_TOKEN_VALUE))
                .thenAnswer(invocation -> SecurityContextHolder.getContext().getAuthentication());
    }
}



@TestConfiguration
class OAuth2MockMvcConfig {

    @Bean
    public SerializationHelper serializationHelper(ObjectFactory<HttpMessageConverters> messageConverters) {
        return new SerializationHelper(messageConverters);
    }

    @Bean
    public OAuth2MockMvcHelper mockMvcHelper(
            MockMvc mockMvc,
            ObjectFactory<HttpMessageConverters> messageConverters,
            @Value("${controllers.default-media-type:application/json;charset=UTF-8}") MediaType defaultMediaType) {
        return new OAuth2MockMvcHelper(mockMvc, messageConverters, defaultMediaType);
    }

}

上面引用的工具,但与OAuth2测试没有直接关系

/**
 * Wraps MockMvc to further ease interaction with tested API:
 * provides with:<ul>
 * <li>many request shortcuts for simple cases (see get, post, put, patch, delete methods)</li>
 * <li>perfom method along with request builder initialisation shortcuts (see getRequestBuilder, etc.) when more control is required (additional headers, ...)</li>
 * </ul>
 */
public class MockMvcHelper {

    private final MockMvc mockMvc;

    private final MediaType defaultMediaType;

    protected final SerializationHelper conv;

    public MockMvcHelper(MockMvc mockMvc, ObjectFactory<HttpMessageConverters> messageConverters, MediaType defaultMediaType) {
        this.mockMvc = mockMvc;
        this.conv = new SerializationHelper(messageConverters);
        this.defaultMediaType = defaultMediaType;
    }

    /**
     * Generic request builder which adds relevant "Accept" and "Content-Type" headers
     *
     * @param contentType should be not-null when issuing request with body (POST, PUT, PATCH), null otherwise
     * @param accept      should be not-null when issuing response with body (GET, POST, OPTION), null otherwise
     * @param method
     * @param urlTemplate
     * @param uriVars
     * @return a request builder with minimal info you can tweak further: add headers, cookies, etc.
     */
    public MockHttpServletRequestBuilder requestBuilder(
            Optional<MediaType> contentType,
            Optional<MediaType> accept,
            HttpMethod method,
            String urlTemplate,
            Object... uriVars) {
        final MockHttpServletRequestBuilder builder = request(method, urlTemplate, uriVars);
        contentType.ifPresent(builder::contentType);
        accept.ifPresent(builder::accept);
        return builder;
    }

    public ResultActions perform(MockHttpServletRequestBuilder request) throws Exception {
        return mockMvc.perform(request);
    }

    /* GET */
    public MockHttpServletRequestBuilder getRequestBuilder(MediaType accept, String urlTemplate, Object... uriVars) {
        return requestBuilder(Optional.empty(), Optional.of(accept), HttpMethod.GET, urlTemplate, uriVars);
    }

    public MockHttpServletRequestBuilder getRequestBuilder(String urlTemplate, Object... uriVars) {
        return getRequestBuilder(defaultMediaType, urlTemplate, uriVars);
    }

    public ResultActions get(MediaType accept, String urlTemplate, Object... uriVars) throws Exception {
        return mockMvc.perform(getRequestBuilder(accept, urlTemplate, uriVars));
    }

    public ResultActions get(String urlTemplate, Object... uriVars) throws Exception {
        return mockMvc.perform(getRequestBuilder(urlTemplate, uriVars));
    }

    /* POST */
    public <T> MockHttpServletRequestBuilder postRequestBuilder(final T payload, MediaType contentType, MediaType accept, String urlTemplate, Object... uriVars) throws Exception {
        return feed(
                requestBuilder(Optional.of(contentType), Optional.of(accept), HttpMethod.POST, urlTemplate, uriVars),
                payload,
                contentType);
    }

    public <T> MockHttpServletRequestBuilder postRequestBuilder(final T payload, String urlTemplate, Object... uriVars) throws Exception {
        return postRequestBuilder(payload, defaultMediaType, defaultMediaType, urlTemplate, uriVars);
    }

    public <T> ResultActions post(final T payload, MediaType contentType, MediaType accept, String urlTemplate, Object... uriVars) throws Exception {
        return mockMvc.perform(postRequestBuilder(payload, contentType, accept, urlTemplate, uriVars));
    }

    public <T> ResultActions post(final T payload, String urlTemplate, Object... uriVars) throws Exception {
        return mockMvc.perform(postRequestBuilder(payload, urlTemplate, uriVars));
    }


    /* PUT */
    public <T> MockHttpServletRequestBuilder putRequestBuilder(final T payload, MediaType contentType, String urlTemplate, Object... uriVars) throws Exception {
        return feed(
                requestBuilder(Optional.of(contentType), Optional.empty(), HttpMethod.PUT, urlTemplate, uriVars),
                payload,
                contentType);
    }

    public <T> MockHttpServletRequestBuilder putRequestBuilder(final T payload, String urlTemplate, Object... uriVars) throws Exception {
        return putRequestBuilder(payload, defaultMediaType, urlTemplate, uriVars);
    }

    public <T> ResultActions put(final T payload, MediaType contentType, String urlTemplate, Object... uriVars) throws Exception {
        return mockMvc.perform(putRequestBuilder(payload, contentType, urlTemplate, uriVars));
    }

    public <T> ResultActions put(final T payload, String urlTemplate, Object... uriVars) throws Exception {
        return mockMvc.perform(putRequestBuilder(payload, urlTemplate, uriVars));
    }


    /* PATCH */
    public <T> MockHttpServletRequestBuilder patchRequestBuilder(final T payload, MediaType contentType, String urlTemplate, Object... uriVars) throws Exception {
        return feed(
                requestBuilder(Optional.of(contentType), Optional.empty(), HttpMethod.PATCH, urlTemplate, uriVars),
                payload,
                contentType);
    }

    public <T> MockHttpServletRequestBuilder patchRequestBuilder(final T payload, String urlTemplate, Object... uriVars) throws Exception {
        return patchRequestBuilder(payload, defaultMediaType, urlTemplate, uriVars);
    }

    public <T> ResultActions patch(final T payload, MediaType contentType, String urlTemplate, Object... uriVars) throws Exception {
        return mockMvc.perform(patchRequestBuilder(payload, contentType, urlTemplate, uriVars));
    }

    public <T> ResultActions patch(final T payload, String urlTemplate, Object... uriVars) throws Exception {
        return mockMvc.perform(patchRequestBuilder(payload, urlTemplate, uriVars));
    }


    /* DELETE */
    public MockHttpServletRequestBuilder deleteRequestBuilder(String urlTemplate, Object... uriVars) {
        return requestBuilder(Optional.empty(), Optional.empty(), HttpMethod.DELETE, urlTemplate, uriVars);
    }

    public ResultActions delete(String urlTemplate, Object... uriVars) throws Exception {
        return mockMvc.perform(deleteRequestBuilder(urlTemplate, uriVars));
    }


    /* HEAD */
    public MockHttpServletRequestBuilder headRequestBuilder(String urlTemplate, Object... uriVars) {
        return requestBuilder(Optional.empty(), Optional.empty(), HttpMethod.HEAD, urlTemplate, uriVars);
    }

    public ResultActions head(String urlTemplate, Object... uriVars) throws Exception {
        return mockMvc.perform(headRequestBuilder(urlTemplate, uriVars));
    }


    /* OPTION */
    public MockHttpServletRequestBuilder optionRequestBuilder(MediaType accept, String urlTemplate, Object... uriVars) {
        return requestBuilder(Optional.empty(), Optional.of(accept), HttpMethod.OPTIONS, urlTemplate, uriVars);
    }

    public MockHttpServletRequestBuilder optionRequestBuilder(String urlTemplate, Object... uriVars) {
        return requestBuilder(Optional.empty(), Optional.of(defaultMediaType), HttpMethod.OPTIONS, urlTemplate, uriVars);
    }

    public ResultActions option(MediaType accept, String urlTemplate, Object... uriVars) throws Exception {
        return mockMvc.perform(optionRequestBuilder(accept, urlTemplate, uriVars));
    }

    public ResultActions option(String urlTemplate, Object... uriVars) throws Exception {
        return mockMvc.perform(optionRequestBuilder(urlTemplate, uriVars));
    }

    /**
     * Adds serialized payload to request content
     *
     * @param request
     * @param payload
     * @param mediaType
     * @param <T>
     * @return the request with provided payload as content
     * @throws Exception if things go wrong (no registered serializer for payload type and asked MediaType, serialization failure, ...)
     */
    public <T> MockHttpServletRequestBuilder feed(
            MockHttpServletRequestBuilder request,
            final T payload,
            final MediaType mediaType) throws Exception {
        if (payload == null) {
            return request;
        }

        final SerializationHelper.ByteArrayHttpOutputMessage msg = conv.outputMessage(payload, mediaType);
        return request
                .headers(msg.headers)
                .content(msg.out.toByteArray());
    }
}



/**
 * Serialize objects to given media type using registered message converters
 */
public class SerializationHelper {

    private final ObjectFactory<HttpMessageConverters> messageConverters;

    public SerializationHelper(ObjectFactory<HttpMessageConverters> messageConverters) {
        this.messageConverters = messageConverters;
    }

    public <T> ByteArrayHttpOutputMessage outputMessage(final T payload, final MediaType mediaType) throws Exception {
        if (payload == null) {
            return null;
        }

        List<HttpMessageConverter<?>> relevantConverters = messageConverters.getObject().getConverters().stream()
                .filter(converter -> converter.canWrite(payload.getClass(), mediaType))
                .collect(Collectors.toList());

        final ByteArrayHttpOutputMessage converted = new ByteArrayHttpOutputMessage();
        boolean isConverted = false;
        for (HttpMessageConverter<?> converter : relevantConverters) {
            try {
                ((HttpMessageConverter<T>) converter).write(payload, mediaType, converted);
                isConverted = true; //won't be reached if a conversion error occurs
                break; //stop iterating over converters after first successful conversion
            } catch (IOException e) {
                //swallow exception so that next converter is tried
            }
        }

        if (!isConverted) {
            throw new Exception("Could not convert " + payload.getClass() + " to " + mediaType.toString());
        }

        return converted;
    }

    /**
     * Provides a String representation of provided payload
     *
     * @param payload
     * @param mediaType
     * @param <T>
     * @return
     * @throws Exception if things go wrong (no registered serializer for payload type and asked MediaType, serialization failure, ...)
     */
    public <T> String asString(T payload, MediaType mediaType) throws Exception {
        return payload == null ?
                null :
                outputMessage(payload, mediaType).out.toString();
    }

    public <T> String asJsonString(T payload) throws Exception {
        return asString(payload, MediaType.APPLICATION_JSON_UTF8);
    }

    public static final class ByteArrayHttpOutputMessage implements HttpOutputMessage {
        public final ByteArrayOutputStream out = new ByteArrayOutputStream();
        public final HttpHeaders headers = new HttpHeaders();

        @Override
        public OutputStream getBody() {
            return out;
        }

        @Override
        public HttpHeaders getHeaders() {
            return headers;
        }
    }
}
2020-05-30