Implementation of the SPNEGO (or "Negotiate") authentication defined in RFC 4559.
+ *A {@link #getUserName() user} is logged in via JAAS (either via userName/password or + * via userName/keyTab) once only.
+ *For every request that needs authentication, a {@link GSSContext} is initiated and + * later established after reading the response from the server.
+ *Applications should create objects of this class and add them to the + * {@link AuthenticationStore} retrieved from the {@link HttpClient} + * via {@link HttpClient#getAuthenticationStore()}.
+ */ +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 PrivilegedActionA configurable (as opposed to using system properties) SPNEGO LoginService.
+ *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.
+ *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.
+ */ +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 PrivilegedActionA service to query for user roles.
+ */ +@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); + + /** + *Wraps a {@link LoginService} as an AuthorizationService
+ * + * @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); + } +} diff --git a/jetty-security/src/main/java/org/eclipse/jetty/security/authentication/ConfigurableSpnegoAuthenticator.java b/jetty-security/src/main/java/org/eclipse/jetty/security/authentication/ConfigurableSpnegoAuthenticator.java new file mode 100644 index 00000000000..6cae1a4745b --- /dev/null +++ b/jetty-security/src/main/java/org/eclipse/jetty/security/authentication/ConfigurableSpnegoAuthenticator.java @@ -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; + +/** + *A LoginAuthenticator that uses SPNEGO and the GSS API to authenticate requests.
+ *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.
+ * + * @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; + } + + /** + *Sets the duration of the authentication.
+ *A negative duration means that the authentication is only valid for the current request.
+ *A zero duration means that the authentication is valid forever.
+ *A positive value means that the authentication is valid for the specified duration.
+ * + * @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; + } + } +} diff --git a/jetty-security/src/main/java/org/eclipse/jetty/security/authentication/SpnegoAuthenticator.java b/jetty-security/src/main/java/org/eclipse/jetty/security/authentication/SpnegoAuthenticator.java index 0dfe1b6add5..8142eb7c4d8 100644 --- a/jetty-security/src/main/java/org/eclipse/jetty/security/authentication/SpnegoAuthenticator.java +++ b/jetty-security/src/main/java/org/eclipse/jetty/security/authentication/SpnegoAuthenticator.java @@ -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); diff --git a/jetty-server/src/main/java/org/eclipse/jetty/server/AbstractConnector.java b/jetty-server/src/main/java/org/eclipse/jetty/server/AbstractConnector.java index 224f523b129..ec7b51c62b4 100644 --- a/jetty-server/src/main/java/org/eclipse/jetty/server/AbstractConnector.java +++ b/jetty-server/src/main/java/org/eclipse/jetty/server/AbstractConnector.java @@ -226,7 +226,7 @@ public abstract class AbstractConnector extends ContainerLifeCycle implements Co } @Override - @ManagedAttribute("Idle timeout") + @ManagedAttribute("The connection idle timeout in milliseconds") public long getIdleTimeout() { return _idleTimeout; diff --git a/jetty-servlet/src/test/java/org/eclipse/jetty/servlet/ErrorPageTest.java b/jetty-servlet/src/test/java/org/eclipse/jetty/servlet/ErrorPageTest.java index dd2f1f7bfec..5ca8913035c 100644 --- a/jetty-servlet/src/test/java/org/eclipse/jetty/servlet/ErrorPageTest.java +++ b/jetty-servlet/src/test/java/org/eclipse/jetty/servlet/ErrorPageTest.java @@ -31,6 +31,7 @@ import javax.servlet.http.HttpServlet; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; +import org.eclipse.jetty.http.BadMessageException; import org.eclipse.jetty.server.Dispatcher; import org.eclipse.jetty.server.HttpChannel; import org.eclipse.jetty.server.LocalConnector; @@ -63,11 +64,13 @@ public class ErrorPageTest context.addServlet(FailServlet.class, "/fail/*"); context.addServlet(FailClosedServlet.class, "/fail-closed/*"); context.addServlet(ErrorServlet.class, "/error/*"); + context.addServlet(AppServlet.class, "/app/*"); ErrorPageErrorHandler error = new ErrorPageErrorHandler(); context.setErrorHandler(error); error.addErrorPage(599,"/error/599"); error.addErrorPage(IllegalStateException.class.getCanonicalName(),"/error/TestException"); + error.addErrorPage(BadMessageException.class,"/error/TestException"); error.addErrorPage(ErrorPageErrorHandler.GLOBAL_ERROR_PAGE,"/error/GlobalErrorPage"); _server.start(); @@ -157,6 +160,33 @@ public class ErrorPageTest } } + @Test + public void testBadMessage() throws Exception + { + String response = _connector.getResponse("GET /app?baa=%88%A4 HTTP/1.0\r\n\r\n"); + assertThat(response, Matchers.containsString("HTTP/1.1 400 Unable to parse URI query")); + assertThat(response, Matchers.containsString("ERROR_PAGE: /TestException")); + assertThat(response, Matchers.containsString("ERROR_MESSAGE: Unable to parse URI query")); + assertThat(response, Matchers.containsString("ERROR_CODE: 400")); + assertThat(response, Matchers.containsString("ERROR_EXCEPTION: org.eclipse.jetty.http.BadMessageException: 400: Unable to parse URI query")); + assertThat(response, Matchers.containsString("ERROR_EXCEPTION_TYPE: class org.eclipse.jetty.http.BadMessageException")); + assertThat(response, Matchers.containsString("ERROR_SERVLET: org.eclipse.jetty.servlet.ErrorPageTest$AppServlet-")); + assertThat(response, Matchers.containsString("ERROR_REQUEST_URI: /app")); + assertThat(response, Matchers.containsString("getParameterMap()= {}")); + } + + + public static class AppServlet extends HttpServlet implements Servlet + { + @Override + protected void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException + { + PrintWriter writer = response.getWriter(); + writer.println(request.getRequestURI()); + writer.println(request.getParameterMap().toString()); + } + } + public static class FailServlet extends HttpServlet implements Servlet { @Override @@ -202,6 +232,7 @@ public class ErrorPageTest writer.println("ERROR_EXCEPTION_TYPE: " + request.getAttribute(Dispatcher.ERROR_EXCEPTION_TYPE)); writer.println("ERROR_SERVLET: " + request.getAttribute(Dispatcher.ERROR_SERVLET_NAME)); writer.println("ERROR_REQUEST_URI: " + request.getAttribute(Dispatcher.ERROR_REQUEST_URI)); + writer.println("getParameterMap()= " + request.getParameterMap()); } } diff --git a/jetty-util/src/test/java/org/eclipse/jetty/util/MultiReleaseJarFileTest.java b/jetty-util/src/test/java/org/eclipse/jetty/util/MultiReleaseJarFileTest.java index e22e98065b2..eaffe9edd5b 100644 --- a/jetty-util/src/test/java/org/eclipse/jetty/util/MultiReleaseJarFileTest.java +++ b/jetty-util/src/test/java/org/eclipse/jetty/util/MultiReleaseJarFileTest.java @@ -18,10 +18,6 @@ package org.eclipse.jetty.util; -import static org.hamcrest.Matchers.is; -import static org.hamcrest.MatcherAssert.assertThat; -import static org.junit.jupiter.api.Assertions.assertTrue; - import java.io.File; import java.net.URL; import java.net.URLClassLoader; @@ -31,9 +27,13 @@ import org.eclipse.jetty.toolchain.test.MavenTestingUtils; import org.eclipse.jetty.util.MultiReleaseJarFile.VersionedJarEntry; import org.hamcrest.Matchers; import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.condition.EnabledOnJre; +import org.junit.jupiter.api.condition.DisabledOnJre; import org.junit.jupiter.api.condition.JRE; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.is; +import static org.junit.jupiter.api.Assertions.assertTrue; + public class MultiReleaseJarFileTest { private File example = MavenTestingUtils.getTestResourceFile("example.jar"); @@ -124,7 +124,7 @@ public class MultiReleaseJarFileTest @Test - @EnabledOnJre({JRE.JAVA_9, JRE.JAVA_10, JRE.JAVA_11}) + @DisabledOnJre(JRE.JAVA_8) public void testClassLoaderJava9() throws Exception { try(URLClassLoader loader = new URLClassLoader(new URL[]{example.toURI().toURL()})) diff --git a/pom.xml b/pom.xml index 115ad628715..7334242c0a2 100644 --- a/pom.xml +++ b/pom.xml @@ -469,13 +469,6 @@