我只是很难让控制器单元测试正常工作,因为IMO,如果使用OAuth ,Spring文档中的内容还不够。就我而言,它是带有JWT的Oauth2。
我尝试使用@WithMockUser,@WithUserDetails甚至定义了自己的注释@WithSecurityContext和一个自定义,UserSecurityContextFactory但是在评估安全性表达式时,无论我在工厂中将测试上下文设置为什么,总是在UserSecurityContext中获得匿名用户。
@WithMockUser
@WithUserDetails
@WithSecurityContext
UserSecurityContextFactory
我提出了我刚想到的解决方案,但是由于我不确定嘲笑TokenService是最有效/干净的方法,请随时提供更好的解决方案。
[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
样本用法
由于这篇文章很长,因此公开了涉及很多代码的解决方案,让我们从结果入手,以便您可以决定是否值得阅读;)
@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。
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; } } }