Merge pull request #4038 from eclipse/jetty-9.4.x-OpenId

OpenID Connect Authentication
This commit is contained in:
Joakim Erdfelt 2019-09-13 16:42:17 -05:00 committed by GitHub
commit e013c24326
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
18 changed files with 1796 additions and 1 deletions

View File

@ -299,6 +299,11 @@
<artifactId>jetty-security</artifactId>
<version>9.4.21-SNAPSHOT</version>
</dependency>
<dependency>
<groupId>org.eclipse.jetty</groupId>
<artifactId>jetty-openid</artifactId>
<version>9.4.21-SNAPSHOT</version>
</dependency>
<dependency>
<groupId>org.eclipse.jetty</groupId>
<artifactId>jetty-server</artifactId>

View File

@ -711,6 +711,11 @@
<artifactId>jetty-alpn-openjdk8-server</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>org.eclipse.jetty</groupId>
<artifactId>jetty-openid</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>org.eclipse.jetty</groupId>
<artifactId>jetty-alpn-conscrypt-server</artifactId>

64
jetty-openid/pom.xml Normal file
View File

@ -0,0 +1,64 @@
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd">
<parent>
<groupId>org.eclipse.jetty</groupId>
<artifactId>jetty-project</artifactId>
<version>9.4.21-SNAPSHOT</version>
</parent>
<modelVersion>4.0.0</modelVersion>
<artifactId>jetty-openid</artifactId>
<name>Jetty :: OpenID</name>
<description>Jetty OpenID Connect infrastructure</description>
<url>http://www.eclipse.org/jetty</url>
<properties>
<bundle-symbolic-name>${project.groupId}.openid</bundle-symbolic-name>
</properties>
<build>
<plugins>
<plugin>
<groupId>org.codehaus.mojo</groupId>
<artifactId>findbugs-maven-plugin</artifactId>
<configuration>
<onlyAnalyze>org.eclipse.jetty.security.openid.*</onlyAnalyze>
</configuration>
</plugin>
</plugins>
</build>
<dependencies>
<dependency>
<groupId>org.eclipse.jetty</groupId>
<artifactId>jetty-server</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>org.eclipse.jetty</groupId>
<artifactId>jetty-security</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>org.eclipse.jetty</groupId>
<artifactId>jetty-util-ajax</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>org.eclipse.jetty</groupId>
<artifactId>jetty-servlet</artifactId>
<version>${project.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.eclipse.jetty.toolchain</groupId>
<artifactId>jetty-test-helper</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.eclipse.jetty</groupId>
<artifactId>jetty-client</artifactId>
<version>${project.version}</version>
<scope>test</scope>
</dependency>
</dependencies>
</project>

View File

@ -0,0 +1,29 @@
<?xml version="1.0"?>
<!DOCTYPE Configure PUBLIC "-//Jetty//Configure//EN" "http://www.eclipse.org/jetty/configure_9_3.dtd">
<Configure id="Server" class="org.eclipse.jetty.server.Server">
<New id="OpenIdConfiguration" class="org.eclipse.jetty.security.openid.OpenIdConfiguration">
<Arg><Property name="jetty.openid.openIdProvider"/></Arg>
<Arg><Property name="jetty.openid.clientId"/></Arg>
<Arg><Property name="jetty.openid.clientSecret"/></Arg>
<Call name="addScopes">
<Arg>
<Call class="org.eclipse.jetty.util.StringUtil" name="csvSplit">
<Arg><Property name="jetty.openid.scopes"/></Arg>
</Call>
</Arg>
</Call>
</New>
<Call name="addBean">
<Arg>
<New class="org.eclipse.jetty.security.openid.OpenIdLoginService">
<Arg><Ref refid="OpenIdConfiguration"/></Arg>
<Arg><Ref refid="BaseLoginService"/></Arg>
<Call name="authenticateNewUsers">
<Arg type="boolean">
<Property name="jetty.openid.authenticateNewUsers" default="false"/>
</Arg>
</Call>
</New>
</Arg>
</Call>
</Configure>

View File

@ -0,0 +1,34 @@
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]
## The OpenID Identity Provider
# jetty.openid.openIdProvider=https://accounts.google.com/
## The Client Identifier
# jetty.openid.clientId=test1234.apps.googleusercontent.com
## The Client Secret
# jetty.openid.clientSecret=XT_Mafv_aUCGheuCaKY8P
## Additional Scopes to Request
# jetty.openid.scopes=email,profile
## Whether to Authenticate users not found by base LoginService
# jetty.openid.authenticateNewUsers=false

View File

@ -0,0 +1,10 @@
<?xml version="1.0"?>
<!DOCTYPE Configure PUBLIC "-//Jetty//Configure//EN" "http://www.eclipse.org/jetty/configure_9_3.dtd">
<Configure id="BaseLoginService">
<!-- Optional code to configure the base LoginService used by the OpenIdLoginService
<New id="BaseLoginService" class="org.eclipse.jetty.security.HashLoginService">
<Set name="config"><SystemProperty name="jetty.home" default="."/>/etc/realm.properties</Set>
<Set name="hotReload">true</Set>
</New>
-->
</Configure>

View File

@ -0,0 +1,491 @@
//
// ========================================================================
// 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.nio.charset.StandardCharsets;
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.UrlEncoded;
import org.eclipse.jetty.util.log.Log;
import org.eclipse.jetty.util.log.Logger;
import org.eclipse.jetty.util.security.Constraint;
/**
* <p>Implements authentication using OpenId Connect on top of OAuth 2.0.
*
* <p>The OpenIdAuthenticator redirects unauthenticated requests to the OpenID Connect Provider. The End-User is
* eventually redirected back with an Authorization Code to the /j_security_check URI within the context.
* The Authorization Code is then used to authenticate the user through the {@link OpenIdCredentials} and {@link OpenIdLoginService}.
* </p>
* <p>
* Once a user is authenticated the OpenID Claims can be retrieved through an attribute on the session with the key {@link #CLAIMS}.
* The full response containing the OAuth 2.0 Access Token can be obtained with the session attribute {@link #RESPONSE}.
* </p>
* <p>{@link SessionAuthentication} is then used to wrap Authentication results so that they are associated with the session.</p>
*/
public class OpenIdAuthenticator extends LoginAuthenticator
{
private static final Logger LOG = Log.getLogger(OpenIdAuthenticator.class);
public static final String CLAIMS = "org.eclipse.jetty.security.openid.claims";
public static final String RESPONSE = "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.
*
* @param alwaysSave true to always save the uri
*/
public void setAlwaysSaveUri(boolean alwaysSave)
{
_alwaysSaveUri = alwaysSave;
}
public boolean isAlwaysSaveUri()
{
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(CLAIMS, ((OpenIdCredentials)credentials).getClaims());
session.setAttribute(RESPONSE, ((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(CLAIMS);
session.removeAttribute(RESPONSE);
}
@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);
if (LOG.isDebugEnabled())
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()))
{
if (LOG.isDebugEnabled())
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
if (LOG.isDebugEnabled())
LOG.debug("auth retry {}->{}", authentication, jUri);
StringBuffer buf = request.getRequestURL();
if (request.getQueryString() != null)
buf.append("?").append(request.getQueryString());
if (jUri.equals(buf.toString()))
{
@SuppressWarnings("unchecked")
MultiMap<String> jPost = (MultiMap<String>)session.getAttribute(J_POST);
if (jPost != null)
{
if (LOG.isDebugEnabled())
LOG.debug("auth rePOST {}->{}", authentication, jUri);
baseRequest.setContentParameters(jPost);
}
session.removeAttribute(J_URI);
session.removeAttribute(J_METHOD);
session.removeAttribute(J_POST);
}
}
}
if (LOG.isDebugEnabled())
LOG.debug("auth {}", authentication);
return authentication;
}
}
// if we can't send challenge
if (DeferredAuthentication.isDeferred(response))
{
if (LOG.isDebugEnabled())
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 || isAlwaysSaveUri())
{
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<String> formParameters = new MultiMap<>();
baseRequest.extractFormParameters(formParameters);
session.setAttribute(J_POST, formParameters);
}
}
}
// send the the challenge
String challengeUri = getChallengeUri(request);
if (LOG.isDebugEnabled())
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(" ").append(s);
}
return _configuration.getAuthEndpoint() +
"?client_id=" + UrlEncoded.encodeString(_configuration.getClientId(), StandardCharsets.UTF_8) +
"&redirect_uri=" + UrlEncoded.encodeString(getRedirectUri(request), StandardCharsets.UTF_8) +
"&scope=openid" + UrlEncoded.encodeString(scopes.toString(), StandardCharsets.UTF_8) +
"&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();
}
}
}

View File

@ -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);
}
}

View File

@ -0,0 +1,141 @@
//
// ========================================================================
// 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.io.Serializable;
import java.net.URI;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
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;
/**
* Holds the configuration for an OpenID Connect service.
*
* This uses the OpenID Provider URL with the path {@link #CONFIG_PATH} to discover
* the required information about the OIDC service.
*/
public class OpenIdConfiguration implements Serializable
{
private static final Logger LOG = Log.getLogger(OpenIdConfiguration.class);
private static final long serialVersionUID = 2227941990601349102L;
private static final String CONFIG_PATH = "/.well-known/openid-configuration";
private final String openIdProvider;
private final String issuer;
private final String authEndpoint;
private final String tokenEndpoint;
private final String clientId;
private final String clientSecret;
private final Map<String, Object> discoveryDocument;
private final List<String> scopes = new ArrayList<>();
/**
* Create an OpenID configuration for a specific OIDC provider.
* @param provider The URL of the OpenID provider.
* @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.
*/
public OpenIdConfiguration(String provider, String clientId, String clientSecret)
{
this.openIdProvider = 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);
if (LOG.isDebugEnabled())
LOG.debug("discovery document {}", discoveryDocument);
}
catch (Throwable e)
{
throw new IllegalArgumentException("invalid identity provider", e);
}
issuer = (String)discoveryDocument.get("issuer");
if (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<String, Object> getDiscoveryDocument()
{
return discoveryDocument;
}
public String getAuthEndpoint()
{
return authEndpoint;
}
public String getClientId()
{
return clientId;
}
public String getClientSecret()
{
return clientSecret;
}
public String getIssuer()
{
return issuer;
}
public String getOpenIdProvider()
{
return openIdProvider;
}
public String getTokenEndpoint()
{
return tokenEndpoint;
}
public void addScopes(String... scopes)
{
Collections.addAll(this.scopes, scopes);
}
public List<String> getScopes()
{
return scopes;
}
}

View File

@ -0,0 +1,214 @@
//
// ========================================================================
// 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.io.Serializable;
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.UrlEncoded;
import org.eclipse.jetty.util.ajax.JSON;
import org.eclipse.jetty.util.log.Log;
import org.eclipse.jetty.util.log.Logger;
/**
* <p>The credentials of an user to be authenticated with OpenID Connect. This will contain
* the OpenID ID Token and the OAuth 2.0 Access Token.</p>
*
* <p>
* This is constructed with an authorization code from the authentication request. This authorization code
* is then exchanged using {@link #redeemAuthCode()} for a response containing the ID Token and Access Token.
* The response is then validated against the {@link OpenIdConfiguration}.
* </p>
*/
public class OpenIdCredentials implements Serializable
{
private static final Logger LOG = Log.getLogger(OpenIdCredentials.class);
private static final long serialVersionUID = 4766053233370044796L;
private final String redirectUri;
private final OpenIdConfiguration configuration;
private String authCode;
private Map<String, Object> response;
private Map<String, Object> 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<String, Object> getClaims()
{
return claims;
}
public Map<String, Object> getResponse()
{
return response;
}
public void redeemAuthCode() throws IOException
{
if (LOG.isDebugEnabled())
LOG.debug("redeemAuthCode() {}", this);
if (authCode != null)
{
try
{
response = claimAuthCode(authCode);
if (LOG.isDebugEnabled())
LOG.debug("response: {}", response);
String idToken = (String)response.get("id_token");
if (idToken == null)
throw new IllegalArgumentException("no id_token");
String accessToken = (String)response.get("access_token");
if (accessToken == null)
throw new IllegalArgumentException("no access_token");
String tokenType = (String)response.get("token_type");
if (!"Bearer".equalsIgnoreCase(tokenType))
throw new IllegalArgumentException("invalid token_type");
claims = decodeJWT(idToken);
if (LOG.isDebugEnabled())
LOG.debug("claims {}", claims);
validateClaims();
}
finally
{
// reset authCode as it can only be used once
authCode = null;
}
}
}
private void validateClaims()
{
// Issuer Identifier for the OpenID Provider MUST exactly match the value of the iss (issuer) Claim.
if (!configuration.getIssuer().equals(claims.get("iss")))
throw new IllegalArgumentException("Issuer Identifier MUST exactly match the iss Claim");
// The aud (audience) Claim MUST contain the client_id value.
if (!configuration.getClientId().equals(claims.get("aud")))
throw new IllegalArgumentException("Audience Claim MUST contain the client_id value");
// If an azp (authorized party) Claim is present, verify that its client_id is the Claim Value.
Object azp = claims.get("azp");
if (azp != null && !configuration.getClientId().equals(azp))
throw new IllegalArgumentException("Authorized party claim value should be the client_id");
}
public boolean isExpired()
{
if (authCode != null || claims == null)
return true;
// 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 true;
}
return false;
}
protected Map<String, Object> 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");
Base64.Decoder decoder = Base64.getDecoder();
String jwtHeaderString = new String(decoder.decode(sections[0]), StandardCharsets.UTF_8);
String jwtClaimString = new String(decoder.decode(sections[1]), StandardCharsets.UTF_8);
String jwtSignature = sections[2];
Map<String, Object> jwtHeader = (Map)JSON.parse(jwtHeaderString);
LOG.debug("JWT Header: {}", jwtHeader);
/* If the ID Token is received via direct communication between the Client
and the Token Endpoint (which it is in this flow), the TLS server validation
MAY be used to validate the issuer in place of checking the token signature. */
if (LOG.isDebugEnabled())
LOG.debug("JWT signature not validated {}", jwtSignature);
return (Map)JSON.parse(jwtClaimString);
}
private Map<String, Object> claimAuthCode(String authCode) throws IOException
{
if (LOG.isDebugEnabled())
LOG.debug("claimAuthCode {}", authCode);
// Use the authorization code to get the id_token from the OpenID Provider
String urlParameters = "code=" + authCode +
"&client_id=" + UrlEncoded.encodeString(configuration.getClientId(), StandardCharsets.UTF_8) +
"&client_secret=" + UrlEncoded.encodeString(configuration.getClientSecret(), StandardCharsets.UTF_8) +
"&redirect_uri=" + UrlEncoded.encodeString(redirectUri, StandardCharsets.UTF_8) +
"&grant_type=authorization_code";
URL url = new URL(configuration.getTokenEndpoint());
HttpURLConnection connection = (HttpURLConnection)url.openConnection();
try
{
connection.setDoOutput(true);
connection.setRequestMethod("POST");
connection.setRequestProperty("Host", configuration.getOpenIdProvider());
connection.setRequestProperty("Content-Type", "application/x-www-form-urlencoded");
try (DataOutputStream wr = new DataOutputStream(connection.getOutputStream()))
{
wr.write(urlParameters.getBytes(StandardCharsets.UTF_8));
}
try (InputStream content = (InputStream)connection.getContent())
{
return (Map)JSON.parse(IO.toString(content));
}
}
finally
{
connection.disconnect();
}
}
}

View File

@ -0,0 +1,170 @@
//
// ========================================================================
// 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 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;
/**
* The implementation of {@link LoginService} required to use OpenID Connect.
*
* <p>
* Can contain an optional wrapped {@link LoginService} which is used to store role information about users.
* </p>
*/
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;
private boolean authenticateNewUsers;
public OpenIdLoginService(OpenIdConfiguration configuration)
{
this(configuration, null);
}
/**
* Use a wrapped {@link LoginService} to store information about user roles.
* Users in the wrapped loginService must be stored with their username as
* the value of the sub (subject) Claim, and a credentials value of the empty string.
* @param configuration the OpenID configuration to use.
* @param loginService the wrapped LoginService to defer to for user roles.
*/
public OpenIdLoginService(OpenIdConfiguration configuration, LoginService loginService)
{
_configuration = configuration;
this.loginService = loginService;
addBean(this.loginService);
}
@Override
public String getName()
{
return _configuration.getOpenIdProvider();
}
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.isExpired())
return null;
}
catch (Throwable 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)
{
if (isAuthenticateNewUsers())
return getIdentityService().newUserIdentity(subject, userPrincipal, new String[0]);
return null;
}
return new OpenIdUserIdentity(subject, userPrincipal, userIdentity);
}
return identityService.newUserIdentity(subject, userPrincipal, new String[0]);
}
public boolean isAuthenticateNewUsers()
{
return authenticateNewUsers;
}
/**
* This setting is only meaningful if a wrapped {@link LoginService} has been set.
* <p>
* If set to true, any users not found by the wrapped {@link LoginService} will still
* be authenticated but with no roles, if set to false users will not be
* authenticated unless they are discovered by the wrapped {@link LoginService}.
* </p>
* @param authenticateNewUsers whether to authenticate users not found by a wrapping LoginService
*/
public void setAuthenticateNewUsers(boolean authenticateNewUsers)
{
this.authenticateNewUsers = authenticateNewUsers;
}
@Override
public boolean validate(UserIdentity user)
{
Principal userPrincipal = user.getUserPrincipal();
if (!(userPrincipal instanceof OpenIdUserPrincipal))
return false;
OpenIdCredentials credentials = ((OpenIdUserPrincipal)userPrincipal).getCredentials();
return !credentials.isExpired();
}
@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)
{
}
}

View File

@ -0,0 +1,56 @@
//
// ========================================================================
// 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, 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 && userIdentity.isUserInRole(role, scope);
}
}

View File

@ -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 = 1521094652756670469L;
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();
}
}

View File

@ -0,0 +1,226 @@
//
// ========================================================================
// 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.client.HttpClient;
import org.eclipse.jetty.client.api.ContentResponse;
import org.eclipse.jetty.http.HttpStatus;
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.server.ServerConnector;
import org.eclipse.jetty.servlet.ServletContextHandler;
import org.eclipse.jetty.util.security.Constraint;
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.is;
public class OpenIdAuthenticationTest
{
public static final String CLIENT_ID = "testClient101";
public static final String CLIENT_SECRET = "secret37989798";
private OpenIdProvider openIdProvider;
private Server server;
private ServerConnector connector;
private HttpClient client;
@BeforeEach
public void setup() throws Exception
{
openIdProvider = new OpenIdProvider(CLIENT_ID, CLIENT_SECRET);
openIdProvider.start();
server = new Server();
connector = new ServerConnector(server);
server.addConnector(connector);
ServletContextHandler context = new ServletContextHandler(server, "/", ServletContextHandler.SESSIONS);
// Add servlets
context.addServlet(LoginPage.class, "/login");
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);
// Authentication using local OIDC Provider
OpenIdConfiguration configuration = new OpenIdConfiguration(openIdProvider.getProvider(), CLIENT_ID, CLIENT_SECRET);
// 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();
String redirectUri = "http://localhost:"+connector.getLocalPort() + "/j_security_check";
openIdProvider.addRedirectUri(redirectUri);
client = new HttpClient();
client.start();
}
@AfterEach
public void stop() throws Exception
{
openIdProvider.stop();
server.stop();
}
@Test
public void testLoginLogout() throws Exception
{
String appUriString = "http://localhost:"+connector.getLocalPort();
// Initially not authenticated
ContentResponse response = client.GET(appUriString + "/");
assertThat(response.getStatus(), is(HttpStatus.OK_200));
String[] content = response.getContentAsString().split("\n");
assertThat(content.length, is(1));
assertThat(content[0], is("not authenticated"));
// Request to login is success
response = client.GET(appUriString + "/login");
assertThat(response.getStatus(), is(HttpStatus.OK_200));
content = response.getContentAsString().split("\n");
assertThat(content.length, is(1));
assertThat(content[0], is("success"));
// Now authenticated we can get info
response = client.GET(appUriString + "/");
assertThat(response.getStatus(), is(HttpStatus.OK_200));
content = response.getContentAsString().split("\n");
assertThat(content.length, is(3));
assertThat(content[0], is("userId: 123456789"));
assertThat(content[1], is("name: FirstName LastName"));
assertThat(content[2], is("email: FirstName@fake-email.com"));
// Request to admin page gives 403 as we do not have admin role
response = client.GET(appUriString + "/admin");
assertThat(response.getStatus(), is(HttpStatus.FORBIDDEN_403));
// We are no longer authenticated after logging out
response = client.GET(appUriString + "/logout");
assertThat(response.getStatus(), is(HttpStatus.OK_200));
content = response.getContentAsString().split("\n");
assertThat(content.length, is(1));
assertThat(content[0], is("not authenticated"));
}
public static class LoginPage extends HttpServlet
{
@Override
protected void doGet(HttpServletRequest request, HttpServletResponse response) throws IOException
{
response.getWriter().println("success");
}
}
public static class LogoutPage extends HttpServlet
{
@Override
protected void doGet(HttpServletRequest request, HttpServletResponse response) throws IOException
{
request.getSession().invalidate();
response.sendRedirect("/");
}
}
public static class AdminPage extends HttpServlet
{
@Override
protected void doGet(HttpServletRequest request, HttpServletResponse response) throws IOException
{
Map<String, Object> userInfo = (Map)request.getSession().getAttribute(OpenIdAuthenticator.CLAIMS);
response.getWriter().println(userInfo.get("sub") + ": success");
}
}
public static class HomePage extends HttpServlet
{
@Override
protected void doGet(HttpServletRequest request, HttpServletResponse response) throws IOException
{
response.setContentType("text/plain");
Principal userPrincipal = request.getUserPrincipal();
if (userPrincipal != null)
{
Map<String, Object> userInfo = (Map)request.getSession().getAttribute(OpenIdAuthenticator.CLAIMS);
response.getWriter().println("userId: " + userInfo.get("sub"));
response.getWriter().println("name: " + userInfo.get("name"));
response.getWriter().println("email: " + userInfo.get("email"));
}
else
{
response.getWriter().println("not authenticated");
}
}
}
public static class ErrorPage extends HttpServlet
{
@Override
protected void doGet(HttpServletRequest request, HttpServletResponse response) throws IOException
{
response.setContentType("text/plain");
response.getWriter().println("not authorized");
}
}
}

View File

@ -0,0 +1,254 @@
//
// ========================================================================
// 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.time.Duration;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Base64;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Random;
import java.util.UUID;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import org.eclipse.jetty.http.HttpVersion;
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.servlet.ServletContextHandler;
import org.eclipse.jetty.servlet.ServletHolder;
import org.eclipse.jetty.util.StringUtil;
import org.eclipse.jetty.util.component.ContainerLifeCycle;
public class OpenIdProvider extends ContainerLifeCycle
{
private static final String CONFIG_PATH = "/.well-known/openid-configuration";
private static final String AUTH_PATH = "/auth";
private static final String TOKEN_PATH = "/token";
private final Map<String, User> issuedAuthCodes = new HashMap<>();
protected final String clientId;
protected final String clientSecret;
protected final List<String> redirectUris = new ArrayList<>();
private String provider;
private Server server;
private ServerConnector connector;
public OpenIdProvider(String clientId, String clientSecret)
{
this.clientId = clientId;
this.clientSecret = clientSecret;
server = new Server();
connector = new ServerConnector(server);
server.addConnector(connector);
ServletContextHandler contextHandler = new ServletContextHandler();
contextHandler.setContextPath("/");
contextHandler.addServlet(new ServletHolder(new OpenIdConfigServlet()), CONFIG_PATH);
contextHandler.addServlet(new ServletHolder(new OpenIdAuthEndpoint()), AUTH_PATH);
contextHandler.addServlet(new ServletHolder(new OpenIdTokenEndpoint()), TOKEN_PATH);
server.setHandler(contextHandler);
addBean(server);
}
@Override
protected void doStart() throws Exception
{
super.doStart();
provider = "http://localhost:" + connector.getLocalPort();
}
public String getProvider()
{
if (!isStarted())
throw new IllegalStateException();
return provider;
}
public void addRedirectUri(String uri)
{
redirectUris.add(uri);
}
public class OpenIdAuthEndpoint extends HttpServlet
{
@Override
protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException
{
if (!clientId.equals(req.getParameter("client_id")))
{
resp.sendError(HttpServletResponse.SC_FORBIDDEN, "invalid client_id");
return;
}
String redirectUri = req.getParameter("redirect_uri");
if (!redirectUris.contains(redirectUri))
{
resp.sendError(HttpServletResponse.SC_FORBIDDEN, "invalid redirect_uri");
return;
}
String scopeString = req.getParameter("scope");
List<String> scopes = (scopeString == null) ? Collections.emptyList() : Arrays.asList(StringUtil.csvSplit(scopeString));
if (!scopes.contains("openid"))
{
resp.sendError(HttpServletResponse.SC_FORBIDDEN, "no openid scope");
return;
}
if (!"code".equals(req.getParameter("response_type")))
{
resp.sendError(HttpServletResponse.SC_FORBIDDEN, "response_type must be code");
return;
}
String state = req.getParameter("state");
if (state == null)
{
resp.sendError(HttpServletResponse.SC_FORBIDDEN, "no state param");
return;
}
String authCode = UUID.randomUUID().toString().replace("-", "");
User user = new User(123456789, "FirstName", "LastName");
issuedAuthCodes.put(authCode, user);
final Request baseRequest = Request.getBaseRequest(req);
final Response baseResponse = baseRequest.getResponse();
redirectUri += "?code=" + authCode + "&state=" + state;
int redirectCode = (baseRequest.getHttpVersion().getVersion() < HttpVersion.HTTP_1_1.getVersion() ?
HttpServletResponse.SC_MOVED_TEMPORARILY : HttpServletResponse.SC_SEE_OTHER);
baseResponse.sendRedirect(redirectCode, resp.encodeRedirectURL(redirectUri));
}
}
public class OpenIdTokenEndpoint extends HttpServlet
{
@Override
protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException
{
String code = req.getParameter("code");
if (!clientId.equals(req.getParameter("client_id")) ||
!clientSecret.equals(req.getParameter("client_secret")) ||
!redirectUris.contains(req.getParameter("redirect_uri")) ||
!"authorization_code".equals(req.getParameter("grant_type")) ||
code == null)
{
resp.sendError(HttpServletResponse.SC_FORBIDDEN, "bad auth request");
return;
}
User user = issuedAuthCodes.remove(code);
if (user == null)
{
resp.sendError(HttpServletResponse.SC_FORBIDDEN, "invalid auth code");
return;
}
String jwtHeader = "{\"INFO\": \"this is not used or checked in our implementation\"}";
String jwtBody = user.getIdToken();
String jwtSignature = "we do not validate signature as we use the authorization code flow";
Base64.Encoder encoder = Base64.getEncoder();
String jwt = encoder.encodeToString(jwtHeader.getBytes()) + "." +
encoder.encodeToString(jwtBody.getBytes()) + "." +
encoder.encodeToString(jwtSignature.getBytes());
String accessToken = "ABCDEFG";
long expiry = System.currentTimeMillis() + Duration.ofMinutes(10).toMillis();
String response = "{" +
"\"access_token\": \"" + accessToken + "\"," +
"\"id_token\": \"" + jwt + "\"," +
"\"expires_in\": " + expiry + "," +
"\"token_type\": \"Bearer\"" +
"}";
resp.setContentType("text/plain");
resp.getWriter().print(response);
}
}
public class OpenIdConfigServlet extends HttpServlet
{
@Override
protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException
{
String discoveryDocument = "{" +
"\"issuer\": \"" + provider + "\"," +
"\"authorization_endpoint\": \"" + provider + AUTH_PATH + "\"," +
"\"token_endpoint\": \"" + provider + TOKEN_PATH + "\"," +
"}";
resp.getWriter().write(discoveryDocument);
}
}
public class User
{
private long subject;
private String firstName;
private String lastName;
public User(String firstName, String lastName)
{
this(new Random().nextLong(), firstName, lastName);
}
public User(long subject, String firstName, String lastName)
{
this.subject = subject;
this.firstName = firstName;
this.lastName = lastName;
}
public String getFirstName()
{
return firstName;
}
public String getLastName()
{
return lastName;
}
public String getIdToken()
{
return "{" +
"\"iss\": \"" + provider + "\"," +
"\"sub\": \"" + subject + "\"," +
"\"aud\": \"" + clientId + "\"," +
"\"exp\": " + System.currentTimeMillis() + Duration.ofMinutes(1).toMillis() + "," +
"\"name\": \"" + firstName + " " + lastName + "\"," +
"\"email\": \"" + firstName + "@fake-email.com" + "\"" +
"}";
}
}
}

View File

@ -0,0 +1,3 @@
org.eclipse.jetty.util.log.class=org.eclipse.jetty.util.log.StdErrLog
# org.eclipse.jetty.LEVEL=DEBUG
# org.eclipse.jetty.security.openid.LEVEL=DEBUG

View File

@ -36,6 +36,7 @@ public class Constraint implements Cloneable, Serializable
public static final String __CERT_AUTH2 = "CLIENT-CERT";
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)
{
@ -48,7 +49,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;

View File

@ -94,6 +94,7 @@
<module>jetty-server</module>
<module>jetty-xml</module>
<module>jetty-security</module>
<module>jetty-openid</module>
<module>jetty-servlet</module>
<module>jetty-webapp</module>
<module>jetty-fcgi</module>