diff --git a/httpclient5/src/main/java/org/apache/hc/client5/http/impl/auth/AuthenticationHandler.java b/httpclient5/src/main/java/org/apache/hc/client5/http/impl/auth/AuthenticationHandler.java index 4c7c485fc8..89e749aa50 100644 --- a/httpclient5/src/main/java/org/apache/hc/client5/http/impl/auth/AuthenticationHandler.java +++ b/httpclient5/src/main/java/org/apache/hc/client5/http/impl/auth/AuthenticationHandler.java @@ -27,6 +27,7 @@ package org.apache.hc.client5.http.impl.auth; +import java.util.ArrayList; import java.util.HashMap; import java.util.LinkedList; import java.util.List; @@ -52,6 +53,7 @@ import org.apache.hc.core5.http.HttpRequest; import org.apache.hc.core5.http.HttpResponse; import org.apache.hc.core5.http.HttpStatus; +import org.apache.hc.core5.http.NameValuePair; import org.apache.hc.core5.http.ParseException; import org.apache.hc.core5.http.message.BasicHeader; import org.apache.hc.core5.http.message.MessageSupport; @@ -230,14 +232,24 @@ public boolean handleResponse( if (challengeMap.isEmpty() && !challenged && isChallengeExpected) { final AuthScheme authScheme = authExchange.getAuthScheme(); if (authScheme != null) { + final String schemeName = authScheme.getName(); + // Authentication-Info is a comma-separated list that a server may split across several + // field lines; combine the parameters from all of them into a single challenge so the + // scheme sees the complete set (e.g. rspauth together with cnonce and nc). + final List authInfoParams = new ArrayList<>(); MessageSupport.parseHeaders( response, challengeType == ChallengeType.PROXY ? "Proxy-Authentication-Info" : "Authentication-Info", (buffer, cursor) -> { - final String schemeName = authScheme.getName(); final AuthChallenge authChallenge = parser.parse(challengeType, schemeName, buffer, cursor); - challengeMap.put(schemeName.toLowerCase(Locale.ROOT), authChallenge); + if (authChallenge.getParams() != null) { + authInfoParams.addAll(authChallenge.getParams()); + } }); + if (!authInfoParams.isEmpty()) { + challengeMap.put(schemeName.toLowerCase(Locale.ROOT), + new AuthChallenge(challengeType, schemeName, null, authInfoParams)); + } } } diff --git a/httpclient5/src/main/java/org/apache/hc/client5/http/impl/auth/DigestScheme.java b/httpclient5/src/main/java/org/apache/hc/client5/http/impl/auth/DigestScheme.java index 7f57e2f78a..d2484d1245 100644 --- a/httpclient5/src/main/java/org/apache/hc/client5/http/impl/auth/DigestScheme.java +++ b/httpclient5/src/main/java/org/apache/hc/client5/http/impl/auth/DigestScheme.java @@ -36,6 +36,7 @@ import java.security.Principal; import java.security.SecureRandom; import java.util.ArrayList; +import java.util.Arrays; import java.util.Formatter; import java.util.HashMap; import java.util.HashSet; @@ -139,6 +140,24 @@ private enum QualityOfProtection { private byte[] a1; private byte[] a2; + /** + * The qop value ("auth" or "auth-int") used by the last generated response, or {@code null} when + * no qop was negotiated. Retained to recompute the server's {@code rspauth} for mutual authentication. + */ + private String lastQop; + + /** + * Set when the last generated response used a qop, so an {@code Authentication-Info} response with + * {@code rspauth} is expected and should be verified. + */ + private boolean expectAuthInfo; + + /** + * Set once an {@code Authentication-Info} response has been processed without a mismatch, so the + * exchange is not mistaken for a failed challenge. + */ + private boolean mutualAuthComplete; + private UsernamePasswordCredentials credentials; public DigestScheme() { @@ -182,12 +201,36 @@ public String getRealm() { return this.paramMap.get("realm"); } + @Override + public boolean isChallengeExpected() { + return this.expectAuthInfo; + } + @Override public void processChallenge( final AuthChallenge authChallenge, final HttpContext context) throws MalformedChallengeException { + parseChallenge(authChallenge); + } + + @Override + public void processChallenge( + final HttpHost host, + final boolean challenged, + final AuthChallenge authChallenge, + final HttpContext context) throws MalformedChallengeException, AuthenticationException { + if (!challenged) { + verifyAuthenticationInfo(authChallenge); + this.mutualAuthComplete = true; + return; + } + parseChallenge(authChallenge); + } + + private void parseChallenge(final AuthChallenge authChallenge) throws MalformedChallengeException { Args.notNull(authChallenge, "AuthChallenge"); this.paramMap.clear(); + this.mutualAuthComplete = false; final List params = authChallenge.getParams(); if (params != null) { for (final NameValuePair param: params) { @@ -204,8 +247,88 @@ public void processChallenge( this.complete = true; } + /** + * Verifies the {@code rspauth} value carried by an {@code Authentication-Info} response against the + * value recomputed from the session state of the last generated response, providing mutual + * authentication. When no {@code Authentication-Info} or no {@code rspauth} + * is present the response is accepted, since a server is not required to send it. + */ + private void verifyAuthenticationInfo(final AuthChallenge authChallenge) throws AuthenticationException { + if (authChallenge == null) { + return; + } + final Map params = new HashMap<>(); + final List list = authChallenge.getParams(); + if (list != null) { + for (final NameValuePair param: list) { + params.put(param.getName().toLowerCase(Locale.ROOT), param.getValue()); + } + } + final String qop = params.get("qop"); + final String rspauth = params.get("rspauth"); + final String serverCnonce = params.get("cnonce"); + final String serverNc = params.get("nc"); + if ("auth".equalsIgnoreCase(qop)) { + // when Authentication-Info states qop=auth it must carry rspauth, cnonce and nc. + if (rspauth == null || serverCnonce == null || serverNc == null) { + throw new AuthenticationException( + "Digest Authentication-Info with qop=auth must include rspauth, cnonce and nc"); + } + } else if (rspauth == null) { + return; + } + if (a1 == null || a2 == null || lastNonce == null || cnonce == null || lastQop == null) { + throw new AuthenticationException("Cannot verify rspauth: missing digest session state"); + } + // For qop=auth the Authentication-Info must echo the exact cnonce and nc used for the request. + final StringBuilder ncBuilder = new StringBuilder(8); + try (final Formatter formatter = new Formatter(ncBuilder, Locale.ROOT)) { + formatter.format("%08x", this.nounceCount); + } + final String nc = ncBuilder.toString(); + if (serverCnonce == null || !serverCnonce.equals(this.cnonce)) { + throw new AuthenticationException("Digest Authentication-Info cnonce mismatch"); + } + if (serverNc == null || !serverNc.equals(nc)) { + throw new AuthenticationException("Digest Authentication-Info nc mismatch"); + } + final String algorithm = this.paramMap.get("algorithm"); + final MessageDigest digester; + try { + digester = createMessageDigest(DigestAlgorithm.fromString(algorithm == null ? "MD5" : algorithm) + .getBaseAlgorithm()); + } catch (final UnsupportedDigestAlgorithmException ex) { + throw new AuthenticationException("Unsupported digest algorithm: " + algorithm); + } + final Charset charset = AuthSchemeSupport.parseCharset(this.paramMap.get("charset"), this.defaultCharset); + + final String hasha1 = formatHex(digester.digest(a1)); + // The rspauth A2 uses an empty method, so reuse the request A2 from its first ':' onwards. + int colon = -1; + for (int i = 0; i < a2.length; i++) { + if (a2[i] == ':') { + colon = i; + break; + } + } + final byte[] a2rsp = colon < 0 ? a2 : Arrays.copyOfRange(a2, colon, a2.length); + final String hasha2 = formatHex(digester.digest(a2rsp)); + + final String kd = hasha1 + ":" + this.lastNonce + ":" + nc + ":" + this.cnonce + ":" + this.lastQop + + ":" + hasha2; + final String expected = formatHex(digester.digest(kd.getBytes(charset))); + if (!MessageDigest.isEqual( + expected.getBytes(StandardCharsets.US_ASCII), + rspauth.toLowerCase(Locale.ROOT).getBytes(StandardCharsets.US_ASCII))) { + throw new AuthenticationException("Digest response authentication failed (rspauth mismatch)"); + } + } + @Override public boolean isChallengeComplete() { + if (this.mutualAuthComplete) { + return false; + } final String s = this.paramMap.get("stale"); return !"true".equalsIgnoreCase(s) && this.complete; } @@ -447,6 +570,12 @@ private String createDigestResponse(final HttpRequest request) throws Authentica final String digest = formatHex(digester.digest(digestInput)); + this.lastQop = qop == QualityOfProtection.MISSING ? null + : qop == QualityOfProtection.AUTH_INT ? "auth-int" : "auth"; + // rspauth for qop=auth-int hashes the response body, which is not available here, so only + // qop=auth is verified. + this.expectAuthInfo = qop == QualityOfProtection.AUTH; + final CharArrayBuffer buffer = new CharArrayBuffer(128); buffer.append(StandardAuthScheme.DIGEST + " "); diff --git a/httpclient5/src/test/java/org/apache/hc/client5/http/impl/auth/TestAuthenticationHandler.java b/httpclient5/src/test/java/org/apache/hc/client5/http/impl/auth/TestAuthenticationHandler.java index 48f5b82056..0d3015557b 100644 --- a/httpclient5/src/test/java/org/apache/hc/client5/http/impl/auth/TestAuthenticationHandler.java +++ b/httpclient5/src/test/java/org/apache/hc/client5/http/impl/auth/TestAuthenticationHandler.java @@ -527,4 +527,38 @@ void testAuthenticationInfoProcessedOnSuccessResponse() throws Exception { Assertions.assertEquals("dj1hYmM", getParam(challenge, "data")); } + @Test + void testAuthenticationInfoCombinesMultipleFieldLines() throws Exception { + final HttpHost host = new HttpHost("somehost", 80); + final HttpResponse response = new BasicHttpResponse(HttpStatus.SC_OK, "OK"); + // A server may split the Authentication-Info list across multiple field lines. + response.addHeader(new BasicHeader("Authentication-Info", "rspauth=\"abc\"")); + response.addHeader(new BasicHeader("Authentication-Info", "cnonce=\"xyz\", nc=00000001")); + + final AuthScheme authScheme = Mockito.mock(AuthScheme.class); + Mockito.when(authScheme.getName()).thenReturn(StandardAuthScheme.DIGEST); + Mockito.when(authScheme.isChallengeExpected()).thenReturn(Boolean.TRUE); + Mockito.when(authScheme.isChallengeComplete()).thenReturn(Boolean.FALSE); + + this.authExchange.select(authScheme); + this.authExchange.setState(AuthExchange.State.HANDSHAKE); + + final DefaultAuthenticationStrategy authStrategy = new DefaultAuthenticationStrategy(); + + this.httpAuthenticator.handleResponse( + host, ChallengeType.TARGET, response, authStrategy, this.authExchange, this.context); + + final ArgumentCaptor challengeCaptor = ArgumentCaptor.forClass(AuthChallenge.class); + Mockito.verify(authScheme).processChallenge( + Mockito.eq(host), + Mockito.eq(false), + challengeCaptor.capture(), + Mockito.same(this.context)); + + final AuthChallenge challenge = challengeCaptor.getValue(); + Assertions.assertEquals("abc", getParam(challenge, "rspauth")); + Assertions.assertEquals("xyz", getParam(challenge, "cnonce")); + Assertions.assertEquals("00000001", getParam(challenge, "nc")); + } + } diff --git a/httpclient5/src/test/java/org/apache/hc/client5/http/impl/auth/TestDigestScheme.java b/httpclient5/src/test/java/org/apache/hc/client5/http/impl/auth/TestDigestScheme.java index e44fe89558..5dce86aa64 100644 --- a/httpclient5/src/test/java/org/apache/hc/client5/http/impl/auth/TestDigestScheme.java +++ b/httpclient5/src/test/java/org/apache/hc/client5/http/impl/auth/TestDigestScheme.java @@ -35,6 +35,7 @@ import java.security.MessageDigest; import java.util.HashMap; import java.util.List; +import java.util.Locale; import java.util.Map; import org.apache.hc.client5.http.auth.AuthChallenge; @@ -57,6 +58,7 @@ import org.apache.hc.core5.http.message.BasicClassicHttpRequest; import org.apache.hc.core5.http.message.BasicHeaderValueParser; import org.apache.hc.core5.http.message.BasicHttpRequest; +import org.apache.hc.core5.http.message.BasicNameValuePair; import org.apache.hc.core5.http.message.ParserCursor; import org.apache.hc.core5.util.CharArrayBuffer; import org.junit.jupiter.api.Assertions; @@ -1159,6 +1161,194 @@ void testDigestSHA512256SessA1AndCnonceConsistency() throws Exception { Assertions.assertNotEquals(sessionKey1, sessionKey4); } + private static final HttpHost RSPAUTH_HOST = new HttpHost("somehost", 80); + private DigestScheme digestSchemeWithResponse(final String qop, final String algorithm) throws Exception { + final CredentialsProvider credentialsProvider = CredentialsProviderBuilder.create() + .add(new AuthScope(RSPAUTH_HOST, "realm", null), "username", "password".toCharArray()) + .build(); + final AuthChallenge challenge = parse(StandardAuthScheme.DIGEST + " realm=\"realm\", nonce=\"TestNonce\", " + + "algorithm=" + algorithm + ", qop=\"" + qop + "\""); + final DigestScheme authscheme = new DigestScheme(); + authscheme.processChallenge(challenge, null); + Assertions.assertTrue(authscheme.isResponseReady(RSPAUTH_HOST, credentialsProvider, null)); + authscheme.generateAuthResponse(RSPAUTH_HOST, new BasicHttpRequest("GET", "/path"), null); + return authscheme; + } + + private static String computeRspauth(final DigestScheme authscheme, final String algorithm, final String qop) + throws Exception { + final MessageDigest md = MessageDigest.getInstance(algorithm); + final byte[] a1 = authscheme.getA1().getBytes(StandardCharsets.US_ASCII); + final String a2 = authscheme.getA2(); + final byte[] a2rsp = a2.substring(a2.indexOf(':')).getBytes(StandardCharsets.US_ASCII); + final String ha1 = DigestScheme.formatHex(md.digest(a1)); + final String ha2 = DigestScheme.formatHex(md.digest(a2rsp)); + final String nc = String.format("%08x", authscheme.getNounceCount()); + final String kd = ha1 + ":" + authscheme.getNonce() + ":" + nc + ":" + authscheme.getCnonce() + + ":" + qop + ":" + ha2; + return DigestScheme.formatHex(md.digest(kd.getBytes(StandardCharsets.UTF_8))); + } + + private static AuthChallenge authInfo(final DigestScheme authscheme, final String rspauth) { + return new AuthChallenge(ChallengeType.TARGET, StandardAuthScheme.DIGEST, + new BasicNameValuePair("rspauth", rspauth), + new BasicNameValuePair("cnonce", authscheme.getCnonce()), + new BasicNameValuePair("nc", String.format("%08x", authscheme.getNounceCount()))); + } + + @Test + void testRspauthMutualAuthenticationSucceeds() throws Exception { + final DigestScheme authscheme = digestSchemeWithResponse("auth", "MD5"); + Assertions.assertTrue(authscheme.isChallengeExpected()); + + final String rspauth = computeRspauth(authscheme, "MD5", "auth"); + final AuthChallenge authInfo = authInfo(authscheme, rspauth); + Assertions.assertDoesNotThrow(() -> + authscheme.processChallenge(RSPAUTH_HOST, false, authInfo, HttpClientContext.create())); + Assertions.assertFalse(authscheme.isChallengeComplete()); + } + + @Test + void testRspauthMismatchFailsMutualAuthentication() throws Exception { + final DigestScheme authscheme = digestSchemeWithResponse("auth", "MD5"); + final AuthChallenge authInfo = authInfo(authscheme, "00000000000000000000000000000000"); + Assertions.assertThrows(AuthenticationException.class, () -> + authscheme.processChallenge(RSPAUTH_HOST, false, authInfo, HttpClientContext.create())); + } + + @Test + void testMissingAuthenticationInfoIsAccepted() throws Exception { + final DigestScheme authscheme = digestSchemeWithResponse("auth", "MD5"); + // No Authentication-Info header at all. + Assertions.assertDoesNotThrow(() -> + authscheme.processChallenge(RSPAUTH_HOST, false, null, HttpClientContext.create())); + Assertions.assertFalse(authscheme.isChallengeComplete()); + // Authentication-Info without rspauth (e.g. only nextnonce) is also accepted. + final AuthChallenge noRspauth = new AuthChallenge(ChallengeType.TARGET, StandardAuthScheme.DIGEST, + new BasicNameValuePair("nextnonce", "abcdef")); + Assertions.assertDoesNotThrow(() -> + authscheme.processChallenge(RSPAUTH_HOST, false, noRspauth, HttpClientContext.create())); + } + + @Test + void testNoQopDoesNotExpectAuthenticationInfo() throws Exception { + final CredentialsProvider credentialsProvider = CredentialsProviderBuilder.create() + .add(new AuthScope(RSPAUTH_HOST, "realm", null), "username", "password".toCharArray()) + .build(); + final AuthChallenge challenge = parse(StandardAuthScheme.DIGEST + + " realm=\"realm\", nonce=\"TestNonce\", algorithm=MD5"); + final DigestScheme authscheme = new DigestScheme(); + authscheme.processChallenge(challenge, null); + Assertions.assertTrue(authscheme.isResponseReady(RSPAUTH_HOST, credentialsProvider, null)); + authscheme.generateAuthResponse(RSPAUTH_HOST, new BasicHttpRequest("GET", "/path"), null); + Assertions.assertFalse(authscheme.isChallengeExpected()); + } + + @Test + void testRspauthUppercaseHexIsAccepted() throws Exception { + final DigestScheme authscheme = digestSchemeWithResponse("auth", "MD5"); + final String rspauth = computeRspauth(authscheme, "MD5", "auth").toUpperCase(Locale.ROOT); + final AuthChallenge authInfo = authInfo(authscheme, rspauth); + Assertions.assertDoesNotThrow(() -> + authscheme.processChallenge(RSPAUTH_HOST, false, authInfo, HttpClientContext.create())); + } + + @Test + void testRspauthMissingCnonceIsRejected() throws Exception { + final DigestScheme authscheme = digestSchemeWithResponse("auth", "MD5"); + final String rspauth = computeRspauth(authscheme, "MD5", "auth"); + // rspauth is valid but the Authentication-Info omits the cnonce echo. + final AuthChallenge authInfo = new AuthChallenge(ChallengeType.TARGET, StandardAuthScheme.DIGEST, + new BasicNameValuePair("rspauth", rspauth), + new BasicNameValuePair("nc", String.format("%08x", authscheme.getNounceCount()))); + Assertions.assertThrows(AuthenticationException.class, () -> + authscheme.processChallenge(RSPAUTH_HOST, false, authInfo, HttpClientContext.create())); + } + + @Test + void testRspauthCnonceMismatchIsRejected() throws Exception { + final DigestScheme authscheme = digestSchemeWithResponse("auth", "MD5"); + final String rspauth = computeRspauth(authscheme, "MD5", "auth"); + // The echoed cnonce does not match the one used for the request. + final AuthChallenge authInfo = new AuthChallenge(ChallengeType.TARGET, StandardAuthScheme.DIGEST, + new BasicNameValuePair("rspauth", rspauth), + new BasicNameValuePair("cnonce", "deadbeef"), + new BasicNameValuePair("nc", String.format("%08x", authscheme.getNounceCount()))); + Assertions.assertThrows(AuthenticationException.class, () -> + authscheme.processChallenge(RSPAUTH_HOST, false, authInfo, HttpClientContext.create())); + } + + @Test + void testRspauthMissingNcIsRejected() throws Exception { + final DigestScheme authscheme = digestSchemeWithResponse("auth", "MD5"); + final String rspauth = computeRspauth(authscheme, "MD5", "auth"); + // rspauth is valid but the Authentication-Info omits the nc echo. + final AuthChallenge authInfo = new AuthChallenge(ChallengeType.TARGET, StandardAuthScheme.DIGEST, + new BasicNameValuePair("rspauth", rspauth), + new BasicNameValuePair("cnonce", authscheme.getCnonce())); + Assertions.assertThrows(AuthenticationException.class, () -> + authscheme.processChallenge(RSPAUTH_HOST, false, authInfo, HttpClientContext.create())); + } + + @Test + void testRspauthNcMismatchIsRejected() throws Exception { + final DigestScheme authscheme = digestSchemeWithResponse("auth", "MD5"); + final String rspauth = computeRspauth(authscheme, "MD5", "auth"); + // The echoed nc does not match the nonce-count used for the request. + final AuthChallenge authInfo = new AuthChallenge(ChallengeType.TARGET, StandardAuthScheme.DIGEST, + new BasicNameValuePair("rspauth", rspauth), + new BasicNameValuePair("cnonce", authscheme.getCnonce()), + new BasicNameValuePair("nc", "00000002")); + Assertions.assertThrows(AuthenticationException.class, () -> + authscheme.processChallenge(RSPAUTH_HOST, false, authInfo, HttpClientContext.create())); + } + + @Test + void testQopAuthWithoutRspauthIsRejected() throws Exception { + final DigestScheme authscheme = digestSchemeWithResponse("auth", "MD5"); + // Authentication-Info states qop=auth but omits rspauth; per RFC rspauth, cnonce and nc are all required. + final AuthChallenge authInfo = new AuthChallenge(ChallengeType.TARGET, StandardAuthScheme.DIGEST, + new BasicNameValuePair("qop", "auth"), + new BasicNameValuePair("cnonce", authscheme.getCnonce()), + new BasicNameValuePair("nc", String.format("%08x", authscheme.getNounceCount()))); + Assertions.assertThrows(AuthenticationException.class, () -> + authscheme.processChallenge(RSPAUTH_HOST, false, authInfo, HttpClientContext.create())); + } + + @Test + void testRspauthMutualAuthenticationSucceedsWithSha256() throws Exception { + final DigestScheme authscheme = digestSchemeWithResponse("auth", "SHA-256"); + final String rspauth = computeRspauth(authscheme, "SHA-256", "auth"); + final AuthChallenge authInfo = authInfo(authscheme, rspauth); + Assertions.assertDoesNotThrow(() -> + authscheme.processChallenge(RSPAUTH_HOST, false, authInfo, HttpClientContext.create())); + Assertions.assertFalse(authscheme.isChallengeComplete()); + } + + @Test + void testAuthIntDoesNotExpectAuthenticationInfo() throws Exception { + final CredentialsProvider credentialsProvider = CredentialsProviderBuilder.create() + .add(new AuthScope(RSPAUTH_HOST, "realm", null), "username", "password".toCharArray()) + .build(); + final AuthChallenge challenge = parse(StandardAuthScheme.DIGEST + + " realm=\"realm\", nonce=\"TestNonce\", algorithm=MD5, qop=\"auth-int\""); + final DigestScheme authscheme = new DigestScheme(); + authscheme.processChallenge(challenge, null); + Assertions.assertTrue(authscheme.isResponseReady(RSPAUTH_HOST, credentialsProvider, null)); + authscheme.generateAuthResponse(RSPAUTH_HOST, new BasicClassicHttpRequest("POST", "/path"), null); + // rspauth for qop=auth-int hashes the response body, which is unavailable, so it is not verified. + Assertions.assertFalse(authscheme.isChallengeExpected()); + } + + @Test + void testProcessChallengeChallengedParsesChallenge() throws Exception { + final DigestScheme authscheme = new DigestScheme(); + final AuthChallenge challenge = parse(StandardAuthScheme.DIGEST + + " realm=\"realm\", nonce=\"TestNonce\", algorithm=MD5, qop=\"auth\""); + authscheme.processChallenge(RSPAUTH_HOST, true, challenge, HttpClientContext.create()); + Assertions.assertEquals("realm", authscheme.getRealm()); + Assertions.assertTrue(authscheme.isChallengeComplete()); + } }