Skip to content

Commit 726eac2

Browse files
committed
Fix SCRAM final response handling
1 parent 77d7d17 commit 726eac2

File tree

6 files changed

+441
-74
lines changed

6 files changed

+441
-74
lines changed

httpclient5/src/main/java/org/apache/hc/client5/http/impl/DefaultAuthenticationStrategy.java

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,6 @@ public class DefaultAuthenticationStrategy implements AuthenticationStrategy {
6969
private static final List<String> DEFAULT_SCHEME_PRIORITY =
7070
Collections.unmodifiableList(Arrays.asList(
7171
StandardAuthScheme.BEARER,
72-
StandardAuthScheme.SCRAM_SHA_256,
7372
StandardAuthScheme.DIGEST,
7473
StandardAuthScheme.BASIC));
7574

httpclient5/src/main/java/org/apache/hc/client5/http/impl/auth/AuthenticationHandler.java

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -227,6 +227,19 @@ public boolean handleResponse(
227227
}
228228

229229
final Map<String, AuthChallenge> challengeMap = extractChallengeMap(challengeType, response, clientContext);
230+
if (challengeMap.isEmpty() && !challenged && isChallengeExpected) {
231+
final AuthScheme authScheme = authExchange.getAuthScheme();
232+
if (authScheme != null) {
233+
MessageSupport.parseHeaders(
234+
response,
235+
challengeType == ChallengeType.PROXY ? "Proxy-Authentication-Info" : "Authentication-Info",
236+
(buffer, cursor) -> {
237+
final String schemeName = authScheme.getName();
238+
final AuthChallenge authChallenge = parser.parse(challengeType, schemeName, buffer, cursor);
239+
challengeMap.put(schemeName.toLowerCase(Locale.ROOT), authChallenge);
240+
});
241+
}
242+
}
230243

231244
if (challengeMap.isEmpty()) {
232245
if (LOG.isDebugEnabled()) {

httpclient5/src/main/java/org/apache/hc/client5/http/impl/auth/ScramScheme.java

Lines changed: 100 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,9 @@ public final class ScramScheme implements AuthScheme {
8080

8181
private static final Logger LOG = LoggerFactory.getLogger(ScramScheme.class);
8282

83+
private static final int DEFAULT_WARN_MIN_ITERATIONS = 4096;
84+
private static final int DEFAULT_MAX_ITERATIONS_ALLOWED = 100000;
85+
8386
// RFC 7804 / RFC 5802 fixed no-CB GS2 header and its base64 value for 'c='
8487
private static final String GS2_HEADER = "n,,";
8588
private static final String C_BIND_B64 = "biws"; // base64("n,,")
@@ -100,6 +103,7 @@ private enum State {
100103
private final SecureRandom secureRandom;
101104
private final int warnMinIterations;
102105
private final int minIterationsRequired;
106+
private final int maxIterationsAllowed;
103107

104108
private State state = State.INIT;
105109
private boolean complete;
@@ -128,7 +132,7 @@ private enum State {
128132
* @since 5.6
129133
*/
130134
public ScramScheme() {
131-
this(4096, 0, null);
135+
this(DEFAULT_WARN_MIN_ITERATIONS, 0, DEFAULT_MAX_ITERATIONS_ALLOWED, null);
132136
}
133137

134138
/**
@@ -140,8 +144,26 @@ public ScramScheme() {
140144
* @since 5.6
141145
*/
142146
public ScramScheme(final int warnMinIterations, final int minIterationsRequired, final SecureRandom rnd) {
147+
this(warnMinIterations, minIterationsRequired, DEFAULT_MAX_ITERATIONS_ALLOWED, rnd);
148+
}
149+
150+
/**
151+
* Constructor with custom iteration policy.
152+
*
153+
* @param warnMinIterations warn if iteration count is lower than this (0 disables warnings)
154+
* @param minIterationsRequired fail if iteration count is lower than this (0 disables enforcement)
155+
* @param maxIterationsAllowed fail if iteration count is greater than this (must be positive)
156+
* @param rnd optional secure random source (null uses system default)
157+
* @since 5.6
158+
*/
159+
public ScramScheme(
160+
final int warnMinIterations,
161+
final int minIterationsRequired,
162+
final int maxIterationsAllowed,
163+
final SecureRandom rnd) {
143164
this.warnMinIterations = Math.max(0, warnMinIterations);
144165
this.minIterationsRequired = Math.max(0, minIterationsRequired);
166+
this.maxIterationsAllowed = Args.positive(maxIterationsAllowed, "Max iterations allowed");
145167
this.secureRandom = rnd != null ? rnd : new SecureRandom();
146168
}
147169

@@ -206,9 +228,10 @@ public void processChallenge(
206228
Args.notNull(context, "HTTP context");
207229

208230
if (authChallenge == null) {
209-
if (!challenged) {
210-
// Final response with no Authentication-Info: nothing to do
211-
return;
231+
if (!challenged && this.state == State.CLIENT_FINAL_SENT) {
232+
zeroAndClearExpectedV();
233+
this.state = State.FAILED;
234+
throw new AuthenticationException("Missing SCRAM Authentication-Info");
212235
}
213236
throw new MalformedChallengeException("Null SCRAM challenge");
214237
}
@@ -232,10 +255,32 @@ public void processChallenge(
232255
return;
233256
}
234257

258+
final String sid = params.get("sid");
259+
if (sid == null || sid.isEmpty()) {
260+
zeroAndClearExpectedV();
261+
this.state = State.FAILED;
262+
throw new MalformedChallengeException("SCRAM server-first missing sid");
263+
}
264+
235265
// server-first (data present)
236-
final String decoded = b64ToString(data);
266+
final String decoded;
267+
try {
268+
decoded = b64ToString(data);
269+
} catch (final MalformedChallengeException ex) {
270+
zeroAndClearExpectedV();
271+
this.state = State.FAILED;
272+
throw ex;
273+
}
237274
this.serverFirstRaw = decoded;
238-
final Map<String, String> attrs = parseAttrs(decoded);
275+
276+
final Map<String, String> attrs;
277+
try {
278+
attrs = parseAttrs(decoded);
279+
} catch (final MalformedChallengeException ex) {
280+
zeroAndClearExpectedV();
281+
this.state = State.FAILED;
282+
throw ex;
283+
}
239284

240285
final String r = attrs.get("r");
241286
final String s = attrs.get("s");
@@ -249,7 +294,6 @@ public void processChallenge(
249294
throw new AuthenticationException("SCRAM server nonce does not start with client nonce");
250295
}
251296

252-
this.sid = params.get("sid");
253297
try {
254298
this.salt = B64D.decode(s);
255299
if (this.salt.length == 0) {
@@ -274,25 +318,66 @@ public void processChallenge(
274318
throw new AuthenticationException(
275319
"SCRAM iteration count below required minimum: " + this.iterations + " < " + this.minIterationsRequired);
276320
}
321+
if (this.iterations > this.maxIterationsAllowed) {
322+
this.state = State.FAILED;
323+
throw new AuthenticationException(
324+
"SCRAM iteration count above allowed maximum: " + this.iterations + " > " + this.maxIterationsAllowed);
325+
}
277326
if (this.warnMinIterations > 0 && this.iterations < this.warnMinIterations && LOG.isWarnEnabled()) {
278327
LOG.warn("SCRAM iteration count ({}) lower than recommended ({})", this.iterations, warnMinIterations);
279328
}
280329

330+
this.sid = sid;
281331
this.serverNonce = r;
282332
this.state = State.SERVER_FIRST_RCVD;
283333
this.complete = false;
284334
zeroAndClearExpectedV();
285335
return;
286336
}
287337

338+
if (this.state != State.CLIENT_FINAL_SENT) {
339+
final State currentState = this.state;
340+
zeroAndClearExpectedV();
341+
this.state = State.FAILED;
342+
throw new AuthenticationException("SCRAM final response out of sequence: " + currentState);
343+
}
344+
345+
final String sid = params.get("sid");
346+
if (sid == null || sid.isEmpty()) {
347+
zeroAndClearExpectedV();
348+
this.state = State.FAILED;
349+
throw new MalformedChallengeException("SCRAM Authentication-Info missing sid");
350+
}
351+
if (this.sid == null || !this.sid.equals(sid)) {
352+
zeroAndClearExpectedV();
353+
this.state = State.FAILED;
354+
throw new AuthenticationException("SCRAM sid mismatch");
355+
}
356+
288357
// --- final-response path (Authentication-Info on any status) ---
289358
// For Authentication-Info, RFC 7804 does NOT mandate a scheme token; do NOT enforce scheme name here.
290359
final String data = params.get("data");
291360
if (data == null) {
292-
return;
361+
zeroAndClearExpectedV();
362+
this.state = State.FAILED;
363+
throw new MalformedChallengeException("SCRAM Authentication-Info missing data");
364+
}
365+
final String decoded;
366+
try {
367+
decoded = b64ToString(data);
368+
} catch (final MalformedChallengeException ex) {
369+
zeroAndClearExpectedV();
370+
this.state = State.FAILED;
371+
throw ex;
372+
}
373+
final Map<String, String> attrs;
374+
try {
375+
attrs = parseAttrs(decoded);
376+
} catch (final MalformedChallengeException ex) {
377+
zeroAndClearExpectedV();
378+
this.state = State.FAILED;
379+
throw ex;
293380
}
294-
final String decoded = b64ToString(data);
295-
final Map<String, String> attrs = parseAttrs(decoded);
296381
final String err = attrs.get("e");
297382
if (err != null) {
298383
this.state = State.FAILED;
@@ -303,7 +388,9 @@ public void processChallenge(
303388
}
304389
final String vB64 = attrs.get("v");
305390
if (vB64 == null) {
306-
return;
391+
zeroAndClearExpectedV();
392+
this.state = State.FAILED;
393+
throw new MalformedChallengeException("SCRAM Authentication-Info missing v");
307394
}
308395

309396
// compare 'v' in constant time; treat bad base64 for v as a signature mismatch (tests expect "signature")
@@ -335,7 +422,7 @@ public void processChallenge(
335422
*/
336423
@Override
337424
public boolean isChallengeComplete() {
338-
return this.complete || this.state == State.COMPLETE || this.state == State.FAILED;
425+
return this.state == State.FAILED;
339426
}
340427

341428
/**
@@ -486,9 +573,7 @@ private String buildClientFinalAndExpectV() throws AuthenticationException {
486573

487574
final StringBuilder sb = new StringBuilder(64);
488575
sb.append(StandardAuthScheme.SCRAM_SHA_256).append(' ');
489-
if (this.sid != null) {
490-
sb.append("sid=").append(quoteParam(this.sid)).append(", ");
491-
}
576+
sb.append("sid=").append(quoteParam(this.sid)).append(", ");
492577
sb.append("data=").append(quoteParam(data)); // quoted
493578

494579
this.state = State.CLIENT_FINAL_SENT;
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
/*
2+
* ====================================================================
3+
* Licensed to the Apache Software Foundation (ASF) under one
4+
* or more contributor license agreements. See the NOTICE file
5+
* distributed with this work for additional information
6+
* regarding copyright ownership. The ASF licenses this file
7+
* to you under the Apache License, Version 2.0 (the
8+
* "License"); you may not use this file except in compliance
9+
* with the License. You may obtain a copy of the License at
10+
*
11+
* http://www.apache.org/licenses/LICENSE-2.0
12+
*
13+
* Unless required by applicable law or agreed to in writing,
14+
* software distributed under the License is distributed on an
15+
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
16+
* KIND, either express or implied. See the License for the
17+
* specific language governing permissions and limitations
18+
* under the License.
19+
* ====================================================================
20+
*
21+
* This software consists of voluntary contributions made by many
22+
* individuals on behalf of the Apache Software Foundation. For more
23+
* information on the Apache Software Foundation, please see
24+
* <http://www.apache.org/>.
25+
*
26+
*/
27+
package org.apache.hc.client5.http.examples;
28+
29+
import java.util.Arrays;
30+
import java.util.Collections;
31+
import java.util.List;
32+
33+
import org.apache.hc.client5.http.auth.StandardAuthScheme;
34+
import org.apache.hc.client5.http.classic.methods.HttpGet;
35+
import org.apache.hc.client5.http.config.RequestConfig;
36+
import org.apache.hc.client5.http.impl.auth.CredentialsProviderBuilder;
37+
import org.apache.hc.client5.http.impl.classic.CloseableHttpClient;
38+
import org.apache.hc.client5.http.impl.classic.HttpClients;
39+
import org.apache.hc.core5.http.HttpHost;
40+
import org.apache.hc.core5.http.io.entity.EntityUtils;
41+
import org.apache.hc.core5.http.message.StatusLine;
42+
43+
/**
44+
* An example of how to explicitly enable {@code SCRAM-SHA-256} authentication.
45+
* SCRAM-SHA-256 is not part of the default auth scheme preference list and
46+
* must be opted into by placing it on the preferred auth schemes list of
47+
* the {@link RequestConfig}.
48+
*
49+
* @since 5.7
50+
*/
51+
public class ClientScramAuthentication {
52+
53+
public static void main(final String[] args) throws Exception {
54+
final List<String> priority = Collections.unmodifiableList(Arrays.asList(
55+
StandardAuthScheme.SCRAM_SHA_256,
56+
StandardAuthScheme.BEARER));
57+
58+
final HttpHost target = new HttpHost("http", "httpbin.org", 80);
59+
60+
try (final CloseableHttpClient httpclient = HttpClients.custom()
61+
.setDefaultRequestConfig(RequestConfig.custom()
62+
.setTargetPreferredAuthSchemes(priority)
63+
.setProxyPreferredAuthSchemes(priority)
64+
.build())
65+
.setDefaultCredentialsProvider(CredentialsProviderBuilder.create()
66+
.add(target, "user", "passwd".toCharArray())
67+
.build())
68+
.build()) {
69+
70+
final HttpGet httpget = new HttpGet("http://httpbin.org/basic-auth/user/passwd");
71+
72+
System.out.println("Executing request " + httpget.getMethod() + " " + httpget.getUri());
73+
httpclient.execute(httpget, response -> {
74+
System.out.println("----------------------------------------");
75+
System.out.println(httpget + "->" + new StatusLine(response));
76+
EntityUtils.consume(response.getEntity());
77+
return null;
78+
});
79+
}
80+
}
81+
}

httpclient5/src/test/java/org/apache/hc/client5/http/impl/auth/TestAuthenticationHandler.java

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
import java.util.LinkedList;
3030
import java.util.Queue;
3131

32+
import org.apache.hc.client5.http.auth.AuthChallenge;
3233
import org.apache.hc.client5.http.auth.AuthExchange;
3334
import org.apache.hc.client5.http.auth.AuthScheme;
3435
import org.apache.hc.client5.http.auth.AuthSchemeFactory;
@@ -48,6 +49,7 @@
4849
import org.apache.hc.core5.http.HttpRequest;
4950
import org.apache.hc.core5.http.HttpResponse;
5051
import org.apache.hc.core5.http.HttpStatus;
52+
import org.apache.hc.core5.http.NameValuePair;
5153
import org.apache.hc.core5.http.config.Lookup;
5254
import org.apache.hc.core5.http.config.RegistryBuilder;
5355
import org.apache.hc.core5.http.message.BasicHeader;
@@ -58,6 +60,7 @@
5860
import org.junit.jupiter.api.BeforeEach;
5961
import org.junit.jupiter.api.Test;
6062
import org.mockito.Answers;
63+
import org.mockito.ArgumentCaptor;
6164
import org.mockito.Mockito;
6265

6366
class TestAuthenticationHandler {
@@ -481,4 +484,47 @@ void testAuthSuccessConnectionBased() throws Exception {
481484
Mockito.any(HttpContext.class));
482485
}
483486

487+
private static String getParam(final AuthChallenge authChallenge, final String name) {
488+
for (final NameValuePair param : authChallenge.getParams()) {
489+
if (param.getName().equalsIgnoreCase(name)) {
490+
return param.getValue();
491+
}
492+
}
493+
return null;
494+
}
495+
496+
@Test
497+
void testAuthenticationInfoProcessedOnSuccessResponse() throws Exception {
498+
final HttpHost host = new HttpHost("somehost", 80);
499+
final HttpResponse response = new BasicHttpResponse(HttpStatus.SC_OK, "OK");
500+
response.addHeader(new BasicHeader("Authentication-Info", "sid=\"sid-1\", data=\"dj1hYmM\""));
501+
502+
final AuthScheme authScheme = Mockito.mock(AuthScheme.class);
503+
Mockito.when(authScheme.getName()).thenReturn(StandardAuthScheme.SCRAM_SHA_256);
504+
Mockito.when(authScheme.isChallengeExpected()).thenReturn(Boolean.TRUE);
505+
Mockito.when(authScheme.isChallengeComplete()).thenReturn(Boolean.FALSE);
506+
507+
this.authExchange.select(authScheme);
508+
this.authExchange.setState(AuthExchange.State.HANDSHAKE);
509+
510+
final DefaultAuthenticationStrategy authStrategy = new DefaultAuthenticationStrategy();
511+
512+
Assertions.assertFalse(this.httpAuthenticator.handleResponse(
513+
host, ChallengeType.TARGET, response, authStrategy, this.authExchange, this.context));
514+
Assertions.assertEquals(AuthExchange.State.SUCCESS, this.authExchange.getState());
515+
516+
final ArgumentCaptor<AuthChallenge> challengeCaptor = ArgumentCaptor.forClass(AuthChallenge.class);
517+
Mockito.verify(authScheme).processChallenge(
518+
Mockito.eq(host),
519+
Mockito.eq(false),
520+
challengeCaptor.capture(),
521+
Mockito.same(this.context));
522+
523+
final AuthChallenge challenge = challengeCaptor.getValue();
524+
Assertions.assertNotNull(challenge);
525+
Assertions.assertEquals(StandardAuthScheme.SCRAM_SHA_256, challenge.getSchemeName());
526+
Assertions.assertEquals("sid-1", getParam(challenge, "sid"));
527+
Assertions.assertEquals("dj1hYmM", getParam(challenge, "data"));
528+
}
529+
484530
}

0 commit comments

Comments
 (0)