Issue #11560 - Implement EIP-4361 Sign-In With Ethereum
Signed-off-by: Lachlan Roberts <lachlan@webtide.com>
This commit is contained in:
parent
05a0498627
commit
436ca41c81
|
@ -95,6 +95,11 @@
|
|||
<artifactId>jetty-openid</artifactId>
|
||||
<version>12.0.11-SNAPSHOT</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.eclipse.jetty</groupId>
|
||||
<artifactId>jetty-siwe</artifactId>
|
||||
<version>12.0.11-SNAPSHOT</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.eclipse.jetty</groupId>
|
||||
<artifactId>jetty-osgi</artifactId>
|
||||
|
|
|
@ -8,7 +8,7 @@
|
|||
<version>12.0.11-SNAPSHOT</version>
|
||||
</parent>
|
||||
<artifactId>jetty-openid</artifactId>
|
||||
<name>EE10 :: OpenID</name>
|
||||
<name>Core :: OpenID</name>
|
||||
<description>Jetty OpenID Connect Infrastructure</description>
|
||||
|
||||
<properties>
|
||||
|
|
|
@ -42,6 +42,7 @@ public interface Authenticator
|
|||
String SPNEGO_AUTH = "SPNEGO";
|
||||
String NEGOTIATE_AUTH = "NEGOTIATE";
|
||||
String OPENID_AUTH = "OPENID";
|
||||
String SIWE_AUTH = "SIWE";
|
||||
|
||||
/**
|
||||
* Configure the Authenticator
|
||||
|
|
|
@ -176,7 +176,7 @@ public class FormFields extends ContentSourceCompletableFuture<Fields>
|
|||
* @param maxLength The maximum total size of the fields
|
||||
* @return A {@link CompletableFuture} that will provide the {@link Fields} or a failure.
|
||||
*/
|
||||
static CompletableFuture<Fields> from(Content.Source source, Attributes attributes, Charset charset, int maxFields, int maxLength)
|
||||
public static CompletableFuture<Fields> from(Content.Source source, Attributes attributes, Charset charset, int maxFields, int maxLength)
|
||||
{
|
||||
Object attr = attributes.getAttribute(FormFields.class.getName());
|
||||
if (attr instanceof FormFields futureFormFields)
|
||||
|
|
|
@ -0,0 +1,89 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<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">
|
||||
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
<parent>
|
||||
<groupId>org.eclipse.jetty</groupId>
|
||||
<artifactId>jetty-core</artifactId>
|
||||
<version>12.0.11-SNAPSHOT</version>
|
||||
</parent>
|
||||
<artifactId>jetty-siwe</artifactId>
|
||||
<name>Core :: Sign-In with Ethereum</name>
|
||||
<description>Jetty Sign-In with Ethereum</description>
|
||||
|
||||
<properties>
|
||||
<bouncycastle.version>1.78.1</bouncycastle.version>
|
||||
<bundle-symbolic-name>${project.groupId}.siwe</bundle-symbolic-name>
|
||||
</properties>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>org.bouncycastle</groupId>
|
||||
<artifactId>bcpkix-jdk15to18</artifactId>
|
||||
<version>${bouncycastle.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.bouncycastle</groupId>
|
||||
<artifactId>bcprov-jdk15to18</artifactId>
|
||||
<version>${bouncycastle.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.eclipse.jetty</groupId>
|
||||
<artifactId>jetty-client</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.eclipse.jetty</groupId>
|
||||
<artifactId>jetty-security</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.eclipse.jetty</groupId>
|
||||
<artifactId>jetty-server</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.eclipse.jetty</groupId>
|
||||
<artifactId>jetty-util-ajax</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.slf4j</groupId>
|
||||
<artifactId>slf4j-api</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.eclipse.jetty</groupId>
|
||||
<artifactId>jetty-session</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.eclipse.jetty</groupId>
|
||||
<artifactId>jetty-slf4j-impl</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.eclipse.jetty.toolchain</groupId>
|
||||
<artifactId>jetty-test-helper</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>org.apache.felix</groupId>
|
||||
<artifactId>maven-bundle-plugin</artifactId>
|
||||
<extensions>true</extensions>
|
||||
<executions>
|
||||
<execution>
|
||||
<goals>
|
||||
<goal>manifest</goal>
|
||||
</goals>
|
||||
<configuration>
|
||||
<instructions>
|
||||
<Require-Capability>osgi.extender; filter:="(osgi.extender=osgi.serviceloader.registrar)"</Require-Capability>
|
||||
<Provide-Capability>osgi.serviceloader;osgi.serviceloader=org.eclipse.jetty.security.Authenticator$Factory</Provide-Capability>
|
||||
</instructions>
|
||||
</configuration>
|
||||
</execution>
|
||||
</executions>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
</project>
|
|
@ -0,0 +1,20 @@
|
|||
//
|
||||
// ========================================================================
|
||||
// Copyright (c) 1995 Mort Bay Consulting Pty Ltd and others.
|
||||
//
|
||||
// This program and the accompanying materials are made available under the
|
||||
// terms of the Eclipse Public License v. 2.0 which is available at
|
||||
// https://www.eclipse.org/legal/epl-2.0, or the Apache License, Version 2.0
|
||||
// which is available at https://www.apache.org/licenses/LICENSE-2.0.
|
||||
//
|
||||
// SPDX-License-Identifier: EPL-2.0 OR Apache-2.0
|
||||
// ========================================================================
|
||||
//
|
||||
|
||||
module org.eclipse.jetty.siwe
|
||||
{
|
||||
requires transitive org.eclipse.jetty.security;
|
||||
requires org.bouncycastle.provider;
|
||||
|
||||
exports org.eclipse.jetty.security.siwe;
|
||||
}
|
|
@ -0,0 +1,790 @@
|
|||
//
|
||||
// ========================================================================
|
||||
// Copyright (c) 1995 Mort Bay Consulting Pty Ltd and others.
|
||||
//
|
||||
// This program and the accompanying materials are made available under the
|
||||
// terms of the Eclipse Public License v. 2.0 which is available at
|
||||
// https://www.eclipse.org/legal/epl-2.0, or the Apache License, Version 2.0
|
||||
// which is available at https://www.apache.org/licenses/LICENSE-2.0.
|
||||
//
|
||||
// SPDX-License-Identifier: EPL-2.0 OR Apache-2.0
|
||||
// ========================================================================
|
||||
//
|
||||
|
||||
package org.eclipse.jetty.security.siwe;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Iterator;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.ExecutionException;
|
||||
import java.util.function.Function;
|
||||
|
||||
import org.eclipse.jetty.http.BadMessageException;
|
||||
import org.eclipse.jetty.http.HttpHeader;
|
||||
import org.eclipse.jetty.http.HttpMethod;
|
||||
import org.eclipse.jetty.http.HttpStatus;
|
||||
import org.eclipse.jetty.http.HttpURI;
|
||||
import org.eclipse.jetty.http.HttpVersion;
|
||||
import org.eclipse.jetty.http.MimeTypes;
|
||||
import org.eclipse.jetty.http.MultiPartConfig;
|
||||
import org.eclipse.jetty.http.MultiPartFormData;
|
||||
import org.eclipse.jetty.io.Content;
|
||||
import org.eclipse.jetty.io.content.ByteBufferContentSource;
|
||||
import org.eclipse.jetty.security.AuthenticationState;
|
||||
import org.eclipse.jetty.security.Authenticator;
|
||||
import org.eclipse.jetty.security.Constraint;
|
||||
import org.eclipse.jetty.security.LoginService;
|
||||
import org.eclipse.jetty.security.ServerAuthException;
|
||||
import org.eclipse.jetty.security.UserIdentity;
|
||||
import org.eclipse.jetty.security.authentication.LoginAuthenticator;
|
||||
import org.eclipse.jetty.security.authentication.SessionAuthentication;
|
||||
import org.eclipse.jetty.security.siwe.internal.AnyUserLoginService;
|
||||
import org.eclipse.jetty.security.siwe.internal.EthereumUtil;
|
||||
import org.eclipse.jetty.security.siwe.internal.SignInWithEthereumParser;
|
||||
import org.eclipse.jetty.security.siwe.internal.SignInWithEthereumToken;
|
||||
import org.eclipse.jetty.server.FormFields;
|
||||
import org.eclipse.jetty.server.Request;
|
||||
import org.eclipse.jetty.server.Response;
|
||||
import org.eclipse.jetty.server.Session;
|
||||
import org.eclipse.jetty.util.Blocker;
|
||||
import org.eclipse.jetty.util.BufferUtil;
|
||||
import org.eclipse.jetty.util.Callback;
|
||||
import org.eclipse.jetty.util.CharsetStringBuilder.Iso88591StringBuilder;
|
||||
import org.eclipse.jetty.util.Fields;
|
||||
import org.eclipse.jetty.util.IncludeExcludeSet;
|
||||
import org.eclipse.jetty.util.URIUtil;
|
||||
import org.eclipse.jetty.util.UrlEncoded;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import static org.eclipse.jetty.server.FormFields.getFormEncodedCharset;
|
||||
|
||||
public class EthereumAuthenticator extends LoginAuthenticator
|
||||
{
|
||||
private static final Logger LOG = LoggerFactory.getLogger(EthereumAuthenticator.class);
|
||||
|
||||
public static final String LOGIN_PATH_PARAM = "org.eclipse.jetty.security.siwe.login_path";
|
||||
public static final String AUTHENTICATION_PATH_PARAM = "org.eclipse.jetty.security.siwe.authentication_path";
|
||||
public static final String NONCE_PATH_PARAM = "org.eclipse.jetty.security.siwe.nonce_path";
|
||||
public static final String MAX_MESSAGE_SIZE_PARAM = "org.eclipse.jetty.security.siwe.max_message_size";
|
||||
public static final String LOGOUT_REDIRECT_PARAM = "org.eclipse.jetty.security.siwe.logout_redirect_path";
|
||||
public static final String DISPATCH_PARAM = "org.eclipse.jetty.security.siwe.dispatch";
|
||||
public static final String ERROR_PAGE = "org.eclipse.jetty.security.siwe.error_page";
|
||||
public static final String J_URI = "org.eclipse.jetty.security.siwe.URI";
|
||||
public static final String J_POST = "org.eclipse.jetty.security.siwe.POST";
|
||||
public static final String J_METHOD = "org.eclipse.jetty.security.siwe.METHOD";
|
||||
public static final String ERROR_PARAMETER = "error_description_jetty";
|
||||
private static final String DEFAULT_AUTHENTICATION_PATH = "/auth/login";
|
||||
private static final String DEFAULT_NONCE_PATH = "/auth/nonce";
|
||||
private static final String NONCE_SET_ATTR = "org.eclipse.jetty.security.siwe.nonce";
|
||||
|
||||
private final IncludeExcludeSet<String, String> _chainIds = new IncludeExcludeSet<>();
|
||||
private final IncludeExcludeSet<String, String> _schemes = new IncludeExcludeSet<>();
|
||||
private final IncludeExcludeSet<String, String> _domains = new IncludeExcludeSet<>();
|
||||
|
||||
private String _loginPath;
|
||||
private String _authenticationPath = DEFAULT_AUTHENTICATION_PATH;
|
||||
private String _noncePath = DEFAULT_NONCE_PATH;
|
||||
private int _maxMessageSize = 4 * 1024;
|
||||
private String _logoutRedirectPath;
|
||||
private String _errorPage;
|
||||
private String _errorPath;
|
||||
private String _errorQuery;
|
||||
private boolean _dispatch;
|
||||
private boolean authenticateNewUsers = true;
|
||||
|
||||
public EthereumAuthenticator()
|
||||
{
|
||||
LOG.warn("Sign-In With Ethereum support is experimental and not suited for production use.");
|
||||
}
|
||||
|
||||
public void includeDomains(String... domains)
|
||||
{
|
||||
_domains.include(domains);
|
||||
}
|
||||
|
||||
public void includeSchemes(String... schemes)
|
||||
{
|
||||
_schemes.include(schemes);
|
||||
}
|
||||
|
||||
public void includeChainIds(String... chainIds)
|
||||
{
|
||||
_chainIds.include(chainIds);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setConfiguration(Authenticator.Configuration authConfig)
|
||||
{
|
||||
String loginPath = authConfig.getParameter(LOGIN_PATH_PARAM);
|
||||
if (loginPath != null)
|
||||
setLoginPath(loginPath);
|
||||
|
||||
String authenticationPath = authConfig.getParameter(AUTHENTICATION_PATH_PARAM);
|
||||
if (authenticationPath != null)
|
||||
setAuthenticationPath(authenticationPath);
|
||||
|
||||
String noncePath = authConfig.getParameter(NONCE_PATH_PARAM);
|
||||
if (noncePath != null)
|
||||
setNoncePath(noncePath);
|
||||
|
||||
String maxMessageSize = authConfig.getParameter(MAX_MESSAGE_SIZE_PARAM);
|
||||
if (maxMessageSize != null)
|
||||
setMaxMessageSize(Integer.parseInt(maxMessageSize));
|
||||
|
||||
String logout = authConfig.getParameter(LOGOUT_REDIRECT_PARAM);
|
||||
if (logout != null)
|
||||
setLogoutRedirectPath(logout);
|
||||
|
||||
String error = authConfig.getParameter(ERROR_PAGE);
|
||||
if (error != null)
|
||||
setErrorPage(error);
|
||||
|
||||
String dispatch = authConfig.getParameter(DISPATCH_PARAM);
|
||||
if (dispatch != null)
|
||||
setDispatch(Boolean.parseBoolean(dispatch));
|
||||
|
||||
if (authenticateNewUsers)
|
||||
{
|
||||
LoginService loginService = new AnyUserLoginService(authConfig.getRealmName(), authConfig.getLoginService());
|
||||
authConfig = new Configuration.Wrapper(authConfig)
|
||||
{
|
||||
@Override
|
||||
public LoginService getLoginService()
|
||||
{
|
||||
return loginService;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
super.setConfiguration(authConfig);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getAuthenticationType()
|
||||
{
|
||||
return Authenticator.SIWE_AUTH;
|
||||
}
|
||||
|
||||
public boolean isAuthenticateNewUsers()
|
||||
{
|
||||
return authenticateNewUsers;
|
||||
}
|
||||
|
||||
/**
|
||||
* This setting is only meaningful if a non-null {@link LoginService} has been set.
|
||||
* <p>
|
||||
* If set to true, any users not found by the {@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;
|
||||
}
|
||||
|
||||
public void setLoginPath(String loginPath)
|
||||
{
|
||||
if (loginPath == null)
|
||||
{
|
||||
LOG.warn("login path must not be null, defaulting to {}", _loginPath);
|
||||
loginPath = _loginPath;
|
||||
}
|
||||
else if (!loginPath.startsWith("/"))
|
||||
{
|
||||
LOG.warn("login path must start with /");
|
||||
loginPath = "/" + loginPath;
|
||||
}
|
||||
|
||||
_loginPath = loginPath;
|
||||
}
|
||||
|
||||
public void setAuthenticationPath(String authenticationPath)
|
||||
{
|
||||
if (authenticationPath == null)
|
||||
{
|
||||
authenticationPath = _authenticationPath;
|
||||
LOG.warn("authentication path must not be null, defaulting to {}", authenticationPath);
|
||||
}
|
||||
else if (!authenticationPath.startsWith("/"))
|
||||
{
|
||||
authenticationPath = "/" + authenticationPath;
|
||||
LOG.warn("authentication path must start with /");
|
||||
}
|
||||
|
||||
_authenticationPath = authenticationPath;
|
||||
}
|
||||
|
||||
public void setNoncePath(String noncePath)
|
||||
{
|
||||
if (noncePath == null)
|
||||
{
|
||||
noncePath = _noncePath;
|
||||
LOG.warn("nonce path must not be null, defaulting to {}", noncePath);
|
||||
}
|
||||
else if (!noncePath.startsWith("/"))
|
||||
{
|
||||
noncePath = "/" + noncePath;
|
||||
LOG.warn("nonce path must start with /");
|
||||
}
|
||||
|
||||
_noncePath = noncePath;
|
||||
}
|
||||
|
||||
public void setMaxMessageSize(int maxMessageSize)
|
||||
{
|
||||
_maxMessageSize = maxMessageSize;
|
||||
}
|
||||
|
||||
public void setDispatch(boolean dispatch)
|
||||
{
|
||||
_dispatch = dispatch;
|
||||
}
|
||||
|
||||
public void setLogoutRedirectPath(String logoutRedirectPath)
|
||||
{
|
||||
if (logoutRedirectPath != null && !logoutRedirectPath.startsWith("/"))
|
||||
{
|
||||
LOG.warn("logout redirect path must start with /");
|
||||
logoutRedirectPath = "/" + logoutRedirectPath;
|
||||
}
|
||||
|
||||
_logoutRedirectPath = logoutRedirectPath;
|
||||
}
|
||||
|
||||
public void setErrorPage(String path)
|
||||
{
|
||||
if (path == null || path.trim().isEmpty())
|
||||
{
|
||||
_errorPath = null;
|
||||
_errorPage = null;
|
||||
}
|
||||
else
|
||||
{
|
||||
if (!path.startsWith("/"))
|
||||
{
|
||||
LOG.warn("error-page must start with /");
|
||||
path = "/" + path;
|
||||
}
|
||||
_errorPage = path;
|
||||
_errorPath = path;
|
||||
_errorQuery = "";
|
||||
|
||||
int queryIndex = _errorPath.indexOf('?');
|
||||
if (queryIndex > 0)
|
||||
{
|
||||
_errorPath = _errorPage.substring(0, queryIndex);
|
||||
_errorQuery = _errorPage.substring(queryIndex + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public UserIdentity login(String username, Object credentials, Request request, Response response)
|
||||
{
|
||||
if (LOG.isDebugEnabled())
|
||||
LOG.debug("login {} {} {}", username, credentials, request);
|
||||
|
||||
UserIdentity user = super.login(username, credentials, request, response);
|
||||
if (user != null)
|
||||
{
|
||||
Session session = request.getSession(true);
|
||||
AuthenticationState cached = new SessionAuthentication(getAuthenticationType(), user, credentials);
|
||||
synchronized (session)
|
||||
{
|
||||
session.setAttribute(SessionAuthentication.AUTHENTICATED_ATTRIBUTE, cached);
|
||||
}
|
||||
}
|
||||
return user;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void logout(Request request, Response response)
|
||||
{
|
||||
attemptLogoutRedirect(request, response);
|
||||
logoutWithoutRedirect(request, response);
|
||||
}
|
||||
|
||||
private void logoutWithoutRedirect(Request request, Response response)
|
||||
{
|
||||
super.logout(request, response);
|
||||
Session session = request.getSession(false);
|
||||
if (session == null)
|
||||
return;
|
||||
synchronized (session)
|
||||
{
|
||||
session.removeAttribute(SessionAuthentication.AUTHENTICATED_ATTRIBUTE);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* <p>This will attempt to redirect the request to the {@link #_logoutRedirectPath}.</p>
|
||||
*
|
||||
* @param request the request to redirect.
|
||||
*/
|
||||
private void attemptLogoutRedirect(Request request, Response response)
|
||||
{
|
||||
try
|
||||
{
|
||||
String redirectUri = null;
|
||||
if (_logoutRedirectPath != null)
|
||||
{
|
||||
HttpURI.Mutable httpURI = HttpURI.build()
|
||||
.scheme(request.getHttpURI().getScheme())
|
||||
.host(Request.getServerName(request))
|
||||
.port(Request.getServerPort(request))
|
||||
.path(URIUtil.compactPath(Request.getContextPath(request) + _logoutRedirectPath));
|
||||
redirectUri = httpURI.toString();
|
||||
}
|
||||
|
||||
Session session = request.getSession(false);
|
||||
if (session == null)
|
||||
{
|
||||
if (redirectUri != null)
|
||||
sendRedirect(request, response, redirectUri);
|
||||
}
|
||||
}
|
||||
catch (Throwable t)
|
||||
{
|
||||
LOG.warn("failed to redirect to end_session_endpoint", t);
|
||||
}
|
||||
}
|
||||
|
||||
private void sendRedirect(Request request, Response response, String location) throws IOException
|
||||
{
|
||||
try (Blocker.Callback callback = Blocker.callback())
|
||||
{
|
||||
Response.sendRedirect(request, response, callback, location);
|
||||
callback.block();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public Request prepareRequest(Request request, AuthenticationState authenticationState)
|
||||
{
|
||||
// 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).
|
||||
if (authenticationState instanceof AuthenticationState.Succeeded)
|
||||
{
|
||||
Session session = request.getSession(false);
|
||||
if (session == null)
|
||||
return request; //not authenticated yet
|
||||
|
||||
// Remove the nonce set used for authentication.
|
||||
session.removeAttribute(NONCE_SET_ATTR);
|
||||
|
||||
HttpURI juri = (HttpURI)session.getAttribute(J_URI);
|
||||
HttpURI uri = request.getHttpURI();
|
||||
if ((uri.equals(juri)))
|
||||
{
|
||||
session.removeAttribute(J_URI);
|
||||
|
||||
Fields fields = (Fields)session.removeAttribute(J_POST);
|
||||
if (fields != null)
|
||||
request.setAttribute(FormFields.class.getName(), fields);
|
||||
|
||||
String method = (String)session.removeAttribute(J_METHOD);
|
||||
if (method != null && request.getMethod().equals(method))
|
||||
{
|
||||
return new Request.Wrapper(request)
|
||||
{
|
||||
@Override
|
||||
public String getMethod()
|
||||
{
|
||||
return method;
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return request;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Constraint.Authorization getConstraintAuthentication(String pathInContext, Constraint.Authorization existing, Function<Boolean, Session> getSession)
|
||||
{
|
||||
if (isAuthenticationRequest(pathInContext))
|
||||
return Constraint.Authorization.ANY_USER;
|
||||
if (isLoginPage(pathInContext) || isErrorPage(pathInContext))
|
||||
return Constraint.Authorization.ALLOWED;
|
||||
return existing;
|
||||
}
|
||||
|
||||
protected String readMessage(InputStream in) throws IOException
|
||||
{
|
||||
Iso88591StringBuilder out = new Iso88591StringBuilder();
|
||||
byte[] buffer = new byte[1024];
|
||||
int totalRead = 0;
|
||||
|
||||
while (true)
|
||||
{
|
||||
int len = in.read(buffer, 0, buffer.length);
|
||||
if (len < 0)
|
||||
break;
|
||||
|
||||
totalRead += len;
|
||||
if (totalRead > _maxMessageSize)
|
||||
throw new BadMessageException("SIWE Message Too Large");
|
||||
out.append(buffer, 0, len);
|
||||
}
|
||||
|
||||
return out.build();
|
||||
}
|
||||
|
||||
protected SignedMessage parseMessage(Request request, Response response, Callback callback)
|
||||
{
|
||||
try
|
||||
{
|
||||
InputStream inputStream = Content.Source.asInputStream(request);
|
||||
String requestContent = readMessage(inputStream);
|
||||
ByteBufferContentSource contentSource = new ByteBufferContentSource(BufferUtil.toBuffer(requestContent));
|
||||
|
||||
String contentType = request.getHeaders().get(HttpHeader.CONTENT_TYPE);
|
||||
MimeTypes.Type mimeType = MimeTypes.getBaseType(contentType);
|
||||
if (mimeType == null)
|
||||
throw new ServerAuthException("Unsupported content type: " + contentType);
|
||||
|
||||
String signature;
|
||||
String message;
|
||||
switch (mimeType)
|
||||
{
|
||||
case FORM_ENCODED ->
|
||||
{
|
||||
Fields fields = FormFields.from(contentSource, request, getFormEncodedCharset(request), 10, _maxMessageSize).get();
|
||||
signature = fields.get("signature").getValue();
|
||||
message = fields.get("message").getValue();
|
||||
}
|
||||
case MULTIPART_FORM_DATA ->
|
||||
{
|
||||
MultiPartConfig config = Request.getMultiPartConfig(request, null)
|
||||
.maxSize(_maxMessageSize)
|
||||
.maxParts(10)
|
||||
.build();
|
||||
|
||||
MultiPartFormData.Parts parts = MultiPartFormData.from(contentSource, request, contentType, config).get();
|
||||
signature = parts.getFirst("signature").getContentAsString(StandardCharsets.ISO_8859_1);
|
||||
message = parts.getFirst("message").getContentAsString(StandardCharsets.ISO_8859_1);
|
||||
}
|
||||
default -> throw new ServerAuthException("Unsupported mime type: " + mimeType);
|
||||
};
|
||||
|
||||
// The browser may convert LF to CRLF, EIP4361 specifies to only use LF.
|
||||
message = message.replace("\r\n", "\n");
|
||||
return new SignedMessage(message, signature);
|
||||
}
|
||||
catch (Throwable t)
|
||||
{
|
||||
if (LOG.isDebugEnabled())
|
||||
LOG.debug("error reading SIWE message and signature", t);
|
||||
sendError(request, response, callback, t.getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
protected AuthenticationState handleNonceRequest(Request request, Response response, Callback callback)
|
||||
{
|
||||
String nonce = createNonce(request.getSession(false));
|
||||
ByteBuffer content = BufferUtil.toBuffer("{ \"nonce\": \"" + nonce + "\" }");
|
||||
response.write(true, content, callback);
|
||||
return AuthenticationState.CHALLENGE;
|
||||
}
|
||||
|
||||
private boolean validateSignInWithEthereumToken(SignInWithEthereumToken siwe, SignedMessage signedMessage, Request request, Response response, Callback callback)
|
||||
{
|
||||
Session session = request.getSession(false);
|
||||
if (siwe == null)
|
||||
{
|
||||
sendError(request, response, callback, "failed to parse SIWE message");
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
siwe.validate(signedMessage, nonce -> redeemNonce(session, nonce), _schemes, _domains, _chainIds);
|
||||
}
|
||||
catch (Throwable t)
|
||||
{
|
||||
sendError(request, response, callback, t.getMessage());
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public AuthenticationState validateRequest(Request request, Response response, Callback callback) throws ServerAuthException
|
||||
{
|
||||
if (LOG.isDebugEnabled())
|
||||
LOG.debug("validateRequest({},{})", request, response);
|
||||
|
||||
String uri = request.getHttpURI().toString();
|
||||
if (uri == null)
|
||||
uri = "/";
|
||||
|
||||
try
|
||||
{
|
||||
Session session = request.getSession(false);
|
||||
if (session == null)
|
||||
{
|
||||
session = request.getSession(true);
|
||||
if (session == null)
|
||||
{
|
||||
sendError(request, response, callback, "session could not be created");
|
||||
return AuthenticationState.SEND_FAILURE;
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: verify the sessionID is obtained from a cookie.
|
||||
|
||||
if (isNonceRequest(uri))
|
||||
return handleNonceRequest(request, response, callback);
|
||||
if (isAuthenticationRequest(uri))
|
||||
{
|
||||
if (LOG.isDebugEnabled())
|
||||
LOG.debug("authentication request");
|
||||
|
||||
// Parse and validate SIWE Message.
|
||||
SignedMessage signedMessage = parseMessage(request, response, callback);
|
||||
if (signedMessage == null)
|
||||
return AuthenticationState.SEND_FAILURE;
|
||||
SignInWithEthereumToken siwe = SignInWithEthereumParser.parse(signedMessage.message());
|
||||
if (siwe == null || !validateSignInWithEthereumToken(siwe, signedMessage, request, response, callback))
|
||||
return AuthenticationState.SEND_FAILURE;
|
||||
|
||||
String address = siwe.address();
|
||||
UserIdentity user = login(address, null, request, response);
|
||||
if (LOG.isDebugEnabled())
|
||||
LOG.debug("user identity: {}", user);
|
||||
if (user != null)
|
||||
{
|
||||
// Redirect to original request
|
||||
HttpURI savedURI = (HttpURI)session.getAttribute(J_URI);
|
||||
String originalURI = savedURI != null
|
||||
? savedURI.getPathQuery()
|
||||
: Request.getContextPath(request);
|
||||
if (originalURI == null)
|
||||
originalURI = "/";
|
||||
UserAuthenticationSent formAuth = new UserAuthenticationSent(getAuthenticationType(), user);
|
||||
String redirectUrl = session.encodeURI(request, originalURI, true);
|
||||
Response.sendRedirect(request, response, callback, redirectUrl, true);
|
||||
return formAuth;
|
||||
}
|
||||
|
||||
// not authenticated
|
||||
if (LOG.isDebugEnabled())
|
||||
LOG.debug("auth failed {}=={}", address, _errorPage);
|
||||
sendError(request, response, callback, "auth failed");
|
||||
return AuthenticationState.SEND_FAILURE;
|
||||
}
|
||||
|
||||
// Look for cached authentication in the Session.
|
||||
AuthenticationState authenticationState = (AuthenticationState)session.getAttribute(SessionAuthentication.AUTHENTICATED_ATTRIBUTE);
|
||||
if (authenticationState != null)
|
||||
{
|
||||
// Has authentication been revoked?
|
||||
if (authenticationState instanceof AuthenticationState.Succeeded && _loginService != null &&
|
||||
!_loginService.validate(((AuthenticationState.Succeeded)authenticationState).getUserIdentity()))
|
||||
{
|
||||
if (LOG.isDebugEnabled())
|
||||
LOG.debug("auth revoked {}", authenticationState);
|
||||
logoutWithoutRedirect(request, response);
|
||||
return AuthenticationState.SEND_FAILURE;
|
||||
}
|
||||
|
||||
if (LOG.isDebugEnabled())
|
||||
LOG.debug("auth {}", authenticationState);
|
||||
return authenticationState;
|
||||
}
|
||||
|
||||
// If we can't send challenge.
|
||||
if (AuthenticationState.Deferred.isDeferred(response))
|
||||
{
|
||||
if (LOG.isDebugEnabled())
|
||||
LOG.debug("auth deferred {}", session.getId());
|
||||
return null;
|
||||
}
|
||||
|
||||
// Save the current URI
|
||||
synchronized (session)
|
||||
{
|
||||
// But only if it is not set already, or we save every uri that leads to a login form redirect
|
||||
if (session.getAttribute(J_URI) == null)
|
||||
{
|
||||
HttpURI juri = request.getHttpURI();
|
||||
session.setAttribute(J_URI, juri.asImmutable());
|
||||
if (!HttpMethod.GET.is(request.getMethod()))
|
||||
session.setAttribute(J_METHOD, request.getMethod());
|
||||
if (HttpMethod.POST.is(request.getMethod()))
|
||||
session.setAttribute(J_POST, getParameters(request));
|
||||
}
|
||||
}
|
||||
|
||||
// Send the challenge.
|
||||
String loginPath = URIUtil.addPaths(request.getContext().getContextPath(), _loginPath);
|
||||
if (_dispatch)
|
||||
{
|
||||
HttpURI.Mutable newUri = HttpURI.build(request.getHttpURI()).pathQuery(loginPath);
|
||||
return new AuthenticationState.ServeAs(newUri);
|
||||
}
|
||||
else
|
||||
{
|
||||
String redirectUri = session.encodeURI(request, loginPath, true);
|
||||
Response.sendRedirect(request, response, callback, redirectUri, true);
|
||||
return AuthenticationState.CHALLENGE;
|
||||
}
|
||||
}
|
||||
catch (Throwable t)
|
||||
{
|
||||
throw new ServerAuthException(t);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Report an error case either by redirecting to the error page if it is defined, otherwise sending a 403 response.
|
||||
* If the message parameter is not null, a query parameter with a key of {@link #ERROR_PARAMETER} and value of the error
|
||||
* message will be logged and added to the error redirect URI if the error page is defined.
|
||||
* @param request the request.
|
||||
* @param response the response.
|
||||
* @param callback the callback.
|
||||
* @param message the reason for the error or null.
|
||||
*/
|
||||
private void sendError(Request request, Response response, Callback callback, String message)
|
||||
{
|
||||
if (LOG.isDebugEnabled())
|
||||
LOG.debug("OpenId authentication FAILED: {}", message);
|
||||
|
||||
if (_errorPage == null)
|
||||
{
|
||||
if (LOG.isDebugEnabled())
|
||||
LOG.debug("auth failed 403");
|
||||
if (response != null)
|
||||
Response.writeError(request, response, callback, HttpStatus.FORBIDDEN_403, message);
|
||||
}
|
||||
else
|
||||
{
|
||||
if (LOG.isDebugEnabled())
|
||||
LOG.debug("auth failed {}", _errorPage);
|
||||
|
||||
String contextPath = Request.getContextPath(request);
|
||||
String redirectUri = URIUtil.addPaths(contextPath, _errorPage);
|
||||
if (message != null)
|
||||
{
|
||||
String query = URIUtil.addQueries(ERROR_PARAMETER + "=" + UrlEncoded.encodeString(message), _errorQuery);
|
||||
redirectUri = URIUtil.addPathQuery(URIUtil.addPaths(contextPath, _errorPath), query);
|
||||
}
|
||||
|
||||
int redirectCode = request.getConnectionMetaData().getHttpVersion().getVersion() < HttpVersion.HTTP_1_1.getVersion()
|
||||
? HttpStatus.MOVED_TEMPORARILY_302 : HttpStatus.SEE_OTHER_303;
|
||||
Response.sendRedirect(request, response, callback, redirectCode, redirectUri, true);
|
||||
}
|
||||
}
|
||||
|
||||
protected Fields getParameters(Request request)
|
||||
{
|
||||
try
|
||||
{
|
||||
Fields queryFields = Request.extractQueryParameters(request);
|
||||
Fields formFields = FormFields.from(request).get();
|
||||
return Fields.combine(queryFields, formFields);
|
||||
}
|
||||
catch (InterruptedException | ExecutionException e)
|
||||
{
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
}
|
||||
|
||||
public boolean isLoginPage(String uri)
|
||||
{
|
||||
return matchURI(uri, _loginPath);
|
||||
}
|
||||
|
||||
public boolean isAuthenticationRequest(String uri)
|
||||
{
|
||||
return matchURI(uri, _authenticationPath);
|
||||
}
|
||||
|
||||
public boolean isNonceRequest(String uri)
|
||||
{
|
||||
return matchURI(uri, _noncePath);
|
||||
}
|
||||
|
||||
private boolean matchURI(String uri, String path)
|
||||
{
|
||||
int jsc = uri.indexOf(path);
|
||||
if (jsc < 0)
|
||||
return false;
|
||||
int e = jsc + path.length();
|
||||
if (e == uri.length())
|
||||
return true;
|
||||
char c = uri.charAt(e);
|
||||
return c == ';' || c == '#' || c == '/' || c == '?';
|
||||
}
|
||||
|
||||
public boolean isErrorPage(String pathInContext)
|
||||
{
|
||||
return pathInContext != null && (pathInContext.equals(_errorPath));
|
||||
}
|
||||
|
||||
protected String createNonce(Session session)
|
||||
{
|
||||
String nonce = EthereumUtil.createNonce();
|
||||
synchronized (session)
|
||||
{
|
||||
@SuppressWarnings("unchecked")
|
||||
Set<String> attribute = (Set<String>)session.getAttribute(NONCE_SET_ATTR);
|
||||
if (attribute == null)
|
||||
session.setAttribute(NONCE_SET_ATTR, attribute = new FixedSizeSet<>(5));
|
||||
if (!attribute.add(nonce))
|
||||
throw new IllegalStateException("Nonce already in use");
|
||||
}
|
||||
return nonce;
|
||||
}
|
||||
|
||||
protected boolean redeemNonce(Session session, String nonce)
|
||||
{
|
||||
synchronized (session)
|
||||
{
|
||||
@SuppressWarnings("unchecked")
|
||||
Set<String> attribute = (Set<String>)session.getAttribute(NONCE_SET_ATTR);
|
||||
if (attribute == null)
|
||||
return false;
|
||||
return attribute.remove(nonce);
|
||||
}
|
||||
}
|
||||
|
||||
public static class FixedSizeSet<T> extends LinkedHashSet<T>
|
||||
{
|
||||
private final int maxSize;
|
||||
|
||||
public FixedSizeSet(int maxSize)
|
||||
{
|
||||
super(maxSize);
|
||||
this.maxSize = maxSize;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean add(T element)
|
||||
{
|
||||
if (size() >= maxSize)
|
||||
{
|
||||
Iterator<T> it = iterator();
|
||||
if (it.hasNext())
|
||||
{
|
||||
it.next();
|
||||
it.remove();
|
||||
}
|
||||
}
|
||||
return super.add(element);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,24 @@
|
|||
//
|
||||
// ========================================================================
|
||||
// Copyright (c) 1995 Mort Bay Consulting Pty Ltd and others.
|
||||
//
|
||||
// This program and the accompanying materials are made available under the
|
||||
// terms of the Eclipse Public License v. 2.0 which is available at
|
||||
// https://www.eclipse.org/legal/epl-2.0, or the Apache License, Version 2.0
|
||||
// which is available at https://www.apache.org/licenses/LICENSE-2.0.
|
||||
//
|
||||
// SPDX-License-Identifier: EPL-2.0 OR Apache-2.0
|
||||
// ========================================================================
|
||||
//
|
||||
|
||||
package org.eclipse.jetty.security.siwe;
|
||||
|
||||
import org.eclipse.jetty.security.siwe.internal.EthereumSignatureVerifier;
|
||||
|
||||
public record SignedMessage(String message, String signature)
|
||||
{
|
||||
public String recoverAddress()
|
||||
{
|
||||
return EthereumSignatureVerifier.recoverAddress(message, signature);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,109 @@
|
|||
//
|
||||
// ========================================================================
|
||||
// Copyright (c) 1995 Mort Bay Consulting Pty Ltd and others.
|
||||
//
|
||||
// This program and the accompanying materials are made available under the
|
||||
// terms of the Eclipse Public License v. 2.0 which is available at
|
||||
// https://www.eclipse.org/legal/epl-2.0, or the Apache License, Version 2.0
|
||||
// which is available at https://www.apache.org/licenses/LICENSE-2.0.
|
||||
//
|
||||
// SPDX-License-Identifier: EPL-2.0 OR Apache-2.0
|
||||
// ========================================================================
|
||||
//
|
||||
|
||||
package org.eclipse.jetty.security.siwe.internal;
|
||||
|
||||
import java.util.function.Function;
|
||||
import javax.security.auth.Subject;
|
||||
|
||||
import org.eclipse.jetty.security.DefaultIdentityService;
|
||||
import org.eclipse.jetty.security.IdentityService;
|
||||
import org.eclipse.jetty.security.LoginService;
|
||||
import org.eclipse.jetty.security.UserIdentity;
|
||||
import org.eclipse.jetty.security.UserPrincipal;
|
||||
import org.eclipse.jetty.server.Request;
|
||||
import org.eclipse.jetty.server.Session;
|
||||
|
||||
public class AnyUserLoginService implements LoginService
|
||||
{
|
||||
private final String _realm;
|
||||
private final LoginService _loginService;
|
||||
private IdentityService _identityService;
|
||||
private boolean _authenticateNewUsers;
|
||||
|
||||
public AnyUserLoginService(String realm)
|
||||
{
|
||||
this(realm, null);
|
||||
}
|
||||
|
||||
public AnyUserLoginService(String realm, LoginService loginService)
|
||||
{
|
||||
_realm = realm;
|
||||
_loginService = loginService;
|
||||
_identityService = (loginService == null) ? new DefaultIdentityService() : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 String getName()
|
||||
{
|
||||
return _realm;
|
||||
}
|
||||
|
||||
@Override
|
||||
public UserIdentity login(String username, Object credentials, Request request, Function<Boolean, Session> getOrCreateSession)
|
||||
{
|
||||
UserPrincipal userPrincipal = new UserPrincipal(username, null);
|
||||
Subject subject = new Subject();
|
||||
subject.getPrincipals().add(userPrincipal);
|
||||
subject.getPrivateCredentials().add(credentials);
|
||||
subject.setReadOnly();
|
||||
|
||||
if (_loginService != null)
|
||||
return _loginService.getUserIdentity(subject, userPrincipal, _authenticateNewUsers);
|
||||
return _identityService.newUserIdentity(subject, userPrincipal, new String[0]);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean validate(UserIdentity user)
|
||||
{
|
||||
if (_loginService == null)
|
||||
return user != null;
|
||||
return _loginService.validate(user);
|
||||
}
|
||||
|
||||
@Override
|
||||
public IdentityService getIdentityService()
|
||||
{
|
||||
return _loginService == null ? _identityService : _loginService.getIdentityService();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setIdentityService(IdentityService service)
|
||||
{
|
||||
if (_loginService != null)
|
||||
_loginService.setIdentityService(service);
|
||||
else
|
||||
_identityService = service;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void logout(UserIdentity user)
|
||||
{
|
||||
if (_loginService != null)
|
||||
_loginService.logout(user);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,111 @@
|
|||
//
|
||||
// ========================================================================
|
||||
// Copyright (c) 1995 Mort Bay Consulting Pty Ltd and others.
|
||||
//
|
||||
// This program and the accompanying materials are made available under the
|
||||
// terms of the Eclipse Public License v. 2.0 which is available at
|
||||
// https://www.eclipse.org/legal/epl-2.0, or the Apache License, Version 2.0
|
||||
// which is available at https://www.apache.org/licenses/LICENSE-2.0.
|
||||
//
|
||||
// SPDX-License-Identifier: EPL-2.0 OR Apache-2.0
|
||||
// ========================================================================
|
||||
//
|
||||
|
||||
package org.eclipse.jetty.security.siwe.internal;
|
||||
|
||||
import java.math.BigInteger;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Arrays;
|
||||
|
||||
import org.bouncycastle.asn1.x9.X9ECParameters;
|
||||
import org.bouncycastle.asn1.x9.X9IntegerConverter;
|
||||
import org.bouncycastle.crypto.ec.CustomNamedCurves;
|
||||
import org.bouncycastle.crypto.params.ECDomainParameters;
|
||||
import org.bouncycastle.jcajce.provider.digest.Keccak;
|
||||
import org.bouncycastle.math.ec.ECAlgorithms;
|
||||
import org.bouncycastle.math.ec.ECPoint;
|
||||
import org.eclipse.jetty.util.StringUtil;
|
||||
|
||||
public class EthereumSignatureVerifier
|
||||
{
|
||||
public static final String PREFIX = "\u0019Ethereum Signed Message:\n";
|
||||
|
||||
private static final int ADDRESS_LENGTH_BYTES = 20;
|
||||
private static final X9ECParameters SEC_P256K1_PARAMS = CustomNamedCurves.getByName("secp256k1");
|
||||
private static final ECDomainParameters DOMAIN_PARAMS = new ECDomainParameters(
|
||||
SEC_P256K1_PARAMS.getCurve(), SEC_P256K1_PARAMS.getG(), SEC_P256K1_PARAMS.getN(), SEC_P256K1_PARAMS.getH());
|
||||
private static final BigInteger PRIME = SEC_P256K1_PARAMS.getCurve().getField().getCharacteristic();
|
||||
private static final X9IntegerConverter INT_CONVERTER = new X9IntegerConverter();
|
||||
|
||||
private EthereumSignatureVerifier()
|
||||
{
|
||||
}
|
||||
|
||||
public static String recoverAddress(String siweMessage, String signatureHex)
|
||||
{
|
||||
if (StringUtil.asciiStartsWithIgnoreCase(signatureHex, "0x"))
|
||||
signatureHex = signatureHex.substring(2);
|
||||
|
||||
byte[] bytes = siweMessage.getBytes(StandardCharsets.ISO_8859_1);
|
||||
int messageLength = bytes.length;
|
||||
String signedMessage = PREFIX + messageLength + siweMessage;
|
||||
byte[] messageHash = keccak256(signedMessage.getBytes(StandardCharsets.ISO_8859_1));
|
||||
byte[] signatureBytes = StringUtil.fromHexString(signatureHex);
|
||||
|
||||
BigInteger r = new BigInteger(1, Arrays.copyOfRange(signatureBytes, 0, 32));
|
||||
BigInteger s = new BigInteger(1, Arrays.copyOfRange(signatureBytes, 32, 64));
|
||||
byte v = (byte)(signatureBytes[64] < 27 ? signatureBytes[64] : signatureBytes[64] - 27);
|
||||
return ecRecover(messageHash, v, r, s);
|
||||
}
|
||||
|
||||
private static String ecRecover(byte[] hash, int v, BigInteger r, BigInteger s)
|
||||
{
|
||||
if (v < 0 || v >= 4)
|
||||
throw new IllegalArgumentException("Invalid v value: " + v);
|
||||
|
||||
// Verify that r and s are integers in [1, n-1]. If not, the signature is invalid.
|
||||
BigInteger n = DOMAIN_PARAMS.getN();
|
||||
if (r.compareTo(BigInteger.ONE) < 0 || r.compareTo(n.subtract(BigInteger.ONE)) > 0)
|
||||
return null;
|
||||
if (s.compareTo(BigInteger.ONE) < 0 || s.compareTo(n.subtract(BigInteger.ONE)) > 0)
|
||||
return null;
|
||||
|
||||
// Calculate the curve point R.
|
||||
BigInteger x = r.add(BigInteger.valueOf(v/2).multiply(n));
|
||||
if (x.compareTo(PRIME) >= 0)
|
||||
return null;
|
||||
byte[] compressedPoint = INT_CONVERTER.integerToBytes(x, 1 + INT_CONVERTER.getByteLength(DOMAIN_PARAMS.getCurve()));
|
||||
compressedPoint[0] = (byte)((v % 2) == 0 ? 0x02 : 0x03);
|
||||
ECPoint R = DOMAIN_PARAMS.getCurve().decodePoint(compressedPoint);
|
||||
if (!R.multiply(n).isInfinity())
|
||||
return null;
|
||||
|
||||
// Calculate the curve point Q = u1 * G + u2 * R, where u1=-zr^(-1)%n and u2=sr^(-1)%n.
|
||||
// Note: for secp256k1 z=e as the hash is 256 bits and z is defined as the Ln leftmost bits of e.
|
||||
BigInteger e = new BigInteger(1, hash);
|
||||
BigInteger rInv = r.modInverse(n);
|
||||
BigInteger u1 = e.negate().multiply(rInv).mod(n);
|
||||
BigInteger u2 = s.multiply(rInv).mod(n);
|
||||
ECPoint Q = ECAlgorithms.sumOfTwoMultiplies(DOMAIN_PARAMS.getG(), u1, R, u2);
|
||||
|
||||
// Remove the 1-byte prefix and return the public key as an ethereum address.
|
||||
byte[] qBytes = Q.getEncoded(false);
|
||||
byte[] qHash = keccak256(qBytes, 1, qBytes.length - 1);
|
||||
byte[] address = new byte[ADDRESS_LENGTH_BYTES];
|
||||
System.arraycopy(qHash, qHash.length - ADDRESS_LENGTH_BYTES, address, 0, ADDRESS_LENGTH_BYTES);
|
||||
return "0x" + StringUtil.toHexString(address);
|
||||
}
|
||||
|
||||
public static byte[] keccak256(byte[] bytes)
|
||||
{
|
||||
Keccak.Digest256 digest256 = new Keccak.Digest256();
|
||||
return digest256.digest(bytes);
|
||||
}
|
||||
|
||||
public static byte[] keccak256(byte[] buf, int offset, int len)
|
||||
{
|
||||
Keccak.Digest256 digest256 = new Keccak.Digest256();
|
||||
digest256.update(buf, offset, len);
|
||||
return digest256.digest();
|
||||
}
|
||||
}
|
|
@ -0,0 +1,38 @@
|
|||
//
|
||||
// ========================================================================
|
||||
// Copyright (c) 1995 Mort Bay Consulting Pty Ltd and others.
|
||||
//
|
||||
// This program and the accompanying materials are made available under the
|
||||
// terms of the Eclipse Public License v. 2.0 which is available at
|
||||
// https://www.eclipse.org/legal/epl-2.0, or the Apache License, Version 2.0
|
||||
// which is available at https://www.apache.org/licenses/LICENSE-2.0.
|
||||
//
|
||||
// SPDX-License-Identifier: EPL-2.0 OR Apache-2.0
|
||||
// ========================================================================
|
||||
//
|
||||
|
||||
package org.eclipse.jetty.security.siwe.internal;
|
||||
|
||||
import java.security.SecureRandom;
|
||||
|
||||
public class EthereumUtil
|
||||
{
|
||||
private static final String NONCE_CHARACTERS = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
|
||||
private static final SecureRandom RANDOM = new SecureRandom();
|
||||
|
||||
private EthereumUtil()
|
||||
{
|
||||
}
|
||||
|
||||
public static String createNonce()
|
||||
{
|
||||
StringBuilder builder = new StringBuilder(8);
|
||||
for (int i = 0; i < 8; i++)
|
||||
{
|
||||
int character = RANDOM.nextInt(NONCE_CHARACTERS.length());
|
||||
builder.append(NONCE_CHARACTERS.charAt(character));
|
||||
}
|
||||
|
||||
return builder.toString();
|
||||
}
|
||||
}
|
|
@ -0,0 +1,65 @@
|
|||
//
|
||||
// ========================================================================
|
||||
// Copyright (c) 1995 Mort Bay Consulting Pty Ltd and others.
|
||||
//
|
||||
// This program and the accompanying materials are made available under the
|
||||
// terms of the Eclipse Public License v. 2.0 which is available at
|
||||
// https://www.eclipse.org/legal/epl-2.0, or the Apache License, Version 2.0
|
||||
// which is available at https://www.apache.org/licenses/LICENSE-2.0.
|
||||
//
|
||||
// SPDX-License-Identifier: EPL-2.0 OR Apache-2.0
|
||||
// ========================================================================
|
||||
//
|
||||
|
||||
package org.eclipse.jetty.security.siwe.internal;
|
||||
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
public class SignInWithEthereumParser
|
||||
{
|
||||
private static final String SCHEME_PATTERN = "[a-zA-Z][a-zA-Z0-9+\\-.]*";
|
||||
private static final String DOMAIN_PATTERN = "(?:[a-zA-Z0-9\\-._~%]+@)?[a-zA-Z0-9\\-._~%]+(?:\\:[0-9]+)?";
|
||||
private static final String ADDRESS_PATTERN = "0x[0-9a-fA-F]{40}";
|
||||
private static final String STATEMENT_PATTERN = "[^\\n]*";
|
||||
private static final String URI_PATTERN = "[^\\n]+";
|
||||
private static final String VERSION_PATTERN = "[0-9]+";
|
||||
private static final String CHAIN_ID_PATTERN = "[0-9]+";
|
||||
private static final String NONCE_PATTERN = "[a-zA-Z0-9]{8}";
|
||||
private static final String DATE_TIME_PATTERN = "\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}(?:\\.\\d+)?(?:Z|[+-]\\d{2}:\\d{2})?";
|
||||
private static final String REQUEST_ID_PATTERN = "[^\\n]*";
|
||||
private static final String RESOURCE_PATTERN = "- " + URI_PATTERN;
|
||||
private static final String RESOURCES_PATTERN = "(?:\n" + RESOURCE_PATTERN + ")*";
|
||||
private static final Pattern SIGN_IN_WITH_ETHEREUM_PATTERN = Pattern.compile(
|
||||
"^(?:(?<scheme>" + SCHEME_PATTERN + ")://)?(?<domain>" + DOMAIN_PATTERN + ") wants you to sign in with your Ethereum account:\n" +
|
||||
"(?<address>" + ADDRESS_PATTERN + ")\n\n" +
|
||||
"(?<statement>" + STATEMENT_PATTERN + ")?\n\n" +
|
||||
"URI: (?<uri>" + URI_PATTERN + ")\n" +
|
||||
"Version: (?<version>" + VERSION_PATTERN + ")\n" +
|
||||
"Chain ID: (?<chainId>" + CHAIN_ID_PATTERN + ")\n" +
|
||||
"Nonce: (?<nonce>" + NONCE_PATTERN + ")\n" +
|
||||
"Issued At: (?<issuedAt>" + DATE_TIME_PATTERN + ")" +
|
||||
"(?:\nExpiration Time: (?<expirationTime>" + DATE_TIME_PATTERN + "))?" +
|
||||
"(?:\nNot Before: (?<notBefore>" + DATE_TIME_PATTERN + "))?" +
|
||||
"(?:\nRequest ID: (?<requestId>" + REQUEST_ID_PATTERN + "))?" +
|
||||
"(?:\nResources:(?<resources>" + RESOURCES_PATTERN + "))?$",
|
||||
Pattern.DOTALL
|
||||
);
|
||||
|
||||
private SignInWithEthereumParser()
|
||||
{
|
||||
}
|
||||
|
||||
public static SignInWithEthereumToken parse(String message)
|
||||
{
|
||||
Matcher matcher = SIGN_IN_WITH_ETHEREUM_PATTERN.matcher(message);
|
||||
if (!matcher.matches())
|
||||
return null;
|
||||
|
||||
return new SignInWithEthereumToken(matcher.group("scheme"), matcher.group("domain"),
|
||||
matcher.group("address"), matcher.group("statement"), matcher.group("uri"),
|
||||
matcher.group("version"), matcher.group("chainId"), matcher.group("nonce"),
|
||||
matcher.group("issuedAt"), matcher.group("expirationTime"), matcher.group("notBefore"),
|
||||
matcher.group("requestId"), matcher.group("resources"));
|
||||
}
|
||||
}
|
|
@ -0,0 +1,76 @@
|
|||
//
|
||||
// ========================================================================
|
||||
// Copyright (c) 1995 Mort Bay Consulting Pty Ltd and others.
|
||||
//
|
||||
// This program and the accompanying materials are made available under the
|
||||
// terms of the Eclipse Public License v. 2.0 which is available at
|
||||
// https://www.eclipse.org/legal/epl-2.0, or the Apache License, Version 2.0
|
||||
// which is available at https://www.apache.org/licenses/LICENSE-2.0.
|
||||
//
|
||||
// SPDX-License-Identifier: EPL-2.0 OR Apache-2.0
|
||||
// ========================================================================
|
||||
//
|
||||
|
||||
package org.eclipse.jetty.security.siwe.internal;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.function.Predicate;
|
||||
|
||||
import org.eclipse.jetty.security.ServerAuthException;
|
||||
import org.eclipse.jetty.security.siwe.SignedMessage;
|
||||
import org.eclipse.jetty.util.IncludeExcludeSet;
|
||||
import org.eclipse.jetty.util.StringUtil;
|
||||
|
||||
public record SignInWithEthereumToken(String scheme,
|
||||
String domain,
|
||||
String address,
|
||||
String statement,
|
||||
String uri,
|
||||
String version,
|
||||
String chainId,
|
||||
String nonce,
|
||||
String issuedAt,
|
||||
String expirationTime,
|
||||
String notBefore,
|
||||
String requestId,
|
||||
String resources)
|
||||
{
|
||||
|
||||
public void validate(SignedMessage signedMessage, Predicate<String> validateNonce,
|
||||
IncludeExcludeSet<String, String> schemes,
|
||||
IncludeExcludeSet<String, String> domains,
|
||||
IncludeExcludeSet<String, String> chainIds) throws ServerAuthException
|
||||
{
|
||||
if (validateNonce != null && !validateNonce.test(nonce()))
|
||||
throw new ServerAuthException("invalid nonce");
|
||||
|
||||
if (!StringUtil.asciiEqualsIgnoreCase(signedMessage.recoverAddress(), address()))
|
||||
throw new ServerAuthException("signature verification failed");
|
||||
|
||||
if (!"1".equals(version()))
|
||||
throw new ServerAuthException("unsupported version");
|
||||
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
if (StringUtil.isNotBlank(expirationTime()))
|
||||
{
|
||||
LocalDateTime expirationTime = LocalDateTime.parse(expirationTime(), DateTimeFormatter.ISO_DATE_TIME);
|
||||
if (now.isAfter(expirationTime))
|
||||
throw new ServerAuthException("expired SIWE message");
|
||||
}
|
||||
|
||||
if (StringUtil.isNotBlank(notBefore()))
|
||||
{
|
||||
LocalDateTime notBefore = LocalDateTime.parse(notBefore(), DateTimeFormatter.ISO_DATE_TIME);
|
||||
if (now.isBefore(notBefore))
|
||||
throw new ServerAuthException("SIWE message not yet valid");
|
||||
}
|
||||
|
||||
if (schemes != null && !schemes.test(scheme()))
|
||||
throw new ServerAuthException("unregistered scheme");
|
||||
if (domains != null && !domains.test(domain()))
|
||||
throw new ServerAuthException("unregistered domain");
|
||||
if (chainIds != null && !chainIds.test(chainId()))
|
||||
throw new ServerAuthException("unregistered chainId");
|
||||
}
|
||||
}
|
|
@ -0,0 +1,158 @@
|
|||
//
|
||||
// ========================================================================
|
||||
// Copyright (c) 1995 Mort Bay Consulting Pty Ltd and others.
|
||||
//
|
||||
// This program and the accompanying materials are made available under the
|
||||
// terms of the Eclipse Public License v. 2.0 which is available at
|
||||
// https://www.eclipse.org/legal/epl-2.0, or the Apache License, Version 2.0
|
||||
// which is available at https://www.apache.org/licenses/LICENSE-2.0.
|
||||
//
|
||||
// SPDX-License-Identifier: EPL-2.0 OR Apache-2.0
|
||||
// ========================================================================
|
||||
//
|
||||
|
||||
package org.eclipse.jetty.security.siwe;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
import org.eclipse.jetty.security.siwe.internal.EthereumUtil;
|
||||
import org.eclipse.jetty.security.siwe.internal.SignInWithEthereumParser;
|
||||
import org.eclipse.jetty.security.siwe.internal.SignInWithEthereumToken;
|
||||
import org.eclipse.jetty.security.siwe.util.SignInWithEthereumGenerator;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.Arguments;
|
||||
import org.junit.jupiter.params.provider.MethodSource;
|
||||
|
||||
import static org.hamcrest.MatcherAssert.assertThat;
|
||||
import static org.hamcrest.Matchers.equalTo;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
|
||||
public class SignInWithEthereumParserTest
|
||||
{
|
||||
public static Stream<Arguments> specExamples()
|
||||
{
|
||||
List<Arguments> data = new ArrayList<>();
|
||||
|
||||
data.add(Arguments.of("""
|
||||
example.com wants you to sign in with your Ethereum account:
|
||||
0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2
|
||||
|
||||
I accept the ExampleOrg Terms of Service: https://example.com/tos
|
||||
|
||||
URI: https://example.com/login
|
||||
Version: 1
|
||||
Chain ID: 1
|
||||
Nonce: 32891756
|
||||
Issued At: 2021-09-30T16:25:24Z
|
||||
Resources:
|
||||
- ipfs://bafybeiemxf5abjwjbikoz4mc3a3dla6ual3jsgpdr4cjr3oz3evfyavhwq/
|
||||
- https://example.com/my-web2-claim.json""",
|
||||
null, "example.com"
|
||||
));
|
||||
|
||||
|
||||
data.add(Arguments.of("""
|
||||
example.com:3388 wants you to sign in with your Ethereum account:
|
||||
0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2
|
||||
|
||||
I accept the ExampleOrg Terms of Service: https://example.com/tos
|
||||
|
||||
URI: https://example.com/login
|
||||
Version: 1
|
||||
Chain ID: 1
|
||||
Nonce: 32891756
|
||||
Issued At: 2021-09-30T16:25:24Z
|
||||
Resources:
|
||||
- ipfs://bafybeiemxf5abjwjbikoz4mc3a3dla6ual3jsgpdr4cjr3oz3evfyavhwq/
|
||||
- https://example.com/my-web2-claim.json""",
|
||||
null, "example.com:3388"
|
||||
));
|
||||
|
||||
data.add(Arguments.of("""
|
||||
https://example.com wants you to sign in with your Ethereum account:
|
||||
0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2
|
||||
|
||||
I accept the ExampleOrg Terms of Service: https://example.com/tos
|
||||
|
||||
URI: https://example.com/login
|
||||
Version: 1
|
||||
Chain ID: 1
|
||||
Nonce: 32891756
|
||||
Issued At: 2021-09-30T16:25:24Z
|
||||
Resources:
|
||||
- ipfs://bafybeiemxf5abjwjbikoz4mc3a3dla6ual3jsgpdr4cjr3oz3evfyavhwq/
|
||||
- https://example.com/my-web2-claim.json""",
|
||||
"https", "example.com"
|
||||
));
|
||||
|
||||
return data.stream();
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@MethodSource("specExamples")
|
||||
public void testSpecExamples(String message, String scheme, String domain)
|
||||
{
|
||||
SignInWithEthereumToken siwe = SignInWithEthereumParser.parse(message);
|
||||
assertNotNull(siwe);
|
||||
assertThat(siwe.address(), equalTo("0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2"));
|
||||
assertThat(siwe.issuedAt(), equalTo("2021-09-30T16:25:24Z"));
|
||||
assertThat(siwe.uri(), equalTo("https://example.com/login"));
|
||||
assertThat(siwe.version(), equalTo("1"));
|
||||
assertThat(siwe.chainId(), equalTo("1"));
|
||||
assertThat(siwe.nonce(), equalTo("32891756"));
|
||||
assertThat(siwe.statement(), equalTo("I accept the ExampleOrg Terms of Service: https://example.com/tos"));
|
||||
assertThat(siwe.scheme(), equalTo(scheme));
|
||||
assertThat(siwe.domain(), equalTo(domain));
|
||||
|
||||
String resources = """
|
||||
|
||||
- ipfs://bafybeiemxf5abjwjbikoz4mc3a3dla6ual3jsgpdr4cjr3oz3evfyavhwq/
|
||||
- https://example.com/my-web2-claim.json""";
|
||||
assertThat(siwe.resources(), equalTo(resources));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testFullMessage()
|
||||
{
|
||||
String scheme = "http";
|
||||
String domain = "example.com";
|
||||
String address = "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2";
|
||||
String statement = "This is the statement asking you to sign in.";
|
||||
String uri = "https://example.com/login";
|
||||
String version = "1";
|
||||
String chainId = "1";
|
||||
String nonce = EthereumUtil.createNonce();
|
||||
LocalDateTime issuedAt = LocalDateTime.now();
|
||||
LocalDateTime expirationTime = LocalDateTime.now().plusDays(1);
|
||||
LocalDateTime notBefore = LocalDateTime.now().minusDays(1);
|
||||
String requestId = "123456789";
|
||||
String resources = """
|
||||
|
||||
- ipfs://bafybeiemxf5abjwjbikoz4mc3a3dla6ual3jsgpdr4cjr3oz3evfyavhwq/
|
||||
- https://example.com/my-web2-claim.json""";
|
||||
|
||||
String message = SignInWithEthereumGenerator.generateMessage(scheme, domain, address, statement, uri, version, chainId, nonce, issuedAt,
|
||||
expirationTime, notBefore, requestId, resources);
|
||||
|
||||
SignInWithEthereumToken siwe = SignInWithEthereumParser.parse(message);
|
||||
assertNotNull(siwe);
|
||||
assertThat(siwe.scheme(), equalTo(scheme));
|
||||
assertThat(siwe.domain(), equalTo(domain));
|
||||
assertThat(siwe.address(), equalTo(address));
|
||||
assertThat(siwe.statement(), equalTo(statement));
|
||||
assertThat(siwe.uri(), equalTo(uri));
|
||||
assertThat(siwe.version(), equalTo(version));
|
||||
assertThat(siwe.chainId(), equalTo(chainId));
|
||||
assertThat(siwe.nonce(), equalTo(nonce));
|
||||
assertThat(siwe.issuedAt(), equalTo(issuedAt.format(DateTimeFormatter.ISO_LOCAL_DATE_TIME)));
|
||||
assertThat(siwe.expirationTime(), equalTo(expirationTime.format(DateTimeFormatter.ISO_LOCAL_DATE_TIME)));
|
||||
assertThat(siwe.notBefore(), equalTo(notBefore.format(DateTimeFormatter.ISO_LOCAL_DATE_TIME)));
|
||||
assertThat(siwe.requestId(), equalTo(requestId));
|
||||
assertThat(siwe.resources(), equalTo(resources));
|
||||
}
|
||||
}
|
|
@ -0,0 +1,292 @@
|
|||
//
|
||||
// ========================================================================
|
||||
// Copyright (c) 1995 Mort Bay Consulting Pty Ltd and others.
|
||||
//
|
||||
// This program and the accompanying materials are made available under the
|
||||
// terms of the Eclipse Public License v. 2.0 which is available at
|
||||
// https://www.eclipse.org/legal/epl-2.0, or the Apache License, Version 2.0
|
||||
// which is available at https://www.apache.org/licenses/LICENSE-2.0.
|
||||
//
|
||||
// SPDX-License-Identifier: EPL-2.0 OR Apache-2.0
|
||||
// ========================================================================
|
||||
//
|
||||
|
||||
package org.eclipse.jetty.security.siwe;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.concurrent.ExecutionException;
|
||||
import java.util.concurrent.TimeoutException;
|
||||
|
||||
import org.eclipse.jetty.client.ContentResponse;
|
||||
import org.eclipse.jetty.client.HttpClient;
|
||||
import org.eclipse.jetty.client.MultiPartRequestContent;
|
||||
import org.eclipse.jetty.http.HttpHeader;
|
||||
import org.eclipse.jetty.http.HttpMethod;
|
||||
import org.eclipse.jetty.http.HttpStatus;
|
||||
import org.eclipse.jetty.http.MultiPart;
|
||||
import org.eclipse.jetty.security.AuthenticationState;
|
||||
import org.eclipse.jetty.security.Constraint;
|
||||
import org.eclipse.jetty.security.SecurityHandler;
|
||||
import org.eclipse.jetty.security.siwe.util.EthereumCredentials;
|
||||
import org.eclipse.jetty.security.siwe.util.SignInWithEthereumGenerator;
|
||||
import org.eclipse.jetty.server.Handler;
|
||||
import org.eclipse.jetty.server.Request;
|
||||
import org.eclipse.jetty.server.Response;
|
||||
import org.eclipse.jetty.server.Server;
|
||||
import org.eclipse.jetty.server.ServerConnector;
|
||||
import org.eclipse.jetty.server.handler.ContextHandler;
|
||||
import org.eclipse.jetty.session.SessionHandler;
|
||||
import org.eclipse.jetty.util.BufferUtil;
|
||||
import org.eclipse.jetty.util.Callback;
|
||||
import org.eclipse.jetty.util.ajax.JSON;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.hamcrest.MatcherAssert.assertThat;
|
||||
import static org.hamcrest.Matchers.containsString;
|
||||
import static org.hamcrest.Matchers.equalTo;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
public class SignInWithEthereumTest
|
||||
{
|
||||
private final EthereumCredentials _credentials = new EthereumCredentials();
|
||||
private Server _server;
|
||||
private ServerConnector _connector;
|
||||
private EthereumAuthenticator _authenticator;
|
||||
private HttpClient _client;
|
||||
|
||||
@BeforeEach
|
||||
public void before() throws Exception
|
||||
{
|
||||
_server = new Server();
|
||||
_connector = new ServerConnector(_server);
|
||||
_server.addConnector(_connector);
|
||||
|
||||
Handler.Abstract handler = new Handler.Abstract()
|
||||
{
|
||||
@Override
|
||||
public boolean handle(Request request, Response response, Callback callback) throws Exception
|
||||
{
|
||||
String pathInContext = Request.getPathInContext(request);
|
||||
if ("/login".equals(pathInContext))
|
||||
{
|
||||
response.write(true, BufferUtil.toBuffer("Please Login"), callback);
|
||||
return true;
|
||||
}
|
||||
else if ("/logout".equals(pathInContext))
|
||||
{
|
||||
AuthenticationState.logout(request, response);
|
||||
callback.succeeded();
|
||||
return true;
|
||||
}
|
||||
|
||||
AuthenticationState authState = Objects.requireNonNull(AuthenticationState.getAuthenticationState(request));
|
||||
response.write(true, BufferUtil.toBuffer("UserPrincipal: " + authState.getUserPrincipal()), callback);
|
||||
return true;
|
||||
}
|
||||
};
|
||||
|
||||
_authenticator = new EthereumAuthenticator();
|
||||
_authenticator.setLoginPath("/login");
|
||||
|
||||
SecurityHandler.PathMapped securityHandler = new SecurityHandler.PathMapped();
|
||||
securityHandler.setAuthenticator(_authenticator);
|
||||
securityHandler.setHandler(handler);
|
||||
securityHandler.put("/*", Constraint.ANY_USER);
|
||||
|
||||
SessionHandler sessionHandler = new SessionHandler();
|
||||
sessionHandler.setHandler(securityHandler);
|
||||
|
||||
ContextHandler contextHandler = new ContextHandler();
|
||||
contextHandler.setContextPath("/");
|
||||
contextHandler.setHandler(sessionHandler);
|
||||
|
||||
_server.setHandler(contextHandler);
|
||||
_server.start();
|
||||
|
||||
_client = new HttpClient();
|
||||
_client.start();
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
public void after() throws Exception
|
||||
{
|
||||
_client.stop();
|
||||
_server.stop();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testLoginLogoutSequence() throws Exception
|
||||
{
|
||||
_client.setFollowRedirects(false);
|
||||
|
||||
// Initial request redirects to /login.html
|
||||
ContentResponse response = _client.GET("http://localhost:" + _connector.getLocalPort() + "/admin");
|
||||
assertTrue(HttpStatus.isRedirection(response.getStatus()), "HttpStatus was not redirect: " + response.getStatus());
|
||||
assertThat(response.getHeaders().get(HttpHeader.LOCATION), equalTo("/login"));
|
||||
|
||||
// Request to Login page bypasses security constraints.
|
||||
response = _client.GET("http://localhost:" + _connector.getLocalPort() + "/login");
|
||||
assertThat(response.getStatus(), equalTo(HttpStatus.OK_200));
|
||||
assertThat(response.getContentAsString(), equalTo("Please Login"));
|
||||
|
||||
// We can get a nonce from the server without being logged in.
|
||||
String nonce = getNonce();
|
||||
|
||||
// Create ethereum credentials to login, and sign a login message.
|
||||
String siweMessage = SignInWithEthereumGenerator.generateMessage(_connector.getLocalPort(), _credentials.getAddress(), nonce);
|
||||
SignedMessage signedMessage = _credentials.signMessage(siweMessage);
|
||||
|
||||
// Send an Authentication request with the signed SIWE message, this should redirect back to initial request.
|
||||
response = sendAuthRequest(signedMessage);
|
||||
assertTrue(HttpStatus.isRedirection(response.getStatus()), "HttpStatus was not redirect: " + response.getStatus());
|
||||
assertThat(response.getHeaders().get(HttpHeader.LOCATION), equalTo("/admin"));
|
||||
|
||||
// Now we are logged in a request to /admin succeeds.
|
||||
response = _client.GET("http://localhost:" + _connector.getLocalPort() + "/admin");
|
||||
assertThat(response.getStatus(), equalTo(HttpStatus.OK_200));
|
||||
assertThat(response.getContentAsString(), equalTo("UserPrincipal: " + _credentials.getAddress()));
|
||||
|
||||
// We are unauthenticated after logging out.
|
||||
response = _client.GET("http://localhost:" + _connector.getLocalPort() + "/logout");
|
||||
assertThat(response.getStatus(), equalTo(HttpStatus.OK_200));
|
||||
response = _client.GET("http://localhost:" + _connector.getLocalPort() + "/admin");
|
||||
assertTrue(HttpStatus.isRedirection(response.getStatus()), "HttpStatus was not redirect: " + response.getStatus());
|
||||
assertThat(response.getHeaders().get(HttpHeader.LOCATION), equalTo("/login"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testAuthRequestTooLarge() throws Exception
|
||||
{
|
||||
int maxMessageSize = 1024 * 4;
|
||||
_authenticator.setMaxMessageSize(maxMessageSize);
|
||||
|
||||
MultiPartRequestContent content = new MultiPartRequestContent();
|
||||
String message = "x".repeat(maxMessageSize + 1);
|
||||
content.addPart(new MultiPart.ByteBufferPart("message", null, null, BufferUtil.toBuffer(message)));
|
||||
content.close();
|
||||
ContentResponse response = _client.newRequest("localhost", _connector.getLocalPort())
|
||||
.path("/auth/login")
|
||||
.method(HttpMethod.POST)
|
||||
.body(content)
|
||||
.send();
|
||||
|
||||
assertThat(response.getStatus(), equalTo(HttpStatus.FORBIDDEN_403));
|
||||
assertThat(response.getContentAsString(), containsString("SIWE Message Too Large"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testInvalidNonce() throws Exception
|
||||
{
|
||||
ContentResponse response;
|
||||
String nonce = getNonce();
|
||||
|
||||
// Create ethereum credentials to login, and sign a login message.
|
||||
String siweMessage = SignInWithEthereumGenerator.generateMessage(_connector.getLocalPort(), _credentials.getAddress(), nonce);
|
||||
SignedMessage signedMessage = _credentials.signMessage(siweMessage);
|
||||
|
||||
// Initial authentication should succeed because it has a valid nonce.
|
||||
response = sendAuthRequest(signedMessage);
|
||||
assertThat(response.getStatus(), equalTo(HttpStatus.OK_200));
|
||||
assertThat(response.getContentAsString(), equalTo("UserPrincipal: " + _credentials.getAddress()));
|
||||
|
||||
// Ensure we are logged out.
|
||||
response = _client.GET("http://localhost:" + _connector.getLocalPort() + "/logout");
|
||||
assertThat(response.getStatus(), equalTo(HttpStatus.OK_200));
|
||||
response = _client.GET("http://localhost:" + _connector.getLocalPort() + "/admin");
|
||||
assertThat(response.getContentAsString(), equalTo("Please Login"));
|
||||
|
||||
// Replay the exact same request, and it should now fail because the nonce is invalid.
|
||||
response = sendAuthRequest(signedMessage);
|
||||
assertThat(response.getStatus(), equalTo(HttpStatus.FORBIDDEN_403));
|
||||
assertThat(response.getContentAsString(), containsString("invalid nonce"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testEnforceDomain() throws Exception
|
||||
{
|
||||
_authenticator.includeDomains("example.com");
|
||||
|
||||
// Test login with invalid domain.
|
||||
String nonce = getNonce();
|
||||
String siweMessage = SignInWithEthereumGenerator.generateMessage(null, "localhost", _credentials.getAddress(), nonce);
|
||||
ContentResponse response = sendAuthRequest(_credentials.signMessage(siweMessage));
|
||||
assertThat(response.getStatus(), equalTo(HttpStatus.FORBIDDEN_403));
|
||||
assertThat(response.getContentAsString(), containsString("unregistered domain"));
|
||||
|
||||
// Test login with valid domain.
|
||||
nonce = getNonce();
|
||||
siweMessage = SignInWithEthereumGenerator.generateMessage(null, "example.com", _credentials.getAddress(), nonce);
|
||||
response = sendAuthRequest(_credentials.signMessage(siweMessage));
|
||||
assertThat(response.getStatus(), equalTo(HttpStatus.OK_200));
|
||||
assertThat(response.getContentAsString(), containsString("UserPrincipal: " + _credentials.getAddress()));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testEnforceScheme() throws Exception
|
||||
{
|
||||
_authenticator.includeSchemes("https");
|
||||
|
||||
// Test login with invalid scheme.
|
||||
String nonce = getNonce();
|
||||
String siweMessage = SignInWithEthereumGenerator.generateMessage("http", "localhost", _credentials.getAddress(), nonce);
|
||||
ContentResponse response = sendAuthRequest(_credentials.signMessage(siweMessage));
|
||||
assertThat(response.getStatus(), equalTo(HttpStatus.FORBIDDEN_403));
|
||||
assertThat(response.getContentAsString(), containsString("unregistered scheme"));
|
||||
|
||||
// Test login with valid scheme.
|
||||
nonce = getNonce();
|
||||
siweMessage = SignInWithEthereumGenerator.generateMessage("https", "localhost", _credentials.getAddress(), nonce);
|
||||
response = sendAuthRequest(_credentials.signMessage(siweMessage));
|
||||
assertThat(response.getStatus(), equalTo(HttpStatus.OK_200));
|
||||
assertThat(response.getContentAsString(), containsString("UserPrincipal: " + _credentials.getAddress()));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testEnforceChainId() throws Exception
|
||||
{
|
||||
_authenticator.includeChainIds("1");
|
||||
|
||||
// Test login with invalid chainId.
|
||||
String nonce = getNonce();
|
||||
String siweMessage = SignInWithEthereumGenerator.generateMessage(null, "localhost", _credentials.getAddress(), nonce, "2");
|
||||
ContentResponse response = sendAuthRequest(_credentials.signMessage(siweMessage));
|
||||
assertThat(response.getStatus(), equalTo(HttpStatus.FORBIDDEN_403));
|
||||
assertThat(response.getContentAsString(), containsString("unregistered chainId"));
|
||||
|
||||
// Test login with valid chainId.
|
||||
nonce = getNonce();
|
||||
siweMessage = SignInWithEthereumGenerator.generateMessage(null, "localhost", _credentials.getAddress(), nonce, "1");
|
||||
response = sendAuthRequest(_credentials.signMessage(siweMessage));
|
||||
assertThat(response.getStatus(), equalTo(HttpStatus.OK_200));
|
||||
assertThat(response.getContentAsString(), containsString("UserPrincipal: " + _credentials.getAddress()));
|
||||
}
|
||||
|
||||
private ContentResponse sendAuthRequest(SignedMessage signedMessage) throws ExecutionException, InterruptedException, TimeoutException
|
||||
{
|
||||
MultiPartRequestContent content = new MultiPartRequestContent();
|
||||
content.addPart(new MultiPart.ByteBufferPart("signature", null, null, BufferUtil.toBuffer(signedMessage.signature())));
|
||||
content.addPart(new MultiPart.ByteBufferPart("message", null, null, BufferUtil.toBuffer(signedMessage.message())));
|
||||
content.close();
|
||||
return _client.newRequest("localhost", _connector.getLocalPort())
|
||||
.path("/auth/login")
|
||||
.method(HttpMethod.POST)
|
||||
.body(content)
|
||||
.send();
|
||||
}
|
||||
|
||||
private String getNonce() throws ExecutionException, InterruptedException, TimeoutException
|
||||
{
|
||||
ContentResponse response = _client.GET("http://localhost:" + _connector.getLocalPort() + "/auth/nonce");
|
||||
assertThat(response.getStatus(), equalTo(HttpStatus.OK_200));
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
Map<String, Object> parsed = (Map<String, Object>)new JSON().parse(new JSON.StringSource(response.getContentAsString()));
|
||||
String nonce = (String)parsed.get("nonce");
|
||||
assertThat(nonce.length(), equalTo(8));
|
||||
|
||||
return nonce;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,265 @@
|
|||
//
|
||||
// ========================================================================
|
||||
// Copyright (c) 1995 Mort Bay Consulting Pty Ltd and others.
|
||||
//
|
||||
// This program and the accompanying materials are made available under the
|
||||
// terms of the Eclipse Public License v. 2.0 which is available at
|
||||
// https://www.eclipse.org/legal/epl-2.0, or the Apache License, Version 2.0
|
||||
// which is available at https://www.apache.org/licenses/LICENSE-2.0.
|
||||
//
|
||||
// SPDX-License-Identifier: EPL-2.0 OR Apache-2.0
|
||||
// ========================================================================
|
||||
//
|
||||
|
||||
package org.eclipse.jetty.security.siwe;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.function.Predicate;
|
||||
|
||||
import org.eclipse.jetty.security.siwe.internal.EthereumUtil;
|
||||
import org.eclipse.jetty.security.siwe.internal.SignInWithEthereumParser;
|
||||
import org.eclipse.jetty.security.siwe.internal.SignInWithEthereumToken;
|
||||
import org.eclipse.jetty.security.siwe.util.EthereumCredentials;
|
||||
import org.eclipse.jetty.security.siwe.util.SignInWithEthereumGenerator;
|
||||
import org.eclipse.jetty.util.IncludeExcludeSet;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.hamcrest.MatcherAssert.assertThat;
|
||||
import static org.hamcrest.Matchers.containsString;
|
||||
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
|
||||
public class SignInWithEthereumTokenTest
|
||||
{
|
||||
@Test
|
||||
public void testInvalidVersion() throws Exception
|
||||
{
|
||||
EthereumCredentials credentials = new EthereumCredentials();
|
||||
LocalDateTime issuedAt = LocalDateTime.now();
|
||||
String message = SignInWithEthereumGenerator.generateMessage(
|
||||
null,
|
||||
"example.com",
|
||||
credentials.getAddress(),
|
||||
"hello this is the statement",
|
||||
"https://example.com",
|
||||
"2",
|
||||
"1",
|
||||
EthereumUtil.createNonce(),
|
||||
issuedAt,
|
||||
null, null, null, null
|
||||
);
|
||||
|
||||
SignedMessage signedMessage = credentials.signMessage(message);
|
||||
SignInWithEthereumToken siwe = SignInWithEthereumParser.parse(message);
|
||||
assertNotNull(siwe);
|
||||
|
||||
Throwable error = assertThrows(Throwable.class, () ->
|
||||
siwe.validate(signedMessage, null, null, null, null));
|
||||
assertThat(error.getMessage(), containsString("unsupported version"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testExpirationTime() throws Exception
|
||||
{
|
||||
EthereumCredentials credentials = new EthereumCredentials();
|
||||
LocalDateTime issuedAt = LocalDateTime.now().minusSeconds(10);
|
||||
LocalDateTime expiry = LocalDateTime.now();
|
||||
String message = SignInWithEthereumGenerator.generateMessage(
|
||||
null,
|
||||
"example.com",
|
||||
credentials.getAddress(),
|
||||
"hello this is the statement",
|
||||
"https://example.com",
|
||||
"1",
|
||||
"1",
|
||||
EthereumUtil.createNonce(),
|
||||
issuedAt,
|
||||
expiry,
|
||||
null, null, null
|
||||
);
|
||||
|
||||
SignedMessage signedMessage = credentials.signMessage(message);
|
||||
SignInWithEthereumToken siwe = SignInWithEthereumParser.parse(message);
|
||||
assertNotNull(siwe);
|
||||
|
||||
Throwable error = assertThrows(Throwable.class, () ->
|
||||
siwe.validate(signedMessage, null, null, null, null));
|
||||
assertThat(error.getMessage(), containsString("expired SIWE message"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testNotBefore() throws Exception
|
||||
{
|
||||
EthereumCredentials credentials = new EthereumCredentials();
|
||||
LocalDateTime issuedAt = LocalDateTime.now();
|
||||
LocalDateTime notBefore = issuedAt.plusMinutes(10);
|
||||
String message = SignInWithEthereumGenerator.generateMessage(
|
||||
null,
|
||||
"example.com",
|
||||
credentials.getAddress(),
|
||||
"hello this is the statement",
|
||||
"https://example.com",
|
||||
"1",
|
||||
"1",
|
||||
EthereumUtil.createNonce(),
|
||||
issuedAt,
|
||||
null,
|
||||
notBefore,
|
||||
null, null
|
||||
);
|
||||
|
||||
SignedMessage signedMessage = credentials.signMessage(message);
|
||||
SignInWithEthereumToken siwe = SignInWithEthereumParser.parse(message);
|
||||
assertNotNull(siwe);
|
||||
|
||||
Throwable error = assertThrows(Throwable.class, () ->
|
||||
siwe.validate(signedMessage, null, null, null, null));
|
||||
assertThat(error.getMessage(), containsString("SIWE message not yet valid"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testInvalidDomain() throws Exception
|
||||
{
|
||||
EthereumCredentials credentials = new EthereumCredentials();
|
||||
LocalDateTime issuedAt = LocalDateTime.now();
|
||||
String message = SignInWithEthereumGenerator.generateMessage(
|
||||
null,
|
||||
"example.com",
|
||||
credentials.getAddress(),
|
||||
"hello this is the statement",
|
||||
"https://example.com",
|
||||
"1",
|
||||
"1",
|
||||
EthereumUtil.createNonce(),
|
||||
issuedAt,
|
||||
null, null, null, null
|
||||
);
|
||||
|
||||
SignedMessage signedMessage = credentials.signMessage(message);
|
||||
SignInWithEthereumToken siwe = SignInWithEthereumParser.parse(message);
|
||||
assertNotNull(siwe);
|
||||
|
||||
IncludeExcludeSet<String, String> domains = new IncludeExcludeSet<>();
|
||||
domains.include("example.org");
|
||||
|
||||
Throwable error = assertThrows(Throwable.class, () ->
|
||||
siwe.validate(signedMessage, null, null, domains, null));
|
||||
assertThat(error.getMessage(), containsString("unregistered domain"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testInvalidScheme() throws Exception
|
||||
{
|
||||
EthereumCredentials credentials = new EthereumCredentials();
|
||||
LocalDateTime issuedAt = LocalDateTime.now();
|
||||
String message = SignInWithEthereumGenerator.generateMessage(
|
||||
"https",
|
||||
"example.com",
|
||||
credentials.getAddress(),
|
||||
"hello this is the statement",
|
||||
"https://example.com",
|
||||
"1",
|
||||
"1",
|
||||
EthereumUtil.createNonce(),
|
||||
issuedAt,
|
||||
null, null, null, null
|
||||
);
|
||||
|
||||
SignedMessage signedMessage = credentials.signMessage(message);
|
||||
SignInWithEthereumToken siwe = SignInWithEthereumParser.parse(message);
|
||||
assertNotNull(siwe);
|
||||
|
||||
IncludeExcludeSet<String, String> schemes = new IncludeExcludeSet<>();
|
||||
schemes.include("wss");
|
||||
|
||||
Throwable error = assertThrows(Throwable.class, () ->
|
||||
siwe.validate(signedMessage, null, schemes, null, null));
|
||||
assertThat(error.getMessage(), containsString("unregistered scheme"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testInvalidChainId() throws Exception
|
||||
{
|
||||
EthereumCredentials credentials = new EthereumCredentials();
|
||||
LocalDateTime issuedAt = LocalDateTime.now();
|
||||
String message = SignInWithEthereumGenerator.generateMessage(
|
||||
"https",
|
||||
"example.com",
|
||||
credentials.getAddress(),
|
||||
"hello this is the statement",
|
||||
"https://example.com",
|
||||
"1",
|
||||
"1",
|
||||
EthereumUtil.createNonce(),
|
||||
issuedAt,
|
||||
null, null, null, null
|
||||
);
|
||||
|
||||
SignedMessage signedMessage = credentials.signMessage(message);
|
||||
SignInWithEthereumToken siwe = SignInWithEthereumParser.parse(message);
|
||||
assertNotNull(siwe);
|
||||
|
||||
IncludeExcludeSet<String, String> chainIds = new IncludeExcludeSet<>();
|
||||
chainIds.include("1337");
|
||||
|
||||
Throwable error = assertThrows(Throwable.class, () ->
|
||||
siwe.validate(signedMessage, null, null, null, chainIds));
|
||||
assertThat(error.getMessage(), containsString("unregistered chainId"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testInvalidNonce() throws Exception
|
||||
{
|
||||
EthereumCredentials credentials = new EthereumCredentials();
|
||||
LocalDateTime issuedAt = LocalDateTime.now();
|
||||
String message = SignInWithEthereumGenerator.generateMessage(
|
||||
"https",
|
||||
"example.com",
|
||||
credentials.getAddress(),
|
||||
"hello this is the statement",
|
||||
"https://example.com",
|
||||
"1",
|
||||
"1",
|
||||
EthereumUtil.createNonce(),
|
||||
issuedAt,
|
||||
null, null, null, null
|
||||
);
|
||||
|
||||
SignedMessage signedMessage = credentials.signMessage(message);
|
||||
SignInWithEthereumToken siwe = SignInWithEthereumParser.parse(message);
|
||||
assertNotNull(siwe);
|
||||
|
||||
Predicate<String> nonceValidation = nonce -> false;
|
||||
Throwable error = assertThrows(Throwable.class, () ->
|
||||
siwe.validate(signedMessage, nonceValidation, null, null, null));
|
||||
assertThat(error.getMessage(), containsString("invalid nonce"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testValidToken() throws Exception
|
||||
{
|
||||
EthereumCredentials credentials = new EthereumCredentials();
|
||||
LocalDateTime issuedAt = LocalDateTime.now();
|
||||
String message = SignInWithEthereumGenerator.generateMessage(
|
||||
"https",
|
||||
"example.com",
|
||||
credentials.getAddress(),
|
||||
"hello this is the statement",
|
||||
"https://example.com",
|
||||
"1",
|
||||
"1",
|
||||
EthereumUtil.createNonce(),
|
||||
issuedAt,
|
||||
null, null, null, null
|
||||
);
|
||||
|
||||
SignedMessage signedMessage = credentials.signMessage(message);
|
||||
SignInWithEthereumToken siwe = SignInWithEthereumParser.parse(message);
|
||||
assertNotNull(siwe);
|
||||
|
||||
Predicate<String> nonceValidation = nonce -> true;
|
||||
assertDoesNotThrow(() ->
|
||||
siwe.validate(signedMessage, nonceValidation, null, null, null));
|
||||
}
|
||||
}
|
|
@ -0,0 +1,45 @@
|
|||
//
|
||||
// ========================================================================
|
||||
// Copyright (c) 1995 Mort Bay Consulting Pty Ltd and others.
|
||||
//
|
||||
// This program and the accompanying materials are made available under the
|
||||
// terms of the Eclipse Public License v. 2.0 which is available at
|
||||
// https://www.eclipse.org/legal/epl-2.0, or the Apache License, Version 2.0
|
||||
// which is available at https://www.apache.org/licenses/LICENSE-2.0.
|
||||
//
|
||||
// SPDX-License-Identifier: EPL-2.0 OR Apache-2.0
|
||||
// ========================================================================
|
||||
//
|
||||
|
||||
package org.eclipse.jetty.security.siwe;
|
||||
|
||||
import org.eclipse.jetty.security.siwe.internal.EthereumSignatureVerifier;
|
||||
import org.eclipse.jetty.security.siwe.util.EthereumCredentials;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.hamcrest.MatcherAssert.assertThat;
|
||||
import static org.hamcrest.Matchers.equalToIgnoringCase;
|
||||
|
||||
public class SignatureVerificationTest
|
||||
{
|
||||
private final EthereumCredentials credentials = new EthereumCredentials();
|
||||
|
||||
@Test
|
||||
public void testSignatureVerification() throws Exception
|
||||
{
|
||||
// String siweMessage = "hello world";
|
||||
// SignedMessage signedMessage = credentials.signMessage(siweMessage);
|
||||
// String address = credentials.getAddress();
|
||||
// System.err.println(signedMessage);
|
||||
// System.err.println("address: " + credentials.getAddress());
|
||||
|
||||
String address = "0x6ea456494436e225335e34dd9ffd53b98109afe3";
|
||||
System.err.println("address: " + address);
|
||||
SignedMessage signedMessage = new SignedMessage("hello world",
|
||||
"0x5e4659c34d3d672ef2840a63b7cca475b223d0cbf78eac1666964f6f16663f7d836bb3fda043173256867f6e9c29be1e401a03be52fd4df227e6d73320201f901b");
|
||||
|
||||
String recoveredAddress = EthereumSignatureVerifier.recoverAddress(signedMessage.message(), signedMessage.signature());
|
||||
System.err.println("recoveredAddress: " + recoveredAddress);
|
||||
assertThat(recoveredAddress, equalToIgnoringCase(address));
|
||||
}
|
||||
}
|
|
@ -0,0 +1,101 @@
|
|||
//
|
||||
// ========================================================================
|
||||
// Copyright (c) 1995 Mort Bay Consulting Pty Ltd and others.
|
||||
//
|
||||
// This program and the accompanying materials are made available under the
|
||||
// terms of the Eclipse Public License v. 2.0 which is available at
|
||||
// https://www.eclipse.org/legal/epl-2.0, or the Apache License, Version 2.0
|
||||
// which is available at https://www.apache.org/licenses/LICENSE-2.0.
|
||||
//
|
||||
// SPDX-License-Identifier: EPL-2.0 OR Apache-2.0
|
||||
// ========================================================================
|
||||
//
|
||||
|
||||
package org.eclipse.jetty.security.siwe.example;
|
||||
|
||||
import java.io.PrintWriter;
|
||||
import java.nio.file.Paths;
|
||||
import java.util.Objects;
|
||||
|
||||
import org.eclipse.jetty.http.HttpHeader;
|
||||
import org.eclipse.jetty.io.Content;
|
||||
import org.eclipse.jetty.security.AuthenticationState;
|
||||
import org.eclipse.jetty.security.Constraint;
|
||||
import org.eclipse.jetty.security.SecurityHandler;
|
||||
import org.eclipse.jetty.security.siwe.EthereumAuthenticator;
|
||||
import org.eclipse.jetty.server.Handler;
|
||||
import org.eclipse.jetty.server.Request;
|
||||
import org.eclipse.jetty.server.Response;
|
||||
import org.eclipse.jetty.server.Server;
|
||||
import org.eclipse.jetty.server.ServerConnector;
|
||||
import org.eclipse.jetty.server.handler.ContextHandler;
|
||||
import org.eclipse.jetty.server.handler.ResourceHandler;
|
||||
import org.eclipse.jetty.session.SessionHandler;
|
||||
import org.eclipse.jetty.util.Callback;
|
||||
|
||||
public class SignInWithEthereumEmbeddedExample
|
||||
{
|
||||
public static void main(String[] args) throws Exception
|
||||
{
|
||||
Server server = new Server();
|
||||
ServerConnector connector = new ServerConnector(server);
|
||||
connector.setPort(8080);
|
||||
server.addConnector(connector);
|
||||
|
||||
String resourcePath = Paths.get(Objects.requireNonNull(SignInWithEthereumEmbeddedExample.class.getClassLoader().getResource("")).toURI())
|
||||
.resolve("../../src/test/resources/")
|
||||
.normalize().toString();
|
||||
ResourceHandler resourceHandler = new ResourceHandler();
|
||||
resourceHandler.setDirAllowed(false);
|
||||
resourceHandler.setBaseResourceAsString(resourcePath);
|
||||
|
||||
Handler.Abstract handler = new Handler.Wrapper(resourceHandler)
|
||||
{
|
||||
@Override
|
||||
public boolean handle(Request request, Response response, Callback callback) throws Exception
|
||||
{
|
||||
String pathInContext = Request.getPathInContext(request);
|
||||
if ("/login.html".equals(pathInContext))
|
||||
{
|
||||
return super.handle(request, response, callback);
|
||||
}
|
||||
else if ("/logout".equals(pathInContext))
|
||||
{
|
||||
AuthenticationState.logout(request, response);
|
||||
Response.sendRedirect(request, response, callback, "/");
|
||||
callback.succeeded();
|
||||
return true;
|
||||
}
|
||||
|
||||
AuthenticationState authState = Objects.requireNonNull(AuthenticationState.getAuthenticationState(request));
|
||||
response.getHeaders().add(HttpHeader.CONTENT_TYPE, "text/html");
|
||||
try (PrintWriter writer = new PrintWriter(Content.Sink.asOutputStream(response)))
|
||||
{
|
||||
writer.write("UserPrincipal: " + authState.getUserPrincipal());
|
||||
writer.write("<br><a href=\"/logout\">Logout</a>");
|
||||
}
|
||||
callback.succeeded();
|
||||
return true;
|
||||
}
|
||||
};
|
||||
|
||||
EthereumAuthenticator authenticator = new EthereumAuthenticator();
|
||||
authenticator.setLoginPath("/login.html");
|
||||
SecurityHandler.PathMapped securityHandler = new SecurityHandler.PathMapped();
|
||||
securityHandler.setAuthenticator(authenticator);
|
||||
securityHandler.setHandler(handler);
|
||||
securityHandler.put("/*", Constraint.ANY_USER);
|
||||
|
||||
SessionHandler sessionHandler = new SessionHandler();
|
||||
sessionHandler.setHandler(securityHandler);
|
||||
|
||||
ContextHandler contextHandler = new ContextHandler();
|
||||
contextHandler.setContextPath("/");
|
||||
contextHandler.setHandler(sessionHandler);
|
||||
|
||||
server.setHandler(contextHandler);
|
||||
server.start();
|
||||
System.err.println(resourceHandler.getBaseResource());
|
||||
server.join();
|
||||
}
|
||||
}
|
|
@ -0,0 +1,209 @@
|
|||
//
|
||||
// ========================================================================
|
||||
// Copyright (c) 1995 Mort Bay Consulting Pty Ltd and others.
|
||||
//
|
||||
// This program and the accompanying materials are made available under the
|
||||
// terms of the Eclipse Public License v. 2.0 which is available at
|
||||
// https://www.eclipse.org/legal/epl-2.0, or the Apache License, Version 2.0
|
||||
// which is available at https://www.apache.org/licenses/LICENSE-2.0.
|
||||
//
|
||||
// SPDX-License-Identifier: EPL-2.0 OR Apache-2.0
|
||||
// ========================================================================
|
||||
//
|
||||
|
||||
package org.eclipse.jetty.security.siwe.util;
|
||||
|
||||
import java.math.BigInteger;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.KeyPair;
|
||||
import java.security.KeyPairGenerator;
|
||||
import java.security.PrivateKey;
|
||||
import java.security.PublicKey;
|
||||
import java.security.SecureRandom;
|
||||
import java.security.Security;
|
||||
import java.security.Signature;
|
||||
import java.security.spec.ECGenParameterSpec;
|
||||
import java.util.Arrays;
|
||||
|
||||
import org.bouncycastle.crypto.digests.KeccakDigest;
|
||||
import org.bouncycastle.jcajce.provider.asymmetric.ec.BCECPublicKey;
|
||||
import org.bouncycastle.jce.ECNamedCurveTable;
|
||||
import org.bouncycastle.jce.provider.BouncyCastleProvider;
|
||||
import org.bouncycastle.jce.spec.ECParameterSpec;
|
||||
import org.bouncycastle.math.ec.ECAlgorithms;
|
||||
import org.bouncycastle.math.ec.ECFieldElement;
|
||||
import org.bouncycastle.math.ec.ECPoint;
|
||||
import org.bouncycastle.util.encoders.Hex;
|
||||
import org.eclipse.jetty.security.siwe.SignedMessage;
|
||||
|
||||
public class EthereumCredentials {
|
||||
private final PrivateKey privateKey;
|
||||
private final PublicKey publicKey;
|
||||
private final String address;
|
||||
private static final ECParameterSpec ecSpec;
|
||||
|
||||
static {
|
||||
Security.addProvider(new BouncyCastleProvider());
|
||||
ecSpec = ECNamedCurveTable.getParameterSpec("secp256k1");
|
||||
}
|
||||
|
||||
public EthereumCredentials() {
|
||||
try {
|
||||
KeyPair keyPair = generateECKeyPair();
|
||||
this.privateKey = keyPair.getPrivate();
|
||||
this.publicKey = keyPair.getPublic();
|
||||
this.address = computeAddress(publicKey);
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
}
|
||||
|
||||
private KeyPair generateECKeyPair() throws Exception {
|
||||
KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("EC", BouncyCastleProvider.PROVIDER_NAME);
|
||||
ECGenParameterSpec ecGenParameterSpec = new ECGenParameterSpec("secp256k1");
|
||||
keyPairGenerator.initialize(ecGenParameterSpec, new SecureRandom());
|
||||
return keyPairGenerator.generateKeyPair();
|
||||
}
|
||||
|
||||
public String getAddress() {
|
||||
return address;
|
||||
}
|
||||
|
||||
public SignedMessage signMessage(String message) throws Exception {
|
||||
byte[] messageBytes = message.getBytes(StandardCharsets.ISO_8859_1);
|
||||
String prefix = "\u0019Ethereum Signed Message:\n" + messageBytes.length + message;
|
||||
byte[] messageHash = keccak256(prefix.getBytes(StandardCharsets.ISO_8859_1));
|
||||
|
||||
Signature ecdsaSign = Signature.getInstance("NONEwithECDSA", BouncyCastleProvider.PROVIDER_NAME);
|
||||
ecdsaSign.initSign(privateKey);
|
||||
ecdsaSign.update(messageHash);
|
||||
byte[] derSignature = ecdsaSign.sign();
|
||||
|
||||
// Decode the DER signature to get r and s
|
||||
int rLength = derSignature[3];
|
||||
byte[] r = Arrays.copyOfRange(derSignature, 4, 4 + rLength);
|
||||
int sLength = derSignature[5 + rLength];
|
||||
byte[] s = Arrays.copyOfRange(derSignature, 6 + rLength, 6 + rLength + sLength);
|
||||
|
||||
// Ensure r and s are exactly 32 bytes
|
||||
r = ensure32Bytes(r);
|
||||
s = ensure32Bytes(s);
|
||||
|
||||
// Calculate v
|
||||
BigInteger bigR = new BigInteger(1, r);
|
||||
BigInteger bigS = new BigInteger(1, s);
|
||||
byte v = calculateV(r, s, messageHash);
|
||||
|
||||
System.err.println("r: " + bigR);
|
||||
System.err.println("s: " + bigS);
|
||||
System.err.println("v: " + v);
|
||||
ECPoint q = ((BCECPublicKey)publicKey).getQ();
|
||||
for (int i = 0; i < 4; i++)
|
||||
{
|
||||
ECPoint ecPoint = recoverFromSignature(i, bigR, bigS, new BigInteger(1, messageHash));
|
||||
System.err.println(q.equals(ecPoint) + " " + ecPoint);
|
||||
}
|
||||
|
||||
// Concatenate r, s, and v to get the final 65-byte signature
|
||||
byte[] signature = new byte[65];
|
||||
System.arraycopy(r, 0, signature, 0, 32);
|
||||
System.arraycopy(s, 0, signature, 32, 32);
|
||||
signature[64] = v;
|
||||
|
||||
System.err.println("generated: ");
|
||||
|
||||
String signatureHex = Hex.toHexString(signature);
|
||||
return new SignedMessage(message, signatureHex);
|
||||
}
|
||||
|
||||
private byte[] ensure32Bytes(byte[] bytes) {
|
||||
if (bytes.length == 32) {
|
||||
return bytes;
|
||||
} else if (bytes.length < 32) {
|
||||
byte[] padded = new byte[32];
|
||||
System.arraycopy(bytes, 0, padded, 32 - bytes.length, bytes.length);
|
||||
return padded;
|
||||
} else {
|
||||
return Arrays.copyOfRange(bytes, bytes.length - 32, bytes.length);
|
||||
}
|
||||
}
|
||||
|
||||
private byte calculateV(byte[] r, byte[] s, byte[] messageHash) {
|
||||
BigInteger R = new BigInteger(1, r);
|
||||
BigInteger S = new BigInteger(1, s);
|
||||
ECPoint publicKeyPoint = ((BCECPublicKey) publicKey).getQ();
|
||||
for (int i = 0; i < 4; i++) {
|
||||
ECPoint Q = recoverFromSignature(i, R, S, new BigInteger(1, messageHash));
|
||||
if (Q != null && Q.equals(publicKeyPoint)) {
|
||||
return (byte) (i + 27);
|
||||
}
|
||||
}
|
||||
throw new RuntimeException("Could not recover public key from signature");
|
||||
}
|
||||
|
||||
private ECPoint recoverFromSignature(int recId, BigInteger r, BigInteger s, BigInteger messageHash) {
|
||||
BigInteger n = ecSpec.getN();
|
||||
BigInteger i = BigInteger.valueOf((long) recId / 2);
|
||||
BigInteger x = r.add(i.multiply(n));
|
||||
|
||||
if (x.compareTo(ecSpec.getCurve().getField().getCharacteristic()) >= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
ECPoint R = decompressKey(x, (recId & 1) == 1);
|
||||
if (!R.multiply(n).isInfinity()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
BigInteger e = messageHash;
|
||||
BigInteger eInv = e.negate().mod(n);
|
||||
BigInteger rInv = r.modInverse(n);
|
||||
BigInteger srInv = rInv.multiply(s).mod(n);
|
||||
BigInteger eInvrInv = rInv.multiply(eInv).mod(n);
|
||||
|
||||
ECPoint q = ECAlgorithms.sumOfTwoMultiplies(ecSpec.getG(), eInvrInv, R, srInv);
|
||||
return q;
|
||||
}
|
||||
|
||||
private ECPoint decompressKey(BigInteger xBN, boolean yBit) {
|
||||
org.bouncycastle.math.ec.ECCurve curve = ecSpec.getCurve();
|
||||
ECFieldElement x = curve.fromBigInteger(xBN);
|
||||
ECFieldElement alpha = x.square().add(curve.getA()).multiply(x).add(curve.getB());
|
||||
ECFieldElement beta = alpha.sqrt();
|
||||
if (beta == null) {
|
||||
throw new IllegalArgumentException("Invalid point compression");
|
||||
}
|
||||
if (beta.testBitZero() != yBit) {
|
||||
beta = beta.negate();
|
||||
}
|
||||
return curve.createPoint(x.toBigInteger(), beta.toBigInteger());
|
||||
}
|
||||
|
||||
private byte[] keccak256(byte[] input) {
|
||||
KeccakDigest digest = new KeccakDigest(256);
|
||||
digest.update(input, 0, input.length);
|
||||
byte[] hash = new byte[digest.getDigestSize()];
|
||||
digest.doFinal(hash, 0);
|
||||
return hash;
|
||||
}
|
||||
|
||||
private String computeAddress(PublicKey publicKey) {
|
||||
ECPoint q = ((BCECPublicKey) publicKey).getQ();
|
||||
byte[] pubKeyBytes = q.getEncoded(false); // false for uncompressed point
|
||||
byte[] hash = keccak256(Arrays.copyOfRange(pubKeyBytes, 1, pubKeyBytes.length));
|
||||
return "0x" + Hex.toHexString(Arrays.copyOfRange(hash, hash.length - 20, hash.length));
|
||||
}
|
||||
|
||||
public static void main(String[] args) {
|
||||
try {
|
||||
EthereumCredentials credentials = new EthereumCredentials();
|
||||
String address = credentials.getAddress();
|
||||
SignedMessage signedMessage = credentials.signMessage("Hello, Ethereum!");
|
||||
|
||||
System.out.println("Address: " + address);
|
||||
System.out.println("Signed Message: " + signedMessage);
|
||||
} catch (Exception e) {
|
||||
e.printStackTrace();
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,111 @@
|
|||
//
|
||||
// ========================================================================
|
||||
// Copyright (c) 1995 Mort Bay Consulting Pty Ltd and others.
|
||||
//
|
||||
// This program and the accompanying materials are made available under the
|
||||
// terms of the Eclipse Public License v. 2.0 which is available at
|
||||
// https://www.eclipse.org/legal/epl-2.0, or the Apache License, Version 2.0
|
||||
// which is available at https://www.apache.org/licenses/LICENSE-2.0.
|
||||
//
|
||||
// SPDX-License-Identifier: EPL-2.0 OR Apache-2.0
|
||||
// ========================================================================
|
||||
//
|
||||
|
||||
package org.eclipse.jetty.security.siwe.util;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
|
||||
import org.eclipse.jetty.security.siwe.internal.EthereumUtil;
|
||||
|
||||
public class SignInWithEthereumGenerator
|
||||
{
|
||||
private SignInWithEthereumGenerator()
|
||||
{
|
||||
}
|
||||
|
||||
public static String generateMessage(int port, String address)
|
||||
{
|
||||
return generateMessage(port, address, EthereumUtil.createNonce());
|
||||
}
|
||||
|
||||
public static String generateMessage(int port, String address, String nonce)
|
||||
{
|
||||
return generateMessage(null, "localhost:" + port, address, nonce, null, null);
|
||||
}
|
||||
|
||||
public static String generateMessage(String scheme, String domain, String address, String nonce)
|
||||
{
|
||||
return generateMessage(scheme, domain, address, nonce, null, null);
|
||||
}
|
||||
|
||||
public static String generateMessage(String scheme, String domain, String address, String nonce, String chainId)
|
||||
{
|
||||
return generateMessage(scheme,
|
||||
domain,
|
||||
address,
|
||||
"I accept the MetaMask Terms of Service: https://community.metamask.io/tos",
|
||||
"http://" + domain,
|
||||
"1",
|
||||
chainId,
|
||||
nonce,
|
||||
LocalDateTime.now(),
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null);
|
||||
}
|
||||
|
||||
public static String generateMessage(String scheme, String domain, String address, String nonce, LocalDateTime expiresAt, LocalDateTime notBefore)
|
||||
{
|
||||
return generateMessage(scheme,
|
||||
domain,
|
||||
address,
|
||||
"I accept the MetaMask Terms of Service: https://community.metamask.io/tos",
|
||||
"http://" + domain,
|
||||
"1",
|
||||
"1",
|
||||
nonce,
|
||||
LocalDateTime.now(),
|
||||
expiresAt,
|
||||
notBefore,
|
||||
null,
|
||||
null);
|
||||
}
|
||||
|
||||
public static String generateMessage(String scheme,
|
||||
String domain,
|
||||
String address,
|
||||
String statement,
|
||||
String uri,
|
||||
String version,
|
||||
String chainId,
|
||||
String nonce,
|
||||
LocalDateTime issuedAt,
|
||||
LocalDateTime expirationTime,
|
||||
LocalDateTime notBefore,
|
||||
String requestId,
|
||||
String resources)
|
||||
{
|
||||
StringBuilder sb = new StringBuilder();
|
||||
if (scheme != null)
|
||||
sb.append(scheme).append("://");
|
||||
sb.append(domain).append(" wants you to sign in with your Ethereum account:\n");
|
||||
sb.append(address).append("\n\n");
|
||||
sb.append(statement).append("\n\n");
|
||||
sb.append("URI: ").append(uri).append("\n");
|
||||
sb.append("Version: ").append(version).append("\n");
|
||||
sb.append("Chain ID: ").append(chainId).append("\n");
|
||||
sb.append("Nonce: ").append(nonce).append("\n");
|
||||
sb.append("Issued At: ").append(issuedAt.format(DateTimeFormatter.ISO_LOCAL_DATE_TIME));
|
||||
if (expirationTime != null)
|
||||
sb.append("\nExpiration Time: ").append(expirationTime.format(DateTimeFormatter.ISO_LOCAL_DATE_TIME));
|
||||
if (notBefore != null)
|
||||
sb.append("\nNot Before: ").append(notBefore.format(DateTimeFormatter.ISO_LOCAL_DATE_TIME));
|
||||
if (requestId != null)
|
||||
sb.append("\nRequest ID: ").append(requestId);
|
||||
if (resources != null)
|
||||
sb.append("\nResources:").append(resources);
|
||||
return sb.toString();
|
||||
}
|
||||
}
|
|
@ -0,0 +1,4 @@
|
|||
# Jetty Logging using jetty-slf4j-impl
|
||||
# org.eclipse.jetty.LEVEL=DEBUG
|
||||
# org.eclipse.jetty.security.siwe.LEVEL=DEBUG
|
||||
# org.eclipse.jetty.session.LEVEL=DEBUG
|
|
@ -0,0 +1,58 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Sign-In with Ethereum</title>
|
||||
<script src="https://cdn.jsdelivr.net/npm/web3@1.6.1/dist/web3.min.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<h4>Sign-In with Ethereum</h4>
|
||||
<button id="siwe">Sign-In with Ethereum</button>
|
||||
<form id="loginForm" action="/auth/login" method="POST" style="display: none;">
|
||||
<input type="hidden" id="signatureField" name="signature">
|
||||
<input type="hidden" id="messageField" name="message">
|
||||
</form>
|
||||
<p class="alert" style="display: none;">Result: <span id="siweResult"></span></p>
|
||||
|
||||
<script>
|
||||
let provider = window.ethereum;
|
||||
let accounts;
|
||||
|
||||
if (!provider) {
|
||||
document.getElementById('siweResult').innerText = 'MetaMask is not installed. Please install MetaMask to use this feature.';
|
||||
} else {
|
||||
document.getElementById('siwe').addEventListener('click', async () => {
|
||||
try {
|
||||
// Request account access if needed
|
||||
accounts = await provider.request({ method: 'eth_requestAccounts' });
|
||||
const domain = window.location.host;
|
||||
const from = accounts[0];
|
||||
|
||||
// Fetch nonce from the server
|
||||
const nonceResponse = await fetch('/auth/nonce');
|
||||
const nonceData = await nonceResponse.json();
|
||||
const nonce = nonceData.nonce;
|
||||
|
||||
const siweMessage = `${domain} wants you to sign in with your Ethereum account:\n${from}\n\nI accept the MetaMask Terms of Service: https://community.metamask.io/tos\n\nURI: https://${domain}\nVersion: 1\nChain ID: 1\nNonce: ${nonce}\nIssued At: ${new Date().toISOString()}`;
|
||||
const signature = await provider.request({
|
||||
method: 'personal_sign',
|
||||
params: [siweMessage, from]
|
||||
});
|
||||
console.log("signature: " + signature)
|
||||
console.log("nonce: " + nonce)
|
||||
console.log("length: " + length)
|
||||
|
||||
document.getElementById('signatureField').value = signature;
|
||||
document.getElementById('messageField').value = siweMessage;
|
||||
document.getElementById('loginForm').submit();
|
||||
} catch (error) {
|
||||
console.error('Error during login:', error);
|
||||
document.getElementById('siweResult').innerText = `Error: ${error.message}`;
|
||||
document.getElementById('siweResult').parentElement.style.display = 'block';
|
||||
}
|
||||
});
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
|
@ -38,6 +38,7 @@
|
|||
<module>jetty-security</module>
|
||||
<module>jetty-server</module>
|
||||
<module>jetty-session</module>
|
||||
<module>jetty-siwe</module>
|
||||
<module>jetty-slf4j-impl</module>
|
||||
<module>jetty-start</module>
|
||||
<module>jetty-tests</module>
|
||||
|
|
5
pom.xml
5
pom.xml
|
@ -731,6 +731,11 @@
|
|||
<artifactId>jetty-openid</artifactId>
|
||||
<version>${project.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.eclipse.jetty</groupId>
|
||||
<artifactId>jetty-siwe</artifactId>
|
||||
<version>${project.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.eclipse.jetty</groupId>
|
||||
<artifactId>jetty-osgi</artifactId>
|
||||
|
|
Loading…
Reference in New Issue