diff --git a/jetty-integrations/jetty-ethereum/src/main/java/org/eclipse/jetty/security/siwe/internal/AnyUserLoginService.java b/jetty-core/jetty-security/src/main/java/org/eclipse/jetty/security/AnyUserLoginService.java similarity index 82% rename from jetty-integrations/jetty-ethereum/src/main/java/org/eclipse/jetty/security/siwe/internal/AnyUserLoginService.java rename to jetty-core/jetty-security/src/main/java/org/eclipse/jetty/security/AnyUserLoginService.java index e1f099cda87b..031717d0fbef 100644 --- a/jetty-integrations/jetty-ethereum/src/main/java/org/eclipse/jetty/security/siwe/internal/AnyUserLoginService.java +++ b/jetty-core/jetty-security/src/main/java/org/eclipse/jetty/security/AnyUserLoginService.java @@ -11,26 +11,27 @@ // ======================================================================== // -package org.eclipse.jetty.security.siwe.internal; +package org.eclipse.jetty.security; import java.util.function.Function; import javax.security.auth.Subject; -import org.eclipse.jetty.security.DefaultIdentityService; -import org.eclipse.jetty.security.IdentityService; -import org.eclipse.jetty.security.LoginService; -import org.eclipse.jetty.security.UserIdentity; -import org.eclipse.jetty.security.UserPrincipal; import org.eclipse.jetty.server.Request; import org.eclipse.jetty.server.Session; /** * A {@link LoginService} which allows unknown users to be authenticated. + *

This is useful for authentication protocols like OpenID Connect and Sign in With Ethereum, where Jetty doesn't store + * a collection of user credentials and passwords. Once the user proves authenticates themselves through the respective + * protocol, Jetty does not have to validate any credential.

*

- * This can delegate to a nested {@link LoginService} if it is supplied to the constructor, it will first attempt to log in + * This can delegate to a nested {@link LoginService} which can supply roles for known users. + * This nested {@link LoginService} is supplied to the constructor, and this will first attempt to log in * with the nested {@link LoginService} and only create a new {@link UserIdentity} if none was found with - * {@link LoginService#login(String, Object, Request, Function)}. + * {@link LoginService#login(String, Object, Request, Function)} *

+ *

This {@link LoginService} does not check credentials, a {@link UserIdentity} will be produced for any + * username provided in {@link #login(String, Object, Request, Function)}.

*/ public class AnyUserLoginService implements LoginService { diff --git a/jetty-core/jetty-security/src/main/java/org/eclipse/jetty/security/Authenticator.java b/jetty-core/jetty-security/src/main/java/org/eclipse/jetty/security/Authenticator.java index 210187dd046c..87c8c5f6f511 100644 --- a/jetty-core/jetty-security/src/main/java/org/eclipse/jetty/security/Authenticator.java +++ b/jetty-core/jetty-security/src/main/java/org/eclipse/jetty/security/Authenticator.java @@ -43,6 +43,7 @@ public interface Authenticator String NEGOTIATE_AUTH = "NEGOTIATE"; String OPENID_AUTH = "OPENID"; String SIWE_AUTH = "SIWE"; + String MULTI_AUTH = "MULTI"; /** * Configure the Authenticator diff --git a/jetty-core/jetty-security/src/main/java/org/eclipse/jetty/security/DefaultAuthenticatorFactory.java b/jetty-core/jetty-security/src/main/java/org/eclipse/jetty/security/DefaultAuthenticatorFactory.java index a47eed687486..a9ef7d5a926b 100644 --- a/jetty-core/jetty-security/src/main/java/org/eclipse/jetty/security/DefaultAuthenticatorFactory.java +++ b/jetty-core/jetty-security/src/main/java/org/eclipse/jetty/security/DefaultAuthenticatorFactory.java @@ -14,6 +14,7 @@ package org.eclipse.jetty.security; import java.util.Collection; +import java.util.List; import org.eclipse.jetty.security.Authenticator.Configuration; import org.eclipse.jetty.security.authentication.BasicAuthenticator; @@ -26,6 +27,7 @@ import org.eclipse.jetty.security.internal.DeferredAuthenticationState; import org.eclipse.jetty.server.Context; import org.eclipse.jetty.server.Server; +import org.eclipse.jetty.util.StringUtil; import org.eclipse.jetty.util.ssl.SslContextFactory; /** @@ -53,27 +55,74 @@ public class DefaultAuthenticatorFactory implements Authenticator.Factory @Override public Authenticator getAuthenticator(Server server, Context context, Configuration configuration) { - String auth = configuration.getAuthenticationType(); - Authenticator authenticator = null; + String auth = StringUtil.asciiToUpperCase(configuration.getAuthenticationType()); + if (auth == null) + return null; - if (Authenticator.BASIC_AUTH.equalsIgnoreCase(auth)) - authenticator = new BasicAuthenticator(); - else if (Authenticator.DIGEST_AUTH.equalsIgnoreCase(auth)) - authenticator = new DigestAuthenticator(); - else if (Authenticator.FORM_AUTH.equalsIgnoreCase(auth)) - authenticator = new FormAuthenticator(); - else if (Authenticator.SPNEGO_AUTH.equalsIgnoreCase(auth)) - authenticator = new SPNEGOAuthenticator(); - else if (Authenticator.NEGOTIATE_AUTH.equalsIgnoreCase(auth)) // see Bug #377076 - authenticator = new SPNEGOAuthenticator(Authenticator.NEGOTIATE_AUTH); - if (Authenticator.CERT_AUTH2.equalsIgnoreCase(auth)) + return switch (auth) { - Collection sslContextFactories = server.getBeans(SslContextFactory.class); - if (sslContextFactories.size() != 1) - throw new IllegalStateException("SslClientCertAuthenticator requires a single SslContextFactory instances."); - authenticator = new SslClientCertAuthenticator(sslContextFactories.iterator().next()); - } + case Authenticator.BASIC_AUTH -> new BasicAuthenticator(); + case Authenticator.DIGEST_AUTH -> new DigestAuthenticator(); + case Authenticator.FORM_AUTH -> new FormAuthenticator(); + case Authenticator.SPNEGO_AUTH -> new SPNEGOAuthenticator(); + case Authenticator.NEGOTIATE_AUTH -> new SPNEGOAuthenticator(Authenticator.NEGOTIATE_AUTH); // see Bug #377076 + case Authenticator.MULTI_AUTH -> getMultiAuthenticator(server, context, configuration); + case Authenticator.CERT_AUTH, Authenticator.CERT_AUTH2 -> + { + Collection sslContextFactories = server.getBeans(SslContextFactory.class); + if (sslContextFactories.size() != 1) + throw new IllegalStateException("SslClientCertAuthenticator requires a single SslContextFactory instances."); + yield new SslClientCertAuthenticator(sslContextFactories.iterator().next()); + } + default -> null; + }; + } - return authenticator; + private Authenticator getMultiAuthenticator(Server server, Context context, Authenticator.Configuration configuration) + { + SecurityHandler securityHandler = SecurityHandler.getCurrentSecurityHandler(); + if (securityHandler == null) + return null; + + String auth = configuration.getAuthenticationType(); + if (Authenticator.MULTI_AUTH.equalsIgnoreCase(auth)) + { + MultiAuthenticator multiAuthenticator = new MultiAuthenticator(); + + String authenticatorConfig = configuration.getParameter("org.eclipse.jetty.security.multi.authenticators"); + for (String config : StringUtil.csvSplit(authenticatorConfig)) + { + String[] parts = config.split(":"); + if (parts.length != 2) + throw new IllegalArgumentException(); + + String authType = parts[0].trim(); + String pathSpec = parts[1].trim(); + + Authenticator.Configuration.Wrapper authConfig = new Authenticator.Configuration.Wrapper(configuration) + { + @Override + public String getAuthenticationType() + { + return authType; + } + }; + + Authenticator authenticator = null; + List authenticatorFactories = securityHandler.getKnownAuthenticatorFactories(); + for (Authenticator.Factory factory : authenticatorFactories) + { + authenticator = factory.getAuthenticator(server, context, authConfig); + if (authenticator != null) + break; + } + + if (authenticator == null) + throw new IllegalStateException(); + multiAuthenticator.addAuthenticator(pathSpec, authenticator); + } + return multiAuthenticator; + } + return null; } } diff --git a/jetty-core/jetty-security/src/main/java/org/eclipse/jetty/security/MultiAuthenticator.java b/jetty-core/jetty-security/src/main/java/org/eclipse/jetty/security/MultiAuthenticator.java new file mode 100644 index 000000000000..ccb1aeaddb56 --- /dev/null +++ b/jetty-core/jetty-security/src/main/java/org/eclipse/jetty/security/MultiAuthenticator.java @@ -0,0 +1,457 @@ +package org.eclipse.jetty.security; + +import java.io.Serial; +import java.io.Serializable; +import java.security.Principal; +import java.util.function.Function; + +import org.eclipse.jetty.http.HttpStatus; +import org.eclipse.jetty.http.HttpURI; +import org.eclipse.jetty.http.pathmap.MatchedResource; +import org.eclipse.jetty.http.pathmap.PathMappings; +import org.eclipse.jetty.http.pathmap.PathSpec; +import org.eclipse.jetty.security.authentication.LoginAuthenticator; +import org.eclipse.jetty.server.Request; +import org.eclipse.jetty.server.Response; +import org.eclipse.jetty.server.Session; +import org.eclipse.jetty.util.Callback; +import org.eclipse.jetty.util.URIUtil; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + *

An {@link Authenticator} which maps different {@link Authenticator}s to {@link PathSpec}s.

+ *

This can be used to support multiple different authentication methods for a single application such as + * FORM, OPENID and SIWE.

+ *

The {@link #setLoginPath(String)} can be used to set a login page where unauthenticated users are + * redirected in the case that no {@link Authenticator}s were matched. This can be used as a page to + * link to other paths where {@link Authenticator}s are mapped to so that users can choose their login method.

+ */ +public class MultiAuthenticator extends LoginAuthenticator +{ + private static final Logger LOG = LoggerFactory.getLogger(MultiAuthenticator.class); + public static final String LOGIN_PATH_PARAM = "org.eclipse.jetty.security.multi.login_path"; + + private static final String AUTH_STATE_ATTR = MultiAuthState.class.getName(); + private final DefaultAuthenticator _defaultAuthenticator = new DefaultAuthenticator(); + private final PathMappings _authenticatorsMappings = new PathMappings<>(); + private String _loginPath; + private boolean _dispatch; + + /** + * Adds an authenticator which maps to the given pathSpec. + * @param pathSpec the pathSpec. + * @param authenticator the authenticator. + */ + public void addAuthenticator(String pathSpec, Authenticator authenticator) + { + _authenticatorsMappings.put(pathSpec, authenticator); + } + + @Override + public void setConfiguration(Configuration configuration) + { + String loginPath = configuration.getParameter(LOGIN_PATH_PARAM); + if (loginPath != null) + setLoginPath(loginPath); + + for (Authenticator authenticator : _authenticatorsMappings.values()) + { + authenticator.setConfiguration(configuration); + } + } + + /** + * If a user is unauthenticated, a request which does not map to any of the {@link Authenticator}s will redirect to this path. + * @param loginPath the loginPath. + */ + public void setLoginPath(String loginPath) + { + if (loginPath != null) + { + if (!loginPath.startsWith("/")) + { + LOG.warn("login path must start with /"); + loginPath = "/" + loginPath; + } + + _loginPath = loginPath; + } + } + + public boolean isLoginPage(String uri) + { + return matchURI(uri, _loginPath); + } + + private boolean matchURI(String uri, String path) + { + int jsc = uri.indexOf(path); + if (jsc < 0) + return false; + int e = jsc + path.length(); + if (e == uri.length()) + return true; + char c = uri.charAt(e); + return c == ';' || c == '#' || c == '/' || c == '?'; + } + + public void setDispatch(boolean dispatch) + { + _dispatch = dispatch; + } + + @Override + public String getAuthenticationType() + { + return "MULTI"; + } + + @Override + public UserIdentity login(String username, Object password, Request request, Response response) + { + Authenticator authenticator = getAuthenticator(request.getSession(false)); + if (authenticator instanceof LoginAuthenticator loginAuthenticator) + { + doLogin(request); + return loginAuthenticator.login(username, password, request, response); + } + + return super.login(username, password, request, response); + } + + @Override + public void logout(Request request, Response response) + { + Authenticator authenticator = getAuthenticator(request.getSession(false)); + if (authenticator instanceof LoginAuthenticator loginAuthenticator) + { + loginAuthenticator.logout(request, response); + doLogout(request); + } + + super.logout(request, response); + } + + @Override + public Constraint.Authorization getConstraintAuthentication(String pathInContext, Constraint.Authorization existing, Function getSession) + { + Session session = getSession.apply(true); + + // If we are logged in we should always use that authenticator until logged out. + if (isLoggedIn(session)) + { + Authenticator authenticator = getAuthenticator(session); + return authenticator.getConstraintAuthentication(pathInContext, existing, getSession); + } + + Authenticator authenticator = null; + MatchedResource matched = _authenticatorsMappings.getMatched(pathInContext); + if (matched != null) + authenticator = matched.getResource(); + if (authenticator == null) + authenticator = getAuthenticator(session); + if (authenticator == null) + authenticator = _defaultAuthenticator; + saveAuthenticator(session, authenticator); + return authenticator.getConstraintAuthentication(pathInContext, existing, getSession); + } + + @Override + public AuthenticationState validateRequest(Request request, Response response, Callback callback) throws ServerAuthException + { + Session session = request.getSession(true); + Authenticator authenticator = getAuthenticator(session); + if (authenticator == null) + { + Response.writeError(request, response, callback, HttpStatus.FORBIDDEN_403); + return AuthenticationState.SEND_FAILURE; + } + + AuthenticationState authenticationState = authenticator.validateRequest(request, response, callback); + if (authenticationState instanceof AuthenticationState.ResponseSent) + { + if (authenticationState instanceof LoginAuthenticator.UserAuthenticationSent) + doLogin(request); + return authenticationState; + } + + if (authenticationState instanceof AuthenticationState.Succeeded succeededState) + return new MultiSucceededAuthenticationState(succeededState); + else if (authenticationState instanceof AuthenticationState.Deferred deferredState) + return new MultiDelegateAuthenticationState(deferredState); + return authenticationState; + } + + @Override + public Request prepareRequest(Request request, AuthenticationState authenticationState) + { + Session session = request.getSession(true); + Authenticator authenticator = getAuthenticator(session); + if (authenticator == null) + throw new IllegalStateException("No authenticator found"); + return authenticator.prepareRequest(request, authenticationState); + } + + private static class MultiDelegateAuthenticationState implements AuthenticationState.Deferred + { + private final AuthenticationState.Deferred _delegate; + + public MultiDelegateAuthenticationState(AuthenticationState.Deferred state) + { + _delegate = state; + } + + @Override + public Succeeded authenticate(Request request) + { + return _delegate.authenticate(request); + } + + @Override + public AuthenticationState authenticate(Request request, Response response, Callback callback) + { + return _delegate.authenticate(request, response, callback); + } + + @Override + public Succeeded login(String username, Object password, Request request, Response response) + { + Succeeded succeeded = _delegate.login(username, password, request, response); + if (succeeded != null) + doLogin(request); + return succeeded; + } + + @Override + public void logout(Request request, Response response) + { + _delegate.logout(request, response); + doLogout(request); + } + + @Override + public IdentityService.Association getAssociation() + { + return _delegate.getAssociation(); + } + + @Override + public Principal getUserPrincipal() + { + return _delegate.getUserPrincipal(); + } + } + + private static class MultiSucceededAuthenticationState implements AuthenticationState.Succeeded + { + private final AuthenticationState.Succeeded _delegate; + + public MultiSucceededAuthenticationState(AuthenticationState.Succeeded state) + { + _delegate = state; + } + + @Override + public String getAuthenticationType() + { + return _delegate.getAuthenticationType(); + } + + @Override + public UserIdentity getUserIdentity() + { + return _delegate.getUserIdentity(); + } + + @Override + public Principal getUserPrincipal() + { + return _delegate.getUserPrincipal(); + } + + @Override + public boolean isUserInRole(String role) + { + return _delegate.isUserInRole(role); + } + + @Override + public void logout(Request request, Response response) + { + _delegate.logout(request, response); + doLogout(request); + } + } + + private class DefaultAuthenticator implements Authenticator + { + @Override + public void setConfiguration(Configuration configuration) + { + } + + @Override + public String getAuthenticationType() + { + return "DEFAULT"; + } + + @Override + public Constraint.Authorization getConstraintAuthentication(String pathInContext, Constraint.Authorization existing, Function getSession) + { + if (isLoginPage(pathInContext)) + return Constraint.Authorization.ALLOWED; + return existing; + } + + @Override + public AuthenticationState validateRequest(Request request, Response response, Callback callback) + { + if (_loginPath != null) + { + String loginPath = URIUtil.addPaths(request.getContext().getContextPath(), _loginPath); + if (_dispatch) + { + HttpURI.Mutable newUri = HttpURI.build(request.getHttpURI()).pathQuery(loginPath); + return new AuthenticationState.ServeAs(newUri); + } + else + { + Session session = request.getSession(true); + String redirectUri = session.encodeURI(request, loginPath, true); + Response.sendRedirect(request, response, callback, redirectUri, true); + return AuthenticationState.CHALLENGE; + } + } + return null; + } + } + + private static MultiAuthState getAuthState(Session session) + { + if (session == null) + return null; + return (MultiAuthState)session.getAttribute(AUTH_STATE_ATTR); + } + + private static MultiAuthState ensureAuthState(Session session) + { + if (session == null) + throw new IllegalArgumentException(); + + MultiAuthState authState = (MultiAuthState)session.getAttribute(AUTH_STATE_ATTR); + if (authState == null) + { + authState = new MultiAuthState(); + session.setAttribute(AUTH_STATE_ATTR, authState); + } + return authState; + } + + private static boolean isLoggedIn(Session session) + { + if (session == null) + return false; + + synchronized (session) + { + MultiAuthState authState = getAuthState(session); + return authState != null && authState.isLoggedIn(); + } + } + + private static void doLogin(Request request) + { + Session session = request.getSession(true); + if (session != null) + { + synchronized (session) + { + MultiAuthState authState = ensureAuthState(session); + authState.setLogin(true); + } + } + } + + private static void doLogout(Request request) + { + Session session = request.getSession(false); + if (session != null) + { + synchronized (session) + { + session.removeAttribute(AUTH_STATE_ATTR); + } + } + } + + private void saveAuthenticator(Session session, Authenticator authenticator) + { + if (session == null) + throw new IllegalArgumentException(); + + synchronized (session) + { + MultiAuthState authState = ensureAuthState(session); + authState.setAuthenticatorName(authenticator.getClass().getName()); + } + } + + private Authenticator getAuthenticator(Session session) + { + if (session == null) + return null; + + synchronized (session) + { + MultiAuthState state = getAuthState(session); + if (state == null || state.getAuthenticatorName() == null) + return null; + + String name = state.getAuthenticatorName(); + if (_defaultAuthenticator.getClass().getName().equals(name)) + return _defaultAuthenticator; + for (Authenticator authenticator : _authenticatorsMappings.values()) + { + if (name.equals(authenticator.getClass().getName())) + return authenticator; + } + + return null; + } + } + + private static class MultiAuthState implements Serializable + { + @Serial + private static final long serialVersionUID = -4292431864385753482L; + + private String _authenticatorName; + private boolean _isLoggedIn; + + public MultiAuthState() + { + } + + public void setAuthenticatorName(String authenticatorName) + { + _authenticatorName = authenticatorName; + } + + public String getAuthenticatorName() + { + return _authenticatorName; + } + + public void setLogin(boolean isLoggedIn) + { + _isLoggedIn = isLoggedIn; + } + + private boolean isLoggedIn() + { + return _isLoggedIn; + } + } +} diff --git a/jetty-core/jetty-security/src/main/java/org/eclipse/jetty/security/authentication/LoginAuthenticator.java b/jetty-core/jetty-security/src/main/java/org/eclipse/jetty/security/authentication/LoginAuthenticator.java index bf0dbc1a1f1e..acfabe94c0e0 100644 --- a/jetty-core/jetty-security/src/main/java/org/eclipse/jetty/security/authentication/LoginAuthenticator.java +++ b/jetty-core/jetty-security/src/main/java/org/eclipse/jetty/security/authentication/LoginAuthenticator.java @@ -82,7 +82,8 @@ public void logout(Request request, Response response) @Override public void setConfiguration(Configuration configuration) { - _loginService = configuration.getLoginService(); + if (_loginService == null) + _loginService = configuration.getLoginService(); if (_loginService == null) throw new IllegalStateException("No LoginService for " + this + " in " + configuration); _identityService = configuration.getIdentityService(); @@ -97,6 +98,11 @@ public LoginService getLoginService() return _loginService; } + public void setLoginService(LoginService loginService) + { + _loginService = loginService; + } + /** * Update the session on authentication. * The session is changed to a new instance with a new ID if and only if:
    diff --git a/jetty-core/jetty-security/src/main/java/org/eclipse/jetty/security/internal/DeferredAuthenticationState.java b/jetty-core/jetty-security/src/main/java/org/eclipse/jetty/security/internal/DeferredAuthenticationState.java index 50725117160c..828997c4380d 100644 --- a/jetty-core/jetty-security/src/main/java/org/eclipse/jetty/security/internal/DeferredAuthenticationState.java +++ b/jetty-core/jetty-security/src/main/java/org/eclipse/jetty/security/internal/DeferredAuthenticationState.java @@ -56,8 +56,7 @@ public Succeeded authenticate(Request request) if (authenticationState instanceof Succeeded succeeded) { LoginService loginService = _authenticator.getLoginService(); - IdentityService identityService = loginService.getIdentityService(); - + IdentityService identityService = loginService == null ? null : loginService.getIdentityService(); if (identityService != null) { UserIdentity user = succeeded.getUserIdentity(); diff --git a/jetty-integrations/jetty-ethereum/src/main/java/org/eclipse/jetty/security/siwe/EthereumAuthenticator.java b/jetty-integrations/jetty-ethereum/src/main/java/org/eclipse/jetty/security/siwe/EthereumAuthenticator.java index d5eb997eec65..a39a392f9393 100644 --- a/jetty-integrations/jetty-ethereum/src/main/java/org/eclipse/jetty/security/siwe/EthereumAuthenticator.java +++ b/jetty-integrations/jetty-ethereum/src/main/java/org/eclipse/jetty/security/siwe/EthereumAuthenticator.java @@ -34,6 +34,7 @@ import org.eclipse.jetty.http.MultiPartFormData; import org.eclipse.jetty.io.Content; import org.eclipse.jetty.io.content.ByteBufferContentSource; +import org.eclipse.jetty.security.AnyUserLoginService; import org.eclipse.jetty.security.AuthenticationState; import org.eclipse.jetty.security.Authenticator; import org.eclipse.jetty.security.Constraint; @@ -42,7 +43,6 @@ import org.eclipse.jetty.security.UserIdentity; import org.eclipse.jetty.security.authentication.LoginAuthenticator; import org.eclipse.jetty.security.authentication.SessionAuthentication; -import org.eclipse.jetty.security.siwe.internal.AnyUserLoginService; import org.eclipse.jetty.security.siwe.internal.EthereumUtil; import org.eclipse.jetty.security.siwe.internal.SignInWithEthereumToken; import org.eclipse.jetty.server.FormFields; diff --git a/jetty-integrations/jetty-openid/src/main/java/org/eclipse/jetty/security/openid/OpenIdConfiguration.java b/jetty-integrations/jetty-openid/src/main/java/org/eclipse/jetty/security/openid/OpenIdConfiguration.java index fcd9049c990d..f61105237466 100644 --- a/jetty-integrations/jetty-openid/src/main/java/org/eclipse/jetty/security/openid/OpenIdConfiguration.java +++ b/jetty-integrations/jetty-openid/src/main/java/org/eclipse/jetty/security/openid/OpenIdConfiguration.java @@ -109,7 +109,7 @@ public OpenIdConfiguration(@Name("issuer") String issuer, * @param issuer The URL of the OpenID provider. * @param authorizationEndpoint the URL of the OpenID provider's authorization endpoint if configured. * @param tokenEndpoint the URL of the OpenID provider's token endpoint if configured. - * @param endSessionEndpoint the URL of the OpdnID provider's end session endpoint if configured. + * @param endSessionEndpoint the URL of the OpenID provider's end session endpoint if configured. * @param clientId OAuth 2.0 Client Identifier valid at the Authorization Server. * @param clientSecret The client secret known only by the Client and the Authorization Server. * @param authenticationMethod Authentication method to use with the Token Endpoint. diff --git a/tests/test-integration/pom.xml b/tests/test-integration/pom.xml index e15a14a5b55a..a1fafe099388 100644 --- a/tests/test-integration/pom.xml +++ b/tests/test-integration/pom.xml @@ -18,10 +18,18 @@ org.eclipse.jetty jetty-client + + org.eclipse.jetty + jetty-openid + org.eclipse.jetty jetty-server + + org.eclipse.jetty + jetty-session + org.slf4j slf4j-api @@ -31,6 +39,11 @@ jetty-slf4j-impl test + + org.eclipse.jetty.tests + jetty-test-common + test + org.eclipse.jetty.toolchain jetty-test-helper diff --git a/tests/test-integration/src/test/java/org/eclipse/jetty/test/MultiAuthenticatorTest.java b/tests/test-integration/src/test/java/org/eclipse/jetty/test/MultiAuthenticatorTest.java new file mode 100644 index 000000000000..0430fc0da0c4 --- /dev/null +++ b/tests/test-integration/src/test/java/org/eclipse/jetty/test/MultiAuthenticatorTest.java @@ -0,0 +1,306 @@ +// +// ======================================================================== +// Copyright (c) 1995 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.test; + +import java.io.PrintWriter; +import java.net.URI; +import java.nio.file.Path; +import java.util.Map; + +import org.eclipse.jetty.client.ContentResponse; +import org.eclipse.jetty.client.FormRequestContent; +import org.eclipse.jetty.client.HttpClient; +import org.eclipse.jetty.http.HttpHeader; +import org.eclipse.jetty.http.HttpStatus; +import org.eclipse.jetty.io.Content; +import org.eclipse.jetty.security.AnyUserLoginService; +import org.eclipse.jetty.security.AuthenticationState; +import org.eclipse.jetty.security.Constraint; +import org.eclipse.jetty.security.HashLoginService; +import org.eclipse.jetty.security.MultiAuthenticator; +import org.eclipse.jetty.security.SecurityHandler; +import org.eclipse.jetty.security.authentication.FormAuthenticator; +import org.eclipse.jetty.security.openid.OpenIdAuthenticator; +import org.eclipse.jetty.security.openid.OpenIdConfiguration; +import org.eclipse.jetty.server.Handler; +import org.eclipse.jetty.server.Request; +import org.eclipse.jetty.server.Response; +import org.eclipse.jetty.server.Server; +import org.eclipse.jetty.server.ServerConnector; +import org.eclipse.jetty.server.Session; +import org.eclipse.jetty.server.handler.ContextHandler; +import org.eclipse.jetty.session.SessionHandler; +import org.eclipse.jetty.tests.OpenIdProvider; +import org.eclipse.jetty.toolchain.test.MavenTestingUtils; +import org.eclipse.jetty.util.BufferUtil; +import org.eclipse.jetty.util.Callback; +import org.eclipse.jetty.util.Fields; +import org.eclipse.jetty.util.resource.Resource; +import org.eclipse.jetty.util.resource.ResourceFactory; +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.equalTo; + +public class MultiAuthenticatorTest +{ + private Server _server; + private ServerConnector _connector; + private HttpClient _client; + private OpenIdProvider _provider; + + @BeforeEach + public void before() throws Exception + { + // Set up a local OIDC provider and add its configuration to the Server. + _provider = new OpenIdProvider(); + _provider.start(); + + _server = new Server(); + _connector = new ServerConnector(_server); + _server.addConnector(_connector); + + OpenIdConfiguration config = new OpenIdConfiguration(_provider.getProvider(), _provider.getClientId(), _provider.getClientSecret()); + _server.addBean(config); + + SecurityHandler.PathMapped securityHandler = new SecurityHandler.PathMapped(); + securityHandler.put("", Constraint.ALLOWED); + securityHandler.put("/logout", Constraint.ALLOWED); + securityHandler.put("/", Constraint.ANY_USER); + securityHandler.setHandler(new AuthTestHandler()); + + MultiAuthenticator multiAuthenticator = new MultiAuthenticator(); + multiAuthenticator.setLoginPath("/login"); + + OpenIdAuthenticator openIdAuthenticator = new OpenIdAuthenticator(config, "/error"); + openIdAuthenticator.setRedirectPath("/redirect_path"); + openIdAuthenticator.setLogoutRedirectPath("/"); + multiAuthenticator.addAuthenticator("/login/openid", openIdAuthenticator); + + Path fooPropsFile = MavenTestingUtils.getTestResourcePathFile("user.properties"); + Resource fooResource = ResourceFactory.root().newResource(fooPropsFile); + HashLoginService loginService = new HashLoginService("users", fooResource); + _server.addBean(loginService); + FormAuthenticator formAuthenticator = new FormAuthenticator("/login/form", "/error", false); + formAuthenticator.setLoginService(loginService); + multiAuthenticator.addAuthenticator("/login/form", formAuthenticator); + + securityHandler.setAuthenticator(multiAuthenticator); + securityHandler.setLoginService(new AnyUserLoginService(_provider.getProvider(), null)); + SessionHandler sessionHandler = new SessionHandler(); + sessionHandler.setHandler(securityHandler); + ContextHandler contextHandler = new ContextHandler(); + contextHandler.setContextPath("/"); + contextHandler.setHandler(sessionHandler); + + _server.setHandler(contextHandler); + _server.start(); + String redirectUri = "http://localhost:" + _connector.getLocalPort() + "/redirect_path"; + _provider.addRedirectUri(redirectUri); + + _client = new HttpClient(); + _client.start(); + } + + @AfterEach + public void after() throws Exception + { + _client.stop(); + _server.stop(); + } + + @Test + public void testMultiAuthentication() throws Exception + { + URI uri = URI.create("http://localhost:" + _connector.getLocalPort()); + ContentResponse response = _client.GET(uri); + assertThat(response.getStatus(), equalTo(HttpStatus.OK_200)); + assertThat(response.getContentAsString(), containsString("

    Multi Login Page

    ")); + assertThat(response.getContentAsString(), containsString("/login/openid")); + assertThat(response.getContentAsString(), containsString("/login/form")); + + // Try Form Login. + response = _client.GET(uri.resolve("/login/form")); + assertThat(response.getStatus(), equalTo(HttpStatus.OK_200)); + assertThat(response.getContentAsString(), containsString("
    ")); + + // Form login is successful. + Fields fields = new Fields(); + fields.put("j_username", "user"); + fields.put("j_password", "password"); + response = _client.POST(uri.resolve("/j_security_check")) + .body(new FormRequestContent(fields)) + .send(); + assertThat(response.getStatus(), equalTo(HttpStatus.OK_200)); + assertThat(response.getContentAsString(), containsString("userPrincipal: user")); + assertThat(response.getContentAsString(), containsString("MultiAuthenticator$MultiSucceededAuthenticationState")); + + // Logout is successful. + response = _client.GET(uri.resolve("/logout")); + assertThat(response.getStatus(), equalTo(HttpStatus.OK_200)); + assertThat(response.getContentAsString(), containsString("

    Multi Login Page

    ")); + assertThat(response.getContentAsString(), containsString("/login/openid")); + assertThat(response.getContentAsString(), containsString("/login/form")); + + // We can now log in with OpenID. + _provider.setUser(new OpenIdProvider.User("UserId1234", "openIdUser")); + response = _client.GET(uri.resolve("/login/openid")); + assertThat(response.getStatus(), equalTo(HttpStatus.OK_200)); + assertThat(response.getContentAsString(), containsString("userPrincipal: UserId1234")); + assertThat(response.getContentAsString(), containsString("Authenticated with OpenID")); + assertThat(response.getContentAsString(), containsString("name: openIdUser")); + + // Logout is successful. + response = _client.GET(uri.resolve("/logout")); + assertThat(response.getStatus(), equalTo(HttpStatus.OK_200)); + assertThat(response.getContentAsString(), containsString("

    Multi Login Page

    ")); + assertThat(response.getContentAsString(), containsString("/login/openid")); + assertThat(response.getContentAsString(), containsString("/login/form")); + } + + private static AuthenticationState.Succeeded getAuthentication(Request request) + { + AuthenticationState authenticationState = AuthenticationState.getAuthenticationState(request); + AuthenticationState.Succeeded auth = null; + if (authenticationState instanceof AuthenticationState.Succeeded succeeded) + auth = succeeded; + else if (authenticationState instanceof AuthenticationState.Deferred deferred) + auth = deferred.authenticate(request); + return auth; + } + + private static class AuthTestHandler extends Handler.Abstract + { + @Override + public boolean handle(Request request, Response response, Callback callback) throws Exception + { + String pathInContext = Request.getPathInContext(request); + if (pathInContext.startsWith("/error")) + return onError(request, response, callback); + else if (pathInContext.startsWith("/logout")) + return onLogout(request, response, callback); + else if (pathInContext.startsWith("/login/form")) + return onFormLogin(request, response, callback); + else if (pathInContext.startsWith("/login/openid")) + return onOpenIdLogin(request, response, callback); + + try (PrintWriter writer = new PrintWriter(Content.Sink.asOutputStream(response))) + { + + response.getHeaders().put(HttpHeader.CONTENT_TYPE, "text/html"); + AuthenticationState.Succeeded auth = getAuthentication(request); + if (auth != null) + { + writer.println("authState: " + auth + "
    "); + writer.println("userPrincipal: " + auth.getUserPrincipal() + "
    "); + + Session session = request.getSession(true); + @SuppressWarnings("unchecked") + Map claims = (Map)session.getAttribute(OpenIdAuthenticator.CLAIMS); + if (claims != null) + { + writer.printf(""" +
    Authenticated with OpenID
    + userId: %s
    + name: %s
    + email: %s
    + """, claims.get("sub"), claims.get("name"), claims.get("email")); + } + + writer.println(""" +
    + Logout
    + """); + } + else + { + writer.println(""" +

    Multi Login Page

    + OpenID Login
    + Form Login
    + Logout
    + """); + } + } + + callback.succeeded(); + return true; + } + + private boolean onOpenIdLogin(Request request, Response response, Callback callback) throws Exception + { + Response.sendRedirect(request, response, callback, "/"); + return true; + } + + private boolean onFormLogin(Request request, Response response, Callback callback) throws Exception + { + AuthenticationState.Succeeded authentication = getAuthentication(request); + if (authentication != null) + { + Response.sendRedirect(request, response, callback, "/"); + return true; + } + + String content = """ +

    Login

    + +
    + + +
    +
    + + +
    +
    + +
    + +

    Username: user or admin
    + Password: password

    + """; + response.write(true, BufferUtil.toBuffer(content), callback); + return true; + } + + private boolean onLogout(Request request, Response response, Callback callback) throws Exception + { + Request.AuthenticationState authState = Request.getAuthenticationState(request); + if (authState instanceof AuthenticationState.Succeeded succeeded) + succeeded.logout(request, response); + else if (authState instanceof AuthenticationState.Deferred deferred) + deferred.logout(request, response); + else + request.getSession(true).invalidate(); + + if (!response.isCommitted()) + Response.sendRedirect(request, response, callback, "/"); + else + callback.succeeded(); + return true; + } + + private boolean onError(Request request, Response response, Callback callback) throws Exception + { + Fields parameters = Request.getParameters(request); + String errorDescription = parameters.getValue("error_description_jetty"); + response.write(true, BufferUtil.toBuffer("error: " + errorDescription), callback); + return true; + } + } +} diff --git a/tests/test-integration/src/test/resources/user.properties b/tests/test-integration/src/test/resources/user.properties new file mode 100644 index 000000000000..f988a961c291 --- /dev/null +++ b/tests/test-integration/src/test/resources/user.properties @@ -0,0 +1,2 @@ +user=password +admin=password