Merge pull request #2910 from eclipse/jetty-9.4.x-2868-spnego_client

Issue #2868 - Adding SPNEGO authentication support for Jetty Client.
This commit is contained in:
Simone Bordet 2018-10-02 22:50:23 +02:00 committed by GitHub
commit 0922644c5b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 1352 additions and 30 deletions

View File

@ -105,6 +105,7 @@
<version>${project.version}</version>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.eclipse.jetty</groupId>
<artifactId>jetty-server</artifactId>
@ -117,6 +118,17 @@
<version>${project.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.apache.kerby</groupId>
<artifactId>kerb-simplekdc</artifactId>
<version>1.1.1</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.slf4j</groupId>
<artifactId>slf4j-simple</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.eclipse.jetty.toolchain</groupId>
<artifactId>jetty-test-helper</artifactId>

View File

@ -49,7 +49,7 @@ public abstract class AuthenticationProtocolHandler implements ProtocolHandler
private final int maxContentLength;
private final ResponseNotifier notifier;
private static final Pattern CHALLENGE_PATTERN = Pattern.compile("(?<schemeOnly>[!#$%&'*+\\-.^_`|~0-9A-Za-z]+)|(?:(?<scheme>[!#$%&'*+\\-.^_`|~0-9A-Za-z]+)\\s+)?(?:(?<token68>[a-zA-Z0-9\\-._~+\\/]+=*)|(?<paramName>[!#$%&'*+\\-.^_`|~0-9A-Za-z]+)\\s*=\\s*(?:(?<paramValue>.*)))");
private static final Pattern CHALLENGE_PATTERN = Pattern.compile("(?<schemeOnly>[!#$%&'*+\\-.^_`|~0-9A-Za-z]+)|(?:(?<scheme>[!#$%&'*+\\-.^_`|~0-9A-Za-z]+)\\s+)?(?:(?<token68>[a-zA-Z0-9\\-._~+/]+=*)|(?<paramName>[!#$%&'*+\\-.^_`|~0-9A-Za-z]+)\\s*=\\s*(?:(?<paramValue>.*)))");
protected AuthenticationProtocolHandler(HttpClient client, int maxContentLength)
{
@ -122,7 +122,6 @@ public abstract class AuthenticationProtocolHandler implements ProtocolHandler
return headerInfos;
}
private class AuthenticationListener extends BufferingResponseListener
{
private AuthenticationListener()
@ -225,13 +224,12 @@ public abstract class AuthenticationProtocolHandler implements ProtocolHandler
copyIfAbsent(request, newRequest, HttpHeader.AUTHORIZATION);
copyIfAbsent(request, newRequest, HttpHeader.PROXY_AUTHORIZATION);
newRequest.onResponseSuccess(r -> client.getAuthenticationStore().addAuthenticationResult(authnResult));
AfterAuthenticationListener listener = new AfterAuthenticationListener(authnResult);
Connection connection = (Connection)request.getAttributes().get(Connection.class.getName());
if (connection != null)
connection.send(newRequest, null);
connection.send(newRequest, listener);
else
newRequest.send(null);
newRequest.send(listener);
}
catch (Throwable x)
{
@ -298,4 +296,20 @@ public abstract class AuthenticationProtocolHandler implements ProtocolHandler
return result;
}
}
private class AfterAuthenticationListener extends Response.Listener.Adapter
{
private final Authentication.Result authenticationResult;
private AfterAuthenticationListener(Authentication.Result authenticationResult)
{
this.authenticationResult = authenticationResult;
}
@Override
public void onSuccess(Response response)
{
client.getAuthenticationStore().addAuthenticationResult(authenticationResult);
}
}
}

View File

@ -0,0 +1,378 @@
//
// ========================================================================
// Copyright (c) 1995-2018 Mort Bay Consulting Pty. Ltd.
// ------------------------------------------------------------------------
// All rights reserved. This program and the accompanying materials
// are made available under the terms of the Eclipse Public License v1.0
// and Apache License v2.0 which accompanies this distribution.
//
// The Eclipse Public License is available at
// http://www.eclipse.org/legal/epl-v10.html
//
// The Apache License v2.0 is available at
// http://www.opensource.org/licenses/apache2.0.php
//
// You may elect to redistribute this code under either of these licenses.
// ========================================================================
//
package org.eclipse.jetty.client.util;
import java.io.IOException;
import java.net.URI;
import java.nio.file.Path;
import java.security.PrivilegedAction;
import java.util.Arrays;
import java.util.Base64;
import java.util.HashMap;
import java.util.Map;
import javax.security.auth.Subject;
import javax.security.auth.callback.Callback;
import javax.security.auth.callback.CallbackHandler;
import javax.security.auth.callback.PasswordCallback;
import javax.security.auth.login.AppConfigurationEntry;
import javax.security.auth.login.Configuration;
import javax.security.auth.login.LoginContext;
import javax.security.auth.login.LoginException;
import org.eclipse.jetty.client.HttpClient;
import org.eclipse.jetty.client.api.AuthenticationStore;
import org.eclipse.jetty.client.api.ContentResponse;
import org.eclipse.jetty.client.api.Request;
import org.eclipse.jetty.http.HttpHeader;
import org.eclipse.jetty.util.Attributes;
import org.eclipse.jetty.util.log.Log;
import org.eclipse.jetty.util.log.Logger;
import org.ietf.jgss.GSSContext;
import org.ietf.jgss.GSSException;
import org.ietf.jgss.GSSManager;
import org.ietf.jgss.GSSName;
import org.ietf.jgss.Oid;
/**
* <p>Implementation of the SPNEGO (or "Negotiate") authentication defined in RFC 4559.</p>
* <p>A {@link #getUserName() user} is logged in via JAAS (either via userName/password or
* via userName/keyTab) once only.</p>
* <p>For every request that needs authentication, a {@link GSSContext} is initiated and
* later established after reading the response from the server.</p>
* <p>Applications should create objects of this class and add them to the
* {@link AuthenticationStore} retrieved from the {@link HttpClient}
* via {@link HttpClient#getAuthenticationStore()}.</p>
*/
public class SPNEGOAuthentication extends AbstractAuthentication
{
private static final Logger LOG = Log.getLogger(SPNEGOAuthentication.class);
private static final String NEGOTIATE = HttpHeader.NEGOTIATE.asString();
private final GSSManager gssManager = GSSManager.getInstance();
private String userName;
private String userPassword;
private Path userKeyTabPath;
private String serviceName;
private boolean useTicketCache;
private Path ticketCachePath;
private boolean renewTGT;
public SPNEGOAuthentication(URI uri)
{
super(uri, ANY_REALM);
}
@Override
public String getType()
{
return NEGOTIATE;
}
/**
* @return the user name of the user to login
*/
public String getUserName()
{
return userName;
}
/**
* @param userName user name of the user to login
*/
public void setUserName(String userName)
{
this.userName = userName;
}
/**
* @return the password of the user to login
*/
public String getUserPassword()
{
return userPassword;
}
/**
* @param userPassword the password of the user to login
* @see #setUserKeyTabPath(Path)
*/
public void setUserPassword(String userPassword)
{
this.userPassword = userPassword;
}
/**
* @return the path of the keyTab file with the user credentials
*/
public Path getUserKeyTabPath()
{
return userKeyTabPath;
}
/**
* @param userKeyTabPath the path of the keyTab file with the user credentials
* @see #setUserPassword(String)
*/
public void setUserKeyTabPath(Path userKeyTabPath)
{
this.userKeyTabPath = userKeyTabPath;
}
/**
* @return the name of the service to use
*/
public String getServiceName()
{
return serviceName;
}
/**
* @param serviceName the name of the service to use
*/
public void setServiceName(String serviceName)
{
this.serviceName = serviceName;
}
/**
* @return whether to use the ticket cache during login
*/
public boolean isUseTicketCache()
{
return useTicketCache;
}
/**
* @param useTicketCache whether to use the ticket cache during login
* @see #setTicketCachePath(Path)
*/
public void setUseTicketCache(boolean useTicketCache)
{
this.useTicketCache = useTicketCache;
}
/**
* @return the path of the ticket cache file
*/
public Path getTicketCachePath()
{
return ticketCachePath;
}
/**
* @param ticketCachePath the path of the ticket cache file
* @see #setUseTicketCache(boolean)
*/
public void setTicketCachePath(Path ticketCachePath)
{
this.ticketCachePath = ticketCachePath;
}
/**
* @return whether to renew the ticket granting ticket
*/
public boolean isRenewTGT()
{
return renewTGT;
}
/**
* @param renewTGT whether to renew the ticket granting ticket
*/
public void setRenewTGT(boolean renewTGT)
{
this.renewTGT = renewTGT;
}
@Override
public Result authenticate(Request request, ContentResponse response, HeaderInfo headerInfo, Attributes context)
{
SPNEGOContext spnegoContext = (SPNEGOContext)context.getAttribute(SPNEGOContext.ATTRIBUTE);
if (LOG.isDebugEnabled())
LOG.debug("Authenticate with context {}", spnegoContext);
if (spnegoContext == null)
{
spnegoContext = login();
context.setAttribute(SPNEGOContext.ATTRIBUTE, spnegoContext);
}
String b64Input = headerInfo.getBase64();
byte[] input = b64Input == null ? new byte[0] : Base64.getDecoder().decode(b64Input);
byte[] output = Subject.doAs(spnegoContext.subject, initGSSContext(spnegoContext, request.getHost(), input));
String b64Output = output == null ? null : new String(Base64.getEncoder().encode(output));
// The result cannot be used for subsequent requests,
// so it always has a null URI to avoid being cached.
return new SPNEGOResult(null, b64Output);
}
private SPNEGOContext login()
{
try
{
// First login via JAAS using the Kerberos AS_REQ call, with a client user.
// This will populate the Subject with the client user principal and the TGT.
String user = getUserName();
if (LOG.isDebugEnabled())
LOG.debug("Logging in user {}", user);
CallbackHandler callbackHandler = new PasswordCallbackHandler();
LoginContext loginContext = new LoginContext("", null, callbackHandler, new SPNEGOConfiguration());
loginContext.login();
Subject subject = loginContext.getSubject();
SPNEGOContext spnegoContext = new SPNEGOContext();
spnegoContext.subject = subject;
if (LOG.isDebugEnabled())
LOG.debug("Initialized {}", spnegoContext);
return spnegoContext;
}
catch (LoginException x)
{
throw new RuntimeException(x);
}
}
private PrivilegedAction<byte[]> initGSSContext(SPNEGOContext spnegoContext, String host, byte[] bytes)
{
return () ->
{
try
{
// The call to initSecContext with the service name will
// trigger the Kerberos TGS_REQ call, asking for the SGT,
// which will be added to the Subject credentials because
// initSecContext() is called from within Subject.doAs().
GSSContext gssContext = spnegoContext.gssContext;
if (gssContext == null)
{
String principal = getServiceName() + "@" + host;
GSSName serviceName = gssManager.createName(principal, GSSName.NT_HOSTBASED_SERVICE);
Oid spnegoOid = new Oid("1.3.6.1.5.5.2");
gssContext = gssManager.createContext(serviceName, spnegoOid, null, GSSContext.INDEFINITE_LIFETIME);
spnegoContext.gssContext = gssContext;
gssContext.requestMutualAuth(true);
}
byte[] result = gssContext.initSecContext(bytes, 0, bytes.length);
if (LOG.isDebugEnabled())
LOG.debug("{} {}", gssContext.isEstablished() ? "Initialized" : "Initializing", gssContext);
return result;
}
catch (GSSException x)
{
throw new RuntimeException(x);
}
};
}
public static class SPNEGOResult implements Result
{
private final URI uri;
private final HttpHeader header;
private final String value;
public SPNEGOResult(URI uri, String token)
{
this(uri, HttpHeader.AUTHORIZATION, token);
}
public SPNEGOResult(URI uri, HttpHeader header, String token)
{
this.uri = uri;
this.header = header;
this.value = NEGOTIATE + (token == null ? "" : " " + token);
}
@Override
public URI getURI()
{
return uri;
}
@Override
public void apply(Request request)
{
request.header(header, value);
}
}
private static class SPNEGOContext
{
private static final String ATTRIBUTE = SPNEGOContext.class.getName();
private Subject subject;
private GSSContext gssContext;
@Override
public String toString()
{
return String.format("%s@%x[context=%s]", getClass().getSimpleName(), hashCode(), gssContext);
}
}
private class PasswordCallbackHandler implements CallbackHandler
{
@Override
public void handle(Callback[] callbacks) throws IOException
{
PasswordCallback callback = Arrays.stream(callbacks)
.filter(PasswordCallback.class::isInstance)
.map(PasswordCallback.class::cast)
.findAny()
.filter(c -> c.getPrompt().contains(getUserName()))
.orElseThrow(IOException::new);
callback.setPassword(getUserPassword().toCharArray());
}
}
private class SPNEGOConfiguration extends Configuration
{
@Override
public AppConfigurationEntry[] getAppConfigurationEntry(String name)
{
Map<String, Object> options = new HashMap<>();
if (LOG.isDebugEnabled())
options.put("debug", "true");
options.put("refreshKrb5Config", "true");
options.put("principal", getUserName());
options.put("isInitiator", "true");
Path keyTabPath = getUserKeyTabPath();
if (keyTabPath != null)
{
options.put("doNotPrompt", "true");
options.put("useKeyTab", "true");
options.put("keyTab", keyTabPath.toAbsolutePath().toString());
options.put("storeKey", "true");
}
boolean useTicketCache = isUseTicketCache();
if (useTicketCache)
{
options.put("useTicketCache", "true");
Path ticketCachePath = getTicketCachePath();
if (ticketCachePath != null)
options.put("ticketCache", ticketCachePath.toAbsolutePath().toString());
options.put("renewTGT", String.valueOf(isRenewTGT()));
}
String moduleClass = "com.sun.security.auth.module.Krb5LoginModule";
AppConfigurationEntry config = new AppConfigurationEntry(moduleClass, AppConfigurationEntry.LoginModuleControlFlag.REQUIRED, options);
return new AppConfigurationEntry[]{config};
}
}
}

View File

@ -26,7 +26,6 @@ import javax.servlet.http.HttpServletResponse;
import org.eclipse.jetty.server.Request;
import org.eclipse.jetty.server.handler.AbstractHandler;
import org.eclipse.jetty.util.log.Log;
public class EmptyServerHandler extends AbstractHandler.ErrorDispatchHandler
{
@ -39,6 +38,5 @@ public class EmptyServerHandler extends AbstractHandler.ErrorDispatchHandler
protected void service(String target, Request jettyRequest, HttpServletRequest request, HttpServletResponse response) throws IOException, ServletException
{
Log.getRootLogger().info("EMPTY service {}",target);
}
}

View File

@ -0,0 +1,301 @@
//
// ========================================================================
// Copyright (c) 1995-2018 Mort Bay Consulting Pty. Ltd.
// ------------------------------------------------------------------------
// All rights reserved. This program and the accompanying materials
// are made available under the terms of the Eclipse Public License v1.0
// and Apache License v2.0 which accompanies this distribution.
//
// The Eclipse Public License is available at
// http://www.eclipse.org/legal/epl-v10.html
//
// The Apache License v2.0 is available at
// http://www.opensource.org/licenses/apache2.0.php
//
// You may elect to redistribute this code under either of these licenses.
// ========================================================================
//
package org.eclipse.jetty.client.util;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.net.URI;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Duration;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.stream.Collectors;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import org.apache.kerby.kerberos.kerb.server.SimpleKdcServer;
import org.eclipse.jetty.client.AbstractHttpClientServerTest;
import org.eclipse.jetty.client.EmptyServerHandler;
import org.eclipse.jetty.client.api.Authentication;
import org.eclipse.jetty.client.api.AuthenticationStore;
import org.eclipse.jetty.client.api.ContentResponse;
import org.eclipse.jetty.client.api.Request;
import org.eclipse.jetty.client.api.Response;
import org.eclipse.jetty.security.ConfigurableSpnegoLoginService;
import org.eclipse.jetty.security.ConstraintMapping;
import org.eclipse.jetty.security.ConstraintSecurityHandler;
import org.eclipse.jetty.security.HashLoginService;
import org.eclipse.jetty.security.authentication.AuthorizationService;
import org.eclipse.jetty.security.authentication.ConfigurableSpnegoAuthenticator;
import org.eclipse.jetty.server.Handler;
import org.eclipse.jetty.server.Server;
import org.eclipse.jetty.server.session.DefaultSessionIdManager;
import org.eclipse.jetty.server.session.SessionHandler;
import org.eclipse.jetty.toolchain.test.MavenTestingUtils;
import org.eclipse.jetty.util.IO;
import org.eclipse.jetty.util.log.Log;
import org.eclipse.jetty.util.log.Logger;
import org.eclipse.jetty.util.security.Constraint;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.condition.DisabledOnJre;
import org.junit.jupiter.api.condition.JRE;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ArgumentsSource;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
// Apparently only JDK 11 is able to run these tests.
// See for example: https://bugs.openjdk.java.net/browse/JDK-8202439
// where apparently the compiler gets the AES CPU instructions wrong.
@DisabledOnJre({JRE.JAVA_8, JRE.JAVA_9, JRE.JAVA_10})
public class SPNEGOAuthenticationTest extends AbstractHttpClientServerTest
{
private static final Logger LOG = Log.getLogger(SPNEGOAuthenticationTest.class);
static
{
if (LOG.isDebugEnabled())
{
System.setProperty("org.slf4j.simpleLogger.defaultLogLevel", "debug");
System.setProperty("sun.security.jgss.debug", "true");
System.setProperty("sun.security.krb5.debug", "true");
System.setProperty("sun.security.spnego.debug", "true");
}
}
private Path testDirPath = MavenTestingUtils.getTargetTestingPath(SPNEGOAuthenticationTest.class.getSimpleName());
private String clientName = "spnego_client";
private String clientPassword = "spnego_client_pwd";
private String serviceName = "srvc";
private String serviceHost = "localhost";
private String realm = "jetty.org";
private Path realmPropsPath = MavenTestingUtils.getTestResourcePath("realm.properties");
private Path serviceKeyTabPath = testDirPath.resolve("service.keytab");
private Path clientKeyTabPath = testDirPath.resolve("client.keytab");
private SimpleKdcServer kdc;
private ConfigurableSpnegoAuthenticator authenticator;
@BeforeEach
public void prepare() throws Exception
{
IO.delete(testDirPath.toFile());
Files.createDirectories(testDirPath);
System.setProperty("java.security.krb5.conf", testDirPath.toAbsolutePath().toString());
kdc = new SimpleKdcServer();
kdc.setAllowUdp(false);
kdc.setAllowTcp(true);
kdc.setKdcRealm(realm);
kdc.setWorkDir(testDirPath.toFile());
kdc.init();
kdc.createAndExportPrincipals(serviceKeyTabPath.toFile(), serviceName + "/" + serviceHost);
kdc.createPrincipal(clientName + "@" + realm, clientPassword);
kdc.exportPrincipal(clientName, clientKeyTabPath.toFile());
kdc.start();
if (LOG.isDebugEnabled())
{
LOG.debug("KDC started on port {}", kdc.getKdcTcpPort());
String krb5 = Files.readAllLines(testDirPath.resolve("krb5.conf")).stream()
.filter(line -> !line.startsWith("#"))
.collect(Collectors.joining(System.lineSeparator()));
LOG.debug("krb5.conf{}{}", System.lineSeparator(), krb5);
}
}
@AfterEach
public void dispose() throws Exception
{
if (kdc != null)
kdc.stop();
}
private void startSPNEGO(Scenario scenario, Handler handler) throws Exception
{
server = new Server();
server.setSessionIdManager(new DefaultSessionIdManager(server));
HashLoginService authorizationService = new HashLoginService(realm, realmPropsPath.toString());
ConfigurableSpnegoLoginService loginService = new ConfigurableSpnegoLoginService(realm, AuthorizationService.from(authorizationService, ""));
loginService.addBean(authorizationService);
loginService.setKeyTabPath(serviceKeyTabPath);
loginService.setServiceName(serviceName);
loginService.setHostName(serviceHost);
server.addBean(loginService);
ConstraintSecurityHandler securityHandler = new ConstraintSecurityHandler();
Constraint constraint = new Constraint();
constraint.setAuthenticate(true);
constraint.setRoles(new String[]{"**"}); //allow any authenticated user
ConstraintMapping mapping = new ConstraintMapping();
mapping.setPathSpec("/secure");
mapping.setConstraint(constraint);
securityHandler.addConstraintMapping(mapping);
authenticator = new ConfigurableSpnegoAuthenticator();
securityHandler.setAuthenticator(authenticator);
securityHandler.setLoginService(loginService);
securityHandler.setHandler(handler);
SessionHandler sessionHandler = new SessionHandler();
sessionHandler.setHandler(securityHandler);
start(scenario, sessionHandler);
}
@ParameterizedTest
@ArgumentsSource(ScenarioProvider.class)
public void testPasswordSPNEGOAuthentication(Scenario scenario) throws Exception
{
testSPNEGOAuthentication(scenario, false);
}
@ParameterizedTest
@ArgumentsSource(ScenarioProvider.class)
public void testKeyTabSPNEGOAuthentication(Scenario scenario) throws Exception
{
testSPNEGOAuthentication(scenario, true);
}
private void testSPNEGOAuthentication(Scenario scenario, boolean useKeyTab) throws Exception
{
startSPNEGO(scenario, new EmptyServerHandler());
authenticator.setAuthenticationDuration(Duration.ZERO);
URI uri = URI.create(scenario.getScheme() + "://localhost:" + connector.getLocalPort());
// Request without Authentication causes a 401
Request request = client.newRequest(uri).path("/secure");
ContentResponse response = request.timeout(15, TimeUnit.SECONDS).send();
assertNotNull(response);
assertEquals(401, response.getStatus());
// Add authentication.
SPNEGOAuthentication authentication = new SPNEGOAuthentication(uri);
authentication.setUserName(clientName + "@" + realm);
if (useKeyTab)
authentication.setUserKeyTabPath(clientKeyTabPath);
else
authentication.setUserPassword(clientPassword);
authentication.setServiceName(serviceName);
AuthenticationStore authenticationStore = client.getAuthenticationStore();
authenticationStore.addAuthentication(authentication);
// Request with authentication causes a 401 (no previous successful authentication) + 200
request = client.newRequest(uri).path("/secure");
response = request.timeout(15, TimeUnit.SECONDS).send();
assertNotNull(response);
assertEquals(200, response.getStatus());
// Authentication results for SPNEGO cannot be cached.
Authentication.Result authnResult = authenticationStore.findAuthenticationResult(uri);
assertNull(authnResult);
AtomicInteger requests = new AtomicInteger();
client.getRequestListeners().add(new Request.Listener.Adapter()
{
@Override
public void onSuccess(Request request)
{
requests.incrementAndGet();
}
});
// The server has infinite authentication duration, so
// subsequent requests will be preemptively authorized.
request = client.newRequest(uri).path("/secure");
response = request.timeout(15, TimeUnit.SECONDS).send();
assertNotNull(response);
assertEquals(200, response.getStatus());
assertEquals(1, requests.get());
}
@ParameterizedTest
@ArgumentsSource(ScenarioProvider.class)
public void testAuthenticationExpiration(Scenario scenario) throws Exception
{
startSPNEGO(scenario, new EmptyServerHandler()
{
@Override
protected void service(String target, org.eclipse.jetty.server.Request jettyRequest, HttpServletRequest request, HttpServletResponse response) throws IOException
{
IO.readBytes(request.getInputStream());
}
});
long timeout = 1000;
authenticator.setAuthenticationDuration(Duration.ofMillis(timeout));
URI uri = URI.create(scenario.getScheme() + "://localhost:" + connector.getLocalPort());
// Add authentication.
SPNEGOAuthentication authentication = new SPNEGOAuthentication(uri);
authentication.setUserName(clientName + "@" + realm);
authentication.setUserPassword(clientPassword);
authentication.setServiceName(serviceName);
AuthenticationStore authenticationStore = client.getAuthenticationStore();
authenticationStore.addAuthentication(authentication);
AtomicInteger requests = new AtomicInteger();
client.getRequestListeners().add(new Request.Listener.Adapter()
{
@Override
public void onSuccess(Request request)
{
requests.incrementAndGet();
}
});
Request request = client.newRequest(uri).path("/secure");
Response response = request.timeout(15, TimeUnit.SECONDS).send();
assertEquals(200, response.getStatus());
// Expect 401 + 200.
assertEquals(2, requests.get());
requests.set(0);
request = client.newRequest(uri).path("/secure");
response = request.timeout(15, TimeUnit.SECONDS).send();
assertEquals(200, response.getStatus());
// Authentication not expired on server, expect 200 only.
assertEquals(1, requests.get());
// Let authentication expire.
Thread.sleep(2 * timeout);
requests.set(0);
request = client.newRequest(uri).path("/secure");
response = request.timeout(15, TimeUnit.SECONDS).send();
assertEquals(200, response.getStatus());
// Authentication expired, expect 401 + 200.
assertEquals(2, requests.get());
// Let authentication expire again.
Thread.sleep(2 * timeout);
requests.set(0);
ByteArrayInputStream input = new ByteArrayInputStream("hello_world".getBytes(StandardCharsets.UTF_8));
request = client.newRequest(uri).method("POST").path("/secure").content(new InputStreamContentProvider(input));
response = request.timeout(15, TimeUnit.SECONDS).send();
assertEquals(200, response.getStatus());
// Authentication expired, but POSTs are allowed.
assertEquals(1, requests.get());
}
}

View File

@ -1,5 +1,5 @@
class=org.eclipse.jetty.util.log.StdErrLog
#org.eclipse.jetty.LEVEL=INFO
org.eclipse.jetty.util.log.class=org.eclipse.jetty.util.log.StdErrLog
#org.eclipse.jetty.LEVEL=DEBUG
#org.eclipse.jetty.client.LEVEL=DEBUG
#org.eclipse.jetty.io.ChannelEndPoint.LEVEL=DEBUG
#org.eclipse.jetty.io.ssl.LEVEL=DEBUG

View File

@ -1,3 +1,4 @@
# Format is <user>:<password>,<roles>
basic:basic
digest:digest
spnego_client:,admin

View File

@ -0,0 +1,331 @@
//
// ========================================================================
// Copyright (c) 1995-2018 Mort Bay Consulting Pty. Ltd.
// ------------------------------------------------------------------------
// All rights reserved. This program and the accompanying materials
// are made available under the terms of the Eclipse Public License v1.0
// and Apache License v2.0 which accompanies this distribution.
//
// The Eclipse Public License is available at
// http://www.eclipse.org/legal/epl-v10.html
//
// The Apache License v2.0 is available at
// http://www.opensource.org/licenses/apache2.0.php
//
// You may elect to redistribute this code under either of these licenses.
// ========================================================================
//
package org.eclipse.jetty.security;
import java.io.Serializable;
import java.net.InetAddress;
import java.nio.file.Path;
import java.security.PrivilegedAction;
import java.util.Base64;
import java.util.HashMap;
import java.util.Map;
import javax.security.auth.Subject;
import javax.security.auth.login.AppConfigurationEntry;
import javax.security.auth.login.Configuration;
import javax.security.auth.login.LoginContext;
import javax.servlet.ServletRequest;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpSession;
import org.eclipse.jetty.security.authentication.AuthorizationService;
import org.eclipse.jetty.server.UserIdentity;
import org.eclipse.jetty.util.component.ContainerLifeCycle;
import org.eclipse.jetty.util.log.Log;
import org.eclipse.jetty.util.log.Logger;
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;
/**
* <p>A configurable (as opposed to using system properties) SPNEGO LoginService.</p>
* <p>At startup, this LoginService will login via JAAS the service principal, composed
* of the {@link #getServiceName() service name} and the {@link #getHostName() host name},
* for example {@code HTTP/wonder.com}, using a {@code keyTab} file as the service principal
* credentials.</p>
* <p>Upon receiving a HTTP request, the server tries to authenticate the client
* calling {@link #login(String, Object, ServletRequest)} where the GSS APIs are used to
* verify client tokens and (perhaps after a few round-trips) a {@code GSSContext} is
* established.</p>
*/
public class ConfigurableSpnegoLoginService extends ContainerLifeCycle implements LoginService
{
private static final Logger LOG = Log.getLogger(ConfigurableSpnegoLoginService.class);
private final GSSManager _gssManager = GSSManager.getInstance();
private final String _realm;
private final AuthorizationService _authorizationService;
private IdentityService _identityService = new DefaultIdentityService();
private String _serviceName;
private Path _keyTabPath;
private String _hostName;
private SpnegoContext _context;
public ConfigurableSpnegoLoginService(String realm, AuthorizationService authorizationService)
{
_realm = realm;
_authorizationService = authorizationService;
}
/**
* @return the realm name
*/
@Override
public String getName()
{
return _realm;
}
/**
* @return the path of the keyTab file containing service credentials
*/
public Path getKeyTabPath()
{
return _keyTabPath;
}
/**
* @param keyTabFile the path of the keyTab file containing service credentials
*/
public void setKeyTabPath(Path keyTabFile)
{
_keyTabPath = keyTabFile;
}
/**
* @return the service name, typically "HTTP"
* @see #getHostName()
*/
public String getServiceName()
{
return _serviceName;
}
/**
* @param serviceName the service name
* @see #setHostName(String)
*/
public void setServiceName(String serviceName)
{
_serviceName = serviceName;
}
/**
* @return the host name of the service
* @see #setServiceName(String)
*/
public String getHostName()
{
return _hostName;
}
/**
* @param hostName the host name of the service
*/
public void setHostName(String hostName)
{
_hostName = hostName;
}
@Override
protected void doStart() throws Exception
{
if (_hostName == null)
_hostName = InetAddress.getLocalHost().getCanonicalHostName();
if (LOG.isDebugEnabled())
LOG.debug("Retrieving credentials for service {}/{}", getServiceName(), getHostName());
LoginContext loginContext = new LoginContext("", null, null, new SpnegoConfiguration());
loginContext.login();
Subject subject = loginContext.getSubject();
_context = Subject.doAs(subject, newSpnegoContext(subject));
super.doStart();
}
private PrivilegedAction<SpnegoContext> newSpnegoContext(Subject subject)
{
return () ->
{
try
{
GSSName serviceName = _gssManager.createName(getServiceName() + "@" + getHostName(), GSSName.NT_HOSTBASED_SERVICE);
Oid kerberosOid = new Oid("1.2.840.113554.1.2.2");
Oid spnegoOid = new Oid("1.3.6.1.5.5.2");
Oid[] mechanisms = new Oid[]{kerberosOid, spnegoOid};
GSSCredential serviceCredential = _gssManager.createCredential(serviceName, GSSCredential.DEFAULT_LIFETIME, mechanisms, GSSCredential.ACCEPT_ONLY);
SpnegoContext context = new SpnegoContext();
context._subject = subject;
context._serviceCredential = serviceCredential;
return context;
}
catch (GSSException x)
{
throw new RuntimeException(x);
}
};
}
@Override
public UserIdentity login(String username, Object credentials, ServletRequest req)
{
Subject subject = _context._subject;
HttpServletRequest request = (HttpServletRequest)req;
HttpSession httpSession = request.getSession(false);
GSSContext gssContext = null;
if (httpSession != null)
{
GSSContextHolder holder = (GSSContextHolder)httpSession.getAttribute(GSSContextHolder.ATTRIBUTE);
gssContext = holder == null ? null : holder.gssContext;
}
if (gssContext == null)
gssContext = Subject.doAs(subject, newGSSContext());
byte[] input = Base64.getDecoder().decode((String)credentials);
byte[] output = Subject.doAs(_context._subject, acceptGSSContext(gssContext, input));
String token = Base64.getEncoder().encodeToString(output);
String userName = toUserName(gssContext);
// Save the token in the principal so it can be sent in the response.
SpnegoUserPrincipal principal = new SpnegoUserPrincipal(userName, token);
if (gssContext.isEstablished())
{
if (httpSession != null)
httpSession.removeAttribute(GSSContextHolder.ATTRIBUTE);
UserIdentity roles = _authorizationService.getUserIdentity(request, userName);
return new SpnegoUserIdentity(subject, principal, roles);
}
else
{
// The GSS context is not established yet, save it into the HTTP session.
if (httpSession == null)
httpSession = request.getSession(true);
GSSContextHolder holder = new GSSContextHolder(gssContext);
httpSession.setAttribute(GSSContextHolder.ATTRIBUTE, holder);
// Return an unestablished UserIdentity.
return new SpnegoUserIdentity(subject, principal, null);
}
}
private PrivilegedAction<GSSContext> newGSSContext()
{
return () ->
{
try
{
return _gssManager.createContext(_context._serviceCredential);
}
catch (GSSException x)
{
throw new RuntimeException(x);
}
};
}
private PrivilegedAction<byte[]> acceptGSSContext(GSSContext gssContext, byte[] token)
{
return () ->
{
try
{
return gssContext.acceptSecContext(token, 0, token.length);
}
catch (GSSException x)
{
throw new RuntimeException(x);
}
};
}
private String toUserName(GSSContext gssContext)
{
try
{
String name = gssContext.getSrcName().toString();
int at = name.indexOf('@');
if (at < 0)
return name;
return name.substring(0, at);
}
catch (GSSException x)
{
throw new RuntimeException(x);
}
}
@Override
public boolean validate(UserIdentity user)
{
return false;
}
@Override
public IdentityService getIdentityService()
{
return _identityService;
}
@Override
public void setIdentityService(IdentityService identityService)
{
_identityService = identityService;
}
@Override
public void logout(UserIdentity user)
{
}
private class SpnegoConfiguration extends Configuration
{
@Override
public AppConfigurationEntry[] getAppConfigurationEntry(String name)
{
String principal = getServiceName() + "/" + getHostName();
Map<String, Object> options = new HashMap<>();
if (LOG.isDebugEnabled())
options.put("debug", "true");
options.put("doNotPrompt", "true");
options.put("refreshKrb5Config", "true");
options.put("principal", principal);
options.put("useKeyTab", "true");
Path keyTabPath = getKeyTabPath();
if (keyTabPath != null)
options.put("keyTab", keyTabPath.toAbsolutePath().toString());
// This option is required to store the service credentials in
// the Subject, so that it can be later used by acceptSecContext().
options.put("storeKey", "true");
options.put("isInitiator", "false");
String moduleClass = "com.sun.security.auth.module.Krb5LoginModule";
AppConfigurationEntry config = new AppConfigurationEntry(moduleClass, AppConfigurationEntry.LoginModuleControlFlag.REQUIRED, options);
return new AppConfigurationEntry[]{config};
}
}
private static class SpnegoContext
{
private Subject _subject;
private GSSCredential _serviceCredential;
}
private static class GSSContextHolder implements Serializable
{
public static final String ATTRIBUTE = GSSContextHolder.class.getName();
private transient final GSSContext gssContext;
private GSSContextHolder(GSSContext gssContext)
{
this.gssContext = gssContext;
}
}
}

View File

@ -36,6 +36,10 @@ import org.ietf.jgss.GSSManager;
import org.ietf.jgss.GSSName;
import org.ietf.jgss.Oid;
/**
* @deprecated use {@link ConfigurableSpnegoLoginService} instead
*/
@Deprecated
public class SpnegoLoginService extends AbstractLifeCycle implements LoginService
{
private static final Logger LOG = Log.getLogger(SpnegoLoginService.class);

View File

@ -19,7 +19,6 @@
package org.eclipse.jetty.security;
import java.security.Principal;
import java.util.List;
import javax.security.auth.Subject;
@ -27,18 +26,17 @@ import org.eclipse.jetty.server.UserIdentity;
public class SpnegoUserIdentity implements UserIdentity
{
private Subject _subject;
private Principal _principal;
private List<String> _roles;
private final Subject _subject;
private final Principal _principal;
private final UserIdentity _roleDelegate;
public SpnegoUserIdentity( Subject subject, Principal principal, List<String> roles )
public SpnegoUserIdentity(Subject subject, Principal principal, UserIdentity roleDelegate)
{
_subject = subject;
_principal = principal;
_roles = roles;
_roleDelegate = roleDelegate;
}
@Override
public Subject getSubject()
{
@ -54,7 +52,11 @@ public class SpnegoUserIdentity implements UserIdentity
@Override
public boolean isUserInRole(String role, Scope scope)
{
return _roles.contains(role);
return _roleDelegate != null && _roleDelegate.isUserInRole(role, scope);
}
public boolean isEstablished()
{
return _roleDelegate != null;
}
}

View File

@ -19,8 +19,7 @@
package org.eclipse.jetty.security;
import java.security.Principal;
import org.eclipse.jetty.util.B64Code;
import java.util.Base64;
public class SpnegoUserPrincipal implements Principal
{
@ -28,13 +27,13 @@ public class SpnegoUserPrincipal implements Principal
private byte[] _token;
private String _encodedToken;
public SpnegoUserPrincipal( String name, String encodedToken )
public SpnegoUserPrincipal(String name, String encodedToken)
{
_name = name;
_encodedToken = encodedToken;
}
public SpnegoUserPrincipal( String name, byte[] token )
public SpnegoUserPrincipal(String name, byte[] token)
{
_name = name;
_token = token;
@ -48,19 +47,15 @@ public class SpnegoUserPrincipal implements Principal
public byte[] getToken()
{
if ( _token == null )
{
_token = B64Code.decode(_encodedToken);
}
if (_token == null)
_token = Base64.getDecoder().decode(_encodedToken);
return _token;
}
public String getEncodedToken()
{
if ( _encodedToken == null )
{
_encodedToken = new String(B64Code.encode(_token,true));
}
if (_encodedToken == null)
_encodedToken = new String(Base64.getEncoder().encode(_token));
return _encodedToken;
}
}

View File

@ -0,0 +1,50 @@
//
// ========================================================================
// Copyright (c) 1995-2018 Mort Bay Consulting Pty. Ltd.
// ------------------------------------------------------------------------
// All rights reserved. This program and the accompanying materials
// are made available under the terms of the Eclipse Public License v1.0
// and Apache License v2.0 which accompanies this distribution.
//
// The Eclipse Public License is available at
// http://www.eclipse.org/legal/epl-v10.html
//
// The Apache License v2.0 is available at
// http://www.opensource.org/licenses/apache2.0.php
//
// You may elect to redistribute this code under either of these licenses.
// ========================================================================
//
package org.eclipse.jetty.security.authentication;
import javax.servlet.http.HttpServletRequest;
import org.eclipse.jetty.security.LoginService;
import org.eclipse.jetty.server.UserIdentity;
/**
* <p>A service to query for user roles.</p>
*/
@FunctionalInterface
public interface AuthorizationService
{
/**
* @param request the current HTTP request
* @param name the user name
* @return a {@link UserIdentity} to query for roles of the given user
*/
UserIdentity getUserIdentity(HttpServletRequest request, String name);
/**
* <p>Wraps a {@link LoginService} as an AuthorizationService</p>
*
* @param loginService the {@link LoginService} to wrap
* @param credentials
* @return an AuthorizationService that delegates the query for roles to the given {@link LoginService}
*/
public static AuthorizationService from(LoginService loginService, Object credentials)
{
return (request, name) -> loginService.login(name, credentials, request);
}
}

View File

@ -0,0 +1,232 @@
//
// ========================================================================
// Copyright (c) 1995-2018 Mort Bay Consulting Pty. Ltd.
// ------------------------------------------------------------------------
// All rights reserved. This program and the accompanying materials
// are made available under the terms of the Eclipse Public License v1.0
// and Apache License v2.0 which accompanies this distribution.
//
// The Eclipse Public License is available at
// http://www.eclipse.org/legal/epl-v10.html
//
// The Apache License v2.0 is available at
// http://www.opensource.org/licenses/apache2.0.php
//
// You may elect to redistribute this code under either of these licenses.
// ========================================================================
//
package org.eclipse.jetty.security.authentication;
import java.io.IOException;
import java.io.Serializable;
import java.time.Duration;
import java.time.Instant;
import javax.servlet.ServletRequest;
import javax.servlet.ServletResponse;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpSession;
import org.eclipse.jetty.http.HttpHeader;
import org.eclipse.jetty.http.HttpMethod;
import org.eclipse.jetty.security.ServerAuthException;
import org.eclipse.jetty.security.SpnegoUserIdentity;
import org.eclipse.jetty.security.SpnegoUserPrincipal;
import org.eclipse.jetty.security.UserAuthentication;
import org.eclipse.jetty.server.Authentication;
import org.eclipse.jetty.server.Authentication.User;
import org.eclipse.jetty.server.UserIdentity;
import org.eclipse.jetty.util.log.Log;
import org.eclipse.jetty.util.log.Logger;
import org.eclipse.jetty.util.security.Constraint;
/**
* <p>A LoginAuthenticator that uses SPNEGO and the GSS API to authenticate requests.</p>
* <p>A successful authentication from a client is cached for a configurable
* {@link #getAuthenticationDuration() duration} using the HTTP session; this avoids
* that the client is asked to authenticate for every request.</p>
*
* @see org.eclipse.jetty.security.ConfigurableSpnegoLoginService
*/
public class ConfigurableSpnegoAuthenticator extends LoginAuthenticator
{
private static final Logger LOG = Log.getLogger(ConfigurableSpnegoAuthenticator.class);
private final String _authMethod;
private Duration _authenticationDuration = Duration.ofNanos(-1);
public ConfigurableSpnegoAuthenticator()
{
this(Constraint.__SPNEGO_AUTH);
}
/**
* Allow for a custom authMethod value to be set for instances where SPNEGO may not be appropriate
*
* @param authMethod the auth method
*/
public ConfigurableSpnegoAuthenticator(String authMethod)
{
_authMethod = authMethod;
}
@Override
public String getAuthMethod()
{
return _authMethod;
}
/**
* @return the authentication duration
*/
public Duration getAuthenticationDuration()
{
return _authenticationDuration;
}
/**
* <p>Sets the duration of the authentication.</p>
* <p>A negative duration means that the authentication is only valid for the current request.</p>
* <p>A zero duration means that the authentication is valid forever.</p>
* <p>A positive value means that the authentication is valid for the specified duration.</p>
*
* @param authenticationDuration the authentication duration
*/
public void setAuthenticationDuration(Duration authenticationDuration)
{
_authenticationDuration = authenticationDuration;
}
@Override
public Authentication validateRequest(ServletRequest req, ServletResponse res, boolean mandatory) throws ServerAuthException
{
if (!mandatory)
return new DeferredAuthentication(this);
HttpServletRequest request = (HttpServletRequest)req;
HttpServletResponse response = (HttpServletResponse)res;
String header = request.getHeader(HttpHeader.AUTHORIZATION.asString());
String spnegoToken = getSpnegoToken(header);
HttpSession httpSession = request.getSession(false);
// We have a token from the client, so run the login.
if (header != null && spnegoToken != null)
{
SpnegoUserIdentity identity = (SpnegoUserIdentity)login(null, spnegoToken, request);
if (identity.isEstablished())
{
if (!DeferredAuthentication.isDeferred(response))
{
if (LOG.isDebugEnabled())
LOG.debug("Sending final token");
// Send to the client the final token so that the
// client can establish the GSS context with the server.
SpnegoUserPrincipal principal = (SpnegoUserPrincipal)identity.getUserPrincipal();
setSpnegoToken(response, principal.getEncodedToken());
}
Duration authnDuration = getAuthenticationDuration();
if (!authnDuration.isNegative())
{
if (httpSession == null)
httpSession = request.getSession(true);
httpSession.setAttribute(UserIdentityHolder.ATTRIBUTE, new UserIdentityHolder(identity));
}
return new UserAuthentication(getAuthMethod(), identity);
}
else
{
if (DeferredAuthentication.isDeferred(response))
return Authentication.UNAUTHENTICATED;
if (LOG.isDebugEnabled())
LOG.debug("Sending intermediate challenge");
SpnegoUserPrincipal principal = (SpnegoUserPrincipal)identity.getUserPrincipal();
sendChallenge(response, principal.getEncodedToken());
return Authentication.SEND_CONTINUE;
}
}
// No token from the client; check if the client has logged in
// successfully before and the authentication has not expired.
else if (httpSession != null)
{
UserIdentityHolder holder = (UserIdentityHolder)httpSession.getAttribute(UserIdentityHolder.ATTRIBUTE);
if (holder != null)
{
UserIdentity identity = holder._userIdentity;
if (identity != null)
{
Duration authnDuration = getAuthenticationDuration();
if (!authnDuration.isNegative())
{
boolean expired = !authnDuration.isZero() && Instant.now().isAfter(holder._validFrom.plus(authnDuration));
// Allow non-GET requests even if they're expired, so that
// the client does not need to send the request content again.
if (!expired || !HttpMethod.GET.is(request.getMethod()))
return new UserAuthentication(getAuthMethod(), identity);
}
}
}
}
if (DeferredAuthentication.isDeferred(response))
return Authentication.UNAUTHENTICATED;
if (LOG.isDebugEnabled())
LOG.debug("Sending initial challenge");
sendChallenge(response, null);
return Authentication.SEND_CONTINUE;
}
private void sendChallenge(HttpServletResponse response, String token) throws ServerAuthException
{
try
{
setSpnegoToken(response, token);
response.sendError(HttpServletResponse.SC_UNAUTHORIZED);
}
catch (IOException x)
{
throw new ServerAuthException(x);
}
}
private void setSpnegoToken(HttpServletResponse response, String token)
{
String value = HttpHeader.NEGOTIATE.asString();
if (token != null)
value += " " + token;
response.setHeader(HttpHeader.WWW_AUTHENTICATE.asString(), value);
}
private String getSpnegoToken(String header)
{
if (header == null)
return null;
String scheme = HttpHeader.NEGOTIATE.asString() + " ";
if (header.regionMatches(true, 0, scheme, 0, scheme.length()))
return header.substring(scheme.length()).trim();
return null;
}
@Override
public boolean secureResponse(ServletRequest request, ServletResponse response, boolean mandatory, User validatedUser)
{
return true;
}
private static class UserIdentityHolder implements Serializable
{
private static final String ATTRIBUTE = UserIdentityHolder.class.getName();
private transient final Instant _validFrom = Instant.now();
private transient final UserIdentity _userIdentity;
private UserIdentityHolder(UserIdentity userIdentity)
{
_userIdentity = userIdentity;
}
}
}

View File

@ -35,6 +35,10 @@ import org.eclipse.jetty.util.log.Log;
import org.eclipse.jetty.util.log.Logger;
import org.eclipse.jetty.util.security.Constraint;
/**
* @deprecated use {@link ConfigurableSpnegoAuthenticator} instead.
*/
@Deprecated
public class SpnegoAuthenticator extends LoginAuthenticator
{
private static final Logger LOG = Log.getLogger(SpnegoAuthenticator.class);