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:
commit
0922644c5b
|
@ -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>
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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};
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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());
|
||||
}
|
||||
}
|
|
@ -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
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
# Format is <user>:<password>,<roles>
|
||||
basic:basic
|
||||
digest:digest
|
||||
spnego_client:,admin
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
|
|
Loading…
Reference in New Issue