diff --git a/httpclient5-testing/src/test/java/org/apache/hc/client5/testing/extension/async/StandardTestClientBuilder.java b/httpclient5-testing/src/test/java/org/apache/hc/client5/testing/extension/async/StandardTestClientBuilder.java index 24f29dd670..b5e85f192a 100644 --- a/httpclient5-testing/src/test/java/org/apache/hc/client5/testing/extension/async/StandardTestClientBuilder.java +++ b/httpclient5-testing/src/test/java/org/apache/hc/client5/testing/extension/async/StandardTestClientBuilder.java @@ -33,6 +33,7 @@ import org.apache.hc.client5.http.HttpRequestRetryStrategy; import org.apache.hc.client5.http.UserTokenHandler; import org.apache.hc.client5.http.auth.AuthSchemeFactory; +import org.apache.hc.client5.http.auth.CredentialsProvider; import org.apache.hc.client5.http.config.ConnectionConfig; import org.apache.hc.client5.http.config.TlsConfig; import org.apache.hc.client5.http.impl.async.CloseableHttpAsyncClient; @@ -153,6 +154,12 @@ public TestAsyncClientBuilder setDefaultAuthSchemeRegistry(final Lookup. + * + */ +package org.apache.hc.client5.testing.sync; + + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.security.Principal; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +import org.apache.hc.client5.http.AuthenticationStrategy; +import org.apache.hc.client5.http.ClientProtocolException; +import org.apache.hc.client5.http.SystemDefaultDnsResolver; +import org.apache.hc.client5.http.auth.AuthScheme; +import org.apache.hc.client5.http.auth.AuthSchemeFactory; +import org.apache.hc.client5.http.auth.AuthScope; +import org.apache.hc.client5.http.auth.AuthenticationException; +import org.apache.hc.client5.http.auth.Credentials; +import org.apache.hc.client5.http.auth.CredentialsProvider; +import org.apache.hc.client5.http.auth.KerberosConfig; +import org.apache.hc.client5.http.auth.KerberosConfig.Option; +import org.apache.hc.client5.http.auth.StandardAuthScheme; +import org.apache.hc.client5.http.classic.methods.HttpGet; +import org.apache.hc.client5.http.impl.DefaultAuthenticationStrategy; +import org.apache.hc.client5.http.impl.auth.CredentialsProviderBuilder; +import org.apache.hc.client5.http.impl.auth.MutualSpnegoScheme; +import org.apache.hc.client5.http.protocol.HttpClientContext; +import org.apache.hc.client5.http.utils.Base64; +import org.apache.hc.client5.testing.extension.sync.ClientProtocolLevel; +import org.apache.hc.core5.http.ClassicHttpRequest; +import org.apache.hc.core5.http.ClassicHttpResponse; +import org.apache.hc.core5.http.HttpException; +import org.apache.hc.core5.http.HttpHost; +import org.apache.hc.core5.http.HttpStatus; +import org.apache.hc.core5.http.URIScheme; +import org.apache.hc.core5.http.config.Registry; +import org.apache.hc.core5.http.config.RegistryBuilder; +import org.apache.hc.core5.http.io.HttpRequestHandler; +import org.apache.hc.core5.http.io.entity.EntityUtils; +import org.apache.hc.core5.http.io.entity.StringEntity; +import org.apache.hc.core5.http.message.BasicHeader; +import org.apache.hc.core5.http.protocol.HttpContext; +import org.apache.hc.core5.util.Timeout; +import org.ietf.jgss.GSSContext; +import org.ietf.jgss.GSSException; +import org.ietf.jgss.GSSManager; +import org.ietf.jgss.GSSName; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.mockito.AdditionalMatchers; +import org.mockito.ArgumentMatchers; +import org.mockito.Mockito; + +/** + * Tests for {@link SPNegoScheme}. + */ +public class TestMutualSpnegoScheme extends AbstractIntegrationTestBase { + + protected TestMutualSpnegoScheme() { + super(URIScheme.HTTP, ClientProtocolLevel.STANDARD); + } + + public static final Timeout TIMEOUT = Timeout.ofMinutes(1); + + private static final String GOOD_TOKEN = "GOOD_TOKEN"; + private static final byte[] GOOD_TOKEN_BYTES = GOOD_TOKEN.getBytes(StandardCharsets.UTF_8); + private static final byte[] GOOD_TOKEN_B64_BYTES = Base64.encodeBase64(GOOD_TOKEN_BYTES); + private static final String GOOD_TOKEN_B64 = new String(GOOD_TOKEN_B64_BYTES); + + private static final String NO_TOKEN = ""; + private static final byte[] NO_TOKEN_BYTES = NO_TOKEN.getBytes(StandardCharsets.UTF_8); + + private static final String GOOD_MUTUAL_AUTH_TOKEN = "GOOD_MUTUAL_AUTH_TOKEN"; + private static final byte[] GOOD_MUTUAL_AUTH_TOKEN_BYTES = GOOD_MUTUAL_AUTH_TOKEN.getBytes(StandardCharsets.UTF_8); + private static final byte[] GOOD_MUTUAL_AUTH_TOKEN_B64_BYTES = Base64.encodeBase64(GOOD_MUTUAL_AUTH_TOKEN_BYTES); + + private static final String BAD_MUTUAL_AUTH_TOKEN = "BAD_MUTUAL_AUTH_TOKEN"; + private static final byte[] BAD_MUTUAL_AUTH_TOKEN_BYTES = BAD_MUTUAL_AUTH_TOKEN.getBytes(StandardCharsets.UTF_8); + private static final byte[] BAD_MUTUAL_AUTH_TOKEN_B64_BYTES = Base64.encodeBase64(BAD_MUTUAL_AUTH_TOKEN_BYTES); + + static KerberosConfig MUTUAL_KERBEROS_CONFIG = KerberosConfig.custom().setRequestMutualAuth(Option.ENABLE).build(); + + private static class SpnegoAuthenticationStrategy extends DefaultAuthenticationStrategy { + + private static final List SPNEGO_SCHEME_PRIORITY = + Collections.unmodifiableList( + Arrays.asList(StandardAuthScheme.SPNEGO, + StandardAuthScheme.BEARER, + StandardAuthScheme.DIGEST, + StandardAuthScheme.BASIC)); + + @Override + protected final List getSchemePriority() { + return SPNEGO_SCHEME_PRIORITY; + } + } + + final AuthenticationStrategy spnegoAuthenticationStrategy = new SpnegoAuthenticationStrategy(); + + final CredentialsProvider jaasCredentialsProvider = CredentialsProviderBuilder.create() + .add(new AuthScope(null, null, -1, null, null), new UseJaasCredentials()) + .build(); + + /** + * This service will continue to ask for authentication. + */ + private static class PleaseNegotiateService implements HttpRequestHandler { + + @Override + public void handle( + final ClassicHttpRequest request, + final ClassicHttpResponse response, + final HttpContext context) throws HttpException, IOException { + response.setCode(HttpStatus.SC_UNAUTHORIZED); + response.addHeader(new BasicHeader("WWW-Authenticate", StandardAuthScheme.SPNEGO + " blablabla")); + response.addHeader(new BasicHeader("Connection", "Keep-Alive")); + response.setEntity(new StringEntity("auth required ")); + } + } + + /** + * This service implements a normal mutualAuth flow + */ + private static class SPNEGOMutualService implements HttpRequestHandler { + + int callCount = 1; + final boolean sendMutualToken; + final byte[] encodedMutualAuthToken; + + SPNEGOMutualService (final boolean sendMutualToken, final byte[] encodedMutualAuthToken) { + this.sendMutualToken = sendMutualToken; + this.encodedMutualAuthToken = encodedMutualAuthToken; + } + + @Override + public void handle( + final ClassicHttpRequest request, + final ClassicHttpResponse response, + final HttpContext context) throws HttpException, IOException { + if (callCount == 1) { + callCount++; + // Send the empty challenge + response.setCode(HttpStatus.SC_UNAUTHORIZED); + response.addHeader(new BasicHeader("WWW-Authenticate", StandardAuthScheme.SPNEGO)); + response.addHeader(new BasicHeader("Connection", "Keep-Alive")); + response.setEntity(new StringEntity("auth required ")); + } else if (callCount == 2) { + callCount++; + if (request.getHeader("Authorization").getValue().contains(GOOD_TOKEN_B64)) { + response.setCode(HttpStatus.SC_OK); + if (sendMutualToken) { + response.addHeader(new BasicHeader("WWW-Authenticate", StandardAuthScheme.SPNEGO + " " + new String(encodedMutualAuthToken))); + } + response.addHeader(new BasicHeader("Connection", "Keep-Alive")); + response.setEntity(new StringEntity("auth successful ")); + } else { + response.setCode(HttpStatus.SC_INTERNAL_SERVER_ERROR); + } + } + } + } + + /** + * NegotatieScheme with a custom GSSManager that does not require any Jaas or + * Kerberos configuration. + * + */ + private static class NegotiateSchemeWithMockGssManager extends MutualSpnegoScheme { + + final GSSManager manager = Mockito.mock(GSSManager.class); + final GSSName name = Mockito.mock(GSSName.class); + final GSSContext context = Mockito.mock(GSSContext.class); + + NegotiateSchemeWithMockGssManager() throws Exception { + super(KerberosConfig.DEFAULT, SystemDefaultDnsResolver.INSTANCE); + Mockito.when(context.initSecContext( + ArgumentMatchers.any(), ArgumentMatchers.anyInt(), ArgumentMatchers.anyInt())) + .thenReturn("12345678".getBytes()); + Mockito.when(manager.createName( + ArgumentMatchers.anyString(), ArgumentMatchers.any())) + .thenReturn(name); + Mockito.when(manager.createContext( + ArgumentMatchers.any(), ArgumentMatchers.any(), + ArgumentMatchers.any(), ArgumentMatchers.anyInt())) + .thenReturn(context); + } + + @Override + protected GSSManager getManager() { + return manager; + } + + } + + private static class MutualNegotiateSchemeWithMockGssManager extends MutualSpnegoScheme { + + final GSSManager manager = Mockito.mock(GSSManager.class); + final GSSName name = Mockito.mock(GSSName.class); + final GSSContext context = Mockito.mock(GSSContext.class); + + MutualNegotiateSchemeWithMockGssManager(final boolean established, final boolean mutual) throws Exception { + super(MUTUAL_KERBEROS_CONFIG, SystemDefaultDnsResolver.INSTANCE); + // Initial empty WWW-Authenticate response header + Mockito.when(context.initSecContext( + AdditionalMatchers.aryEq(NO_TOKEN_BYTES), ArgumentMatchers.anyInt(), ArgumentMatchers.anyInt())) + .thenReturn(GOOD_TOKEN_BYTES); + // Valid mutual token + Mockito.when(context.initSecContext( + AdditionalMatchers.aryEq(GOOD_MUTUAL_AUTH_TOKEN_BYTES), ArgumentMatchers.anyInt(), ArgumentMatchers.anyInt())) + .thenReturn(NO_TOKEN_BYTES); + // Invalid mutual token + Mockito.when(context.initSecContext( + AdditionalMatchers.aryEq(BAD_MUTUAL_AUTH_TOKEN_BYTES), ArgumentMatchers.anyInt(), ArgumentMatchers.anyInt())) + .thenThrow(new GSSException(GSSException.DEFECTIVE_CREDENTIAL)); + // It's hard to mock state, so instead we specify the complete and mutualAuth states + // in the constructor + Mockito.when(context.isEstablished()).thenReturn(established); + Mockito.when(context.getMutualAuthState()).thenReturn(mutual); + Mockito.when(manager.createName( + ArgumentMatchers.anyString(), ArgumentMatchers.any())) + .thenReturn(name); + Mockito.when(manager.createContext( + ArgumentMatchers.any(), ArgumentMatchers.any(), + ArgumentMatchers.any(), ArgumentMatchers.anyInt())) + .thenReturn(context); + } + + @Override + protected GSSManager getManager() { + return manager; + } + + } + + private static class UseJaasCredentials implements Credentials { + + @Override + public char[] getPassword() { + return null; + } + + @Override + public Principal getUserPrincipal() { + return null; + } + + } + + private static class TestAuthSchemeFactory implements AuthSchemeFactory { + + AuthScheme scheme; + + TestAuthSchemeFactory(final AuthScheme scheme) throws Exception { + this.scheme = scheme; + } + + @Override + public AuthScheme create(final HttpContext context) { + return scheme; + } + + } + + + /** + * Tests that the client will stop connecting to the server if + * the server still keep asking for a valid ticket. + */ + @Test + void testDontTryToAuthenticateEndlessly() throws Exception { + configureServer(t -> { + t.register("*", new PleaseNegotiateService()); + }); + + final AuthSchemeFactory nsf = new TestAuthSchemeFactory(new NegotiateSchemeWithMockGssManager()); + final Registry authSchemeRegistry = RegistryBuilder.create() + .register(StandardAuthScheme.SPNEGO, nsf) + .build(); + configureClient(t -> { + t.setTargetAuthenticationStrategy(spnegoAuthenticationStrategy); + t.setDefaultAuthSchemeRegistry(authSchemeRegistry); + t.setDefaultCredentialsProvider(jaasCredentialsProvider); + }); + + final HttpHost target = startServer(); + final String s = "/path"; + final HttpGet httpget = new HttpGet(s); + client().execute(target, httpget, response -> { + EntityUtils.consume(response.getEntity()); + Assertions.assertEquals(HttpStatus.SC_UNAUTHORIZED, response.getCode()); + return null; + }); + } + + /** + * Javadoc specifies that {@link GSSContext#initSecContext(byte[], int, int)} can return null + * if no token is generated. Client should be able to deal with this response. + */ + @Test + void testNoTokenGeneratedError() throws Exception { + configureServer(t -> { + t.register("*", new PleaseNegotiateService()); + }); + + final AuthSchemeFactory nsf = new TestAuthSchemeFactory(new NegotiateSchemeWithMockGssManager()); + final Registry authSchemeRegistry = RegistryBuilder.create() + .register(StandardAuthScheme.SPNEGO, nsf) + .build(); + configureClient(t -> { + t.setTargetAuthenticationStrategy(spnegoAuthenticationStrategy); + t.setDefaultAuthSchemeRegistry(authSchemeRegistry); + t.setDefaultCredentialsProvider(jaasCredentialsProvider); + }); + + + final HttpHost target = startServer(); + final String s = "/path"; + final HttpGet httpget = new HttpGet(s); + client().execute(target, httpget, response -> { + EntityUtils.consume(response.getEntity()); + Assertions.assertEquals(HttpStatus.SC_UNAUTHORIZED, response.getCode()); + return null; + }); + + } + + /** + * Test the success case for mutual auth + */ + @Test + void testMutualSuccess() throws Exception { + configureServer(t -> { + t.register("*", new SPNEGOMutualService(true, GOOD_MUTUAL_AUTH_TOKEN_B64_BYTES)); + }); + final HttpHost target = startServer(); + + final MutualNegotiateSchemeWithMockGssManager mockAuthScheme = new MutualNegotiateSchemeWithMockGssManager(true, true); + final AuthSchemeFactory nsf = new TestAuthSchemeFactory(mockAuthScheme); + final Registry authSchemeRegistry = RegistryBuilder.create() + .register(StandardAuthScheme.SPNEGO, nsf) + .build(); + + configureClient(t -> { + t.setTargetAuthenticationStrategy(spnegoAuthenticationStrategy); + t.setDefaultAuthSchemeRegistry(authSchemeRegistry); + t.setDefaultCredentialsProvider(jaasCredentialsProvider); + }); + + final String s = "/path"; + final HttpGet httpget = new HttpGet(s); + client().execute(target, httpget, response -> { + EntityUtils.consume(response.getEntity()); + Assertions.assertEquals(HttpStatus.SC_OK, response.getCode()); + return null; + }); + + Mockito.verify(mockAuthScheme.context, Mockito.atLeastOnce()).isEstablished(); + Mockito.verify(mockAuthScheme.context, Mockito.atLeastOnce()).getMutualAuthState(); + } + + /** + * No mutual auth response token sent by server. + */ + @Test + void testMutualFailureNoToken() throws Exception { + configureServer(t -> { + t.register("*", new SPNEGOMutualService(false, null)); + }); + + final MutualNegotiateSchemeWithMockGssManager mockAuthScheme = new MutualNegotiateSchemeWithMockGssManager(false, false); + final AuthSchemeFactory nsf = new TestAuthSchemeFactory(mockAuthScheme); + final Registry authSchemeRegistry = RegistryBuilder.create() + .register(StandardAuthScheme.SPNEGO, nsf) + .build(); + + configureClient(t -> { + t.setTargetAuthenticationStrategy(spnegoAuthenticationStrategy); + t.setDefaultAuthSchemeRegistry(authSchemeRegistry); + }); + + final HttpClientContext context = new HttpClientContext(); + context.setCredentialsProvider(jaasCredentialsProvider); + + final HttpHost target = startServer(); + final String s = "/path"; + final HttpGet httpget = new HttpGet(s); + try { + client().execute(target, httpget, context, response -> { + EntityUtils.consume(response.getEntity()); + Assertions.fail(); + return null; + }); + Assertions.fail(); + } catch (final Exception e) { + Assertions.assertTrue(e instanceof ClientProtocolException); + Assertions.assertTrue(e.getCause() instanceof AuthenticationException); + } + + Mockito.verify(mockAuthScheme.context, Mockito.atLeastOnce()).isEstablished(); + Mockito.verify(mockAuthScheme.context, Mockito.never()).getMutualAuthState(); + } + + /** + * Server sends a "valid" token, but we mock the established status to false + */ + @Test + void testMutualFailureEstablishedStatusFalse() throws Exception { + configureServer(t -> { + t.register("*", new SPNEGOMutualService(true, GOOD_MUTUAL_AUTH_TOKEN_B64_BYTES)); + }); + + final MutualNegotiateSchemeWithMockGssManager mockAuthScheme = new MutualNegotiateSchemeWithMockGssManager(false, false); + final AuthSchemeFactory nsf = new TestAuthSchemeFactory(mockAuthScheme); + final Registry authSchemeRegistry = RegistryBuilder.create() + .register(StandardAuthScheme.SPNEGO, nsf) + .build(); + configureClient(t -> { + t.setTargetAuthenticationStrategy(spnegoAuthenticationStrategy); + t.setDefaultAuthSchemeRegistry(authSchemeRegistry); + }); + + final HttpClientContext context = new HttpClientContext(); + context.setCredentialsProvider(jaasCredentialsProvider); + + final HttpHost target = startServer(); + final String s = "/path"; + final HttpGet httpget = new HttpGet(s); + try { + client().execute(target, httpget, context, response -> { + EntityUtils.consume(response.getEntity()); + Assertions.fail(); + return null; + }); + Assertions.fail(); + } catch (final Exception e) { + Assertions.assertTrue(e instanceof ClientProtocolException); + Assertions.assertTrue(e.getCause() instanceof AuthenticationException); + } + + Mockito.verify(mockAuthScheme.context, Mockito.atLeastOnce()).isEstablished(); + Mockito.verify(mockAuthScheme.context, Mockito.never()).getMutualAuthState(); + } + + /** + * Server sends a "valid" token, but we mock the mutual auth status to false + */ + @Test + void testMutualFailureMutualStatusFalse() throws Exception { + configureServer(t -> { + t.register("*", new SPNEGOMutualService(true, GOOD_MUTUAL_AUTH_TOKEN_B64_BYTES)); + }); + + final MutualNegotiateSchemeWithMockGssManager mockAuthScheme = new MutualNegotiateSchemeWithMockGssManager(true, false); + final AuthSchemeFactory nsf = new TestAuthSchemeFactory(mockAuthScheme); + final Registry authSchemeRegistry = RegistryBuilder.create() + .register(StandardAuthScheme.SPNEGO, nsf) + .build(); + configureClient(t -> { + t.setTargetAuthenticationStrategy(spnegoAuthenticationStrategy); + t.setDefaultAuthSchemeRegistry(authSchemeRegistry); + }); + + final HttpClientContext context = new HttpClientContext(); + context.setCredentialsProvider(jaasCredentialsProvider); + + final HttpHost target = startServer(); + final String s = "/path"; + final HttpGet httpget = new HttpGet(s); + try { + client().execute(target, httpget, context, response -> { + EntityUtils.consume(response.getEntity()); + Assertions.fail(); + return null; + }); + Assertions.fail(); + } catch (final Exception e) { + Assertions.assertTrue(e instanceof ClientProtocolException); + Assertions.assertTrue(e.getCause() instanceof AuthenticationException); + } + + Mockito.verify(mockAuthScheme.context, Mockito.atLeastOnce()).isEstablished(); + Mockito.verify(mockAuthScheme.context, Mockito.atLeastOnce()).getMutualAuthState(); + } + + /** + * Server sends a "bad" token, and GSS throws an exception. + */ + @Test + void testMutualFailureBadToken() throws Exception { + configureServer(t -> { + t.register("*", new SPNEGOMutualService(true, BAD_MUTUAL_AUTH_TOKEN_B64_BYTES)); + }); + + // We except that the initSecContent throws an exception, so the status is irrelevant + final MutualNegotiateSchemeWithMockGssManager mockAuthScheme = new MutualNegotiateSchemeWithMockGssManager(true, true); + final AuthSchemeFactory nsf = new TestAuthSchemeFactory(mockAuthScheme); + final Registry authSchemeRegistry = RegistryBuilder.create() + .register(StandardAuthScheme.SPNEGO, nsf) + .build(); + + configureClient(t -> { + t.setTargetAuthenticationStrategy(spnegoAuthenticationStrategy); + t.setDefaultAuthSchemeRegistry(authSchemeRegistry); + }); + + final HttpClientContext context = new HttpClientContext(); + context.setCredentialsProvider(jaasCredentialsProvider); + + final HttpHost target = startServer(); + final String s = "/path"; + final HttpGet httpget = new HttpGet(s); + try { + client().execute(target, httpget, context, response -> { + EntityUtils.consume(response.getEntity()); + Assertions.fail(); + return null; + }); + Assertions.fail(); + } catch (final Exception e) { + Assertions.assertTrue(e instanceof ClientProtocolException); + Assertions.assertTrue(e.getCause() instanceof AuthenticationException); + } + + Mockito.verify(mockAuthScheme.context, Mockito.never()).isEstablished(); + Mockito.verify(mockAuthScheme.context, Mockito.never()).getMutualAuthState(); + } +} diff --git a/httpclient5/src/main/java/org/apache/hc/client5/http/auth/AuthExchange.java b/httpclient5/src/main/java/org/apache/hc/client5/http/auth/AuthExchange.java index 2aaf1fb66a..f4ace069db 100644 --- a/httpclient5/src/main/java/org/apache/hc/client5/http/auth/AuthExchange.java +++ b/httpclient5/src/main/java/org/apache/hc/client5/http/auth/AuthExchange.java @@ -38,6 +38,9 @@ */ public class AuthExchange { + // This only tracks the server state. In particular, even if the state is SUCCESS, + // the authentication may still fail if the challenge sent with an authorized response cannot + // be validated locally for AuthScheme2 schemes. public enum State { UNCHALLENGED, CHALLENGED, HANDSHAKE, FAILURE, SUCCESS diff --git a/httpclient5/src/main/java/org/apache/hc/client5/http/auth/AuthScheme.java b/httpclient5/src/main/java/org/apache/hc/client5/http/auth/AuthScheme.java index 22d884a97d..f791198075 100644 --- a/httpclient5/src/main/java/org/apache/hc/client5/http/auth/AuthScheme.java +++ b/httpclient5/src/main/java/org/apache/hc/client5/http/auth/AuthScheme.java @@ -86,6 +86,10 @@ * containing the terminal authorization response, the scheme is considered unsuccessful * and in FAILED state. *

+ *

+ * This interface cannot correctly handle some authentication methods, like SPNEGO. + * See {@link AuthScheme2} for a more capable interface. + *

* * @since 4.0 */ @@ -128,6 +132,9 @@ void processChallenge( * successfully or unsuccessfully), that is, all the required authorization * challenges have been processed in their entirety. * + * Note that due to some assumptions made about the control flow by the authentication code + * returning true will immediately cause the authentication process to fail. + * * @return {@code true} if the authentication process has been completed, * {@code false} otherwise. * diff --git a/httpclient5/src/main/java/org/apache/hc/client5/http/auth/AuthScheme2.java b/httpclient5/src/main/java/org/apache/hc/client5/http/auth/AuthScheme2.java new file mode 100644 index 0000000000..7c380d728e --- /dev/null +++ b/httpclient5/src/main/java/org/apache/hc/client5/http/auth/AuthScheme2.java @@ -0,0 +1,102 @@ +/* + * ==================================================================== + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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. + * ==================================================================== + * + * This software consists of voluntary contributions made by many + * individuals on behalf of the Apache Software Foundation. For more + * information on the Apache Software Foundation, please see + * . + * + */ +package org.apache.hc.client5.http.auth; + +import org.apache.hc.core5.http.HttpHost; +import org.apache.hc.core5.http.HttpRequest; +import org.apache.hc.core5.http.protocol.HttpContext; + +/** + * This is an improved version of the {@link AuthScheme} interface, amended to be able to handle + * a conversation involving multiple challenge-response transactions and adding the ability to check + * the results of a final token sent together with the successful HTTP request as required by + * RFC 4559 and RFC 7546. + * + * @since 5.5 + */ +public interface AuthScheme2 extends AuthScheme { + + /** + * Processes the given auth challenge. Some authentication schemes may involve multiple + * challenge-response exchanges. Such schemes must be able to maintain internal state + * when dealing with sequential challenges. + * + * The {@link AuthScheme} interface implicitly assumes that that the token passed here is + * simply stored in this method, and the actual authentication takes place in + * {@link org.apache.hc.client5.http.auth.AuthScheme#generateAuthResponse(HttpHost, HttpRequest, HttpContext) generateAuthResponse } + * and/or {@link org.apache.hc.client5.http.auth.AuthScheme#isResponseReady(HttpHost, HttpRequest, HttpContext) generateAuthResponse }, + * as only those methods receive the HttpHost, and only those can throw an + * AuthenticationException. + * + * This new methods signature makes it possible to process the token and throw an + * AuthenticationException immediately even when no response is sent (i.e. processing the mutual + * authentication response) + * + * When {@link isChallengeExpected} returns true, but no challenge was sent, then this method must + * be called with a null {@link AuthChallenge} so that the Scheme can handle this situation. + * + * @param host HTTP host + * @param authChallenge the auth challenge or null if no challenge was received + * @param context HTTP context + * @param challenged true if the response was unauthorised (401/407) + * @throws AuthenticationException in case the authentication process is unsuccessful. + * @since 5.5 + */ + void processChallenge( + HttpHost host, + AuthChallenge authChallenge, + HttpContext context, + boolean challenged) throws AuthenticationException; + + /** + * The old processChallenge signature is unfit for use in AuthScheme2. + * If the old signature is sufficient for a scheme, then it should implement {@link AuthScheme} + * instead AuthScheme2. + */ + @Override + default void processChallenge( + AuthChallenge authChallenge, + HttpContext context) throws MalformedChallengeException { + throw new UnsupportedOperationException("on AuthScheme2 implementations only the four " + + "argument processChallenge method can be called"); + } + + /** + * Indicates that the even authorized (i.e. not 401 or 407) responses must be processed + * by this Scheme. + * + * The original AuthScheme interface only processes unauthorised responses. + * This method indicates that non unauthorised responses are expected to contain challenges + * and must be processed by the Scheme. + * This is required to implement the SPENGO RFC and Kerberos mutual authentication. + * + * @return true if responses with non 401/407 response codes must be processed by the scheme. + * @since 5.5 + */ + boolean isChallengeExpected(); + +} diff --git a/httpclient5/src/main/java/org/apache/hc/client5/http/auth/KerberosConfig.java b/httpclient5/src/main/java/org/apache/hc/client5/http/auth/KerberosConfig.java index 508eeb9b0e..2793b8e1f7 100644 --- a/httpclient5/src/main/java/org/apache/hc/client5/http/auth/KerberosConfig.java +++ b/httpclient5/src/main/java/org/apache/hc/client5/http/auth/KerberosConfig.java @@ -35,11 +35,7 @@ * * @since 4.6 * - * @deprecated Do not use. The GGS based experimental authentication schemes are no longer - * supported. Consider using Basic or Bearer authentication with TLS instead. - * */ -@Deprecated @Contract(threading = ThreadingBehavior.IMMUTABLE) public class KerberosConfig implements Cloneable { @@ -53,25 +49,28 @@ public enum Option { public static final KerberosConfig DEFAULT = new Builder().build(); - private final Option stripPort; - private final Option useCanonicalHostname; - private final Option requestDelegCreds; + private final Option stripPort; //Effective default is ENABLE + private final Option useCanonicalHostname; //Effective default is ENABLE + private final Option requestDelegCreds; //Effective default is DISABLE + private final Option requestMutualAuth; //Effective default is DISABLE /** * Intended for CDI compatibility */ protected KerberosConfig() { - this(Option.DEFAULT, Option.DEFAULT, Option.DEFAULT); + this(Option.DEFAULT, Option.DEFAULT, Option.DEFAULT, Option.DEFAULT); } KerberosConfig( final Option stripPort, final Option useCanonicalHostname, - final Option requestDelegCreds) { + final Option requestDelegCreds, + final Option requestMutualAuth) { super(); this.stripPort = stripPort; this.useCanonicalHostname = useCanonicalHostname; this.requestDelegCreds = requestDelegCreds; + this.requestMutualAuth = requestMutualAuth; } public Option getStripPort() { @@ -86,6 +85,10 @@ public Option getRequestDelegCreds() { return requestDelegCreds; } + public Option getRequestMutualAuth() { + return requestMutualAuth; + } + @Override protected KerberosConfig clone() throws CloneNotSupportedException { return (KerberosConfig) super.clone(); @@ -98,6 +101,7 @@ public String toString() { builder.append("stripPort=").append(stripPort); builder.append(", useCanonicalHostname=").append(useCanonicalHostname); builder.append(", requestDelegCreds=").append(requestDelegCreds); + builder.append(", requestMutualAuth=").append(requestMutualAuth); builder.append("]"); return builder.toString(); } @@ -110,7 +114,9 @@ public static KerberosConfig.Builder copy(final KerberosConfig config) { return new Builder() .setStripPort(config.getStripPort()) .setUseCanonicalHostname(config.getUseCanonicalHostname()) - .setRequestDelegCreds(config.getRequestDelegCreds()); + .setRequestDelegCreds(config.getRequestDelegCreds()) + .setRequestMutualAuth(config.getRequestMutualAuth() + ); } public static class Builder { @@ -118,12 +124,14 @@ public static class Builder { private Option stripPort; private Option useCanonicalHostname; private Option requestDelegCreds; + private Option requestMutualAuth; Builder() { super(); this.stripPort = Option.DEFAULT; this.useCanonicalHostname = Option.DEFAULT; this.requestDelegCreds = Option.DEFAULT; + this.requestMutualAuth = Option.DEFAULT; } public Builder setStripPort(final Option stripPort) { @@ -151,11 +159,17 @@ public Builder setRequestDelegCreds(final Option requestDelegCreds) { return this; } + public Builder setRequestMutualAuth(final Option requestMutualAuth) { + this.requestMutualAuth = requestMutualAuth; + return this; + } + public KerberosConfig build() { return new KerberosConfig( stripPort, useCanonicalHostname, - requestDelegCreds); + requestDelegCreds, + requestMutualAuth); } } diff --git a/httpclient5/src/main/java/org/apache/hc/client5/http/auth/KerberosCredentials.java b/httpclient5/src/main/java/org/apache/hc/client5/http/auth/KerberosCredentials.java index 92bab8d4f3..e40963b2a8 100644 --- a/httpclient5/src/main/java/org/apache/hc/client5/http/auth/KerberosCredentials.java +++ b/httpclient5/src/main/java/org/apache/hc/client5/http/auth/KerberosCredentials.java @@ -37,14 +37,7 @@ * Kerberos specific {@link Credentials} representation based on {@link GSSCredential}. * * @since 4.4 - * - * @deprecated Do not use. The GGS based experimental authentication schemes are no longer - * supported. Consider using Basic or Bearer authentication with TLS instead. - * - * @see UsernamePasswordCredentials - * @see BearerToken */ -@Deprecated @Contract(threading = ThreadingBehavior.IMMUTABLE) public class KerberosCredentials implements Credentials, Serializable { diff --git a/httpclient5/src/main/java/org/apache/hc/client5/http/auth/StandardAuthScheme.java b/httpclient5/src/main/java/org/apache/hc/client5/http/auth/StandardAuthScheme.java index 1345282c0b..79232cac10 100644 --- a/httpclient5/src/main/java/org/apache/hc/client5/http/auth/StandardAuthScheme.java +++ b/httpclient5/src/main/java/org/apache/hc/client5/http/auth/StandardAuthScheme.java @@ -66,18 +66,14 @@ private StandardAuthScheme() { /** * SPNEGO authentication scheme as defined in RFC 4559 and RFC 4178. - * - * @deprecated Do not use. The GGS based experimental authentication schemes are no longer - * supported. Consider using Basic or Bearer authentication with TLS instead. */ - @Deprecated public static final String SPNEGO = "Negotiate"; /** * Kerberos authentication scheme as defined in RFC 4120. * - * @deprecated Do not use. The GGS based experimental authentication schemes are no longer - * supported. Consider using Basic or Bearer authentication with TLS instead. + * @deprecated Do not use. The Kerberos scheme was never standardized, and its + * implementation uses the old deprecated non mutual auth capable logic. */ @Deprecated public static final String KERBEROS = "Kerberos"; diff --git a/httpclient5/src/main/java/org/apache/hc/client5/http/impl/DefaultAuthenticationStrategy.java b/httpclient5/src/main/java/org/apache/hc/client5/http/impl/DefaultAuthenticationStrategy.java index 0440a1322f..9409d6cfa4 100644 --- a/httpclient5/src/main/java/org/apache/hc/client5/http/impl/DefaultAuthenticationStrategy.java +++ b/httpclient5/src/main/java/org/apache/hc/client5/http/impl/DefaultAuthenticationStrategy.java @@ -72,6 +72,10 @@ public class DefaultAuthenticationStrategy implements AuthenticationStrategy { StandardAuthScheme.DIGEST, StandardAuthScheme.BASIC)); + protected List getSchemePriority() { + return DEFAULT_SCHEME_PRIORITY; + } + @Override public List select( final ChallengeType challengeType, @@ -95,7 +99,7 @@ public List select( Collection authPrefs = challengeType == ChallengeType.TARGET ? config.getTargetPreferredAuthSchemes() : config.getProxyPreferredAuthSchemes(); if (authPrefs == null) { - authPrefs = DEFAULT_SCHEME_PRIORITY; + authPrefs = getSchemePriority(); } if (LOG.isDebugEnabled()) { LOG.debug("{} Authentication schemes in the order of preference: {}", exchangeId, authPrefs); diff --git a/httpclient5/src/main/java/org/apache/hc/client5/http/impl/async/AsyncConnectExec.java b/httpclient5/src/main/java/org/apache/hc/client5/http/impl/async/AsyncConnectExec.java index 33802920fa..a97afef70d 100644 --- a/httpclient5/src/main/java/org/apache/hc/client5/http/impl/async/AsyncConnectExec.java +++ b/httpclient5/src/main/java/org/apache/hc/client5/http/impl/async/AsyncConnectExec.java @@ -43,7 +43,9 @@ import org.apache.hc.client5.http.async.AsyncExecChainHandler; import org.apache.hc.client5.http.async.AsyncExecRuntime; import org.apache.hc.client5.http.auth.AuthExchange; +import org.apache.hc.client5.http.auth.AuthenticationException; import org.apache.hc.client5.http.auth.ChallengeType; +import org.apache.hc.client5.http.auth.MalformedChallengeException; import org.apache.hc.client5.http.config.RequestConfig; import org.apache.hc.client5.http.impl.auth.AuthCacheKeeper; import org.apache.hc.client5.http.impl.auth.HttpAuthenticator; @@ -515,10 +517,11 @@ private boolean needAuthentication( final AuthExchange proxyAuthExchange, final HttpHost proxy, final HttpResponse response, - final HttpClientContext context) { + final HttpClientContext context) throws AuthenticationException, MalformedChallengeException { final RequestConfig config = context.getRequestConfigOrDefault(); if (config.isAuthenticationEnabled()) { final boolean proxyAuthRequested = authenticator.isChallenged(proxy, ChallengeType.PROXY, response, proxyAuthExchange, context); + final boolean proxyMutualAuthRequired = authenticator.isChallengeExpected(proxyAuthExchange); if (authCacheKeeper != null) { if (proxyAuthRequested) { @@ -528,7 +531,7 @@ private boolean needAuthentication( } } - if (proxyAuthRequested) { + if (proxyAuthRequested || proxyMutualAuthRequired) { final boolean updated = authenticator.updateAuthState(proxy, ChallengeType.PROXY, response, proxyAuthStrategy, proxyAuthExchange, context); diff --git a/httpclient5/src/main/java/org/apache/hc/client5/http/impl/async/AsyncProtocolExec.java b/httpclient5/src/main/java/org/apache/hc/client5/http/impl/async/AsyncProtocolExec.java index 907b23e46b..579607a6d9 100644 --- a/httpclient5/src/main/java/org/apache/hc/client5/http/impl/async/AsyncProtocolExec.java +++ b/httpclient5/src/main/java/org/apache/hc/client5/http/impl/async/AsyncProtocolExec.java @@ -38,7 +38,9 @@ import org.apache.hc.client5.http.async.AsyncExecChainHandler; import org.apache.hc.client5.http.async.AsyncExecRuntime; import org.apache.hc.client5.http.auth.AuthExchange; +import org.apache.hc.client5.http.auth.AuthenticationException; import org.apache.hc.client5.http.auth.ChallengeType; +import org.apache.hc.client5.http.auth.MalformedChallengeException; import org.apache.hc.client5.http.config.RequestConfig; import org.apache.hc.client5.http.impl.DefaultSchemePortResolver; import org.apache.hc.client5.http.impl.RequestSupport; @@ -305,11 +307,12 @@ private boolean needAuthentication( final HttpHost target, final String pathPrefix, final HttpResponse response, - final HttpClientContext context) { + final HttpClientContext context) throws AuthenticationException, MalformedChallengeException { final RequestConfig config = context.getRequestConfigOrDefault(); if (config.isAuthenticationEnabled()) { final boolean targetAuthRequested = authenticator.isChallenged( target, ChallengeType.TARGET, response, targetAuthExchange, context); + final boolean targetMutualAuthRequired = authenticator.isChallengeExpected(targetAuthExchange); if (authCacheKeeper != null) { if (targetAuthRequested) { @@ -321,6 +324,7 @@ private boolean needAuthentication( final boolean proxyAuthRequested = authenticator.isChallenged( proxy, ChallengeType.PROXY, response, proxyAuthExchange, context); + final boolean proxyMutualAuthRequired = authenticator.isChallengeExpected(proxyAuthExchange); if (authCacheKeeper != null) { if (proxyAuthRequested) { @@ -330,7 +334,7 @@ private boolean needAuthentication( } } - if (targetAuthRequested) { + if (targetAuthRequested || targetMutualAuthRequired) { final boolean updated = authenticator.updateAuthState(target, ChallengeType.TARGET, response, targetAuthStrategy, targetAuthExchange, context); @@ -340,7 +344,7 @@ private boolean needAuthentication( return updated; } - if (proxyAuthRequested) { + if (proxyAuthRequested || proxyMutualAuthRequired) { final boolean updated = authenticator.updateAuthState(proxy, ChallengeType.PROXY, response, proxyAuthStrategy, proxyAuthExchange, context); diff --git a/httpclient5/src/main/java/org/apache/hc/client5/http/impl/auth/GGSSchemeBase.java b/httpclient5/src/main/java/org/apache/hc/client5/http/impl/auth/GGSSchemeBase.java index 773746b612..2953416f34 100644 --- a/httpclient5/src/main/java/org/apache/hc/client5/http/impl/auth/GGSSchemeBase.java +++ b/httpclient5/src/main/java/org/apache/hc/client5/http/impl/auth/GGSSchemeBase.java @@ -60,8 +60,9 @@ * * @since 4.2 * - * @deprecated Do not use. The GGS based experimental authentication schemes are no longer - * supported. Consider using Basic or Bearer authentication with TLS instead. + * @deprecated Do not use. This class implements functionality for the old deprecated non mutual + * authentication capable {@link SPNegoScheme} and {@link KerberosScheme} classes. + * The new mutual authentication capable implementation is {@link MutualGSSSchemeBase}. */ @Deprecated public abstract class GGSSchemeBase implements AuthScheme { diff --git a/httpclient5/src/main/java/org/apache/hc/client5/http/impl/auth/HttpAuthenticator.java b/httpclient5/src/main/java/org/apache/hc/client5/http/impl/auth/HttpAuthenticator.java index cd9f7ce723..1deab416c6 100644 --- a/httpclient5/src/main/java/org/apache/hc/client5/http/impl/auth/HttpAuthenticator.java +++ b/httpclient5/src/main/java/org/apache/hc/client5/http/impl/auth/HttpAuthenticator.java @@ -38,12 +38,14 @@ import org.apache.hc.client5.http.auth.AuthChallenge; import org.apache.hc.client5.http.auth.AuthExchange; import org.apache.hc.client5.http.auth.AuthScheme; +import org.apache.hc.client5.http.auth.AuthScheme2; import org.apache.hc.client5.http.auth.AuthenticationException; import org.apache.hc.client5.http.auth.ChallengeType; import org.apache.hc.client5.http.auth.CredentialsProvider; import org.apache.hc.client5.http.auth.MalformedChallengeException; import org.apache.hc.client5.http.protocol.HttpClientContext; import org.apache.hc.core5.annotation.Contract; +import org.apache.hc.core5.annotation.Internal; import org.apache.hc.core5.annotation.ThreadingBehavior; import org.apache.hc.core5.http.FormattedHeader; import org.apache.hc.core5.http.Header; @@ -69,6 +71,7 @@ * * @since 4.3 */ +@Internal @Contract(threading = ThreadingBehavior.STATELESS) public final class HttpAuthenticator { @@ -81,12 +84,13 @@ public HttpAuthenticator() { } /** - * Determines whether the given response represents an authentication challenge. + * Determines whether the given response represents an authentication challenge, and updates + * the autheExchange status. * * @param host the hostname of the opposite endpoint. * @param challengeType the challenge type (target or proxy). * @param response the response message head. - * @param authExchange the current authentication exchange state. + * @param authExchange the current authentication exchange state. Gets updated. * @param context the current execution context. * @return {@code true} if the response message represents an authentication challenge, * {@code false} otherwise. @@ -97,32 +101,17 @@ public boolean isChallenged( final HttpResponse response, final AuthExchange authExchange, final HttpContext context) { - final int challengeCode; - switch (challengeType) { - case TARGET: - challengeCode = HttpStatus.SC_UNAUTHORIZED; - break; - case PROXY: - challengeCode = HttpStatus.SC_PROXY_AUTHENTICATION_REQUIRED; - break; - default: - throw new IllegalStateException("Unexpected challenge type: " + challengeType); - } - - final HttpClientContext clientContext = HttpClientContext.cast(context); - final String exchangeId = clientContext.getExchangeId(); - - if (response.getCode() == challengeCode) { - if (LOG.isDebugEnabled()) { - LOG.debug("{} Authentication required", exchangeId); - } + if (checkChallenged(challengeType, response, context)) { return true; } switch (authExchange.getState()) { case CHALLENGED: case HANDSHAKE: if (LOG.isDebugEnabled()) { - LOG.debug("{} Authentication succeeded", exchangeId); + final HttpClientContext clientContext = HttpClientContext.cast(context); + final String exchangeId = clientContext.getExchangeId(); + // The mutual auth may still fail + LOG.debug("{} Server has accepted authorization", exchangeId); } authExchange.setState(AuthExchange.State.SUCCESS); break; @@ -135,37 +124,64 @@ public boolean isChallenged( } /** - * Updates the {@link AuthExchange} state based on the challenge presented in the response message - * using the given {@link AuthenticationStrategy}. + * Determines whether the given response represents an authentication challenge, without + * changing the AuthExchange state. * - * @param host the hostname of the opposite endpoint. * @param challengeType the challenge type (target or proxy). * @param response the response message head. - * @param authStrategy the authentication strategy. - * @param authExchange the current authentication exchange state. * @param context the current execution context. - * @return {@code true} if the authentication state has been updated, - * {@code false} if unchanged. + * @return {@code true} if the response message represents an authentication challenge, + * {@code false} otherwise. */ - public boolean updateAuthState( - final HttpHost host, - final ChallengeType challengeType, - final HttpResponse response, - final AuthenticationStrategy authStrategy, - final AuthExchange authExchange, - final HttpContext context) { + private boolean checkChallenged(final ChallengeType challengeType, final HttpResponse response, final HttpContext context) { + final int challengeCode; + switch (challengeType) { + case TARGET: + challengeCode = HttpStatus.SC_UNAUTHORIZED; + break; + case PROXY: + challengeCode = HttpStatus.SC_PROXY_AUTHENTICATION_REQUIRED; + break; + default: + throw new IllegalStateException("Unexpected challenge type: " + challengeType); + } - final HttpClientContext clientContext = HttpClientContext.cast(context); - final String exchangeId = clientContext.getExchangeId(); + if (response.getCode() == challengeCode) { + if (LOG.isDebugEnabled()) { + final HttpClientContext clientContext = HttpClientContext.cast(context); + final String exchangeId = clientContext.getExchangeId(); + LOG.debug("{} Authentication required", exchangeId); + } + return true; + } + return false; + } - if (LOG.isDebugEnabled()) { - LOG.debug("{} {} requested authentication", exchangeId, host.toHostString()); + /** + * Determines if the scheme requires an auth challenge for responses that do not + * have challenge HTTP code. (i.e whether it needs a mutual authentication token) + * + * @param authExchange + * @return true is authExchange's scheme is AuthScheme2, which currently expects + * a WWW-Authenticate header even for authorized HTTP responses + */ + public boolean isChallengeExpected(final AuthExchange authExchange) { + final AuthScheme authScheme = authExchange.getAuthScheme(); + if (authScheme != null && authScheme instanceof AuthScheme2) { + return ((AuthScheme2)authScheme).isChallengeExpected(); + } else { + return false; } + } - final Header[] headers = response.getHeaders( - challengeType == ChallengeType.PROXY ? HttpHeaders.PROXY_AUTHENTICATE : HttpHeaders.WWW_AUTHENTICATE); + public Map extractChallengeMap(final ChallengeType challengeType, + final HttpResponse response, final HttpClientContext context) { + final Header[] headers = + response.getHeaders( + challengeType == ChallengeType.PROXY ? HttpHeaders.PROXY_AUTHENTICATE + : HttpHeaders.WWW_AUTHENTICATE); final Map challengeMap = new HashMap<>(); - for (final Header header: headers) { + for (final Header header : headers) { final CharArrayBuffer buffer; final int pos; if (header instanceof FormattedHeader) { @@ -186,52 +202,109 @@ public boolean updateAuthState( authChallenges = parser.parse(challengeType, buffer, cursor); } catch (final ParseException ex) { if (LOG.isWarnEnabled()) { + final HttpClientContext clientContext = HttpClientContext.cast(context); + final String exchangeId = clientContext.getExchangeId(); LOG.warn("{} Malformed challenge: {}", exchangeId, header.getValue()); } continue; } - for (final AuthChallenge authChallenge: authChallenges) { + for (final AuthChallenge authChallenge : authChallenges) { final String schemeName = authChallenge.getSchemeName().toLowerCase(Locale.ROOT); if (!challengeMap.containsKey(schemeName)) { challengeMap.put(schemeName, authChallenge); } } } + return challengeMap; + } + + /** + * Updates the {@link AuthExchange} state based on the challenge presented in the response message + * using the given {@link AuthenticationStrategy}. + * + * @param host the hostname of the opposite endpoint. + * @param challengeType the challenge type (target or proxy). + * @param response the response message head. + * @param authStrategy the authentication strategy. + * @param authExchange the current authentication exchange state. + * @param context the current execution context. + * @return {@code true} if the request needs-to be re-sent , + * {@code false} if the authentication is complete (successful or not). + * + * @throws AuthenticationException if the AuthScheme throws one. In most cases this indicates a + * client side problem, as final server error responses are simply returned. + * @throws MalformedChallengeException if the AuthScheme throws one. In most cases this indicates a + * client side problem, as final server error responses are simply returned. + */ + public boolean updateAuthState( + final HttpHost host, + final ChallengeType challengeType, + final HttpResponse response, + final AuthenticationStrategy authStrategy, + final AuthExchange authExchange, + final HttpContext context) throws AuthenticationException, MalformedChallengeException { + + final HttpClientContext clientContext = HttpClientContext.cast(context); + final String exchangeId = clientContext.getExchangeId(); + final boolean challenged = checkChallenged(challengeType, response, context); + final boolean isChallengeExpected = isChallengeExpected(authExchange); + + if (LOG.isDebugEnabled()) { + LOG.debug("{} {} requested authentication", exchangeId, host.toHostString()); + } + + final Map challengeMap = extractChallengeMap(challengeType, response, clientContext); + if (challengeMap.isEmpty()) { if (LOG.isDebugEnabled()) { LOG.debug("{} Response contains no valid authentication challenges", exchangeId); } - authExchange.reset(); - return false; + if (!isChallengeExpected) { + authExchange.reset(); + return false; + } } switch (authExchange.getState()) { case FAILURE: return false; case SUCCESS: - authExchange.reset(); - break; + if (!isChallengeExpected) { + authExchange.reset(); + break; + } + // otherwise fall through case CHALLENGED: + // fall through case HANDSHAKE: Asserts.notNull(authExchange.getAuthScheme(), "AuthScheme"); + // fall through case UNCHALLENGED: final AuthScheme authScheme = authExchange.getAuthScheme(); + // AuthScheme is only set if we have already sent an auth response, either + // because we have received a challenge for it, or preemptively. if (authScheme != null) { final String schemeName = authScheme.getName(); final AuthChallenge challenge = challengeMap.get(schemeName.toLowerCase(Locale.ROOT)); - if (challenge != null) { + if (challenge != null || isChallengeExpected) { if (LOG.isDebugEnabled()) { - LOG.debug("{} Authorization challenge processed", exchangeId); + LOG.debug("{} Processing authorization challenge {}", exchangeId, challenge); } try { - authScheme.processChallenge(challenge, context); - } catch (final MalformedChallengeException ex) { + if (authScheme instanceof AuthScheme2) { + ((AuthScheme2)authScheme).processChallenge(host, challenge, context, challenged); + } else { + authScheme.processChallenge(challenge, context); + } + } catch (final AuthenticationException | MalformedChallengeException ex) { if (LOG.isWarnEnabled()) { - LOG.warn("{} {}", exchangeId, ex.getMessage()); + LOG.warn("Exception processing Challange {}", exchangeId, ex); } authExchange.reset(); authExchange.setState(AuthExchange.State.FAILURE); - return false; + if (isChallengeExpected) { + throw ex; + } } if (authScheme.isChallengeComplete()) { if (LOG.isDebugEnabled()) { @@ -241,7 +314,14 @@ public boolean updateAuthState( authExchange.setState(AuthExchange.State.FAILURE); return false; } - authExchange.setState(AuthExchange.State.HANDSHAKE); + if (!challenged) { + // There are no more challanges sent after the 200 message, + // and if we get here, then the mutual auth phase has succeeded. + authExchange.setState(AuthExchange.State.SUCCESS); + return false; + } else { + authExchange.setState(AuthExchange.State.HANDSHAKE); + } return true; } authExchange.reset(); @@ -249,6 +329,9 @@ public boolean updateAuthState( } } + // We reach this if we fell through above because the authScheme has not yet been set, or if + // we receive a 401/407 response for an unexpected scheme. Normally this processes the first + // 401/407 response final List preferredSchemes = authStrategy.select(challengeType, challengeMap, context); final CredentialsProvider credsProvider = clientContext.getCredentialsProvider(); if (credsProvider == null) { @@ -263,16 +346,23 @@ public boolean updateAuthState( LOG.debug("{} Selecting authentication options", exchangeId); } for (final AuthScheme authScheme: preferredSchemes) { + // We only respond to the the first successfully processed challenge. However, the + // original AuthScheme API does not really process the challenge at this point, so we need + // to process/store each challenge here anyway. try { final String schemeName = authScheme.getName(); final AuthChallenge challenge = challengeMap.get(schemeName.toLowerCase(Locale.ROOT)); - authScheme.processChallenge(challenge, context); + if (authScheme instanceof AuthScheme2) { + ((AuthScheme2)authScheme).processChallenge(host, challenge, context, challenged); + } else { + authScheme.processChallenge(challenge, context); + } if (authScheme.isResponseReady(host, credsProvider, context)) { authOptions.add(authScheme); } } catch (final AuthenticationException | MalformedChallengeException ex) { if (LOG.isWarnEnabled()) { - LOG.warn(ex.getMessage()); + LOG.warn("Exception while processing Challange", ex); } } } @@ -331,12 +421,14 @@ public void addAuthResponse( } try { final String authResponse = authScheme.generateAuthResponse(host, request, context); - final Header header = new BasicHeader( - challengeType == ChallengeType.TARGET ? HttpHeaders.AUTHORIZATION : HttpHeaders.PROXY_AUTHORIZATION, - authResponse); - request.addHeader(header); + if (authResponse != null) { + final Header header = new BasicHeader( + challengeType == ChallengeType.TARGET ? HttpHeaders.AUTHORIZATION : HttpHeaders.PROXY_AUTHORIZATION, + authResponse); + request.addHeader(header); + } break; - } catch (final AuthenticationException ex) { + } catch (final AuthenticationException ex ) { if (LOG.isWarnEnabled()) { LOG.warn("{} {} authentication error: {}", exchangeId, authScheme, ex.getMessage()); } @@ -347,6 +439,9 @@ public void addAuthResponse( Asserts.notNull(authScheme, "AuthScheme"); default: } + // This is the SUCCESS and HANDSHAKE states, same as the initial response. + // This only happens if the NEGOTIATE handshake requires multiple requests, which is + // defined in the RFC, but unlikely in practice. if (authScheme != null) { try { final String authResponse = authScheme.generateAuthResponse(host, request, context); diff --git a/httpclient5/src/main/java/org/apache/hc/client5/http/impl/auth/KerberosScheme.java b/httpclient5/src/main/java/org/apache/hc/client5/http/impl/auth/KerberosScheme.java index 656f29633a..9b2adb96d2 100644 --- a/httpclient5/src/main/java/org/apache/hc/client5/http/impl/auth/KerberosScheme.java +++ b/httpclient5/src/main/java/org/apache/hc/client5/http/impl/auth/KerberosScheme.java @@ -41,9 +41,10 @@ * * @since 4.2 * - * @deprecated Do not use. The GGS based experimental authentication schemes are no longer - * supported. Consider using Basic or Bearer authentication with TLS instead. + * @deprecated Do not use. The Kerberos authentication scheme was never standardised. + * Use {@link MutualSpnegoScheme} or some other scheme instead. * + * @see MutualSpnegoScheme * @see BasicScheme * @see BearerScheme */ @@ -70,8 +71,8 @@ public String getName() { } @Override - protected byte[] generateToken(final byte[] input, final String serviceName, final String authServer) throws GSSException { - return generateGSSToken(input, new Oid(KERBEROS_OID), serviceName, authServer); + protected byte[] generateToken(final byte[] input, final String gssServiceName, final String gssHostname) throws GSSException { + return generateGSSToken(input, new Oid(KERBEROS_OID), gssServiceName, gssHostname); } @Override diff --git a/httpclient5/src/main/java/org/apache/hc/client5/http/impl/auth/KerberosSchemeFactory.java b/httpclient5/src/main/java/org/apache/hc/client5/http/impl/auth/KerberosSchemeFactory.java index 25930f0997..9c6cf9349a 100644 --- a/httpclient5/src/main/java/org/apache/hc/client5/http/impl/auth/KerberosSchemeFactory.java +++ b/httpclient5/src/main/java/org/apache/hc/client5/http/impl/auth/KerberosSchemeFactory.java @@ -45,9 +45,10 @@ * * @since 4.2 * - * @deprecated Do not use. The GGS based experimental authentication schemes are no longer - * supported. Consider using Basic or Bearer authentication with TLS instead. + * @deprecated Do not use. The Kerberos authentication scheme was never standardised. + * Use {@link MutualSpnegoScheme} or some other scheme instead. * + * @see MutualSpnegoSchemeFactory * @see BasicSchemeFactory * @see BearerSchemeFactory */ diff --git a/httpclient5/src/main/java/org/apache/hc/client5/http/impl/auth/MutualGssSchemeBase.java b/httpclient5/src/main/java/org/apache/hc/client5/http/impl/auth/MutualGssSchemeBase.java new file mode 100644 index 0000000000..9fbee575a6 --- /dev/null +++ b/httpclient5/src/main/java/org/apache/hc/client5/http/impl/auth/MutualGssSchemeBase.java @@ -0,0 +1,348 @@ +/* + * ==================================================================== + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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. + * ==================================================================== + * + * This software consists of voluntary contributions made by many + * individuals on behalf of the Apache Software Foundation. For more + * information on the Apache Software Foundation, please see + * . + * + */ +package org.apache.hc.client5.http.impl.auth; + +import java.net.UnknownHostException; +import java.security.Principal; + +import org.apache.hc.client5.http.DnsResolver; +import org.apache.hc.client5.http.SystemDefaultDnsResolver; +import org.apache.hc.client5.http.auth.AuthChallenge; +import org.apache.hc.client5.http.auth.AuthScheme2; +import org.apache.hc.client5.http.auth.AuthScope; +import org.apache.hc.client5.http.auth.AuthenticationException; +import org.apache.hc.client5.http.auth.Credentials; +import org.apache.hc.client5.http.auth.CredentialsProvider; +import org.apache.hc.client5.http.auth.InvalidCredentialsException; +import org.apache.hc.client5.http.auth.StandardAuthScheme; +import org.apache.hc.client5.http.auth.KerberosConfig; +import org.apache.hc.client5.http.protocol.HttpClientContext; +import org.apache.hc.client5.http.utils.Base64; +import org.apache.hc.core5.http.HttpHost; +import org.apache.hc.core5.http.HttpRequest; +import org.apache.hc.core5.http.protocol.HttpContext; +import org.apache.hc.core5.util.Args; +import org.ietf.jgss.GSSContext; +import org.ietf.jgss.GSSCredential; +import org.ietf.jgss.GSSException; +import org.ietf.jgss.GSSManager; +import org.ietf.jgss.GSSName; +import org.ietf.jgss.Oid; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Common behaviour for the new mutual authentication capable {@code GSS} based authentication + * schemes. + * + * This class is derived from the old {@link GGSScheme} class, which was deprecated in 5.3. + * + * @since 5.5 + * + * @see GGSSchemeBase + */ +public abstract class MutualGssSchemeBase implements AuthScheme2 { + + enum State { + UNINITIATED, + TOKEN_READY, + TOKEN_SENT, + SUCCEEDED, + FAILED, + } + + private static final Logger LOG = LoggerFactory.getLogger(MutualGssSchemeBase.class); + private static final String NO_TOKEN = ""; + private static final String KERBEROS_SCHEME = "HTTP"; + + // The GSS spec does not specify how long the conversation can be. This should be plenty. + // Realistically, we get one initial token, then one maybe one more for mutual authentication. + private static final int MAX_GSS_CHALLENGES = 3; + private final KerberosConfig config; + private final DnsResolver dnsResolver; + private final boolean mutualAuth; + private int challengesLeft = MAX_GSS_CHALLENGES; + + /** Authentication process state */ + private State state; + private GSSCredential gssCredential; + private GSSContext gssContext; + private String challenge; + private byte[] queuedToken = new byte[0]; + + MutualGssSchemeBase(final KerberosConfig config, final DnsResolver dnsResolver) { + super(); + this.config = config != null ? config : KerberosConfig.DEFAULT; + this.dnsResolver = dnsResolver != null ? dnsResolver : SystemDefaultDnsResolver.INSTANCE; + this.mutualAuth = config.getRequestMutualAuth() == KerberosConfig.Option.ENABLE; + this.state = State.UNINITIATED; + } + + MutualGssSchemeBase(final KerberosConfig config) { + this(config, SystemDefaultDnsResolver.INSTANCE); + } + + MutualGssSchemeBase() { + this(KerberosConfig.DEFAULT, SystemDefaultDnsResolver.INSTANCE); + } + + @Override + public String getRealm() { + return null; + } + + // The AuthScheme API maps awkwardly to GSSAPI, where proccessChallange and generateAuthResponse + // map to the same single method call. Hence the generated token is only stored in this method. + @Override + public void processChallenge( + final HttpHost host, + final AuthChallenge authChallenge, + final HttpContext context, + final boolean challenged) throws AuthenticationException { + + if (challengesLeft-- <= 0 ) { + if (LOG.isDebugEnabled()) { + final HttpClientContext clientContext = HttpClientContext.cast(context); + final String exchangeId = clientContext.getExchangeId(); + LOG.debug("{} GSS error: too many challenges received. Infinite loop ?", exchangeId); + } + // TODO: Should we throw an exception ? There is a test for this behaviour. + state = State.FAILED; + return; + } + + final byte[] challengeToken = Base64.decodeBase64(authChallenge == null ? null : authChallenge.getValue()); + + final String gssHostname; + String hostname = host.getHostName(); + if (config.getUseCanonicalHostname() != KerberosConfig.Option.DISABLE) { + try { + hostname = dnsResolver.resolveCanonicalHostname(host.getHostName()); + } catch (final UnknownHostException ignore) { + } + } + if (config.getStripPort() != KerberosConfig.Option.DISABLE) { + gssHostname = hostname; + } else { + gssHostname = hostname + ":" + host.getPort(); + } + + if (LOG.isDebugEnabled()) { + final HttpClientContext clientContext = HttpClientContext.cast(context); + final String exchangeId = clientContext.getExchangeId(); + LOG.debug("{} GSS init {}", exchangeId, gssHostname); + } + try { + queuedToken = generateToken(challengeToken, KERBEROS_SCHEME, gssHostname); + switch (state) { + case UNINITIATED: + if (challenge != NO_TOKEN) { + if (LOG.isDebugEnabled()) { + final HttpClientContext clientContext = HttpClientContext.cast(context); + final String exchangeId = clientContext.getExchangeId(); + LOG.debug("{} Internal GSS error: token received when none was sent yet: {}", exchangeId, challengeToken); + } + // TODO Should we fail ? That would break existing tests that send a token + // in the first response, which is against the RFC. + } + state = State.TOKEN_READY; + break; + case TOKEN_SENT: + if (challenged) { + state = State.TOKEN_READY; + } else if (mutualAuth) { + // We should have received a valid mutualAuth token + if (!gssContext.isEstablished()) { + if (LOG.isDebugEnabled()) { + final HttpClientContext clientContext = + HttpClientContext.cast(context); + final String exchangeId = clientContext.getExchangeId(); + LOG.debug("{} GSSContext is not established ", exchangeId); + } + state = State.FAILED; + // TODO should we have specific exception(s) for these ? + throw new AuthenticationException( + "requireMutualAuth is set but GSSContext is not established"); + } else if (!gssContext.getMutualAuthState()) { + if (LOG.isDebugEnabled()) { + final HttpClientContext clientContext = + HttpClientContext.cast(context); + final String exchangeId = clientContext.getExchangeId(); + LOG.debug("{} requireMutualAuth is set but GSSAUthContext does not have" + + " mutualAuthState set", exchangeId); + } + state = State.FAILED; + throw new AuthenticationException( + "requireMutualAuth is set but GSSContext mutualAuthState is not set"); + } else { + state = State.SUCCEEDED; + } + } + break; + default: + state = State.FAILED; + throw new IllegalStateException("Illegal state: " + state); + + } + } catch (final GSSException gsse) { + state = State.FAILED; + if (gsse.getMajor() == GSSException.DEFECTIVE_CREDENTIAL + || gsse.getMajor() == GSSException.CREDENTIALS_EXPIRED) { + throw new InvalidCredentialsException(gsse.getMessage(), gsse); + } + if (gsse.getMajor() == GSSException.NO_CRED) { + throw new InvalidCredentialsException(gsse.getMessage(), gsse); + } + if (gsse.getMajor() == GSSException.DEFECTIVE_TOKEN + || gsse.getMajor() == GSSException.DUPLICATE_TOKEN + || gsse.getMajor() == GSSException.OLD_TOKEN) { + throw new AuthenticationException(gsse.getMessage(), gsse); + } + // other error + throw new AuthenticationException(gsse.getMessage(), gsse); + } + } + + protected GSSManager getManager() { + return GSSManager.getInstance(); + } + + /** + * @since 4.4 + */ + protected byte[] generateGSSToken( + final byte[] input, final Oid oid, final String gssServiceName, final String gssHostname) throws GSSException { + final GSSManager manager = getManager(); + final GSSName peerName = manager.createName(gssServiceName + "@" + gssHostname, GSSName.NT_HOSTBASED_SERVICE); + + if (gssContext == null) { + gssContext = createGSSContext(manager, oid, peerName, gssCredential); + } + if (input != null) { + return gssContext.initSecContext(input, 0, input.length); + } + return gssContext.initSecContext(new byte[] {}, 0, 0); + } + + /** + * @since 5.0 + */ + protected GSSContext createGSSContext( + final GSSManager manager, + final Oid oid, + final GSSName peerName, + final GSSCredential gssCredential) throws GSSException { + final GSSContext gssContext = manager.createContext(peerName.canonicalize(oid), oid, gssCredential, + GSSContext.DEFAULT_LIFETIME); + gssContext.requestMutualAuth(true); + if (config.getRequestDelegCreds() != KerberosConfig.Option.DEFAULT) { + gssContext.requestCredDeleg(config.getRequestDelegCreds() == KerberosConfig.Option.ENABLE); + } + if (config.getRequestMutualAuth() != KerberosConfig.Option.DEFAULT) { + gssContext.requestMutualAuth(config.getRequestMutualAuth() == KerberosConfig.Option.ENABLE); + } + return gssContext; + } + /** + * @since 4.4 + */ + protected abstract byte[] generateToken(byte[] input, String gssServiceName, String gssHostname) throws GSSException; + + @Override + public boolean isChallengeComplete() { + // For the mutual authentication response, this is should technically return true. + // However, the HttpAuthenticator immediately fails the authentication + // process if we return true, so we only return true here if the authentication has failed. + return this.state == State.FAILED; + } + + @Override + public boolean isChallengeExpected() { + return state == State.TOKEN_SENT && mutualAuth; + } + + @Override + public boolean isResponseReady( + final HttpHost host, + final CredentialsProvider credentialsProvider, + final HttpContext context) throws AuthenticationException { + + Args.notNull(host, "Auth host"); + Args.notNull(credentialsProvider, "CredentialsProvider"); + + final Credentials credentials = credentialsProvider.getCredentials( + new AuthScope(host, null, getName()), context); + if (credentials instanceof org.apache.hc.client5.http.auth.KerberosCredentials) { + this.gssCredential = ((org.apache.hc.client5.http.auth.KerberosCredentials) credentials).getGSSCredential(); + } else { + this.gssCredential = null; + } + return true; + } + + @Override + public Principal getPrincipal() { + return null; + } + + // Format the queued token and update the state. + // All token processing is done in processChallenge() + @Override + public String generateAuthResponse( + final HttpHost host, + final HttpRequest request, + final HttpContext context) throws AuthenticationException { + Args.notNull(host, "HTTP host"); + Args.notNull(request, "HTTP request"); + switch (state) { + case UNINITIATED: + throw new AuthenticationException(getName() + " authentication has not been initiated"); + case FAILED: + throw new AuthenticationException(getName() + " authentication has failed"); + case SUCCEEDED: + return null; + case TOKEN_READY: + state = State.TOKEN_SENT; + final Base64 codec = new Base64(0); + final String tokenstr = new String(codec.encode(queuedToken)); + if (LOG.isDebugEnabled()) { + final HttpClientContext clientContext = HttpClientContext.cast(context); + final String exchangeId = clientContext.getExchangeId(); + LOG.debug("{} Sending GSS response '{}' back to the auth server", exchangeId, tokenstr); + } + return StandardAuthScheme.SPNEGO + " " + tokenstr; + default: + throw new IllegalStateException("Illegal state: " + state); + } + } + + @Override + public String toString() { + return getName() + "{" + this.state + " " + challenge + '}'; + } + +} diff --git a/httpclient5/src/main/java/org/apache/hc/client5/http/impl/auth/MutualSpnegoScheme.java b/httpclient5/src/main/java/org/apache/hc/client5/http/impl/auth/MutualSpnegoScheme.java new file mode 100644 index 0000000000..5403912ba0 --- /dev/null +++ b/httpclient5/src/main/java/org/apache/hc/client5/http/impl/auth/MutualSpnegoScheme.java @@ -0,0 +1,113 @@ +/* + * ==================================================================== + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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. + * ==================================================================== + * + * This software consists of voluntary contributions made by many + * individuals on behalf of the Apache Software Foundation. For more + * information on the Apache Software Foundation, please see + * . + * + */ +package org.apache.hc.client5.http.impl.auth; + +import org.apache.hc.client5.http.AuthenticationStrategy; +import org.apache.hc.client5.http.DnsResolver; +import org.apache.hc.client5.http.auth.StandardAuthScheme; +import org.apache.hc.core5.annotation.Experimental; +import org.ietf.jgss.GSSException; +import org.ietf.jgss.Oid; + +/** + * SPNEGO (Simple and Protected GSSAPI Negotiation Mechanism) authentication + * scheme. + *

+ * This is the new mutual authentication capable Scheme which replaces the old deprecated non mutual + * authentication capable {@link SPNegoScheme} + *

+ * + *

+ * Note that this scheme is not enabled by default. To use it, you need create a custom + * {@link AuthenticationStrategy} and a custom {@link AuthSchemeFactory} {@link Registry}, + * and set them on the HttpClientBuilder. + *

+ * + *
+ * {@code
+ * private static class SpnegoAuthenticationStrategy extends DefaultAuthenticationStrategy {
+ *   private static final List SPNEGO_SCHEME_PRIORITY =
+ *       Collections.unmodifiableList(
+ *           Arrays.asList(StandardAuthScheme.SPNEGO
+ *           // Add other Schemes as needed
+ *           );
+ *
+ *   @Override
+ *   protected final List getSchemePriority() {
+ *     return SPNEGO_SCHEME_PRIORITY;
+ *   }
+ * }
+ *
+ * AuthenticationStrategy mutualStrategy = new SpnegoAuthenticationStrategy();
+ *
+ * AuthSchemeFactory mutualFactory = new MutualSpnegoSchemeFactory();
+ * Registry mutualSchemeRegistry = RegistryBuilder.create()
+ *     .register(StandardAuthScheme.SPNEGO, mutualFactory)
+ *     //register other schemes as needed
+ *     .build();
+ *
+ * CloseableHttpClient mutualClient = HttpClientBuilder.create()
+ *    .setTargetAuthenticationStrategy(mutualStrategy);
+ *    .setDefaultAuthSchemeRegistry(mutualSchemeRegistry);
+ *    .build();
+ * }
+ * 
+ * + * @since 5.5 + */ +@Experimental +public class MutualSpnegoScheme extends MutualGssSchemeBase { + + private static final String SPNEGO_OID = "1.3.6.1.5.5.2"; + + /** + * @since 5.0 + */ + public MutualSpnegoScheme(final org.apache.hc.client5.http.auth.KerberosConfig config, final DnsResolver dnsResolver) { + super(config, dnsResolver); + } + + public MutualSpnegoScheme() { + super(); + } + + @Override + public String getName() { + return StandardAuthScheme.SPNEGO; + } + + @Override + protected byte[] generateToken(final byte[] input, final String gssServiceName, final String gssHostname) throws GSSException { + return generateGSSToken(input, new Oid(SPNEGO_OID), gssServiceName, gssHostname); + } + + @Override + public boolean isConnectionBased() { + return true; + } + +} diff --git a/httpclient5/src/main/java/org/apache/hc/client5/http/impl/auth/MutualSpnegoSchemeFactory.java b/httpclient5/src/main/java/org/apache/hc/client5/http/impl/auth/MutualSpnegoSchemeFactory.java new file mode 100644 index 0000000000..5880f47adc --- /dev/null +++ b/httpclient5/src/main/java/org/apache/hc/client5/http/impl/auth/MutualSpnegoSchemeFactory.java @@ -0,0 +1,76 @@ +/* + * ==================================================================== + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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. + * ==================================================================== + * + * This software consists of voluntary contributions made by many + * individuals on behalf of the Apache Software Foundation. For more + * information on the Apache Software Foundation, please see + * . + * + */ +package org.apache.hc.client5.http.impl.auth; + +import org.apache.hc.client5.http.DnsResolver; +import org.apache.hc.client5.http.SystemDefaultDnsResolver; +import org.apache.hc.client5.http.auth.AuthScheme; +import org.apache.hc.client5.http.auth.AuthSchemeFactory; +import org.apache.hc.core5.annotation.Contract; +import org.apache.hc.core5.annotation.Experimental; +import org.apache.hc.core5.annotation.ThreadingBehavior; +import org.apache.hc.core5.http.protocol.HttpContext; + +/** + * {@link AuthSchemeFactory} implementation that creates and initialises + * {@link MutualSpnegoScheme} instances. + *

+ * This replaces the old deprecated {@link SPNegoSchemeFactory} + *

+ * + * @since 5.5 + * + * @see SPNegoSchemeFactory + */ +@Contract(threading = ThreadingBehavior.STATELESS) +@Experimental +public class MutualSpnegoSchemeFactory implements AuthSchemeFactory { + + /** + * Singleton instance for the default configuration. + */ + public static final MutualSpnegoSchemeFactory DEFAULT = new MutualSpnegoSchemeFactory(org.apache.hc.client5.http.auth.KerberosConfig.DEFAULT, + SystemDefaultDnsResolver.INSTANCE); + + private final org.apache.hc.client5.http.auth.KerberosConfig config; + private final DnsResolver dnsResolver; + + /** + * @since 5.0 + */ + public MutualSpnegoSchemeFactory(final org.apache.hc.client5.http.auth.KerberosConfig config, final DnsResolver dnsResolver) { + super(); + this.config = config; + this.dnsResolver = dnsResolver; + } + + @Override + public AuthScheme create(final HttpContext context) { + return new MutualSpnegoScheme(this.config, this.dnsResolver); + } + +} diff --git a/httpclient5/src/main/java/org/apache/hc/client5/http/impl/auth/SPNegoScheme.java b/httpclient5/src/main/java/org/apache/hc/client5/http/impl/auth/SPNegoScheme.java index 7971ff935d..6d9f9408fe 100644 --- a/httpclient5/src/main/java/org/apache/hc/client5/http/impl/auth/SPNegoScheme.java +++ b/httpclient5/src/main/java/org/apache/hc/client5/http/impl/auth/SPNegoScheme.java @@ -36,15 +36,15 @@ * SPNEGO (Simple and Protected GSSAPI Negotiation Mechanism) authentication * scheme. *

- * Please note this class is considered experimental and may be discontinued or removed - * in the future. + * This class implements the old deprecated non mutual authentication capable SPNEGO implementation. + * Use {@link MutualSpnegoScheme} instead. *

* * @since 4.2 * - * @deprecated Do not use. The GGS based experimental authentication schemes are no longer - * supported. Consider using Basic or Bearer authentication with TLS instead. + * @deprecated Use {@link MutualSpnegoScheme} or some other auth scheme instead. * + * @see MutualSpnegoScheme * @see BasicScheme * @see BearerScheme */ diff --git a/httpclient5/src/main/java/org/apache/hc/client5/http/impl/auth/SPNegoSchemeFactory.java b/httpclient5/src/main/java/org/apache/hc/client5/http/impl/auth/SPNegoSchemeFactory.java index 14d8528c5e..1231d47e49 100644 --- a/httpclient5/src/main/java/org/apache/hc/client5/http/impl/auth/SPNegoSchemeFactory.java +++ b/httpclient5/src/main/java/org/apache/hc/client5/http/impl/auth/SPNegoSchemeFactory.java @@ -39,15 +39,15 @@ * {@link AuthSchemeFactory} implementation that creates and initializes * {@link SPNegoScheme} instances. *

- * Please note this class is considered experimental and may be discontinued or removed - * in the future. + * This factory creates the old deprecated non mutual authentication capable SPNEGO implementation. + * Use {@link MutualSpnegoAuthFactory} instead. *

* * @since 4.2 * - * @deprecated Do not use. The GGS based experimental authentication schemes are no longer - * supported. Consider using Basic or Bearer authentication with TLS instead. + * @deprecated Use {@link MutualSpnegoAuthFactory} or some other auth scheme instead. * + * @see MutualSpnegoAuthFactory * @see BasicSchemeFactory * @see BearerSchemeFactory */ diff --git a/httpclient5/src/main/java/org/apache/hc/client5/http/impl/classic/ConnectExec.java b/httpclient5/src/main/java/org/apache/hc/client5/http/impl/classic/ConnectExec.java index 482d6be154..dd36d335a7 100644 --- a/httpclient5/src/main/java/org/apache/hc/client5/http/impl/classic/ConnectExec.java +++ b/httpclient5/src/main/java/org/apache/hc/client5/http/impl/classic/ConnectExec.java @@ -253,6 +253,7 @@ private ClassicHttpResponse createTunnelToTarget( if (config.isAuthenticationEnabled()) { final boolean proxyAuthRequested = authenticator.isChallenged(proxy, ChallengeType.PROXY, response, proxyAuthExchange, context); + final boolean proxyMutualAuthRequired = authenticator.isChallengeExpected(proxyAuthExchange); if (authCacheKeeper != null) { if (proxyAuthRequested) { @@ -262,7 +263,7 @@ private ClassicHttpResponse createTunnelToTarget( } } - if (proxyAuthRequested) { + if (proxyAuthRequested || proxyMutualAuthRequired) { final boolean updated = authenticator.updateAuthState(proxy, ChallengeType.PROXY, response, proxyAuthStrategy, proxyAuthExchange, context); diff --git a/httpclient5/src/main/java/org/apache/hc/client5/http/impl/classic/ProtocolExec.java b/httpclient5/src/main/java/org/apache/hc/client5/http/impl/classic/ProtocolExec.java index bfebce0eaf..41517e2e6f 100644 --- a/httpclient5/src/main/java/org/apache/hc/client5/http/impl/classic/ProtocolExec.java +++ b/httpclient5/src/main/java/org/apache/hc/client5/http/impl/classic/ProtocolExec.java @@ -34,7 +34,9 @@ import org.apache.hc.client5.http.HttpRoute; import org.apache.hc.client5.http.SchemePortResolver; import org.apache.hc.client5.http.auth.AuthExchange; +import org.apache.hc.client5.http.auth.AuthenticationException; import org.apache.hc.client5.http.auth.ChallengeType; +import org.apache.hc.client5.http.auth.MalformedChallengeException; import org.apache.hc.client5.http.classic.ExecChain; import org.apache.hc.client5.http.classic.ExecChainHandler; import org.apache.hc.client5.http.classic.ExecRuntime; @@ -189,6 +191,7 @@ public ClassicHttpResponse execute( authenticator.addAuthResponse(proxy, ChallengeType.PROXY, request, proxyAuthExchange, context); } + //The is where the actual network communications happens (eventually) final ClassicHttpResponse response = chain.proceed(request, scope); if (Method.TRACE.isSame(request.getMethod())) { @@ -218,6 +221,8 @@ public ClassicHttpResponse execute( EntityUtils.consume(responseEntity); } else { execRuntime.disconnectEndpoint(); + // We don't have any connection based AuthScheme2 implementations. + // If one existed, we'd have think about how to handle it if (proxyAuthExchange.getState() == AuthExchange.State.SUCCESS && proxyAuthExchange.isConnectionBased()) { if (LOG.isDebugEnabled()) { @@ -265,11 +270,12 @@ private boolean needAuthentication( final HttpHost target, final String pathPrefix, final HttpResponse response, - final HttpClientContext context) { - final RequestConfig config = context.getRequestConfigOrDefault(); + final HttpClientContext context) throws AuthenticationException, MalformedChallengeException { + final RequestConfig config = context.getRequestConfigOrDefault(); if (config.isAuthenticationEnabled()) { final boolean targetAuthRequested = authenticator.isChallenged( target, ChallengeType.TARGET, response, targetAuthExchange, context); + final boolean targetMutualAuthRequired = authenticator.isChallengeExpected(targetAuthExchange); if (authCacheKeeper != null) { if (targetAuthRequested) { @@ -281,6 +287,7 @@ private boolean needAuthentication( final boolean proxyAuthRequested = authenticator.isChallenged( proxy, ChallengeType.PROXY, response, proxyAuthExchange, context); + final boolean proxyMutualAuthRequired = authenticator.isChallengeExpected(proxyAuthExchange); if (authCacheKeeper != null) { if (proxyAuthRequested) { @@ -290,7 +297,7 @@ private boolean needAuthentication( } } - if (targetAuthRequested) { + if (targetAuthRequested || targetMutualAuthRequired) { final boolean updated = authenticator.updateAuthState(target, ChallengeType.TARGET, response, targetAuthStrategy, targetAuthExchange, context); @@ -300,7 +307,7 @@ private boolean needAuthentication( return updated; } - if (proxyAuthRequested) { + if (proxyAuthRequested || proxyMutualAuthRequired) { final boolean updated = authenticator.updateAuthState(proxy, ChallengeType.PROXY, response, proxyAuthStrategy, proxyAuthExchange, context); diff --git a/httpclient5/src/main/java/org/apache/hc/client5/http/impl/classic/ProxyClient.java b/httpclient5/src/main/java/org/apache/hc/client5/http/impl/classic/ProxyClient.java index a4657a26ab..0f5347495a 100644 --- a/httpclient5/src/main/java/org/apache/hc/client5/http/impl/classic/ProxyClient.java +++ b/httpclient5/src/main/java/org/apache/hc/client5/http/impl/classic/ProxyClient.java @@ -175,7 +175,8 @@ public Socket tunnel( if (status < 200) { throw new HttpException("Unexpected response to CONNECT request: " + response); } - if (this.authenticator.isChallenged(proxy, ChallengeType.PROXY, response, this.proxyAuthExchange, context)) { + if (this.authenticator.isChallenged(proxy, ChallengeType.PROXY, response, this.proxyAuthExchange, context) + || authenticator.isChallengeExpected(proxyAuthExchange)) { if (this.authenticator.updateAuthState(proxy, ChallengeType.PROXY, response, this.proxyAuthStrategy, this.proxyAuthExchange, context)) { // Retry request diff --git a/httpclient5/src/test/java/org/apache/hc/client5/http/impl/auth/TestHttpAuthenticator.java b/httpclient5/src/test/java/org/apache/hc/client5/http/impl/auth/TestHttpAuthenticator.java index 9107e88016..61df88722c 100644 --- a/httpclient5/src/test/java/org/apache/hc/client5/http/impl/auth/TestHttpAuthenticator.java +++ b/httpclient5/src/test/java/org/apache/hc/client5/http/impl/auth/TestHttpAuthenticator.java @@ -38,6 +38,7 @@ import org.apache.hc.client5.http.auth.ChallengeType; import org.apache.hc.client5.http.auth.Credentials; import org.apache.hc.client5.http.auth.CredentialsProvider; +import org.apache.hc.client5.http.auth.MalformedChallengeException; import org.apache.hc.client5.http.auth.StandardAuthScheme; import org.apache.hc.client5.http.auth.UsernamePasswordCredentials; import org.apache.hc.client5.http.impl.DefaultAuthenticationStrategy; @@ -150,7 +151,7 @@ void testAuthenticationNotRequestedSuccess2() { } @Test - void testAuthentication() { + void testAuthentication() throws AuthenticationException, MalformedChallengeException { final HttpHost host = new HttpHost("somehost", 80); final HttpResponse response = new BasicHttpResponse(HttpStatus.SC_UNAUTHORIZED, "UNAUTHORIZED"); response.addHeader(new BasicHeader(HttpHeaders.WWW_AUTHENTICATE, StandardAuthScheme.BASIC + " realm=\"test\"")); @@ -179,7 +180,7 @@ void testAuthentication() { } @Test - void testAuthenticationCredentialsForBasic() { + void testAuthenticationCredentialsForBasic() throws AuthenticationException, MalformedChallengeException { final HttpHost host = new HttpHost("somehost", 80); final HttpResponse response = new BasicHttpResponse(HttpStatus.SC_UNAUTHORIZED, "UNAUTHORIZED"); @@ -205,7 +206,7 @@ void testAuthenticationCredentialsForBasic() { } @Test - void testAuthenticationNoChallenges() { + void testAuthenticationNoChallenges() throws AuthenticationException, MalformedChallengeException { final HttpHost host = new HttpHost("somehost", 80); final HttpResponse response = new BasicHttpResponse(HttpStatus.SC_UNAUTHORIZED, "UNAUTHORIZED"); @@ -216,7 +217,7 @@ void testAuthenticationNoChallenges() { } @Test - void testAuthenticationNoSupportedChallenges() { + void testAuthenticationNoSupportedChallenges() throws AuthenticationException, MalformedChallengeException { final HttpHost host = new HttpHost("somehost", 80); final HttpResponse response = new BasicHttpResponse(HttpStatus.SC_UNAUTHORIZED, "UNAUTHORIZED"); response.addHeader(new BasicHeader(HttpHeaders.WWW_AUTHENTICATE, "This realm=\"test\"")); @@ -229,7 +230,7 @@ void testAuthenticationNoSupportedChallenges() { } @Test - void testAuthenticationNoCredentials() { + void testAuthenticationNoCredentials() throws AuthenticationException, MalformedChallengeException { final HttpHost host = new HttpHost("somehost", 80); final HttpResponse response = new BasicHttpResponse(HttpStatus.SC_UNAUTHORIZED, "UNAUTHORIZED"); response.addHeader(new BasicHeader(HttpHeaders.WWW_AUTHENTICATE, StandardAuthScheme.BASIC + " realm=\"test\"")); @@ -242,7 +243,7 @@ void testAuthenticationNoCredentials() { } @Test - void testAuthenticationFailed() { + void testAuthenticationFailed() throws AuthenticationException, MalformedChallengeException { final HttpHost host = new HttpHost("somehost", 80); final HttpResponse response = new BasicHttpResponse(HttpStatus.SC_UNAUTHORIZED, "UNAUTHORIZED"); response.addHeader(new BasicHeader(HttpHeaders.WWW_AUTHENTICATE, StandardAuthScheme.BASIC + " realm=\"test\"")); @@ -260,7 +261,7 @@ void testAuthenticationFailed() { } @Test - void testAuthenticationFailedPreviously() { + void testAuthenticationFailedPreviously() throws AuthenticationException, MalformedChallengeException { final HttpHost host = new HttpHost("somehost", 80); final HttpResponse response = new BasicHttpResponse(HttpStatus.SC_UNAUTHORIZED, "UNAUTHORIZED"); response.addHeader(new BasicHeader(HttpHeaders.WWW_AUTHENTICATE, StandardAuthScheme.BASIC + " realm=\"test\"")); @@ -277,7 +278,7 @@ void testAuthenticationFailedPreviously() { } @Test - void testAuthenticationFailure() { + void testAuthenticationFailure() throws AuthenticationException, MalformedChallengeException { final HttpHost host = new HttpHost("somehost", 80); final HttpResponse response = new BasicHttpResponse(HttpStatus.SC_UNAUTHORIZED, "UNAUTHORIZED"); response.addHeader(new BasicHeader(HttpHeaders.WWW_AUTHENTICATE, StandardAuthScheme.BASIC + " realm=\"test\"")); @@ -295,7 +296,7 @@ void testAuthenticationFailure() { } @Test - void testAuthenticationHandshaking() { + void testAuthenticationHandshaking() throws AuthenticationException, MalformedChallengeException { final HttpHost host = new HttpHost("somehost", 80); final HttpResponse response = new BasicHttpResponse(HttpStatus.SC_UNAUTHORIZED, "UNAUTHORIZED"); response.addHeader(new BasicHeader(HttpHeaders.WWW_AUTHENTICATE, StandardAuthScheme.BASIC + " realm=\"test\"")); @@ -314,7 +315,7 @@ void testAuthenticationHandshaking() { } @Test - void testAuthenticationNoMatchingChallenge() { + void testAuthenticationNoMatchingChallenge() throws AuthenticationException, MalformedChallengeException { final HttpHost host = new HttpHost("somehost", 80); final HttpResponse response = new BasicHttpResponse(HttpStatus.SC_UNAUTHORIZED, "UNAUTHORIZED"); response.addHeader(new BasicHeader(HttpHeaders.WWW_AUTHENTICATE, StandardAuthScheme.DIGEST + " realm=\"realm1\", nonce=\"1234\"")); @@ -342,7 +343,7 @@ void testAuthenticationNoMatchingChallenge() { } @Test - void testAuthenticationException() { + void testAuthenticationException() throws AuthenticationException, MalformedChallengeException { final HttpHost host = new HttpHost("somehost", 80); final HttpResponse response = new BasicHttpResponse(HttpStatus.SC_UNAUTHORIZED, "UNAUTHORIZED"); response.addHeader(new BasicHeader(HttpHeaders.WWW_AUTHENTICATE, "blah blah blah")); diff --git a/pom.xml b/pom.xml index 5b0bff4b95..6404722637 100644 --- a/pom.xml +++ b/pom.xml @@ -270,6 +270,8 @@ com.github.siom79.japicmp japicmp-maven-plugin + + true ${project.groupId}