diff --git a/jetty-bom/pom.xml b/jetty-bom/pom.xml index 9c3b6c8b20e..60a6720c55e 100644 --- a/jetty-bom/pom.xml +++ b/jetty-bom/pom.xml @@ -299,6 +299,11 @@ jetty-security 9.4.21-SNAPSHOT + + org.eclipse.jetty + jetty-openid + 9.4.21-SNAPSHOT + org.eclipse.jetty jetty-server diff --git a/jetty-home/pom.xml b/jetty-home/pom.xml index 02201232284..89fca7a12e5 100644 --- a/jetty-home/pom.xml +++ b/jetty-home/pom.xml @@ -711,6 +711,11 @@ jetty-alpn-openjdk8-server ${project.version} + + org.eclipse.jetty + jetty-openid + ${project.version} + org.eclipse.jetty jetty-alpn-conscrypt-server diff --git a/jetty-openid/pom.xml b/jetty-openid/pom.xml new file mode 100644 index 00000000000..99d0afc8bf2 --- /dev/null +++ b/jetty-openid/pom.xml @@ -0,0 +1,64 @@ + + + org.eclipse.jetty + jetty-project + 9.4.21-SNAPSHOT + + + 4.0.0 + jetty-openid + Jetty :: OpenID + Jetty OpenID Connect infrastructure + http://www.eclipse.org/jetty + + + ${project.groupId}.openid + + + + + + org.codehaus.mojo + findbugs-maven-plugin + + org.eclipse.jetty.security.openid.* + + + + + + + + org.eclipse.jetty + jetty-server + ${project.version} + + + org.eclipse.jetty + jetty-security + ${project.version} + + + org.eclipse.jetty + jetty-util-ajax + ${project.version} + + + org.eclipse.jetty + jetty-servlet + ${project.version} + test + + + org.eclipse.jetty.tests + jetty-http-tools + ${project.version} + test + + + org.eclipse.jetty.toolchain + jetty-test-helper + test + + + diff --git a/jetty-openid/src/main/config/etc/jetty-openid.xml b/jetty-openid/src/main/config/etc/jetty-openid.xml new file mode 100644 index 00000000000..df21f0ffc3a --- /dev/null +++ b/jetty-openid/src/main/config/etc/jetty-openid.xml @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/jetty-openid/src/main/config/modules/openid.mod b/jetty-openid/src/main/config/modules/openid.mod new file mode 100644 index 00000000000..869ddd5cac7 --- /dev/null +++ b/jetty-openid/src/main/config/modules/openid.mod @@ -0,0 +1,31 @@ +DO NOT EDIT - See: https://www.eclipse.org/jetty/documentation/current/startup-modules.html + +[description] +Adds OpenId Connect authentication. + +[depend] +security + +[lib] +lib/jetty-openid-${jetty.version}.jar +lib/jetty-util-ajax-${jetty.version}.jar + +[files] +basehome:modules/openid/openid-baseloginservice.xml|etc/openid-baseloginservice.xml + +[xml] +etc/openid-baseloginservice.xml +etc/jetty-openid.xml + +[ini-template] +## Identity Provider +# jetty.openid.identityProvider=https://accounts.google.com/ + +## Client ID +# jetty.openid.clientId=1051168419525-5nl60mkugb77p9j194mrh287p1e0ahfi.apps.googleusercontent.com + +## Client Secret +# jetty.openid.clientSecret=XT_MIsSv_aUCGollauCaJY8S + +## Scopes +# jetty.openid.scopes=email,profile diff --git a/jetty-openid/src/main/config/modules/openid/openid-baseloginservice.xml b/jetty-openid/src/main/config/modules/openid/openid-baseloginservice.xml new file mode 100644 index 00000000000..89bbc3de290 --- /dev/null +++ b/jetty-openid/src/main/config/modules/openid/openid-baseloginservice.xml @@ -0,0 +1,10 @@ + + + + + \ No newline at end of file diff --git a/jetty-openid/src/main/java/org/eclipse/jetty/security/openid/OpenIdAuthenticator.java b/jetty-openid/src/main/java/org/eclipse/jetty/security/openid/OpenIdAuthenticator.java new file mode 100644 index 00000000000..1608d7a5acb --- /dev/null +++ b/jetty-openid/src/main/java/org/eclipse/jetty/security/openid/OpenIdAuthenticator.java @@ -0,0 +1,481 @@ +// +// ======================================================================== +// Copyright (c) 1995-2019 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.openid; + +import java.io.IOException; +import java.math.BigInteger; +import java.security.SecureRandom; +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.HttpMethod; +import org.eclipse.jetty.http.HttpVersion; +import org.eclipse.jetty.http.MimeTypes; +import org.eclipse.jetty.security.LoginService; +import org.eclipse.jetty.security.ServerAuthException; +import org.eclipse.jetty.security.UserAuthentication; +import org.eclipse.jetty.security.authentication.DeferredAuthentication; +import org.eclipse.jetty.security.authentication.LoginAuthenticator; +import org.eclipse.jetty.security.authentication.SessionAuthentication; +import org.eclipse.jetty.server.Authentication; +import org.eclipse.jetty.server.Authentication.User; +import org.eclipse.jetty.server.Request; +import org.eclipse.jetty.server.Response; +import org.eclipse.jetty.server.UserIdentity; +import org.eclipse.jetty.util.MultiMap; +import org.eclipse.jetty.util.URIUtil; +import org.eclipse.jetty.util.log.Log; +import org.eclipse.jetty.util.log.Logger; +import org.eclipse.jetty.util.security.Constraint; + +/** + * OpenId Connect Authenticator. + * + *

This authenticator implements authentication using OpenId Connect on top of OAuth 2.0. + * + *

The authenticator redirects unauthenticated requests to the identity providers authorization endpoint + * which will eventually redirect back to the redirectUri with an authorization code which will be exchanged with + * the token_endpoint for an id_token. The request is then restored back to the original uri requested. + * {@link SessionAuthentication} is then used to wrap Authentication results so that they are associated with the session.

+ */ +public class OpenIdAuthenticator extends LoginAuthenticator +{ + private static final Logger LOG = Log.getLogger(OpenIdAuthenticator.class); + + public static final String __USER_CLAIMS = "org.eclipse.jetty.security.openid.user_claims"; + public static final String __RESPONSE_JSON = "org.eclipse.jetty.security.openid.response"; + public static final String __ERROR_PAGE = "org.eclipse.jetty.security.openid.error_page"; + public static final String __J_URI = "org.eclipse.jetty.security.openid.URI"; + public static final String __J_POST = "org.eclipse.jetty.security.openid.POST"; + public static final String __J_METHOD = "org.eclipse.jetty.security.openid.METHOD"; + public static final String __CSRF_TOKEN = "org.eclipse.jetty.security.openid.csrf_token"; + public static final String __J_SECURITY_CHECK = "/j_security_check"; + + private OpenIdConfiguration _configuration; + private String _errorPage; + private String _errorPath; + private boolean _alwaysSaveUri; + + public OpenIdAuthenticator() + { + } + + public OpenIdAuthenticator(OpenIdConfiguration configuration, String errorPage) + { + this._configuration = configuration; + if (errorPage != null) + setErrorPage(errorPage); + } + + @Override + public void setConfiguration(AuthConfiguration configuration) + { + super.setConfiguration(configuration); + + String error = configuration.getInitParameter(__ERROR_PAGE); + if (error != null) + setErrorPage(error); + + if (_configuration != null) + return; + + LoginService loginService = configuration.getLoginService(); + if (!(loginService instanceof OpenIdLoginService)) + throw new IllegalArgumentException("invalid LoginService"); + this._configuration = ((OpenIdLoginService)loginService).getConfiguration(); + } + + @Override + public String getAuthMethod() + { + return Constraint.__OPENID_AUTH; + } + + /** + * If true, uris that cause a redirect to a login page will always + * be remembered. If false, only the first uri that leads to a login + * page redirect is remembered. + * See https://bugs.eclipse.org/bugs/show_bug.cgi?id=379909 + * + * @param alwaysSave true to always save the uri + */ + public void setAlwaysSaveUri(boolean alwaysSave) + { + _alwaysSaveUri = alwaysSave; + } + + public boolean getAlwaysSaveUri() + { + return _alwaysSaveUri; + } + + private void setErrorPage(String path) + { + if (path == null || path.trim().length() == 0) + { + _errorPath = null; + _errorPage = null; + } + else + { + if (!path.startsWith("/")) + { + LOG.warn("error-page must start with /"); + path = "/" + path; + } + _errorPage = path; + _errorPath = path; + + if (_errorPath.indexOf('?') > 0) + _errorPath = _errorPath.substring(0, _errorPath.indexOf('?')); + } + } + + @Override + public UserIdentity login(String username, Object credentials, ServletRequest request) + { + if (LOG.isDebugEnabled()) + LOG.debug("login {} {} {}", username, credentials, request); + + UserIdentity user = super.login(username, credentials, request); + if (user != null) + { + HttpSession session = ((HttpServletRequest)request).getSession(); + Authentication cached = new SessionAuthentication(getAuthMethod(), user, credentials); + session.setAttribute(SessionAuthentication.__J_AUTHENTICATED, cached); + session.setAttribute(__USER_CLAIMS, ((OpenIdCredentials)credentials).getClaims()); + session.setAttribute(__RESPONSE_JSON, ((OpenIdCredentials)credentials).getResponse()); + } + return user; + } + + @Override + public void logout(ServletRequest request) + { + super.logout(request); + HttpServletRequest httpRequest = (HttpServletRequest)request; + HttpSession session = httpRequest.getSession(false); + + if (session == null) + return; + + //clean up session + session.removeAttribute(SessionAuthentication.__J_AUTHENTICATED); + session.removeAttribute(__USER_CLAIMS); + session.removeAttribute(__RESPONSE_JSON); + } + + @Override + public void prepareRequest(ServletRequest request) + { + //if this is a request resulting from a redirect after auth is complete + //(ie its from a redirect to the original request uri) then due to + //browser handling of 302 redirects, the method may not be the same as + //that of the original request. Replace the method and original post + //params (if it was a post). + // + //See Servlet Spec 3.1 sec 13.6.3 + HttpServletRequest httpRequest = (HttpServletRequest)request; + HttpSession session = httpRequest.getSession(false); + if (session == null || session.getAttribute(SessionAuthentication.__J_AUTHENTICATED) == null) + return; //not authenticated yet + + String juri = (String)session.getAttribute(__J_URI); + if (juri == null || juri.length() == 0) + return; //no original uri saved + + String method = (String)session.getAttribute(__J_METHOD); + if (method == null || method.length() == 0) + return; //didn't save original request method + + StringBuffer buf = httpRequest.getRequestURL(); + if (httpRequest.getQueryString() != null) + buf.append("?").append(httpRequest.getQueryString()); + + if (!juri.equals(buf.toString())) + return; //this request is not for the same url as the original + + //restore the original request's method on this request + if (LOG.isDebugEnabled()) + LOG.debug("Restoring original method {} for {} with method {}", method, juri, httpRequest.getMethod()); + Request baseRequest = Request.getBaseRequest(request); + baseRequest.setMethod(method); + } + + @Override + public Authentication validateRequest(ServletRequest req, ServletResponse res, boolean mandatory) throws ServerAuthException + { + final HttpServletRequest request = (HttpServletRequest)req; + final HttpServletResponse response = (HttpServletResponse)res; + final Request baseRequest = Request.getBaseRequest(request); + final Response baseResponse = baseRequest.getResponse(); + + String uri = request.getRequestURI(); + if (uri == null) + uri = URIUtil.SLASH; + + mandatory |= isJSecurityCheck(uri); + if (!mandatory) + return new DeferredAuthentication(this); + + if (isErrorPage(URIUtil.addPaths(request.getServletPath(), request.getPathInfo())) && !DeferredAuthentication.isDeferred(response)) + return new DeferredAuthentication(this); + + try + { + // Handle a request for authentication. + if (isJSecurityCheck(uri)) + { + String authCode = request.getParameter("code"); + if (authCode != null) + { + // Verify anti-forgery state token + String state = request.getParameter("state"); + String antiForgeryToken = (String)request.getSession().getAttribute(__CSRF_TOKEN); + if (antiForgeryToken == null || !antiForgeryToken.equals(state)) + { + LOG.warn("auth failed 403: invalid state parameter"); + if (response != null) + response.sendError(HttpServletResponse.SC_FORBIDDEN); + return Authentication.SEND_FAILURE; + } + + // Attempt to login with the provided authCode + OpenIdCredentials credentials = new OpenIdCredentials(authCode, getRedirectUri(request), _configuration); + UserIdentity user = login(null, credentials, request); + HttpSession session = request.getSession(false); + if (user != null) + { + // Redirect to original request + String nuri; + synchronized (session) + { + nuri = (String)session.getAttribute(__J_URI); + + if (nuri == null || nuri.length() == 0) + { + nuri = request.getContextPath(); + if (nuri.length() == 0) + nuri = URIUtil.SLASH; + } + } + OpenIdAuthentication openIdAuth = new OpenIdAuthentication(getAuthMethod(), user); + LOG.debug("authenticated {}->{}", openIdAuth, nuri); + + response.setContentLength(0); + int redirectCode = (baseRequest.getHttpVersion().getVersion() < HttpVersion.HTTP_1_1.getVersion() ? HttpServletResponse.SC_MOVED_TEMPORARILY : HttpServletResponse.SC_SEE_OTHER); + baseResponse.sendRedirect(redirectCode, response.encodeRedirectURL(nuri)); + return openIdAuth; + } + } + + // not authenticated + if (LOG.isDebugEnabled()) + LOG.debug("OpenId authentication FAILED"); + if (_errorPage == null) + { + if (LOG.isDebugEnabled()) + LOG.debug("auth failed 403"); + if (response != null) + response.sendError(HttpServletResponse.SC_FORBIDDEN); + } + else + { + if (LOG.isDebugEnabled()) + LOG.debug("auth failed {}", _errorPage); + int redirectCode = (baseRequest.getHttpVersion().getVersion() < HttpVersion.HTTP_1_1.getVersion() ? HttpServletResponse.SC_MOVED_TEMPORARILY : HttpServletResponse.SC_SEE_OTHER); + baseResponse.sendRedirect(redirectCode, response.encodeRedirectURL(URIUtil.addPaths(request.getContextPath(), _errorPage))); + } + + return Authentication.SEND_FAILURE; + } + + // Look for cached authentication + HttpSession session = request.getSession(false); + Authentication authentication = session == null ? null : (Authentication)session.getAttribute(SessionAuthentication.__J_AUTHENTICATED); + if (authentication != null) + { + // Has authentication been revoked? + if (authentication instanceof Authentication.User && + _loginService != null && + !_loginService.validate(((Authentication.User)authentication).getUserIdentity())) + { + LOG.debug("auth revoked {}", authentication); + session.removeAttribute(SessionAuthentication.__J_AUTHENTICATED); + } + else + { + synchronized (session) + { + String jUri = (String)session.getAttribute(__J_URI); + if (jUri != null) + { + //check if the request is for the same url as the original and restore + //params if it was a post + LOG.debug("auth retry {}->{}", authentication, jUri); + StringBuffer buf = request.getRequestURL(); + if (request.getQueryString() != null) + buf.append("?").append(request.getQueryString()); + + if (jUri.equals(buf.toString())) + { + MultiMap jPost = (MultiMap)session.getAttribute(__J_POST); + if (jPost != null) + { + LOG.debug("auth rePOST {}->{}", authentication, jUri); + baseRequest.setContentParameters(jPost); + } + session.removeAttribute(__J_URI); + session.removeAttribute(__J_METHOD); + session.removeAttribute(__J_POST); + } + } + } + LOG.debug("auth {}", authentication); + return authentication; + } + } + + + // if we can't send challenge + if (DeferredAuthentication.isDeferred(response)) + { + LOG.debug("auth deferred {}", session == null ? null : session.getId()); + return Authentication.UNAUTHENTICATED; + } + + // remember the current URI + session = (session != null ? session : request.getSession(true)); + synchronized (session) + { + // But only if it is not set already, or we save every uri that leads to a login redirect + if (session.getAttribute(__J_URI) == null || _alwaysSaveUri) + { + StringBuffer buf = request.getRequestURL(); + if (request.getQueryString() != null) + buf.append("?").append(request.getQueryString()); + session.setAttribute(__J_URI, buf.toString()); + session.setAttribute(__J_METHOD, request.getMethod()); + + if (MimeTypes.Type.FORM_ENCODED.is(req.getContentType()) && HttpMethod.POST.is(request.getMethod())) + { + MultiMap formParameters = new MultiMap<>(); + baseRequest.extractFormParameters(formParameters); + session.setAttribute(__J_POST, formParameters); + } + } + } + + // send the the challenge + String challengeUri = getChallengeUri(request); + LOG.debug("challenge {}->{}", session.getId(), challengeUri); + int redirectCode = (baseRequest.getHttpVersion().getVersion() < HttpVersion.HTTP_1_1.getVersion() ? HttpServletResponse.SC_MOVED_TEMPORARILY : HttpServletResponse.SC_SEE_OTHER); + baseResponse.sendRedirect(redirectCode, response.encodeRedirectURL(challengeUri)); + + return Authentication.SEND_CONTINUE; + } + catch (IOException e) + { + throw new ServerAuthException(e); + } + } + + public boolean isJSecurityCheck(String uri) + { + int jsc = uri.indexOf(__J_SECURITY_CHECK); + + if (jsc < 0) + return false; + int e = jsc + __J_SECURITY_CHECK.length(); + if (e == uri.length()) + return true; + char c = uri.charAt(e); + return c == ';' || c == '#' || c == '/' || c == '?'; + } + + public boolean isErrorPage(String pathInContext) + { + return pathInContext != null && (pathInContext.equals(_errorPath)); + } + + private String getRedirectUri(HttpServletRequest request) + { + final StringBuffer redirectUri = new StringBuffer(128); + URIUtil.appendSchemeHostPort(redirectUri, request.getScheme(), + request.getServerName(), request.getServerPort()); + redirectUri.append(request.getContextPath()); + redirectUri.append(__J_SECURITY_CHECK); + return redirectUri.toString(); + } + + protected String getChallengeUri(HttpServletRequest request) + { + HttpSession session = request.getSession(); + String antiForgeryToken; + synchronized (session) + { + antiForgeryToken = (session.getAttribute(__CSRF_TOKEN) == null) + ? new BigInteger(130, new SecureRandom()).toString(32) + : (String)session.getAttribute(__CSRF_TOKEN); + session.setAttribute(__CSRF_TOKEN, antiForgeryToken); + } + + // any custom scopes requested from configuration + StringBuilder scopes = new StringBuilder(); + for (String s : _configuration.getScopes()) + { + scopes.append("%20" + s); + } + + return _configuration.getAuthEndpoint() + + "?client_id=" + _configuration.getClientId() + + "&redirect_uri=" + getRedirectUri(request) + + "&scope=openid" + scopes + + "&state=" + antiForgeryToken + + "&response_type=code"; + } + + @Override + public boolean secureResponse(ServletRequest req, ServletResponse res, boolean mandatory, User validatedUser) + { + return true; + } + + /** + * This Authentication represents a just completed OpenId Connect authentication. + * Subsequent requests from the same user are authenticated by the presents + * of a {@link SessionAuthentication} instance in their session. + */ + public static class OpenIdAuthentication extends UserAuthentication implements Authentication.ResponseSent + { + public OpenIdAuthentication(String method, UserIdentity userIdentity) + { + super(method, userIdentity); + } + + @Override + public String toString() + { + return "OpenId" + super.toString(); + } + } +} \ No newline at end of file diff --git a/jetty-openid/src/main/java/org/eclipse/jetty/security/openid/OpenIdAuthenticatorFactory.java b/jetty-openid/src/main/java/org/eclipse/jetty/security/openid/OpenIdAuthenticatorFactory.java new file mode 100644 index 00000000000..86eea6cdbde --- /dev/null +++ b/jetty-openid/src/main/java/org/eclipse/jetty/security/openid/OpenIdAuthenticatorFactory.java @@ -0,0 +1,40 @@ +// +// ======================================================================== +// Copyright (c) 1995-2019 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.openid; + +import javax.servlet.ServletContext; + +import org.eclipse.jetty.security.Authenticator; +import org.eclipse.jetty.security.DefaultAuthenticatorFactory; +import org.eclipse.jetty.security.IdentityService; +import org.eclipse.jetty.security.LoginService; +import org.eclipse.jetty.server.Server; +import org.eclipse.jetty.util.security.Constraint; + +public class OpenIdAuthenticatorFactory extends DefaultAuthenticatorFactory +{ + @Override + public Authenticator getAuthenticator(Server server, ServletContext context, Authenticator.AuthConfiguration configuration, IdentityService identityService, LoginService loginService) + { + String auth = configuration.getAuthMethod(); + if (Constraint.__OPENID_AUTH.equalsIgnoreCase(auth)) + return new OpenIdAuthenticator(); + return super.getAuthenticator(server, context, configuration, identityService, loginService); + } +} \ No newline at end of file diff --git a/jetty-openid/src/main/java/org/eclipse/jetty/security/openid/OpenIdConfiguration.java b/jetty-openid/src/main/java/org/eclipse/jetty/security/openid/OpenIdConfiguration.java new file mode 100644 index 00000000000..8390eb5e037 --- /dev/null +++ b/jetty-openid/src/main/java/org/eclipse/jetty/security/openid/OpenIdConfiguration.java @@ -0,0 +1,118 @@ +// +// ======================================================================== +// Copyright (c) 1995-2019 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.openid; + +import java.io.InputStream; +import java.net.URI; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +import org.eclipse.jetty.util.IO; +import org.eclipse.jetty.util.ajax.JSON; + +public class OpenIdConfiguration +{ + private static String CONFIG_PATH = "/.well-known/openid-configuration"; + + private final String identityProvider; + private final String authEndpoint; + private final String tokenEndpoint; + private final String clientId; + private final String clientSecret; + private final Map discoveryDocument; + + private List scopes = new ArrayList<>(); + + public OpenIdConfiguration(String provider, String clientId, String clientSecret) + { + this.identityProvider = provider; + this.clientId = clientId; + this.clientSecret = clientSecret; + + try + { + if (provider.endsWith("/")) + provider = provider.substring(0, provider.length() - 1); + + URI providerUri = URI.create(provider + CONFIG_PATH); + InputStream inputStream = providerUri.toURL().openConnection().getInputStream(); + String content = IO.toString(inputStream); + discoveryDocument = (Map)JSON.parse(content); + } + catch (Throwable e) + { + throw new IllegalArgumentException("invalid identity provider", e); + } + + if (discoveryDocument.get("issuer") == null) + throw new IllegalArgumentException(); + + authEndpoint = (String)discoveryDocument.get("authorization_endpoint"); + if (authEndpoint == null) + throw new IllegalArgumentException("authorization_endpoint"); + + tokenEndpoint = (String)discoveryDocument.get("token_endpoint"); + if (tokenEndpoint == null) + throw new IllegalArgumentException("token_endpoint"); + } + + public Map getDiscoveryDocument() + { + return discoveryDocument; + } + + public String getAuthEndpoint() + { + return authEndpoint; + } + + public String getClientId() + { + return clientId; + } + + public String getClientSecret() + { + return clientSecret; + } + + public String getIdentityProvider() + { + return identityProvider; + } + + public String getTokenEndpoint() + { + return tokenEndpoint; + } + + public void addScopes(String... scopes) + { + for (String scope : scopes) + { + this.scopes.add(scope); + } + } + + public List getScopes() + { + return scopes; + } +} diff --git a/jetty-openid/src/main/java/org/eclipse/jetty/security/openid/OpenIdCredentials.java b/jetty-openid/src/main/java/org/eclipse/jetty/security/openid/OpenIdCredentials.java new file mode 100644 index 00000000000..21095ff768d --- /dev/null +++ b/jetty-openid/src/main/java/org/eclipse/jetty/security/openid/OpenIdCredentials.java @@ -0,0 +1,181 @@ +// +// ======================================================================== +// Copyright (c) 1995-2019 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.openid; + +import java.io.DataOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.net.HttpURLConnection; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.util.Base64; +import java.util.Map; + +import org.eclipse.jetty.util.IO; +import org.eclipse.jetty.util.ajax.JSON; +import org.eclipse.jetty.util.log.Log; +import org.eclipse.jetty.util.log.Logger; + +public class OpenIdCredentials +{ + private static final Logger LOG = Log.getLogger(OpenIdCredentials.class); + + private final String redirectUri; + private final OpenIdConfiguration configuration; + private String authCode; + private Map response; + private Map claims; + + public OpenIdCredentials(String authCode, String redirectUri, OpenIdConfiguration configuration) + { + this.authCode = authCode; + this.redirectUri = redirectUri; + this.configuration = configuration; + } + + public String getUserId() + { + return (String)claims.get("sub"); + } + + public Map getClaims() + { + return claims; + } + + public Map getResponse() + { + return response; + } + + public void redeemAuthCode() throws IOException + { + if (LOG.isDebugEnabled()) + LOG.debug("redeemAuthCode() {}", this); + + if (authCode != null) + { + try + { + String jwt = getJWT(); + decodeJWT(jwt); + + if (LOG.isDebugEnabled()) + LOG.debug("userInfo {}", claims); + } + finally + { + // reset authCode as it can only be used once + authCode = null; + } + } + } + + public boolean validate() + { + if (authCode != null) + return false; + + // Check audience should be clientId + String audience = (String)claims.get("aud"); + if (!configuration.getIdentityProvider().equals(audience)) + { + LOG.warn("Audience claim MUST contain the value of the Issuer Identifier for the OP", this); + //return false; + } + + String issuer = (String)claims.get("iss"); + if (!configuration.getClientId().equals(issuer)) + { + LOG.warn("Issuer claim MUST be the client_id of the OAuth Client {}", this); + //return false; + } + + // Check expiry + long expiry = (Long)claims.get("exp"); + long currentTimeSeconds = (long)(System.currentTimeMillis() / 1000F); + if (currentTimeSeconds > expiry) + { + if (LOG.isDebugEnabled()) + LOG.debug("OpenId Credentials expired {}", this); + return false; + } + + return true; + } + + private void decodeJWT(String jwt) throws IOException + { + if (LOG.isDebugEnabled()) + LOG.debug("decodeJWT {}", jwt); + + String[] sections = jwt.split("\\."); + if (sections.length != 3) + throw new IllegalArgumentException("JWT does not contain 3 sections"); + + String jwtHeaderString = new String(Base64.getDecoder().decode(sections[0]), StandardCharsets.UTF_8); + String jwtClaimString = new String(Base64.getDecoder().decode(sections[1]), StandardCharsets.UTF_8); + String jwtSignature = sections[2]; + + Map jwtHeader = (Map)JSON.parse(jwtHeaderString); + LOG.debug("JWT Header: {}", jwtHeader); + + // validate signature + LOG.warn("Signature NOT validated {}", jwtSignature); + + // response should be a set of name/value pairs + claims = (Map)JSON.parse(jwtClaimString); + } + + private String getJWT() throws IOException + { + if (LOG.isDebugEnabled()) + LOG.debug("getJWT {}", authCode); + + // Use the auth code to get the id_token from the OpenID Provider + String urlParameters = "code=" + authCode + + "&client_id=" + configuration.getClientId() + + "&client_secret=" + configuration.getClientSecret() + + "&redirect_uri=" + redirectUri + + "&grant_type=authorization_code"; + + byte[] payload = urlParameters.getBytes(StandardCharsets.UTF_8); + URL url = new URL(configuration.getTokenEndpoint()); + HttpURLConnection connection = (HttpURLConnection)url.openConnection(); + connection.setDoOutput(true); + connection.setRequestMethod("POST"); + connection.setRequestProperty("Host", configuration.getIdentityProvider()); + connection.setRequestProperty("Content-Type", "application/x-www-form-urlencoded"); + connection.setRequestProperty("charset", "utf-8"); + + try (DataOutputStream wr = new DataOutputStream(connection.getOutputStream())) + { + wr.write(payload); + } + + // get response and extract id_token jwt + InputStream content = (InputStream)connection.getContent(); + response = (Map)JSON.parse(IO.toString(content)); + + if (LOG.isDebugEnabled()) + LOG.debug("responseMap: {}", response); + + return (String)response.get("id_token"); + } +} diff --git a/jetty-openid/src/main/java/org/eclipse/jetty/security/openid/OpenIdLoginService.java b/jetty-openid/src/main/java/org/eclipse/jetty/security/openid/OpenIdLoginService.java new file mode 100644 index 00000000000..8a3914ce605 --- /dev/null +++ b/jetty-openid/src/main/java/org/eclipse/jetty/security/openid/OpenIdLoginService.java @@ -0,0 +1,134 @@ +// +// ======================================================================== +// Copyright (c) 1995-2019 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.openid; + +import java.io.IOException; +import java.security.Principal; +import javax.security.auth.Subject; +import javax.servlet.ServletRequest; + +import org.eclipse.jetty.security.IdentityService; +import org.eclipse.jetty.security.LoginService; +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; + +public class OpenIdLoginService extends ContainerLifeCycle implements LoginService +{ + private static final Logger LOG = Log.getLogger(OpenIdLoginService.class); + + private final OpenIdConfiguration _configuration; + private final LoginService loginService; + private IdentityService identityService; + + public OpenIdLoginService(OpenIdConfiguration configuration) + { + this(configuration, null); + } + + public OpenIdLoginService(OpenIdConfiguration configuration, LoginService loginService) + { + _configuration = configuration; + this.loginService = loginService; + addBean(this.loginService); + } + + @Override + public String getName() + { + return _configuration.getIdentityProvider(); + } + + public OpenIdConfiguration getConfiguration() + { + return _configuration; + } + + @Override + public UserIdentity login(String identifier, Object credentials, ServletRequest req) + { + if (LOG.isDebugEnabled()) + LOG.debug("login({}, {}, {})", identifier, credentials, req); + + OpenIdCredentials openIdCredentials = (OpenIdCredentials)credentials; + try + { + openIdCredentials.redeemAuthCode(); + if (!openIdCredentials.validate()) + return null; + } + catch (IOException e) + { + LOG.warn(e); + return null; + } + + OpenIdUserPrincipal userPrincipal = new OpenIdUserPrincipal(openIdCredentials); + Subject subject = new Subject(); + subject.getPrincipals().add(userPrincipal); + subject.getPrivateCredentials().add(credentials); + subject.setReadOnly(); + + if (loginService != null) + { + UserIdentity userIdentity = loginService.login(openIdCredentials.getUserId(), "", req); + if (userIdentity == null) + return null; + + return new OpenIdUserIdentity(subject, userPrincipal, userIdentity); + } + + return identityService.newUserIdentity(subject, userPrincipal, new String[0]); + } + + @Override + public boolean validate(UserIdentity user) + { + Principal userPrincipal = user.getUserPrincipal(); + if (!(userPrincipal instanceof OpenIdUserPrincipal)) + return false; + + OpenIdCredentials credentials = ((OpenIdUserPrincipal)userPrincipal).getCredentials(); + return credentials.validate(); + } + + @Override + public IdentityService getIdentityService() + { + return loginService == null ? identityService : loginService.getIdentityService(); + } + + @Override + public void setIdentityService(IdentityService service) + { + if (isRunning()) + throw new IllegalStateException("Running"); + + if (loginService != null) + loginService.setIdentityService(service); + else + identityService = service; + } + + @Override + public void logout(UserIdentity user) + { + } +} diff --git a/jetty-openid/src/main/java/org/eclipse/jetty/security/openid/OpenIdUserIdentity.java b/jetty-openid/src/main/java/org/eclipse/jetty/security/openid/OpenIdUserIdentity.java new file mode 100644 index 00000000000..1477484c5e1 --- /dev/null +++ b/jetty-openid/src/main/java/org/eclipse/jetty/security/openid/OpenIdUserIdentity.java @@ -0,0 +1,61 @@ +// +// ======================================================================== +// Copyright (c) 1995-2019 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.openid; + +import java.security.Principal; +import javax.security.auth.Subject; + +import org.eclipse.jetty.server.UserIdentity; + +public class OpenIdUserIdentity implements UserIdentity +{ + private final Subject subject; + private final Principal userPrincipal; + private final UserIdentity userIdentity; + + public OpenIdUserIdentity(Subject subject, Principal userPrincipal) + { + this(subject, userPrincipal, null); + } + + public OpenIdUserIdentity(Subject subject, Principal userPrincipal, UserIdentity userIdentity) + { + this.subject = subject; + this.userPrincipal = userPrincipal; + this.userIdentity = userIdentity; + } + + @Override + public Subject getSubject() + { + return subject; + } + + @Override + public Principal getUserPrincipal() + { + return userPrincipal; + } + + @Override + public boolean isUserInRole(String role, Scope scope) + { + return userIdentity == null ? false : userIdentity.isUserInRole(role, scope); + } +} diff --git a/jetty-openid/src/main/java/org/eclipse/jetty/security/openid/OpenIdUserPrincipal.java b/jetty-openid/src/main/java/org/eclipse/jetty/security/openid/OpenIdUserPrincipal.java new file mode 100644 index 00000000000..018547ed34f --- /dev/null +++ b/jetty-openid/src/main/java/org/eclipse/jetty/security/openid/OpenIdUserPrincipal.java @@ -0,0 +1,50 @@ +// +// ======================================================================== +// Copyright (c) 1995-2019 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.openid; + +import java.io.Serializable; +import java.security.Principal; + +public class OpenIdUserPrincipal implements Principal, Serializable +{ + private static final long serialVersionUID = -6226920753748399662L; + private final OpenIdCredentials _credentials; + + public OpenIdUserPrincipal(OpenIdCredentials credentials) + { + _credentials = credentials; + } + + public OpenIdCredentials getCredentials() + { + return _credentials; + } + + @Override + public String getName() + { + return _credentials.getUserId(); + } + + @Override + public String toString() + { + return _credentials.getUserId(); + } +} \ No newline at end of file diff --git a/jetty-openid/src/test/java/org/eclipse/jetty/security/openid/OpenIdAuthenticationDemo.java b/jetty-openid/src/test/java/org/eclipse/jetty/security/openid/OpenIdAuthenticationDemo.java new file mode 100644 index 00000000000..323af0d16f1 --- /dev/null +++ b/jetty-openid/src/test/java/org/eclipse/jetty/security/openid/OpenIdAuthenticationDemo.java @@ -0,0 +1,217 @@ +// +// ======================================================================== +// Copyright (c) 1995-2019 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.openid; + +import java.io.IOException; +import java.security.Principal; +import java.util.Map; +import javax.servlet.http.HttpServlet; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; + +import org.eclipse.jetty.http.MimeTypes; +import org.eclipse.jetty.security.Authenticator; +import org.eclipse.jetty.security.ConstraintMapping; +import org.eclipse.jetty.security.ConstraintSecurityHandler; +import org.eclipse.jetty.server.Server; +import org.eclipse.jetty.servlet.ServletContextHandler; +import org.eclipse.jetty.util.security.Constraint; + +public class OpenIdAuthenticationDemo +{ + public static class AdminPage extends HttpServlet + { + @Override + protected void doGet(HttpServletRequest request, HttpServletResponse response) throws IOException + { + response.getWriter().println("

this is the admin page "+request.getUserPrincipal()+": Home

"); + } + } + + public static class LoginPage extends HttpServlet + { + @Override + protected void doGet(HttpServletRequest request, HttpServletResponse response) throws IOException + { + response.getWriter().println("

you logged in Home

"); + } + } + + public static class LogoutPage extends HttpServlet + { + @Override + protected void doGet(HttpServletRequest request, HttpServletResponse response) throws IOException + { + request.getSession().invalidate(); + response.sendRedirect("/"); + } + } + + public static class HomePage extends HttpServlet + { + @Override + protected void doGet(HttpServletRequest request, HttpServletResponse response) throws IOException + { + response.setContentType(MimeTypes.Type.TEXT_HTML.asString()); + response.getWriter().println("

Home Page

"); + + Principal userPrincipal = request.getUserPrincipal(); + if (userPrincipal != null) + { + Map userInfo = (Map)request.getSession().getAttribute(OpenIdAuthenticator.__USER_CLAIMS); + response.getWriter().println("

Welcome: " + userInfo.get("name") + "

"); + response.getWriter().println("Profile
"); + response.getWriter().println("Admin
"); + response.getWriter().println("Logout
"); + } + else + { + response.getWriter().println("

Please Login Login

"); + } + } + } + + public static class ProfilePage extends HttpServlet + { + @Override + protected void doGet(HttpServletRequest request, HttpServletResponse response) throws IOException + { + response.setContentType(MimeTypes.Type.TEXT_HTML.asString()); + Map userInfo = (Map)request.getSession().getAttribute(OpenIdAuthenticator.__USER_CLAIMS); + + response.getWriter().println("\n" + + "
\n" + + " \n" + + "

"+ userInfo.get("name") +"

\n" + + "

"+userInfo.get("email")+"

\n" + + "

UserId: " + userInfo.get("sub") +"

\n" + + "
"); + + response.getWriter().println("Home
"); + response.getWriter().println("Logout
"); + } + } + + public static class ErrorPage extends HttpServlet + { + @Override + protected void doGet(HttpServletRequest request, HttpServletResponse response) throws IOException + { + response.setContentType(MimeTypes.Type.TEXT_HTML.asString()); + response.getWriter().println("

error: not authorized

"); + response.getWriter().println("

" + request.getUserPrincipal() + "

"); + } + } + + public static void main(String[] args) throws Exception + { + Server server = new Server(8080); + ServletContextHandler context = new ServletContextHandler(server, "/", ServletContextHandler.SESSIONS); + + // Add servlets + context.addServlet(ProfilePage.class, "/profile"); + context.addServlet(LoginPage.class, "/login"); + context.addServlet(AdminPage.class, "/admin"); + context.addServlet(LogoutPage.class, "/logout"); + context.addServlet(HomePage.class, "/*"); + context.addServlet(ErrorPage.class, "/error"); + + // configure security constraints + Constraint constraint = new Constraint(); + constraint.setName(Constraint.__OPENID_AUTH); + constraint.setRoles(new String[]{"**"}); + constraint.setAuthenticate(true); + + Constraint adminConstraint = new Constraint(); + adminConstraint.setName(Constraint.__OPENID_AUTH); + adminConstraint.setRoles(new String[]{"admin"}); + adminConstraint.setAuthenticate(true); + + // constraint mappings + ConstraintMapping profileMapping = new ConstraintMapping(); + profileMapping.setConstraint(constraint); + profileMapping.setPathSpec("/profile"); + ConstraintMapping loginMapping = new ConstraintMapping(); + loginMapping.setConstraint(constraint); + loginMapping.setPathSpec("/login"); + ConstraintMapping adminMapping = new ConstraintMapping(); + adminMapping.setConstraint(adminConstraint); + adminMapping.setPathSpec("/admin"); + + // security handler + ConstraintSecurityHandler securityHandler = new ConstraintSecurityHandler(); + securityHandler.setRealmName("OpenID Connect Authentication"); + securityHandler.addConstraintMapping(profileMapping); + securityHandler.addConstraintMapping(loginMapping); + securityHandler.addConstraintMapping(adminMapping); + + + // Google Authentication + OpenIdConfiguration configuration = new OpenIdConfiguration( + "https://accounts.google.com/", + "1051168419525-5nl60mkugb77p9j194mrh287p1e0ahfi.apps.googleusercontent.com", + "XT_MIsSv_aUCGollauCaJY8S"); + configuration.addScopes("email", "profile"); + + /* + // Microsoft Authentication + OpenIdConfiguration configuration = new OpenIdConfiguration( + "https://login.microsoftonline.com/common/v2.0", + "5f05dea8-2bd9-45de-b30f-cf5c102b8784", + "IfhQJKi-5[vxhh_=ldqt0y4PkV3z_1ca"); + */ + + /* + // Yahoo Authentication + OpenIdConfiguration configuration = new OpenIdConfiguration( + "https://login.yahoo.com", + "dj0yJmk9ME5Id05yTkdGNDdPJmQ9WVdrOU9VcHVZWEp4TkdrbWNHbzlNQS0tJnM9Y29uc3VtZXJzZWNyZXQmc3Y9MCZ4PTE2", + "1e7f0eeb0ba0af9d9198f9be760f66ae3ea9e3b5"); + configuration.addScopes("sdps-r"); + */ + + /* + // Create a realm.properties file to associate roles with users + Path tmpDir = Paths.get(System.getProperty("java.io.tmpdir")); + Path tmpPath = Files.createTempFile(tmpDir, "realm", ".properties"); + tmpPath.toFile().deleteOnExit(); + try (BufferedWriter writer = Files.newBufferedWriter(tmpPath, StandardCharsets.UTF_8, StandardOpenOption.WRITE)) + { + // :[, ...] + writer.write("114260987481616800581:,admin"); + } + + // This must be added to the OpenIdLoginService in constructor below + HashLoginService hashLoginService = new HashLoginService(); + hashLoginService.setConfig(tmpPath.toAbsolutePath().toString()); + hashLoginService.setHotReload(true); + */ + + // Configure OpenIdLoginService optionally providing a base LoginService to provide user roles + OpenIdLoginService loginService = new OpenIdLoginService(configuration);//, hashLoginService); + securityHandler.setLoginService(loginService); + + Authenticator authenticator = new OpenIdAuthenticator(configuration, "/error"); + securityHandler.setAuthenticator(authenticator); + context.setSecurityHandler(securityHandler); + + server.start(); + server.join(); + } +} diff --git a/jetty-openid/src/test/resources/jetty-logging.properties b/jetty-openid/src/test/resources/jetty-logging.properties new file mode 100755 index 00000000000..c63d0a5bf4b --- /dev/null +++ b/jetty-openid/src/test/resources/jetty-logging.properties @@ -0,0 +1,3 @@ +# Setup default logging implementation for during testing +org.eclipse.jetty.util.log.class=org.eclipse.jetty.util.log.StdErrLog +# org.eclipse.jetty.security.openid.LEVEL=DEBUG \ No newline at end of file diff --git a/jetty-util/src/main/java/org/eclipse/jetty/util/security/Constraint.java b/jetty-util/src/main/java/org/eclipse/jetty/util/security/Constraint.java index db4b96db940..89c41a3ea25 100644 --- a/jetty-util/src/main/java/org/eclipse/jetty/util/security/Constraint.java +++ b/jetty-util/src/main/java/org/eclipse/jetty/util/security/Constraint.java @@ -37,6 +37,8 @@ public class Constraint implements Cloneable, Serializable public static final String __SPNEGO_AUTH = "SPNEGO"; public static final String __NEGOTIATE_AUTH = "NEGOTIATE"; + public static final String __OPENID_AUTH = "OPENID"; + public static boolean validateMethod(String method) { if (method == null) @@ -48,7 +50,8 @@ public class Constraint implements Cloneable, Serializable method.equals(__CERT_AUTH) || method.equals(__CERT_AUTH2) || method.equals(__SPNEGO_AUTH) || - method.equals(__NEGOTIATE_AUTH)); + method.equals(__NEGOTIATE_AUTH) || + method.equals(__OPENID_AUTH)); } public static final int DC_UNSET = -1; diff --git a/pom.xml b/pom.xml index e717c2a81c1..6d8d4a864d6 100644 --- a/pom.xml +++ b/pom.xml @@ -94,6 +94,7 @@ jetty-server jetty-xml jetty-security + jetty-openid jetty-servlet jetty-webapp jetty-fcgi