java - 如何让@WebMvcTest 与 OAuth 一起工作?

标签 java spring-boot jwt spring-security-oauth2 spring-test

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

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

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

最佳答案

[2019年5月编辑]
下面的解决方案特定于现已弃用的 spring-security-oauth2。
我写了a lib to achieve the same goal with Spring5 ,其中一些贡献于 spring-security-test 5.2。他们选择仅集成 JWT 流 API,因此如果您需要测试服务(需要使用注释)或使用不透明 token 自省(introspection),您可能需要稍微浏览一下我的 repo...

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

[spring-security-oauth2 的解决方案]
我迭代的解决方案是将请求中的虚拟“授权” header 与拦截它的模拟 token 服务结合起来(如果您查看编辑堆栈,经过多次尝试)。

我在 lib on Github 中提供了完整的助手源代码你可以找到示例 OAuth2 Controller 测试 there .

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

这里有两种情况:

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

一种类似的方法,我在整理了几天的头发并从头开始构建之后了解到,已经被描述过 here .我只是进一步模拟了 @WebMvcTest 的 Oauth2Authentication 配置和工具。

示例用法

由于这篇文章很长,公开了一个涉及相当多代码的解决方案,让我们从结果开始,以便您可以决定它是否值得一读;)

@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(...));
    }
}

时髦,不是吗?

附言apiMockMvcHelper 的实例,它是我自己的 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 帮助构建具有预期授权 header 的测试请求

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 Controller 单元测试的父级

@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;
        }
    }
}

关于java - 如何让@WebMvcTest 与 OAuth 一起工作?,我们在Stack Overflow上找到一个类似的问题: https://stackoverflow.com/questions/48540152/

相关文章:

java - iText - 格式化子字符串

Spring Boot Servlet API 版本

c# - 使用 jwt 验证后验证用户操作

node.js - 使用 google calendar API oauth2 服务帐户(node.js)模拟用户

java - Mockito.doNothing() 不断返回空指针异常

java - Linux 上 C 与 Java 的接口(interface)

java - 无法加载 RSA 公钥

java - JPA 选择查询返回带有 @ManyToOne 映射的实体

java - Spring Boot - 基于其他属性自动配置属性?

python - Django-rest-framework API 测试 403 {'detail' : 'You do not have permission to perform this action.' }