diff --git a/jetty-security/src/main/java/org/eclipse/jetty/security/DefaultAuthenticatorFactory.java b/jetty-security/src/main/java/org/eclipse/jetty/security/DefaultAuthenticatorFactory.java index 71da2b6750fb..6bcbc7b10d98 100644 --- a/jetty-security/src/main/java/org/eclipse/jetty/security/DefaultAuthenticatorFactory.java +++ b/jetty-security/src/main/java/org/eclipse/jetty/security/DefaultAuthenticatorFactory.java @@ -13,6 +13,7 @@ package org.eclipse.jetty.security; +import java.util.Collection; import javax.servlet.ServletContext; import org.eclipse.jetty.security.Authenticator.AuthConfiguration; @@ -21,8 +22,12 @@ import org.eclipse.jetty.security.authentication.ConfigurableSpnegoAuthenticator; import org.eclipse.jetty.security.authentication.DigestAuthenticator; import org.eclipse.jetty.security.authentication.FormAuthenticator; +import org.eclipse.jetty.security.authentication.SslClientCertAuthenticator; import org.eclipse.jetty.server.Server; import org.eclipse.jetty.util.security.Constraint; +import org.eclipse.jetty.util.ssl.SslContextFactory; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; /** * The Default Authenticator Factory. @@ -31,6 +36,7 @@ *
This {@link org.eclipse.jetty.security.Authenticator} implements client certificate authentication. + * The client certificates available in the request will be verified against the configured {@link SslContextFactory} instance + *
+ */ +public class SslClientCertAuthenticator + extends LoginAuthenticator +{ + + /** + * Set to true if SSL certificate validation is not required + * per default it's true as this is the goal of this implementation + */ + private boolean validateCerts = true; + + private SslContextFactory sslContextFactory; + + public SslClientCertAuthenticator(SslContextFactory sslContextFactory) + { + super(); + Objects.nonNull(sslContextFactory); + this.sslContextFactory = sslContextFactory; + } + + @Override + public String getAuthMethod() + { + return Constraint.__CERT_AUTH; + } + + @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; + X509Certificate[] certs = (X509Certificate[])request.getAttribute("javax.servlet.request.X509Certificate"); + + try + { + // Need certificates. + if (certs != null && certs.length > 0) + { + + if (validateCerts) + { + sslContextFactory.validateCerts(certs); + } + + for (X509Certificate cert : certs) + { + if (cert == null) + continue; + + Principal principal = cert.getSubjectDN(); + if (principal == null) + principal = cert.getIssuerDN(); + final String username = principal == null ? "clientcert" : principal.getName(); + + UserIdentity user = login(username, "", req); + if (user != null) + { + return new UserAuthentication(getAuthMethod(), user); + } + // try with null password + user = login(username, null, req); + if (user != null) + { + return new UserAuthentication(getAuthMethod(), user); + } + // try with certs sig against login service as previous behaviour + final char[] credential = Base64.getEncoder().encodeToString(cert.getSignature()).toCharArray(); + user = login(username, credential, req); + if (user != null) + { + return new UserAuthentication(getAuthMethod(), user); + } + } + } + + if (!DeferredAuthentication.isDeferred(response)) + { + response.sendError(HttpServletResponse.SC_FORBIDDEN); + return Authentication.SEND_FAILURE; + } + + return Authentication.UNAUTHENTICATED; + } + catch (Exception e) + { + throw new ServerAuthException(e.getMessage()); + } + } + + @Override + public boolean secureResponse(ServletRequest req, ServletResponse res, boolean mandatory, User validatedUser) throws ServerAuthException + { + return true; + } + + /** + * @return true if SSL certificate has to be validated + */ + public boolean isValidateCerts() + { + return validateCerts; + } + + /** + * @param validateCerts true if SSL certificates have to be validated + */ + public void setValidateCerts(boolean validateCerts) + { + validateCerts = validateCerts; + } + +} diff --git a/jetty-security/src/test/java/org/eclipse/jetty/security/ClientCertAuthenticatorTest.java b/jetty-security/src/test/java/org/eclipse/jetty/security/ClientCertAuthenticatorTest.java new file mode 100644 index 000000000000..63aaf0e67503 --- /dev/null +++ b/jetty-security/src/test/java/org/eclipse/jetty/security/ClientCertAuthenticatorTest.java @@ -0,0 +1,193 @@ +// +// ======================================================================== +// Copyright (c) 1995-2021 Mort Bay Consulting Pty Ltd and others. +// +// This program and the accompanying materials are made available under the +// terms of the Eclipse Public License v. 2.0 which is available at +// https://www.eclipse.org/legal/epl-2.0, or the Apache License, Version 2.0 +// which is available at https://www.apache.org/licenses/LICENSE-2.0. +// +// SPDX-License-Identifier: EPL-2.0 OR Apache-2.0 +// ======================================================================== +// + +package org.eclipse.jetty.security; + +import java.io.IOException; +import java.io.PrintWriter; +import java.net.HttpURLConnection; +import java.net.URI; +import java.net.URL; +import javax.net.ssl.HostnameVerifier; +import javax.net.ssl.HttpsURLConnection; +import javax.net.ssl.SSLSocketFactory; +import javax.servlet.ServletException; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; + +import org.eclipse.jetty.server.Connector; +import org.eclipse.jetty.server.HttpConfiguration; +import org.eclipse.jetty.server.HttpConnectionFactory; +import org.eclipse.jetty.server.Request; +import org.eclipse.jetty.server.SecureRequestCustomizer; +import org.eclipse.jetty.server.Server; +import org.eclipse.jetty.server.ServerConnector; +import org.eclipse.jetty.server.SslConnectionFactory; +import org.eclipse.jetty.server.handler.AbstractHandler; +import org.eclipse.jetty.toolchain.test.MavenTestingUtils; +import org.eclipse.jetty.util.IO; +import org.eclipse.jetty.util.resource.Resource; +import org.eclipse.jetty.util.security.Constraint; +import org.eclipse.jetty.util.ssl.SslContextFactory; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.is; + +public class ClientCertAuthenticatorTest +{ + + private Server server; + + private URI serverHttpsUri; + private URI serverHttpUri; + + private HostnameVerifier origVerifier; + private SSLSocketFactory origSsf; + + private static final String MESSAGE = "Yep CLIENT-CERT works"; + + @BeforeEach + public void setup() throws Exception + { + origSsf = HttpsURLConnection.getDefaultSSLSocketFactory(); + origVerifier = HttpsURLConnection.getDefaultHostnameVerifier(); + + server = new Server(); + + int port = 32080; + int securePort = 32443; + SslContextFactory.Server sslContextFactory = createServerSslContextFactory("cacerts.jks", "changeit"); + // Setup HTTP Configuration + HttpConfiguration httpConf = new HttpConfiguration(); + httpConf.setSecurePort(securePort); + httpConf.setSecureScheme("https"); + + ServerConnector httpConnector = new ServerConnector(server, new HttpConnectionFactory(httpConf)); + httpConnector.setName("unsecured"); + httpConnector.setPort(port); + + // Setup HTTPS Configuration + HttpConfiguration httpsConf = new HttpConfiguration(httpConf); + SecureRequestCustomizer secureRequestCustomizer = new SecureRequestCustomizer(); + secureRequestCustomizer.setSniRequired(false); + secureRequestCustomizer.setSniHostCheck(false); + httpsConf.addCustomizer(secureRequestCustomizer); + + ServerConnector httpsConnector = new ServerConnector(server, new SslConnectionFactory(sslContextFactory, "http/1.1"), new HttpConnectionFactory(httpsConf)); + httpsConnector.setName("secured"); + httpsConnector.setPort(securePort); + + // Add connectors + server.setConnectors(new Connector[]{httpConnector, httpsConnector}); + + ConstraintSecurityHandler constraintSecurityHandler = new ConstraintSecurityHandler(); + constraintSecurityHandler.setAuthMethod(Constraint.__CERT_AUTH2); + ConstraintMapping constraintMapping = new ConstraintMapping(); + Constraint constraint = new Constraint(); + constraint.setName(Constraint.__CERT_AUTH2); + constraint.setRoles(new String[]{"Administrator"}); + constraint.setAuthenticate(true); + constraintMapping.setConstraint(constraint); + constraintMapping.setMethod("GET"); + constraintMapping.setPathSpec("/"); + constraintSecurityHandler.addConstraintMapping(constraintMapping); + + HashLoginService loginService = new HashLoginService(); + constraintSecurityHandler.setLoginService(loginService); + loginService.setConfig("src/test/resources/realm.properties"); + + constraintSecurityHandler.setHandler(new FooHandler()); + server.setHandler(constraintSecurityHandler); + server.addBean(sslContextFactory); + server.start(); + + // calculate serverUri + String host = httpConnector.getHost(); + if (host == null) + { + host = "localhost"; + } + serverHttpsUri = new URI(String.format("https://%s:%d/", host, httpsConnector.getLocalPort())); + serverHttpUri = new URI(String.format("http://%s:%d/", host, httpConnector.getLocalPort())); + } + + @AfterEach + public void stopServer() throws Exception + { + if (origVerifier != null) + { + HttpsURLConnection.setDefaultHostnameVerifier(origVerifier); + } + if (origSsf != null) + { + HttpsURLConnection.setDefaultSSLSocketFactory(origSsf); + } + server.stop(); + } + + private SslContextFactory.Server createServerSslContextFactory(String trustStorePath, String trustStorePassword) + { + SslContextFactory.Server cf = new SslContextFactory.Server(); + cf.setNeedClientAuth(true); + cf.setTrustStorePassword(trustStorePassword); + cf.setTrustStoreResource(Resource.newResource(MavenTestingUtils.getTestResourcePath(trustStorePath))); + cf.setKeyStoreResource(Resource.newResource(MavenTestingUtils.getTestResourcePath("clientcert.jks"))); + cf.setKeyStorePassword("changeit"); + cf.setSniRequired(false); + cf.setWantClientAuth(true); + return cf; + } + + @Test + public void authzPass() throws Exception + { + HttpsURLConnection.setDefaultHostnameVerifier((s,sslSession) -> true); + SslContextFactory.Server cf = createServerSslContextFactory("cacerts.jks", "changeit"); + cf.start(); + HttpsURLConnection.setDefaultSSLSocketFactory(cf.getSslContext().getSocketFactory()); + URL url = serverHttpsUri.resolve("/").toURL(); + HttpURLConnection connection = (HttpURLConnection)url.openConnection(); + assertThat("response code", connection.getResponseCode(), is(200)); + String response = IO.toString(connection.getInputStream()); + assertThat("response message", response, containsString(MESSAGE)); + } + + @Test + public void authzNotPass() throws Exception + { + URL url = serverHttpUri.resolve("/").toURL(); + HttpURLConnection connection = (HttpURLConnection)url.openConnection(); + assertThat("response code", connection.getResponseCode(), is(403)); + } + + static class FooHandler extends AbstractHandler + { + @Override + public void handle(String target, Request baseRequest, HttpServletRequest request, + HttpServletResponse response) + throws IOException, ServletException + { + response.setContentType("text/plain; charset=utf-8"); + response.setStatus(HttpServletResponse.SC_OK); + + PrintWriter out = response.getWriter(); + + out.println(MESSAGE); + baseRequest.setHandled(true); + } + } +} diff --git a/jetty-security/src/test/resources/cacerts.jks b/jetty-security/src/test/resources/cacerts.jks new file mode 100644 index 000000000000..c9ea7a72a968 Binary files /dev/null and b/jetty-security/src/test/resources/cacerts.jks differ diff --git a/jetty-security/src/test/resources/clientcert.jks b/jetty-security/src/test/resources/clientcert.jks new file mode 100644 index 000000000000..89f56cee0651 Binary files /dev/null and b/jetty-security/src/test/resources/clientcert.jks differ diff --git a/jetty-security/src/test/resources/jetty-logging.properties b/jetty-security/src/test/resources/jetty-logging.properties index bd86a8c53693..7b4d94805e97 100755 --- a/jetty-security/src/test/resources/jetty-logging.properties +++ b/jetty-security/src/test/resources/jetty-logging.properties @@ -1,5 +1,6 @@ # Setup default logging implementation for during testing # Jetty Logging using jetty-slf4j-impl #org.eclipse.jetty.LEVEL=DEBUG +#org.eclipse.jetty.security.LEVEL=DEBUG #org.eclipse.jetty.util.PathWatcher.LEVEL=DEBUG #org.eclipse.jetty.util.PathWatcher.Noisy.LEVEL=OFF diff --git a/jetty-security/src/test/resources/realm.properties b/jetty-security/src/test/resources/realm.properties new file mode 100644 index 000000000000..1a441165817f --- /dev/null +++ b/jetty-security/src/test/resources/realm.properties @@ -0,0 +1,27 @@ +# +# This file defines users passwords and roles for a HashUserRealm +# +# The format is +#