From 144710bbb3734f485e44b0848332cae54a455614 Mon Sep 17 00:00:00 2001 From: Lachlan Roberts Date: Fri, 7 Jun 2024 00:07:31 +1000 Subject: [PATCH] Issue #11560 - Implement EIP-4361 Sign-In With Ethereum Signed-off-by: Lachlan Roberts --- .../eclipse/jetty/http/MultiPartFormData.java | 15 +- jetty-core/jetty-openid/pom.xml | 2 +- .../eclipse/jetty/security/Authenticator.java | 1 + .../jetty/server/MultiPartFormFields.java | 152 ++++ .../eclipse/jetty/session/SessionHandler.java | 3 + jetty-core/jetty-siwe/pom.xml | 118 +++ .../security/siwe/AnyUserLoginService.java | 70 ++ .../security/siwe/EthereumAuthenticator.java | 750 ++++++++++++++++++ .../siwe/EthereumSignatureVerifier.java | 57 ++ .../jetty/security/siwe/EthereumUtil.java | 34 + .../siwe/SignInWithEthereumParser.java | 65 ++ .../siwe/SignInWithEthereumToken.java | 91 +++ .../jetty/security/siwe/SignedMessage.java | 22 + .../siwe/SightInWithEthereumTokenTest.java | 234 ++++++ .../siwe/SignInWithEthereumParserTest.java | 155 ++++ .../security/siwe/SignInWithEthereumTest.java | 292 +++++++ .../siwe/SignatureVerificationTest.java | 36 + .../siwe/util/EthereumCredentials.java | 64 ++ .../util/SignInWithEthereumGenerator.java | 111 +++ .../test/resources/jetty-logging.properties | 4 + .../jetty-siwe/src/test/resources/login.html | 63 ++ jetty-core/pom.xml | 1 + 22 files changed, 2337 insertions(+), 3 deletions(-) create mode 100644 jetty-core/jetty-server/src/main/java/org/eclipse/jetty/server/MultiPartFormFields.java create mode 100644 jetty-core/jetty-siwe/pom.xml create mode 100644 jetty-core/jetty-siwe/src/main/java/org/eclipse/jetty/security/siwe/AnyUserLoginService.java create mode 100644 jetty-core/jetty-siwe/src/main/java/org/eclipse/jetty/security/siwe/EthereumAuthenticator.java create mode 100644 jetty-core/jetty-siwe/src/main/java/org/eclipse/jetty/security/siwe/EthereumSignatureVerifier.java create mode 100644 jetty-core/jetty-siwe/src/main/java/org/eclipse/jetty/security/siwe/EthereumUtil.java create mode 100644 jetty-core/jetty-siwe/src/main/java/org/eclipse/jetty/security/siwe/SignInWithEthereumParser.java create mode 100644 jetty-core/jetty-siwe/src/main/java/org/eclipse/jetty/security/siwe/SignInWithEthereumToken.java create mode 100644 jetty-core/jetty-siwe/src/main/java/org/eclipse/jetty/security/siwe/SignedMessage.java create mode 100644 jetty-core/jetty-siwe/src/test/java/org/eclipse/jetty/security/siwe/SightInWithEthereumTokenTest.java create mode 100644 jetty-core/jetty-siwe/src/test/java/org/eclipse/jetty/security/siwe/SignInWithEthereumParserTest.java create mode 100644 jetty-core/jetty-siwe/src/test/java/org/eclipse/jetty/security/siwe/SignInWithEthereumTest.java create mode 100644 jetty-core/jetty-siwe/src/test/java/org/eclipse/jetty/security/siwe/SignatureVerificationTest.java create mode 100644 jetty-core/jetty-siwe/src/test/java/org/eclipse/jetty/security/siwe/util/EthereumCredentials.java create mode 100644 jetty-core/jetty-siwe/src/test/java/org/eclipse/jetty/security/siwe/util/SignInWithEthereumGenerator.java create mode 100755 jetty-core/jetty-siwe/src/test/resources/jetty-logging.properties create mode 100644 jetty-core/jetty-siwe/src/test/resources/login.html diff --git a/jetty-core/jetty-http/src/main/java/org/eclipse/jetty/http/MultiPartFormData.java b/jetty-core/jetty-http/src/main/java/org/eclipse/jetty/http/MultiPartFormData.java index e1cae2531fa7..a2a46adfa3c1 100644 --- a/jetty-core/jetty-http/src/main/java/org/eclipse/jetty/http/MultiPartFormData.java +++ b/jetty-core/jetty-http/src/main/java/org/eclipse/jetty/http/MultiPartFormData.java @@ -98,8 +98,7 @@ public static CompletableFuture from(Attributes attributes, String bounda */ public static CompletableFuture from(Attributes attributes, MultiPartCompliance compliance, ComplianceViolation.Listener listener, String boundary, Function> parse) { - @SuppressWarnings("unchecked") - CompletableFuture futureParts = (CompletableFuture)attributes.getAttribute(MultiPartFormData.class.getName()); + CompletableFuture futureParts = get(attributes); if (futureParts == null) { futureParts = parse.apply(new Parser(boundary, compliance, listener)); @@ -108,6 +107,18 @@ public static CompletableFuture from(Attributes attributes, MultiPartComp return futureParts; } + /** + * Returns {@code multipart/form-data} parts if they have already been created. + * + * @param attributes the attributes where the futureParts are tracked + * @return the future parts + */ + @SuppressWarnings("unchecked") + public static CompletableFuture get(Attributes attributes) + { + return (CompletableFuture)attributes.getAttribute(MultiPartFormData.class.getName()); + } + /** *

An ordered list of {@link MultiPart.Part}s that can * be accessed by index or by name, or iterated over.

diff --git a/jetty-core/jetty-openid/pom.xml b/jetty-core/jetty-openid/pom.xml index c836c6bdb298..422ba2e92f47 100644 --- a/jetty-core/jetty-openid/pom.xml +++ b/jetty-core/jetty-openid/pom.xml @@ -8,7 +8,7 @@ 12.0.10-SNAPSHOT jetty-openid - EE10 :: OpenID + Core :: OpenID Jetty OpenID Connect Infrastructure diff --git a/jetty-core/jetty-security/src/main/java/org/eclipse/jetty/security/Authenticator.java b/jetty-core/jetty-security/src/main/java/org/eclipse/jetty/security/Authenticator.java index d992532b9dde..210187dd046c 100644 --- a/jetty-core/jetty-security/src/main/java/org/eclipse/jetty/security/Authenticator.java +++ b/jetty-core/jetty-security/src/main/java/org/eclipse/jetty/security/Authenticator.java @@ -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 diff --git a/jetty-core/jetty-server/src/main/java/org/eclipse/jetty/server/MultiPartFormFields.java b/jetty-core/jetty-server/src/main/java/org/eclipse/jetty/server/MultiPartFormFields.java new file mode 100644 index 000000000000..f20fd8bfbd22 --- /dev/null +++ b/jetty-core/jetty-server/src/main/java/org/eclipse/jetty/server/MultiPartFormFields.java @@ -0,0 +1,152 @@ +// +// ======================================================================== +// 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.server; + +import java.io.File; +import java.util.concurrent.CompletableFuture; + +import org.eclipse.jetty.http.ComplianceViolation; +import org.eclipse.jetty.http.HttpField; +import org.eclipse.jetty.http.HttpHeader; +import org.eclipse.jetty.http.MimeTypes; +import org.eclipse.jetty.http.MultiPart; +import org.eclipse.jetty.http.MultiPartCompliance; +import org.eclipse.jetty.http.MultiPartFormData; +import org.eclipse.jetty.io.Content; +import org.eclipse.jetty.util.Attributes; +import org.eclipse.jetty.util.StringUtil; + +public class MultiPartFormFields +{ + public static class Config + { + private final long fileSizeThreshold; + private final long maxFileSize; + private final long maxRequestSize; + private final int maxFormKeys; + private final File tempDir; + private final String location; + + public Config(int maxFormKeys, long maxRequestSize, long maxFileSize, long fileSizeThreshold, String location, File tempDir) + { + this.location = location; + this.tempDir = tempDir; + this.maxFormKeys = maxFormKeys; + this.maxRequestSize = maxRequestSize; + this.maxFileSize = maxFileSize; + this.fileSizeThreshold = fileSizeThreshold; + } + + public long getFileSizeThreshold() + { + return fileSizeThreshold; + } + + public long getMaxFileSize() + { + return maxFileSize; + } + + public long getMaxRequestSize() + { + return maxRequestSize; + } + + public int getMaxFormKeys() + { + return maxFormKeys; + } + + public File getTempDirectory() + { + return tempDir; + } + + public String getLocation() + { + return location; + } + } + + public static CompletableFuture from(Request request, Config config) + { + return from(request, request, config); + } + + public static CompletableFuture from(Request request, Content.Source source, Config config) + { + String contentType = request.getHeaders().get(HttpHeader.CONTENT_TYPE); + HttpChannel httpChannel = HttpChannel.from(request); + ComplianceViolation.Listener complianceViolationListener = httpChannel.getComplianceViolationListener(); + HttpConfiguration httpConfiguration = request.getConnectionMetaData().getHttpConfiguration(); + return from(source, request, contentType, config, httpConfiguration, complianceViolationListener); + } + + public static CompletableFuture from(Content.Source content, Attributes attributes, String contentType, Config config, HttpConfiguration httpConfiguration, ComplianceViolation.Listener violationListener) + { + // Look for an existing future (we use the future here rather than the parts as it can remember any failure). + CompletableFuture futureParts = MultiPartFormData.get(attributes); + if (futureParts == null) + { + // No existing parts, so we need to try to read them ourselves + + // Are we the right content type to produce our own parts? + if (contentType == null || !MimeTypes.Type.MULTIPART_FORM_DATA.is(HttpField.getValueParameters(contentType, null))) + return CompletableFuture.failedFuture(new IllegalStateException("Not multipart Content-Type")); + + // Do we have a boundary? + String boundary = MultiPart.extractBoundary(contentType); + if (boundary == null) + return CompletableFuture.failedFuture(new IllegalStateException("No multipart boundary parameter in Content-Type")); + + // Get a temporary directory for larger parts. + File filesDirectory; + boolean locationIsBlank = StringUtil.isBlank(config.getLocation()); + if (locationIsBlank && config.getTempDirectory() == null) + filesDirectory = null; + else + { + filesDirectory = locationIsBlank + ? config.getTempDirectory() + : new File(config.getLocation()); + } + + MultiPartCompliance compliance = httpConfiguration.getMultiPartCompliance(); + + // Look for an existing future MultiPartFormData.Parts + futureParts = MultiPartFormData.from(attributes, compliance, violationListener, boundary, parser -> + { + try + { + // No existing core parts, so we need to configure the parser. + parser.setMaxParts(config.getMaxFormKeys()); + parser.setMaxMemoryFileSize(config.getFileSizeThreshold()); + parser.setMaxFileSize(config.getMaxFileSize()); + parser.setMaxLength(config.getMaxRequestSize()); + parser.setPartHeadersMaxLength(httpConfiguration.getRequestHeaderSize()); + if (filesDirectory != null) + parser.setFilesDirectory(filesDirectory.toPath()); + + // parse the core parts. + return parser.parse(content); + } + catch (Throwable failure) + { + return CompletableFuture.failedFuture(failure); + } + }); + } + return futureParts; + } +} diff --git a/jetty-core/jetty-session/src/main/java/org/eclipse/jetty/session/SessionHandler.java b/jetty-core/jetty-session/src/main/java/org/eclipse/jetty/session/SessionHandler.java index c847d7c94742..97e376076522 100644 --- a/jetty-core/jetty-session/src/main/java/org/eclipse/jetty/session/SessionHandler.java +++ b/jetty-core/jetty-session/src/main/java/org/eclipse/jetty/session/SessionHandler.java @@ -131,6 +131,9 @@ public boolean process(Handler handler, Response response, Callback callback) th _requestedSessionId = requestedSession.sessionId(); ManagedSession session = requestedSession.session(); + // TODO: Implement a better mechanism in core to determine if the Session ID is from a Cookie. + response.getRequest().setAttribute("org.eclipse.jetty.session.sessionIdFromCookie", requestedSession.sessionIdFromCookie()); + if (session != null) { _session.set(session); diff --git a/jetty-core/jetty-siwe/pom.xml b/jetty-core/jetty-siwe/pom.xml new file mode 100644 index 000000000000..55a84be20de3 --- /dev/null +++ b/jetty-core/jetty-siwe/pom.xml @@ -0,0 +1,118 @@ + + + + 4.0.0 + + org.eclipse.jetty + jetty-core + 12.0.10-SNAPSHOT + + jetty-siwe + Core :: Sign-In with Ethereum + Jetty Sign-In with Ethereum + + + ${project.groupId}.siwe + + + + + + org.jetbrains.kotlin + kotlin-stdlib-common + 1.9.10 + + + org.jetbrains.kotlin + kotlin-stdlib-jdk7 + 1.9.10 + + + org.jetbrains.kotlin + kotlin-stdlib-jdk8 + 1.9.10 + + + + + + + org.bouncycastle + bcprov-jdk15to18 + 1.78.1 + + + org.eclipse.jetty + jetty-client + + + org.eclipse.jetty + jetty-security + + + org.eclipse.jetty + jetty-server + + + org.eclipse.jetty + jetty-util-ajax + + + org.slf4j + slf4j-api + + + org.web3j + core + 4.12.0 + + + com.fasterxml.jackson.core + jackson-databind + + + org.slf4j + slf4j-api + + + + + org.eclipse.jetty + jetty-session + test + + + org.eclipse.jetty + jetty-slf4j-impl + test + + + org.eclipse.jetty.toolchain + jetty-test-helper + test + + + + + + + org.apache.felix + maven-bundle-plugin + true + + + + manifest + + + + osgi.extender; filter:="(osgi.extender=osgi.serviceloader.registrar)" + osgi.serviceloader;osgi.serviceloader=org.eclipse.jetty.security.Authenticator$Factory + + + + + + + + diff --git a/jetty-core/jetty-siwe/src/main/java/org/eclipse/jetty/security/siwe/AnyUserLoginService.java b/jetty-core/jetty-siwe/src/main/java/org/eclipse/jetty/security/siwe/AnyUserLoginService.java new file mode 100644 index 000000000000..fc83c67e77b8 --- /dev/null +++ b/jetty-core/jetty-siwe/src/main/java/org/eclipse/jetty/security/siwe/AnyUserLoginService.java @@ -0,0 +1,70 @@ +// +// ======================================================================== +// 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.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 IdentityService identityService = new DefaultIdentityService(); + + @Override + public String getName() + { + return "ANY_USER"; + } + + @Override + public UserIdentity login(String username, Object credentials, Request request, Function getOrCreateSession) + { + UserPrincipal principal = new UserPrincipal(username, null); + Subject subject = new Subject(); + subject.getPrincipals().add(principal); + subject.setReadOnly(); + return identityService.newUserIdentity(subject, principal, new String[0]); + } + + @Override + public boolean validate(UserIdentity user) + { + return user != null; + } + + @Override + public IdentityService getIdentityService() + { + return identityService; + } + + @Override + public void setIdentityService(IdentityService service) + { + identityService = service; + } + + @Override + public void logout(UserIdentity user) + { + // Do nothing. + } +} diff --git a/jetty-core/jetty-siwe/src/main/java/org/eclipse/jetty/security/siwe/EthereumAuthenticator.java b/jetty-core/jetty-siwe/src/main/java/org/eclipse/jetty/security/siwe/EthereumAuthenticator.java new file mode 100644 index 000000000000..98a76bb98115 --- /dev/null +++ b/jetty-core/jetty-siwe/src/main/java/org/eclipse/jetty/security/siwe/EthereumAuthenticator.java @@ -0,0 +1,750 @@ +// +// ======================================================================== +// 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.HttpMethod; +import org.eclipse.jetty.http.HttpStatus; +import org.eclipse.jetty.http.HttpURI; +import org.eclipse.jetty.http.HttpVersion; +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.server.FormFields; +import org.eclipse.jetty.server.MultiPartFormFields; +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; + +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 AUTH_PATH_PARAM = "org.eclipse.jetty.security.siwe.auth_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_AUTH_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 _chainIds = new IncludeExcludeSet<>(); + private final IncludeExcludeSet _schemes = new IncludeExcludeSet<>(); + private final IncludeExcludeSet _domains = new IncludeExcludeSet<>(); + + private String _loginPath; + private String _authPath = DEFAULT_AUTH_PATH; + private String _noncePath = DEFAULT_NONCE_PATH; + private long _maxMessageSize = 4 * 1024; + private String _logoutRedirectPath; + private String _errorPage; + private String _errorPath; + private String _errorQuery; + private boolean _dispatch; + + public EthereumAuthenticator() + { + // todo: javadoc and documentation + // testing improvements (validation, parsing, end-to-end test, signature verification) + } + + 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 authPath = authConfig.getParameter(AUTH_PATH_PARAM); + if (authPath != null) + setAuthPath(authPath); + + 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 no LoginService is set we allow any user to log in. + if (authConfig.getLoginService() == null) + { + LoginService loginService = new AnyUserLoginService(); + authConfig = new Configuration.Wrapper(authConfig) + { + @Override + public LoginService getLoginService() + { + return loginService; + } + }; + } + + super.setConfiguration(authConfig); + } + + @Override + public String getAuthenticationType() + { + return Authenticator.SIWE_AUTH; + } + + 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 setAuthPath(String authPath) + { + if (authPath == null) + { + authPath = _authPath; + LOG.warn("login path must not be null, defaulting to " + authPath); + } + else if (!authPath.startsWith("/")) + { + authPath = "/" + authPath; + LOG.warn("login path must start with /"); + } + + _authPath = authPath; + } + + public void setNoncePath(String noncePath) + { + if (noncePath == null) + { + noncePath = _noncePath; + LOG.warn("login path must not be null, defaulting to " + noncePath); + } + else if (!noncePath.startsWith("/")) + { + noncePath = "/" + noncePath; + LOG.warn("login 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) + { + LOG.warn("logout redirect path must not be null, defaulting to /"); + logoutRedirectPath = "/"; + } + else if (!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); + } + } + + /** + *

This will attempt to redirect the request to the {@link #_logoutRedirectPath}.

+ * + * @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 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); + + MultiPartFormFields.Config config = new MultiPartFormFields.Config(10, 1024 * 8, -1, -1, null, null); + MultiPartFormData.Parts parts = MultiPartFormFields.from(request, new ByteBufferContentSource(BufferUtil.toBuffer(requestContent)), config).get(); + + String signature = parts.getFirst("signature").getContentAsString(StandardCharsets.ISO_8859_1); + String message = parts.getFirst("message").getContentAsString(StandardCharsets.ISO_8859_1); + + // 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; + } + } + +// boolean sessionIdFromCookie = (Boolean)request.getAttribute("org.eclipse.jetty.session.sessionIdFromCookie"); +// if (!sessionIdFromCookie) +// { +// sendError(request, response, callback, "Session ID must be a cookie to support SIWE authentication"); +// return AuthenticationState.SEND_FAILURE; +// } + + 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 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, _authPath); + } + + 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 attribute = (Set)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 attribute = (Set)session.getAttribute(NONCE_SET_ATTR); + if (attribute == null) + return false; + return attribute.remove(nonce); + } + } + + public static class FixedSizeSet extends LinkedHashSet + { + private final int maxSize; + + public FixedSizeSet(int maxSize) + { + super(maxSize); + this.maxSize = maxSize; + } + + @Override + public boolean add(T element) + { + if (size() >= maxSize) + { + Iterator it = iterator(); + if (it.hasNext()) + { + it.next(); + it.remove(); + } + } + return super.add(element); + } + } +} diff --git a/jetty-core/jetty-siwe/src/main/java/org/eclipse/jetty/security/siwe/EthereumSignatureVerifier.java b/jetty-core/jetty-siwe/src/main/java/org/eclipse/jetty/security/siwe/EthereumSignatureVerifier.java new file mode 100644 index 000000000000..e5d7df512044 --- /dev/null +++ b/jetty-core/jetty-siwe/src/main/java/org/eclipse/jetty/security/siwe/EthereumSignatureVerifier.java @@ -0,0 +1,57 @@ +// +// ======================================================================== +// 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.math.BigInteger; +import java.nio.charset.StandardCharsets; +import java.util.Arrays; + +import org.bouncycastle.jcajce.provider.digest.Keccak; +import org.eclipse.jetty.util.StringUtil; +import org.web3j.crypto.ECDSASignature; +import org.web3j.crypto.Keys; +import org.web3j.crypto.Sign; + +public class EthereumSignatureVerifier +{ + public static final String PREFIX = "\u0019Ethereum Signed Message:\n"; + + private EthereumSignatureVerifier() + { + } + + public static String recoverAddress(String siweMessage, String signatureHex) + { + 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)); + + if (StringUtil.asciiStartsWithIgnoreCase(signatureHex, "0x")) + signatureHex = signatureHex.substring(2); + 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); + + BigInteger publicKey = Sign.recoverFromSignature(v, new ECDSASignature(r, s), messageHash); + return "0x" + Keys.getAddress(publicKey); + } + + public static byte[] keccak256(byte[] bytes) + { + Keccak.Digest256 digest256 = new Keccak.Digest256(); + return digest256.digest(bytes); + } +} diff --git a/jetty-core/jetty-siwe/src/main/java/org/eclipse/jetty/security/siwe/EthereumUtil.java b/jetty-core/jetty-siwe/src/main/java/org/eclipse/jetty/security/siwe/EthereumUtil.java new file mode 100644 index 000000000000..5a4eb327128a --- /dev/null +++ b/jetty-core/jetty-siwe/src/main/java/org/eclipse/jetty/security/siwe/EthereumUtil.java @@ -0,0 +1,34 @@ +// +// ======================================================================== +// 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.security.SecureRandom; + +public class EthereumUtil +{ + private static final String NONCE_CHARACTERS = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; + private static final SecureRandom RANDOM = new SecureRandom(); + + 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(); + } +} diff --git a/jetty-core/jetty-siwe/src/main/java/org/eclipse/jetty/security/siwe/SignInWithEthereumParser.java b/jetty-core/jetty-siwe/src/main/java/org/eclipse/jetty/security/siwe/SignInWithEthereumParser.java new file mode 100644 index 000000000000..f2a42d8df98c --- /dev/null +++ b/jetty-core/jetty-siwe/src/main/java/org/eclipse/jetty/security/siwe/SignInWithEthereumParser.java @@ -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; + +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_PATTERN + ")://)?(?" + DOMAIN_PATTERN + ") wants you to sign in with your Ethereum account:\n" + + "(?
" + ADDRESS_PATTERN + ")\n\n" + + "(?" + STATEMENT_PATTERN + ")?\n\n" + + "URI: (?" + URI_PATTERN + ")\n" + + "Version: (?" + VERSION_PATTERN + ")\n" + + "Chain ID: (?" + CHAIN_ID_PATTERN + ")\n" + + "Nonce: (?" + NONCE_PATTERN + ")\n" + + "Issued At: (?" + DATE_TIME_PATTERN + ")" + + "(?:\nExpiration Time: (?" + DATE_TIME_PATTERN + "))?" + + "(?:\nNot Before: (?" + DATE_TIME_PATTERN + "))?" + + "(?:\nRequest ID: (?" + REQUEST_ID_PATTERN + "))?" + + "(?:\nResources:(?" + 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")); + } +} diff --git a/jetty-core/jetty-siwe/src/main/java/org/eclipse/jetty/security/siwe/SignInWithEthereumToken.java b/jetty-core/jetty-siwe/src/main/java/org/eclipse/jetty/security/siwe/SignInWithEthereumToken.java new file mode 100644 index 000000000000..3d4d86b61e5f --- /dev/null +++ b/jetty-core/jetty-siwe/src/main/java/org/eclipse/jetty/security/siwe/SignInWithEthereumToken.java @@ -0,0 +1,91 @@ +// +// ======================================================================== +// 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.function.Predicate; + +import org.eclipse.jetty.security.ServerAuthException; +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 validateNonce, + IncludeExcludeSet schemes, + IncludeExcludeSet domains, + IncludeExcludeSet 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"); + } + + @Override + public String toString() + { + return String.format( + "Scheme: %s" + + "%nDomain: %s" + + "%nAddress: %s" + + "%nURI: %s" + + "%nVersion: %s" + + "%nChainID: %s" + + "%nNonce: %s" + + "%nIssuedAt: %s" + + "%nStatement: %s", + scheme, domain, address, uri, version, chainId, nonce, issuedAt, statement); + } +} diff --git a/jetty-core/jetty-siwe/src/main/java/org/eclipse/jetty/security/siwe/SignedMessage.java b/jetty-core/jetty-siwe/src/main/java/org/eclipse/jetty/security/siwe/SignedMessage.java new file mode 100644 index 000000000000..b05d3f1746cc --- /dev/null +++ b/jetty-core/jetty-siwe/src/main/java/org/eclipse/jetty/security/siwe/SignedMessage.java @@ -0,0 +1,22 @@ +// +// ======================================================================== +// 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; + +public record SignedMessage(String message, String signature) +{ + public String recoverAddress() + { + return EthereumSignatureVerifier.recoverAddress(message, signature); + } +} diff --git a/jetty-core/jetty-siwe/src/test/java/org/eclipse/jetty/security/siwe/SightInWithEthereumTokenTest.java b/jetty-core/jetty-siwe/src/test/java/org/eclipse/jetty/security/siwe/SightInWithEthereumTokenTest.java new file mode 100644 index 000000000000..d90e43787d84 --- /dev/null +++ b/jetty-core/jetty-siwe/src/test/java/org/eclipse/jetty/security/siwe/SightInWithEthereumTokenTest.java @@ -0,0 +1,234 @@ +// +// ======================================================================== +// 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.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.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; + +public class SightInWithEthereumTokenTest +{ + @Test + public void testInvalidVersion() + { + 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() + { + 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() + { + 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() + { + 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 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() + { + 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 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() + { + 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 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() + { + 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 nonceValidation = nonce -> false; + Throwable error = assertThrows(Throwable.class, () -> + siwe.validate(signedMessage, nonceValidation, null, null, null)); + assertThat(error.getMessage(), containsString("invalid nonce")); + } +} diff --git a/jetty-core/jetty-siwe/src/test/java/org/eclipse/jetty/security/siwe/SignInWithEthereumParserTest.java b/jetty-core/jetty-siwe/src/test/java/org/eclipse/jetty/security/siwe/SignInWithEthereumParserTest.java new file mode 100644 index 000000000000..9a5799f6df72 --- /dev/null +++ b/jetty-core/jetty-siwe/src/test/java/org/eclipse/jetty/security/siwe/SignInWithEthereumParserTest.java @@ -0,0 +1,155 @@ +// +// ======================================================================== +// 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.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 specExamples() + { + List 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)); + } +} diff --git a/jetty-core/jetty-siwe/src/test/java/org/eclipse/jetty/security/siwe/SignInWithEthereumTest.java b/jetty-core/jetty-siwe/src/test/java/org/eclipse/jetty/security/siwe/SignInWithEthereumTest.java new file mode 100644 index 000000000000..afa69e3d9cb2 --- /dev/null +++ b/jetty-core/jetty-siwe/src/test/java/org/eclipse/jetty/security/siwe/SignInWithEthereumTest.java @@ -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 parsed = (Map)new JSON().parse(new JSON.StringSource(response.getContentAsString())); + String nonce = (String)parsed.get("nonce"); + assertThat(nonce.length(), equalTo(8)); + + return nonce; + } +} diff --git a/jetty-core/jetty-siwe/src/test/java/org/eclipse/jetty/security/siwe/SignatureVerificationTest.java b/jetty-core/jetty-siwe/src/test/java/org/eclipse/jetty/security/siwe/SignatureVerificationTest.java new file mode 100644 index 000000000000..373639108ce5 --- /dev/null +++ b/jetty-core/jetty-siwe/src/test/java/org/eclipse/jetty/security/siwe/SignatureVerificationTest.java @@ -0,0 +1,36 @@ +// +// ======================================================================== +// 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.util.EthereumCredentials; +import org.eclipse.jetty.security.siwe.util.SignInWithEthereumGenerator; +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() + { + String siweMessage = SignInWithEthereumGenerator.generateMessage(8080, credentials.getAddress()); + SignedMessage signedMessage = credentials.signMessage(siweMessage); + + String recoveredAddress = EthereumSignatureVerifier.recoverAddress(siweMessage, signedMessage.signature()); + assertThat(recoveredAddress, equalToIgnoringCase(recoveredAddress)); + } +} diff --git a/jetty-core/jetty-siwe/src/test/java/org/eclipse/jetty/security/siwe/util/EthereumCredentials.java b/jetty-core/jetty-siwe/src/test/java/org/eclipse/jetty/security/siwe/util/EthereumCredentials.java new file mode 100644 index 000000000000..2280e6610216 --- /dev/null +++ b/jetty-core/jetty-siwe/src/test/java/org/eclipse/jetty/security/siwe/util/EthereumCredentials.java @@ -0,0 +1,64 @@ +// +// ======================================================================== +// 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.nio.charset.StandardCharsets; + +import org.eclipse.jetty.security.siwe.EthereumSignatureVerifier; +import org.eclipse.jetty.security.siwe.SignedMessage; +import org.web3j.crypto.Credentials; +import org.web3j.crypto.ECKeyPair; +import org.web3j.crypto.Keys; +import org.web3j.crypto.Sign; +import org.web3j.utils.Numeric; + +public class EthereumCredentials +{ + Credentials credentials; + + public EthereumCredentials() + { + try + { + ECKeyPair keyPair = Keys.createEcKeyPair(); + credentials = Credentials.create(keyPair); + } + catch (Exception e) + { + throw new RuntimeException(e); + } + } + + private ECKeyPair getEcKeyPair() + { + return credentials.getEcKeyPair(); + } + + public String getAddress() + { + return credentials.getAddress(); + } + + public SignedMessage signMessage(String message) + { + byte[] messageBytes = message.getBytes(StandardCharsets.ISO_8859_1); + String prefix = EthereumSignatureVerifier.PREFIX + messageBytes.length + message; + byte[] messageHash = EthereumSignatureVerifier.keccak256(prefix.getBytes(StandardCharsets.ISO_8859_1)); + Sign.SignatureData signature = Sign.signMessage(messageHash, credentials.getEcKeyPair(), false); + String signatureHex = Numeric.toHexString(signature.getR()) + + Numeric.toHexString(signature.getS()).substring(2) + + Numeric.toHexString(signature.getV()).substring(2); + return new SignedMessage(message, signatureHex); + } +} diff --git a/jetty-core/jetty-siwe/src/test/java/org/eclipse/jetty/security/siwe/util/SignInWithEthereumGenerator.java b/jetty-core/jetty-siwe/src/test/java/org/eclipse/jetty/security/siwe/util/SignInWithEthereumGenerator.java new file mode 100644 index 000000000000..688a4edb7479 --- /dev/null +++ b/jetty-core/jetty-siwe/src/test/java/org/eclipse/jetty/security/siwe/util/SignInWithEthereumGenerator.java @@ -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.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(); + } +} diff --git a/jetty-core/jetty-siwe/src/test/resources/jetty-logging.properties b/jetty-core/jetty-siwe/src/test/resources/jetty-logging.properties new file mode 100755 index 000000000000..a5c0825874c3 --- /dev/null +++ b/jetty-core/jetty-siwe/src/test/resources/jetty-logging.properties @@ -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 \ No newline at end of file diff --git a/jetty-core/jetty-siwe/src/test/resources/login.html b/jetty-core/jetty-siwe/src/test/resources/login.html new file mode 100644 index 000000000000..30cd57286090 --- /dev/null +++ b/jetty-core/jetty-siwe/src/test/resources/login.html @@ -0,0 +1,63 @@ + + + + + + Sign-In with Ethereum + + + +

Sign-In with Ethereum

+ + +

Result:

+ + + + diff --git a/jetty-core/pom.xml b/jetty-core/pom.xml index 76360c94426e..9b5805c4bc2f 100644 --- a/jetty-core/pom.xml +++ b/jetty-core/pom.xml @@ -38,6 +38,7 @@ jetty-security jetty-server jetty-session + jetty-siwe jetty-slf4j-impl jetty-start jetty-tests