diff --git a/test/src/main/java/org/springframework/security/test/web/servlet/request/SecurityMockMvcRequestPostProcessors.java b/test/src/main/java/org/springframework/security/test/web/servlet/request/SecurityMockMvcRequestPostProcessors.java index 3c5617462e..434570fc2a 100644 --- a/test/src/main/java/org/springframework/security/test/web/servlet/request/SecurityMockMvcRequestPostProcessors.java +++ b/test/src/main/java/org/springframework/security/test/web/servlet/request/SecurityMockMvcRequestPostProcessors.java @@ -53,6 +53,7 @@ import org.springframework.security.web.csrf.CsrfTokenRepository; import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.request.RequestPostProcessor; import org.springframework.util.Assert; +import org.springframework.util.DigestUtils; /** * Contains {@link MockMvc} {@link RequestPostProcessor} implementations for @@ -63,6 +64,25 @@ import org.springframework.util.Assert; */ public final class SecurityMockMvcRequestPostProcessors { + /** + * Creates a DigestRequestPostProcessor that enables easily adding digest based authentication to a request. + * + * @return the DigestRequestPostProcessor to use + */ + public static DigestRequestPostProcessor digest() { + return new DigestRequestPostProcessor(); + } + + /** + * Creates a DigestRequestPostProcessor that enables easily adding digest based authentication to a request. + * + * @param username the username to use + * @return the DigestRequestPostProcessor to use + */ + public static DigestRequestPostProcessor digest(String username) { + return digest().username(username); + } + /** * Populates the provided X509Certificate instances on the request. * @param certificates the X509Certificate instances to pouplate @@ -255,6 +275,134 @@ public final class SecurityMockMvcRequestPostProcessors { private CsrfRequestPostProcessor() {} } + public static class DigestRequestPostProcessor implements RequestPostProcessor { + private String username = "user"; + + private String password = "password"; + + private String realm = "Spring Security"; + + private String nonce = generateNonce(60); + + private String qop = "auth"; + + private String nc = "00000001"; + + private String cnonce = "c822c727a648aba7"; + + /** + * Configures the username to use + * @param username the username to use + * @return the DigestRequestPostProcessor for further customization + */ + private DigestRequestPostProcessor username(String username) { + Assert.notNull(username, "username cannot be null"); + this.username = username; + return this; + } + + /** + * Configures the password to use + * @param password the password to use + * @return the DigestRequestPostProcessor for further customization + */ + public DigestRequestPostProcessor password(String password) { + Assert.notNull(password, "password cannot be null"); + this.password = password; + return this; + } + + /** + * Configures the realm to use + * @param realm the realm to use + * @return the DigestRequestPostProcessor for further customization + */ + public DigestRequestPostProcessor realm(String realm) { + Assert.notNull(realm, "realm cannot be null"); + this.realm = realm; + return this; + } + + private static String generateNonce(int validitySeconds) { + long expiryTime = System.currentTimeMillis() + (validitySeconds * 1000); + String toDigest = expiryTime + ":" + "key"; + String signatureValue = md5Hex(toDigest); + String nonceValue = expiryTime + ":" + signatureValue; + + return new String(Base64.encode(nonceValue.getBytes())); + } + + private String createAuthorizationHeader(MockHttpServletRequest request) { + String uri = request.getRequestURI(); + String responseDigest = generateDigest(username, realm, password, request.getMethod(), + uri, qop, nonce, nc, cnonce); + return "Digest username=\"" + username + "\", realm=\"" + realm + "\", nonce=\"" + nonce + "\", uri=\"" + uri + + "\", response=\"" + responseDigest + "\", qop=" + qop + ", nc=" + nc + ", cnonce=\"" + cnonce + "\""; + } + + @Override + public MockHttpServletRequest postProcessRequest(MockHttpServletRequest request) { + + request.addHeader("Authorization", + createAuthorizationHeader(request)); + return request; + } + + + /** + * Computes the response portion of a Digest authentication header. Both the server and user + * agent should compute the response independently. Provided as a static method to simplify the + * coding of user agents. + * + * @param username the user's login name. + * @param realm the name of the realm. + * @param password the user's password in plaintext or ready-encoded. + * @param httpMethod the HTTP request method (GET, POST etc.) + * @param uri the request URI. + * @param qop the qop directive, or null if not set. + * @param nonce the nonce supplied by the server + * @param nc the "nonce-count" as defined in RFC 2617. + * @param cnonce opaque string supplied by the client when qop is set. + * @return the MD5 of the digest authentication response, encoded in hex + * @throws IllegalArgumentException if the supplied qop value is unsupported. + */ + private static String generateDigest(String username, String realm, String password, + String httpMethod, String uri, String qop, String nonce, String nc, String cnonce) + throws IllegalArgumentException { + String a1Md5 = encodePasswordInA1Format(username, realm, password); + String a2 = httpMethod + ":" + uri; + String a2Md5 = md5Hex(a2); + + String digest; + + if (qop == null) { + // as per RFC 2069 compliant clients (also reaffirmed by RFC 2617) + digest = a1Md5 + ":" + nonce + ":" + a2Md5; + } else if ("auth".equals(qop)) { + // As per RFC 2617 compliant clients + digest = a1Md5 + ":" + nonce + ":" + nc + ":" + cnonce + ":" + qop + ":" + a2Md5; + } else { + throw new IllegalArgumentException("This method does not support a qop: '" + qop + "'"); + } + + return md5Hex(digest); + } + + static String encodePasswordInA1Format(String username, String realm, String password) { + String a1 = username + ":" + realm + ":" + password; + + return md5Hex(a1); + } + + private static String md5Hex(String a2) { + try { + return DigestUtils.md5DigestAsHex(a2.getBytes("UTF-8")); + } catch(UnsupportedEncodingException e) { + throw new RuntimeException(e); + } + } + } + /** * Support class for {@link RequestPostProcessor}'s that establish a Spring * Security context diff --git a/test/src/test/java/org/springframework/security/test/web/servlet/request/SecurityMockMvcRequestPostProcessorsDigestTests.java b/test/src/test/java/org/springframework/security/test/web/servlet/request/SecurityMockMvcRequestPostProcessorsDigestTests.java new file mode 100644 index 0000000000..96809be670 --- /dev/null +++ b/test/src/test/java/org/springframework/security/test/web/servlet/request/SecurityMockMvcRequestPostProcessorsDigestTests.java @@ -0,0 +1,128 @@ +/* + * Copyright 2002-2014 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.security.test.web.servlet.request; + +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.springframework.mock.web.MockFilterChain; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.authority.AuthorityUtils; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.core.userdetails.User; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.security.web.authentication.www.DigestAuthenticationEntryPoint; +import org.springframework.security.web.authentication.www.DigestAuthenticationFilter; + +import javax.servlet.ServletException; +import javax.servlet.ServletRequest; +import javax.servlet.ServletResponse; +import java.io.IOException; + +import static org.fest.assertions.Assertions.assertThat; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.digest; + +public class SecurityMockMvcRequestPostProcessorsDigestTests { + + private DigestAuthenticationFilter filter; + private MockHttpServletRequest request; + + private String username; + + private String password; + + private DigestAuthenticationEntryPoint entryPoint; + + @Before + public void setup() { + this.password = "password"; + request = new MockHttpServletRequest(); + + entryPoint = new DigestAuthenticationEntryPoint(); + entryPoint.setKey("key"); + entryPoint.setRealmName("Spring Security"); + filter = new DigestAuthenticationFilter(); + filter.setUserDetailsService(new UserDetailsService() { + @Override + public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { + return new User(username,password, AuthorityUtils.createAuthorityList("ROLE_USER")); + } + }); + filter.setAuthenticationEntryPoint(entryPoint); + filter.afterPropertiesSet(); + } + + @After + public void cleanup() { + SecurityContextHolder.clearContext(); + } + + @Test + public void digestWithFilter() throws Exception { + MockHttpServletRequest postProcessedRequest = digest().postProcessRequest(request); + + assertThat(extractUser()).isEqualTo("user"); + } + + @Test + public void digestWithFilterCustomUsername() throws Exception { + String username = "admin"; + MockHttpServletRequest postProcessedRequest = digest(username).postProcessRequest(request); + + assertThat(extractUser()).isEqualTo(username); + } + + @Test + public void digestWithFilterCustomPassword() throws Exception { + String username = "custom"; + password = "secret"; + MockHttpServletRequest postProcessedRequest = digest(username).password(password).postProcessRequest(request); + + assertThat(extractUser()).isEqualTo(username); + } + + @Test + public void digestWithFilterCustomRealm() throws Exception { + String username = "admin"; + entryPoint.setRealmName("Custom"); + MockHttpServletRequest postProcessedRequest = digest(username).realm(entryPoint.getRealmName()).postProcessRequest(request); + + assertThat(extractUser()).isEqualTo(username); + } + + @Test + public void digestWithFilterFails() throws Exception { + String username = "admin"; + MockHttpServletRequest postProcessedRequest = digest(username).realm("Invalid").postProcessRequest(request); + + assertThat(extractUser()).isNull(); + } + + private String extractUser() throws IOException, ServletException { + filter.doFilter(request, new MockHttpServletResponse(), new MockFilterChain() { + @Override + public void doFilter(ServletRequest request, ServletResponse response) throws IOException, ServletException { + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + username = authentication == null ? null : authentication.getName(); + } + }); + return username; + } +} \ No newline at end of file