diff --git a/documentation/jetty/modules/code/examples/pom.xml b/documentation/jetty/modules/code/examples/pom.xml index 87c524d85fb..ae7103350cf 100644 --- a/documentation/jetty/modules/code/examples/pom.xml +++ b/documentation/jetty/modules/code/examples/pom.xml @@ -26,6 +26,10 @@ org.eclipse.jetty jetty-client + + org.eclipse.jetty + jetty-ethereum + org.eclipse.jetty jetty-infinispan-embedded-query @@ -54,6 +58,10 @@ org.eclipse.jetty jetty-session + + org.eclipse.jetty + jetty-slf4j-impl + org.eclipse.jetty jetty-unixdomain-server diff --git a/documentation/jetty/modules/code/examples/src/main/java/org/eclipse/jetty/docs/programming/security/siwe/SignInWithEthereum.java b/documentation/jetty/modules/code/examples/src/main/java/org/eclipse/jetty/docs/programming/security/siwe/SignInWithEthereum.java new file mode 100644 index 00000000000..beb4a0987db --- /dev/null +++ b/documentation/jetty/modules/code/examples/src/main/java/org/eclipse/jetty/docs/programming/security/siwe/SignInWithEthereum.java @@ -0,0 +1,58 @@ +// +// ======================================================================== +// 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 SignInWithEthereum +{ + public static SecurityHandler createSecurityHandler(Handler handler) + { + // tag::configureSecurityHandler[] + // This uses jetty-core, but you can configure a ConstraintSecurityHandler for use with EE10. + SecurityHandler.PathMapped securityHandler = new SecurityHandler.PathMapped(); + securityHandler.setHandler(handler); + securityHandler.put("/*", Constraint.ANY_USER); + + // Add the EthereumAuthenticator to the securityHandler. + EthereumAuthenticator authenticator = new EthereumAuthenticator(); + securityHandler.setAuthenticator(authenticator); + + // In embedded you can configure via EthereumAuthenticator APIs. + authenticator.setLoginPath("/login.html"); + + // Or you can configure with parameters on the SecurityHandler. + securityHandler.setParameter(EthereumAuthenticator.LOGIN_PATH_PARAM, "/login.html"); + // end::configureSecurityHandler[] + return securityHandler; + } +} \ No newline at end of file diff --git a/documentation/jetty/modules/code/examples/src/main/java/org/eclipse/jetty/docs/programming/security/siwe/SignInWithEthereumEmbeddedExample.java b/documentation/jetty/modules/code/examples/src/main/java/org/eclipse/jetty/docs/programming/security/siwe/SignInWithEthereumEmbeddedExample.java new file mode 100644 index 00000000000..7cebeafd045 --- /dev/null +++ b/documentation/jetty/modules/code/examples/src/main/java/org/eclipse/jetty/docs/programming/security/siwe/SignInWithEthereumEmbeddedExample.java @@ -0,0 +1,117 @@ +// +// ======================================================================== +// 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.docs.programming.security.siwe; + +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/main/resources/") + .normalize().toString(); + System.err.println(resourcePath); + 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("
Logout"); + } + callback.succeeded(); + return true; + } + }; + + SecurityHandler securityHandler = createSecurityHandler(handler); + SessionHandler sessionHandler = new SessionHandler(); + sessionHandler.setHandler(securityHandler); + + ContextHandler contextHandler = new ContextHandler(); + contextHandler.setContextPath("/"); + contextHandler.setHandler(sessionHandler); + + server.setHandler(contextHandler); + server.start(); + server.join(); + } + + public static SecurityHandler createSecurityHandler(Handler handler) + { + // tag::configureSecurityHandler[] + // This uses jetty-core, but you can configure a ConstraintSecurityHandler for use with EE10. + SecurityHandler.PathMapped securityHandler = new SecurityHandler.PathMapped(); + securityHandler.setHandler(handler); + securityHandler.put("/*", Constraint.ANY_USER); + + // Add the EthereumAuthenticator to the securityHandler. + EthereumAuthenticator authenticator = new EthereumAuthenticator(); + securityHandler.setAuthenticator(authenticator); + + // In embedded you can configure via EthereumAuthenticator APIs. + authenticator.setLoginPath("/login.html"); + + // Or you can configure with parameters on the SecurityHandler. + securityHandler.setParameter(EthereumAuthenticator.LOGIN_PATH_PARAM, "/login.html"); + // end::configureSecurityHandler[] + + return securityHandler; + } +} \ No newline at end of file diff --git a/documentation/jetty/modules/code/examples/src/main/resources/login.html b/documentation/jetty/modules/code/examples/src/main/resources/login.html new file mode 100644 index 00000000000..6bd85e68968 --- /dev/null +++ b/documentation/jetty/modules/code/examples/src/main/resources/login.html @@ -0,0 +1,58 @@ + + + + + + Sign-In with Ethereum + + + +

Sign-In with Ethereum

+ + + + + + + diff --git a/documentation/jetty/modules/programming-guide/nav.adoc b/documentation/jetty/modules/programming-guide/nav.adoc index 3e843d0bcd2..d4571b36f3e 100644 --- a/documentation/jetty/modules/programming-guide/nav.adoc +++ b/documentation/jetty/modules/programming-guide/nav.adoc @@ -43,6 +43,8 @@ ** xref:troubleshooting/state-tracking.adoc[] ** xref:troubleshooting/component-dump.adoc[] ** xref:troubleshooting/debugging.adoc[] +* Jetty Security +** xref:security/siwe-support.adoc[] * Migration Guides ** xref:migration/94-to-10.adoc[] ** xref:migration/11-to-12.adoc[] diff --git a/documentation/jetty/modules/programming-guide/pages/security/siwe-support.adoc b/documentation/jetty/modules/programming-guide/pages/security/siwe-support.adoc new file mode 100644 index 00000000000..715f8f8283a --- /dev/null +++ b/documentation/jetty/modules/programming-guide/pages/security/siwe-support.adoc @@ -0,0 +1,180 @@ +// +// ======================================================================== +// 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 +// ======================================================================== +// + +[[siwe-support]] += SIWE Support + +== Introduction + +Sign-In with Ethereum (SIWE) is a decentralized authentication protocol that allows users to authenticate using their Ethereum account. + +This enables users to retain more control over their identity and provides an alternative to protocols such as OpenID Connect, which rely on a centralized identity provider. + +Sign-In with Ethereum works by using off-chain services to sign a standard message format defined by EIP-4361 (https://eips.ethereum.org/EIPS/eip-4361). The user signs the SIWE message to prove ownership of the Ethereum address. This is verified by the server by extracting the Ethereum address from the signature and comparing it to the address supplied in the SIWE message. + +Typically, you would rely on a browser extension such as MetaMask to provide a user-friendly way for users to sign the message with their Ethereum account. + +=== Support + +Currently Jetty only provides support SIWE in Jetty 12.1+ and only for `jetty-core`, and `ee10`+ environments. It is enabled by adding the `EtheremAuthenticator` to the `SecurityHandler` of your web application. + +== Usage + +=== Enabling SIWE +The Sign-In with Ethereum module can be enabled when using Standalone Jetty with. +[source,subs=attributes+] +---- +$ java -jar $JETTY_HOME/start.jar --add-modules=siwe +---- + +If using embedded Jetty you must add the `EthereumAuthenticator` to your `SecurityHandler`. + +=== Configuration + +Configuration of the `EthereumAuthenticator` is done through init params on the `ServletContext` or `SecurityHandler`. The `loginPath` is the only mandatory configuration and the others have defaults that you may wish to configure. + +Login Path:: +* Init param: `org.eclipse.jetty.security.siwe.login_path` +* Description: Unauthenticated requests are redirected to a login page where they must sign a SIWE message and send it to the server. This path represents a page in the application that contains the SIWE login page. + +Nonce Path:: +* Init param: `org.eclipse.jetty.security.siwe.nonce_path` +* Description: Requests to this path will generate a random nonce string which is associated with the session. The nonce is used in the SIWE Message to avoid replay attacks. The path at which this nonce is served can be configured through the init parameter. The application does not need to implement their own nonce endpoint, they just configure this path and the Authenticator handles it. The default value for this is `/auth/nonce` if left un-configured. + +Authentication Path:: +* Init param: `org.eclipse.jetty.security.siwe.authentication_path` +* Description: The authentication path is where requests containing a signed SIWE message are sent in order to authenticate the user. The default value for this is `/auth/login`. + +Max Message Size:: +* Init Param: `org.eclipse.jetty.security.siwe.max_message_size` +* Description: This is the max size of the authentication message which can be read by the implementation. This limit defaults to `4 * 1024`. This is necessary because the complete request content is read into a string and then parsed. + +Logout Redirect Path:: +* Init Param: `org.eclipse.jetty.security.siwe.logout_redirect_path` +* Description: Where the request is redirected to after logout. If left un-configured no redirect will be done upon logout. + +Error Path:: +* Init Param: `org.eclipse.jetty.security.siwe.error_path` +* Description: Path where Authentication errors are sent, this may contain an optional query string. An error description is available on the error page through the request parameter `error_description_jetty`. If this configuration is not set Jetty will send a 403 Forbidden response upon authentication errors. + +Dispatch:: +* Init Param: `org.eclipse.jetty.security.siwe.dispatch` +* Description: If set to true a dispatch will be done instead of a redirect to the login page in the case of an unauthenticated request. This defaults to false. + +Authenticate New Users:: +* Init Param: `org.eclipse.jetty.security.siwe.authenticate_new_users` +* Description: This can be set to false if you have a nested `LoginService` and only want to authenticate users known by the `LoginService`. This defaults to `true` meaning that any user will be authenticated regardless if they are known by the nested `LoginService`. + +Domains:: +* Init Param: org.eclipse.jetty.security.siwe.domains +* Description: This list of allowed domains to be declared in the `domain` field of the SIWE Message. If left blank this will allow all domains. + +Chain IDs:: +* Init Param: org.eclipse.jetty.security.siwe.chainIds +* Description: This list of allowed Chain IDs to be declared in the `chain-id` field of the SIWE Message. If left blank this will allow all Chain IDs. + +=== Nested LoginService + +A nested `LoginService` may be used to assign roles to users of a known Ethereum Address. Or the nested `LoginService` may be combined with the setting `authenticateNewUsers == false` to only allow authentication of known users. + +For example a `HashLoginService` may be configured through the `jetty-ee10-web.xml` file: +[, xml, indent=0] +---- + + + + + /etc/realm.properties + + + + + + + myRealm + + + + + +---- + +=== Application Implementation +EIP-4361 specifies the format of a SIWE Message, the overview of the Sign-In with Ethereum process, and message validation. However, it does not specify certain things like how the SIWE Message and signature are sent to the server for validation, and it does not specify the process the client acquires the nonce from the server. For this reason the `EthereumAuthenticator` has been made extensible to allow different implementations. + +Currently Jetty supports authentication requests of type `application/x-www-form-urlencoded` or `multipart/form-data`, which contains the fields `message` and `signature`. Where `message` contains the full SIWE message, and `signature` is the ERC-1271 signature of the SIWE message. + +The nonce endpoint provided by the `EthereumAuthenticator` returns a response with `application/json` format, with a single key of `nonce`. + +=== Configuring Security Handler +[,java,indent=0] +---- +include::code:example$src/main/java/org/eclipse/jetty/docs/programming/security/siwe/SignInWithEthereumEmbeddedExample.java[tags=configureSecurityHandler] +---- + +=== Login Page Example + +Include the `Web3.js` library to interact with the users Ethereum wallet. +[,html,indent=0] +---- + +---- + +HTML form to submit the sign in request. +[,html,indent=0] +---- + + + +---- + +Add script to generate and sign the SIWE message when the sign-in button is pressed. +[,html,indent=0] +---- + +---- \ No newline at end of file diff --git a/jetty-core/jetty-bom/pom.xml b/jetty-core/jetty-bom/pom.xml index 39288818be5..ae78ba2bca5 100644 --- a/jetty-core/jetty-bom/pom.xml +++ b/jetty-core/jetty-bom/pom.xml @@ -55,6 +55,11 @@ jetty-deploy 12.1.0-SNAPSHOT
+ + org.eclipse.jetty + jetty-ethereum + 12.0.13-SNAPSHOT + org.eclipse.jetty jetty-http diff --git a/jetty-core/jetty-keystore/pom.xml b/jetty-core/jetty-keystore/pom.xml index 5ce81278e30..2dbfe515aec 100644 --- a/jetty-core/jetty-keystore/pom.xml +++ b/jetty-core/jetty-keystore/pom.xml @@ -12,7 +12,6 @@ Test keystore with self-signed SSL Certificate. - 1.77 ${project.groupId}.keystore @@ -20,17 +19,14 @@ org.bouncycastle bcpkix-jdk15to18 - ${bouncycastle.version} org.bouncycastle bcprov-jdk15to18 - ${bouncycastle.version} org.bouncycastle bcutil-jdk15to18 - ${bouncycastle.version} org.eclipse.jetty 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 d992532b9dd..210187dd046 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/FormFields.java b/jetty-core/jetty-server/src/main/java/org/eclipse/jetty/server/FormFields.java index ed0ba41fcfc..a3558caf89c 100644 --- a/jetty-core/jetty-server/src/main/java/org/eclipse/jetty/server/FormFields.java +++ b/jetty-core/jetty-server/src/main/java/org/eclipse/jetty/server/FormFields.java @@ -176,7 +176,7 @@ public static CompletableFuture from(Request request, Charset charset, i * @param maxLength The maximum total size of the fields * @return A {@link CompletableFuture} that will provide the {@link Fields} or a failure. */ - static CompletableFuture from(Content.Source source, Attributes attributes, Charset charset, int maxFields, int maxLength) + public static CompletableFuture from(Content.Source source, Attributes attributes, Charset charset, int maxFields, int maxLength) { Object attr = attributes.getAttribute(FormFields.class.getName()); if (attr instanceof FormFields futureFormFields) diff --git a/jetty-ee10/jetty-ee10-servlet/src/main/java/org/eclipse/jetty/ee10/servlet/security/ConstraintSecurityHandler.java b/jetty-ee10/jetty-ee10-servlet/src/main/java/org/eclipse/jetty/ee10/servlet/security/ConstraintSecurityHandler.java index 035486a1d49..83e74fe072b 100644 --- a/jetty-ee10/jetty-ee10-servlet/src/main/java/org/eclipse/jetty/ee10/servlet/security/ConstraintSecurityHandler.java +++ b/jetty-ee10/jetty-ee10-servlet/src/main/java/org/eclipse/jetty/ee10/servlet/security/ConstraintSecurityHandler.java @@ -17,6 +17,7 @@ import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; +import java.util.Enumeration; import java.util.HashMap; import java.util.HashSet; import java.util.List; @@ -29,6 +30,7 @@ import jakarta.servlet.HttpConstraintElement; import jakarta.servlet.HttpMethodConstraintElement; +import jakarta.servlet.ServletContext; import jakarta.servlet.ServletSecurityElement; import jakarta.servlet.annotation.ServletSecurity.EmptyRoleSemantic; import jakarta.servlet.annotation.ServletSecurity.TransportGuarantee; @@ -40,6 +42,7 @@ import org.eclipse.jetty.security.Constraint; import org.eclipse.jetty.security.Constraint.Transport; import org.eclipse.jetty.security.SecurityHandler; +import org.eclipse.jetty.server.Context; import org.eclipse.jetty.server.Request; import org.eclipse.jetty.server.Server; import org.eclipse.jetty.server.handler.ContextHandler; @@ -373,6 +376,20 @@ protected void doStart() throws Exception //Servlet Spec 3.1 pg 147 sec 13.8.4.2 log paths for which there are uncovered http methods checkPathsWithUncoveredHttpMethods(); + Context context = ContextHandler.getCurrentContext(); + if (context instanceof ServletContextHandler.ServletScopedContext servletScopedContext) + { + ServletContext servletContext = servletScopedContext.getServletContext(); + Enumeration names = servletContext.getInitParameterNames(); + while (names != null && names.hasMoreElements()) + { + String name = names.nextElement(); + if (name.startsWith("org.eclipse.jetty.security.") && + getParameter(name) == null) + setParameter(name, servletContext.getInitParameter(name)); + } + } + super.doStart(); } diff --git a/jetty-ee10/jetty-ee10-tests/jetty-ee10-test-siwe-webapp/pom.xml b/jetty-ee10/jetty-ee10-tests/jetty-ee10-test-siwe-webapp/pom.xml new file mode 100644 index 00000000000..7937d36af74 --- /dev/null +++ b/jetty-ee10/jetty-ee10-tests/jetty-ee10-test-siwe-webapp/pom.xml @@ -0,0 +1,27 @@ + + + + 4.0.0 + + org.eclipse.jetty.ee10 + jetty-ee10-tests + 12.1.0-SNAPSHOT + + jetty-ee10-test-siwe-webapp + war + + EE10 :: Tests :: SIWE WebApp + + + + org.eclipse.jetty + jetty-slf4j-impl + compile + + + jakarta.servlet + jakarta.servlet-api + provided + + + diff --git a/jetty-ee10/jetty-ee10-tests/jetty-ee10-test-siwe-webapp/src/main/java/com/acme/AdminServlet.java b/jetty-ee10/jetty-ee10-tests/jetty-ee10-test-siwe-webapp/src/main/java/com/acme/AdminServlet.java new file mode 100644 index 00000000000..5949b1d0a54 --- /dev/null +++ b/jetty-ee10/jetty-ee10-tests/jetty-ee10-test-siwe-webapp/src/main/java/com/acme/AdminServlet.java @@ -0,0 +1,32 @@ +// +// ======================================================================== +// 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 com.acme; + +import java.io.IOException; + +import jakarta.servlet.annotation.WebServlet; +import jakarta.servlet.http.HttpServlet; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; + +@WebServlet("/admin") +public class AdminServlet extends HttpServlet +{ + @Override + protected void doGet(HttpServletRequest request, HttpServletResponse response) throws IOException + { + response.setContentType("text/plain"); + response.getWriter().print("adminPage userPrincipal: " + request.getUserPrincipal().getName()); + } +} diff --git a/jetty-ee10/jetty-ee10-tests/jetty-ee10-test-siwe-webapp/src/main/java/com/acme/ErrorServlet.java b/jetty-ee10/jetty-ee10-tests/jetty-ee10-test-siwe-webapp/src/main/java/com/acme/ErrorServlet.java new file mode 100644 index 00000000000..2a0644b1bca --- /dev/null +++ b/jetty-ee10/jetty-ee10-tests/jetty-ee10-test-siwe-webapp/src/main/java/com/acme/ErrorServlet.java @@ -0,0 +1,37 @@ +// +// ======================================================================== +// 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 com.acme; + +import java.io.IOException; + +import jakarta.servlet.annotation.WebServlet; +import jakarta.servlet.http.HttpServlet; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; + +@WebServlet("/error") +public class ErrorServlet extends HttpServlet +{ + @Override + protected void doGet(HttpServletRequest request, HttpServletResponse response) throws IOException + { + + response.setContentType("text/html"); + response.getWriter().println("

error: not authorized

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

" + request.getUserPrincipal() + "

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

" + request.getParameter("error_description_jetty") + "

"); + String home = request.getContextPath().isEmpty() ? "/" : request.getContextPath(); + response.getWriter().println("Home
"); + } +} diff --git a/jetty-ee10/jetty-ee10-tests/jetty-ee10-test-siwe-webapp/src/main/java/com/acme/ForbiddenServlet.java b/jetty-ee10/jetty-ee10-tests/jetty-ee10-test-siwe-webapp/src/main/java/com/acme/ForbiddenServlet.java new file mode 100644 index 00000000000..1e7c2ec3ac0 --- /dev/null +++ b/jetty-ee10/jetty-ee10-tests/jetty-ee10-test-siwe-webapp/src/main/java/com/acme/ForbiddenServlet.java @@ -0,0 +1,33 @@ +// +// ======================================================================== +// 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 com.acme; + +import java.io.IOException; + +import jakarta.servlet.annotation.WebServlet; +import jakarta.servlet.http.HttpServlet; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; + +@WebServlet("/forbidden") +public class ForbiddenServlet extends HttpServlet +{ + @Override + protected void doGet(HttpServletRequest request, HttpServletResponse response) throws IOException + { + String home = request.getContextPath().isEmpty() ? "/" : request.getContextPath(); + response.getWriter().println("

Not authorized to access this page.

"); + response.getWriter().println("Home
"); + } +} diff --git a/jetty-ee10/jetty-ee10-tests/jetty-ee10-test-siwe-webapp/src/main/java/com/acme/HomeServlet.java b/jetty-ee10/jetty-ee10-tests/jetty-ee10-test-siwe-webapp/src/main/java/com/acme/HomeServlet.java new file mode 100644 index 00000000000..6edf8f94d04 --- /dev/null +++ b/jetty-ee10/jetty-ee10-tests/jetty-ee10-test-siwe-webapp/src/main/java/com/acme/HomeServlet.java @@ -0,0 +1,37 @@ +// +// ======================================================================== +// 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 com.acme; + +import java.io.IOException; +import java.security.Principal; + +import jakarta.servlet.annotation.WebServlet; +import jakarta.servlet.http.HttpServlet; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; + +@WebServlet("") +public class HomeServlet extends HttpServlet +{ + @Override + protected void doGet(HttpServletRequest request, HttpServletResponse response) throws IOException + { + response.setContentType("text/plain"); + Principal userPrincipal = request.getUserPrincipal(); + if (userPrincipal != null) + response.getWriter().print("userPrincipal: " + userPrincipal.getName()); + else + response.getWriter().print("not authenticated"); + } +} diff --git a/jetty-ee10/jetty-ee10-tests/jetty-ee10-test-siwe-webapp/src/main/java/com/acme/LogoutServlet.java b/jetty-ee10/jetty-ee10-tests/jetty-ee10-test-siwe-webapp/src/main/java/com/acme/LogoutServlet.java new file mode 100644 index 00000000000..055be42607b --- /dev/null +++ b/jetty-ee10/jetty-ee10-tests/jetty-ee10-test-siwe-webapp/src/main/java/com/acme/LogoutServlet.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 com.acme; + +import java.io.IOException; + +import jakarta.servlet.ServletException; +import jakarta.servlet.annotation.WebServlet; +import jakarta.servlet.http.HttpServlet; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; + +@WebServlet("/logout") +public class LogoutServlet extends HttpServlet +{ + @Override + protected void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException + { + request.logout(); + if (!response.isCommitted()) + response.sendRedirect(request.getContextPath()); + } +} diff --git a/jetty-ee10/jetty-ee10-tests/jetty-ee10-test-siwe-webapp/src/main/webapp/WEB-INF/jetty-ee10-web.xml b/jetty-ee10/jetty-ee10-tests/jetty-ee10-test-siwe-webapp/src/main/webapp/WEB-INF/jetty-ee10-web.xml new file mode 100644 index 00000000000..6da307c0395 --- /dev/null +++ b/jetty-ee10/jetty-ee10-tests/jetty-ee10-test-siwe-webapp/src/main/webapp/WEB-INF/jetty-ee10-web.xml @@ -0,0 +1,20 @@ + + + + + + + + /etc/realm.properties + + + + + + + myRealm + + + + + \ No newline at end of file diff --git a/jetty-ee10/jetty-ee10-tests/jetty-ee10-test-siwe-webapp/src/main/webapp/WEB-INF/web.xml b/jetty-ee10/jetty-ee10-tests/jetty-ee10-test-siwe-webapp/src/main/webapp/WEB-INF/web.xml new file mode 100644 index 00000000000..da4fe0c2b68 --- /dev/null +++ b/jetty-ee10/jetty-ee10-tests/jetty-ee10-test-siwe-webapp/src/main/webapp/WEB-INF/web.xml @@ -0,0 +1,69 @@ + + + + SIWE Authentication Webapp + + + SIWE + myRealm + + + + org.eclipse.jetty.security.siwe.login_path + /login.html + + + org.eclipse.jetty.security.siwe.error_path + /error?foobar=3 + + + org.eclipse.jetty.security.siwe.chainIds + 2,3,4,1,5,7 + + + + admin + + + forbidden + + + ** + + + + + User Pages + /login + /expiry + + + ** + + + + + + Admin Page + /admin + + + admin + + + + + + Forbidden Page + /forbidden + + + forbidden + + + + + diff --git a/jetty-ee10/jetty-ee10-tests/jetty-ee10-test-siwe-webapp/src/main/webapp/favicon.ico b/jetty-ee10/jetty-ee10-tests/jetty-ee10-test-siwe-webapp/src/main/webapp/favicon.ico new file mode 100644 index 00000000000..ea9e174b48b Binary files /dev/null and b/jetty-ee10/jetty-ee10-tests/jetty-ee10-test-siwe-webapp/src/main/webapp/favicon.ico differ diff --git a/jetty-ee10/jetty-ee10-tests/pom.xml b/jetty-ee10/jetty-ee10-tests/pom.xml index cb0530eda11..8577882bb9e 100644 --- a/jetty-ee10/jetty-ee10-tests/pom.xml +++ b/jetty-ee10/jetty-ee10-tests/pom.xml @@ -27,6 +27,7 @@ jetty-ee10-test-log4j2-webapp jetty-ee10-test-loginservice jetty-ee10-test-openid-webapp + jetty-ee10-test-siwe-webapp jetty-ee10-test-owb-cdi-webapp jetty-ee10-test-quickstart jetty-ee10-test-sessions diff --git a/jetty-home/pom.xml b/jetty-home/pom.xml index 1972104a95e..43937f48902 100644 --- a/jetty-home/pom.xml +++ b/jetty-home/pom.xml @@ -43,6 +43,10 @@ org.eclipse.jetty jetty-ee
+ + org.eclipse.jetty + jetty-ethereum + org.eclipse.jetty jetty-hazelcast diff --git a/jetty-integrations/jetty-ethereum/pom.xml b/jetty-integrations/jetty-ethereum/pom.xml new file mode 100644 index 00000000000..d8388d1c96a --- /dev/null +++ b/jetty-integrations/jetty-ethereum/pom.xml @@ -0,0 +1,82 @@ + + + + 4.0.0 + + org.eclipse.jetty + jetty-integrations + 12.1.0-SNAPSHOT + + jetty-ethereum + Core :: Sign-In with Ethereum + Jetty Sign-In with Ethereum + + + ${project.groupId}.siwe + + + + + org.bouncycastle + bcprov-jdk15to18 + + + 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.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-integrations/jetty-ethereum/src/main/config/modules/ethereum.mod b/jetty-integrations/jetty-ethereum/src/main/config/modules/ethereum.mod new file mode 100644 index 00000000000..2d94b2abebc --- /dev/null +++ b/jetty-integrations/jetty-ethereum/src/main/config/modules/ethereum.mod @@ -0,0 +1,18 @@ +# DO NOT EDIT THIS FILE - See: https://eclipse.dev/jetty/documentation/ + +[description] +Adds Sign-In with Ethereum (SIWE) authentication to the server. + +[depend] +security + +[files] +maven://org.bouncycastle/bcprov-jdk15to18/${bouncycastle.version}|lib/bouncycastle/bcprov-jdk15to18-${bouncycastle.version}.jar + +[lib] +lib/jetty-ethereum-${jetty.version}.jar +lib/bouncycastle/bcprov-jdk15to18-${bouncycastle.version}.jar + +[ini] +bouncycastle.version?=@bouncycastle.version@ +jetty.webapp.addHiddenClasses+=,${jetty.base.uri}/lib/bouncycastle/ \ No newline at end of file diff --git a/jetty-integrations/jetty-ethereum/src/main/java/module-info.java b/jetty-integrations/jetty-ethereum/src/main/java/module-info.java new file mode 100644 index 00000000000..d511ea20a5c --- /dev/null +++ b/jetty-integrations/jetty-ethereum/src/main/java/module-info.java @@ -0,0 +1,25 @@ +// +// ======================================================================== +// 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 +// ======================================================================== +// + +import org.eclipse.jetty.security.Authenticator; +import org.eclipse.jetty.security.siwe.EthereumAuthenticatorFactory; + +module org.eclipse.jetty.siwe +{ + requires transitive org.eclipse.jetty.security; + requires org.bouncycastle.provider; + + exports org.eclipse.jetty.security.siwe; + + provides Authenticator.Factory with EthereumAuthenticatorFactory; +} diff --git a/jetty-integrations/jetty-ethereum/src/main/java/org/eclipse/jetty/security/siwe/EthereumAuthenticator.java b/jetty-integrations/jetty-ethereum/src/main/java/org/eclipse/jetty/security/siwe/EthereumAuthenticator.java new file mode 100644 index 00000000000..d5eb997eec6 --- /dev/null +++ b/jetty-integrations/jetty-ethereum/src/main/java/org/eclipse/jetty/security/siwe/EthereumAuthenticator.java @@ -0,0 +1,827 @@ +// +// ======================================================================== +// 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.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.StringUtil; +import org.eclipse.jetty.util.URIUtil; +import org.eclipse.jetty.util.UrlEncoded; +import org.eclipse.jetty.util.component.Dumpable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import static org.eclipse.jetty.server.FormFields.getFormEncodedCharset; + +public class EthereumAuthenticator extends LoginAuthenticator implements Dumpable +{ + 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 LOGOUT_REDIRECT_PARAM = "org.eclipse.jetty.security.siwe.logout_redirect_path"; + public static final String ERROR_PATH_PARAM = "org.eclipse.jetty.security.siwe.error_path"; + public static final String ERROR_PARAMETER = "error_description_jetty"; + public static final String MAX_MESSAGE_SIZE_PARAM = "org.eclipse.jetty.security.siwe.max_message_size"; + public static final String DISPATCH_PARAM = "org.eclipse.jetty.security.siwe.dispatch"; + public static final String AUTHENTICATE_NEW_USERS_PARAM = "org.eclipse.jetty.security.siwe.authenticate_new_users"; + public static final String CHAIN_IDS_PARAM = "org.eclipse.jetty.security.siwe.chainIds"; + public static final String DOMAINS_PARAM = "org.eclipse.jetty.security.siwe.domains"; + private static final String J_URI = "org.eclipse.jetty.security.siwe.URI"; + private static final String J_POST = "org.eclipse.jetty.security.siwe.POST"; + private static final String J_METHOD = "org.eclipse.jetty.security.siwe.METHOD"; + 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 _chainIds = new IncludeExcludeSet<>(); + private final IncludeExcludeSet _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 _errorPath; + private String _errorQuery; + private boolean _dispatch; + private boolean _authenticateNewUsers = true; + + public EthereumAuthenticator() + { + } + + public void includeDomains(String... domains) + { + _domains.include(domains); + } + + 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_PATH_PARAM); + if (error != null) + setErrorPage(error); + + String dispatch = authConfig.getParameter(DISPATCH_PARAM); + if (dispatch != null) + setDispatch(Boolean.parseBoolean(dispatch)); + + String authenticateNewUsers = authConfig.getParameter(AUTHENTICATE_NEW_USERS_PARAM); + if (authenticateNewUsers != null) + setAuthenticateNewUsers(Boolean.parseBoolean(authenticateNewUsers)); + + String chainIds = authConfig.getParameter(CHAIN_IDS_PARAM); + if (chainIds != null) + includeChainIds(StringUtil.csvSplit(chainIds)); + + String domains = authConfig.getParameter(DOMAINS_PARAM); + if (domains != null) + includeDomains(StringUtil.csvSplit(domains)); + + if (isAuthenticateNewUsers()) + { + LoginService loginService = new AnyUserLoginService(authConfig.getRealmName(), authConfig.getLoginService()); + authConfig = new Configuration.Wrapper(authConfig) + { + @Override + public LoginService getLoginService() + { + return loginService; + } + }; + } + + if (_loginPath == null) + throw new IllegalStateException("No loginPath"); + super.setConfiguration(authConfig); + } + + @Override + public String getAuthenticationType() + { + return Authenticator.SIWE_AUTH; + } + + public boolean isAuthenticateNewUsers() + { + return _authenticateNewUsers; + } + + /** + * Configures the behavior for authenticating users not found by a wrapped {@link LoginService}. + *

+ * This setting is only meaningful if a wrapped {@link LoginService} has been set. + *

+ *

+ * If set to {@code true}, users not found by a wrapped {@link LoginService} will authenticated with no roles. + * If set to {@code false}, only users found by a wrapped {@link LoginService} will be authenticated. + *

+ * + * @param authenticateNewUsers whether to authenticate users not found by the wrapped {@link 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; + } + else + { + if (!path.startsWith("/")) + { + LOG.warn("error-page must start with /"); + path = "/" + path; + } + _errorPath = path; + _errorQuery = ""; + + int queryIndex = _errorPath.indexOf('?'); + if (queryIndex > 0) + { + _errorPath = path.substring(0, queryIndex); + _errorQuery = path.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; + if (isNonceRequest(pathInContext)) + return Constraint.Authorization.ANY_USER; + 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 (_maxMessageSize >= 0 && 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)); + response.getHeaders().put(HttpHeader.CONTENT_TYPE, "application/json"); + 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), _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; + } + } + + 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 = SignInWithEthereumToken.from(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; + } + + 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("Authentication FAILED: {}", message); + + if (_errorPath == 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 {}", _errorPath); + + String contextPath = Request.getContextPath(request); + String redirectUri = URIUtil.addPaths(contextPath, _errorPath); + 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) + { + if (_errorPath == null) + return false; + 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); + } + } + + @Override + public void dump(Appendable out, String indent) throws IOException + { + Dumpable.dumpObjects(out, indent, this, + "loginPath=" + _loginPath, + "authenticationPath=" + _authenticationPath, + "noncePath=" + _noncePath, + "errorPath=" + _errorPath, + "errorQuery=" + _errorQuery, + "dispatch=" + _dispatch, + "authenticateNewUsers=" + _authenticateNewUsers, + "logoutRedirectPath=" + _logoutRedirectPath, + "maxMessageSize=" + _maxMessageSize, + "chainIds=" + _chainIds, + "domains=" + _domains + ); + } + + 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); + } + } + + public record SignedMessage(String message, String signature) + { + public String recoverAddress() + { + return EthereumUtil.recoverAddress(this); + } + } +} diff --git a/jetty-integrations/jetty-ethereum/src/main/java/org/eclipse/jetty/security/siwe/EthereumAuthenticatorFactory.java b/jetty-integrations/jetty-ethereum/src/main/java/org/eclipse/jetty/security/siwe/EthereumAuthenticatorFactory.java new file mode 100644 index 00000000000..da2d5266516 --- /dev/null +++ b/jetty-integrations/jetty-ethereum/src/main/java/org/eclipse/jetty/security/siwe/EthereumAuthenticatorFactory.java @@ -0,0 +1,30 @@ +// +// ======================================================================== +// 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.Authenticator; +import org.eclipse.jetty.server.Context; +import org.eclipse.jetty.server.Server; + +public class EthereumAuthenticatorFactory implements Authenticator.Factory +{ + @Override + public Authenticator getAuthenticator(Server server, Context context, Authenticator.Configuration configuration) + { + String auth = configuration.getAuthenticationType(); + if (Authenticator.SIWE_AUTH.equalsIgnoreCase(auth)) + return new EthereumAuthenticator(); + return null; + } +} diff --git a/jetty-integrations/jetty-ethereum/src/main/java/org/eclipse/jetty/security/siwe/internal/AnyUserLoginService.java b/jetty-integrations/jetty-ethereum/src/main/java/org/eclipse/jetty/security/siwe/internal/AnyUserLoginService.java new file mode 100644 index 00000000000..e1f099cda87 --- /dev/null +++ b/jetty-integrations/jetty-ethereum/src/main/java/org/eclipse/jetty/security/siwe/internal/AnyUserLoginService.java @@ -0,0 +1,114 @@ +// +// ======================================================================== +// 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; + +/** + * A {@link LoginService} which allows unknown users to be authenticated. + *

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

+ */ +public class AnyUserLoginService implements LoginService +{ + private final String _realm; + private final LoginService _loginService; + private IdentityService _identityService; + + /** + * @param realm the realm name. + * @param loginService optional {@link LoginService} which can be used to assign roles to known users. + */ + public AnyUserLoginService(String realm, LoginService loginService) + { + _realm = realm; + _loginService = loginService; + _identityService = (loginService == null) ? new DefaultIdentityService() : null; + } + + @Override + public String getName() + { + return _realm; + } + + @Override + public UserIdentity login(String username, Object credentials, Request request, Function getOrCreateSession) + { + if (_loginService != null) + { + UserIdentity login = _loginService.login(username, credentials, request, getOrCreateSession); + if (login != null) + return login; + + UserPrincipal userPrincipal = new UserPrincipal(username, null); + Subject subject = new Subject(); + subject.getPrincipals().add(userPrincipal); + if (credentials != null) + subject.getPrivateCredentials().add(credentials); + subject.setReadOnly(); + return _loginService.getUserIdentity(subject, userPrincipal, true); + } + + UserPrincipal userPrincipal = new UserPrincipal(username, null); + Subject subject = new Subject(); + subject.getPrincipals().add(userPrincipal); + if (credentials != null) + subject.getPrivateCredentials().add(credentials); + subject.setReadOnly(); + 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); + } +} diff --git a/jetty-integrations/jetty-ethereum/src/main/java/org/eclipse/jetty/security/siwe/internal/EthereumUtil.java b/jetty-integrations/jetty-ethereum/src/main/java/org/eclipse/jetty/security/siwe/internal/EthereumUtil.java new file mode 100644 index 00000000000..9e915f107fe --- /dev/null +++ b/jetty-integrations/jetty-ethereum/src/main/java/org/eclipse/jetty/security/siwe/internal/EthereumUtil.java @@ -0,0 +1,150 @@ +// +// ======================================================================== +// 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.Charset; +import java.nio.charset.StandardCharsets; +import java.security.SecureRandom; +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.security.siwe.EthereumAuthenticator; +import org.eclipse.jetty.util.StringUtil; + +public class EthereumUtil +{ + public static final String PREFIX = "\u0019Ethereum Signed Message:\n"; + private static final String NONCE_CHARACTERS = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; + private static final SecureRandom RANDOM = new SecureRandom(); + 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 static final Charset CHARSET = StandardCharsets.UTF_8; + + private EthereumUtil() + { + } + + /** + * Recover the Ethereum Address from the {@link EthereumAuthenticator.SignedMessage}. + *

+ * This uses algorithms and terminology defined in EIP-191 and + * ECDSA. + *

+ * @param signedMessage the signed message used to recover the address. + * @return the ethereum address recovered from the signature. + */ + public static String recoverAddress(EthereumAuthenticator.SignedMessage signedMessage) + { + String siweMessage = signedMessage.message(); + String signatureHex = signedMessage.signature(); + if (StringUtil.asciiStartsWithIgnoreCase(signatureHex, "0x")) + signatureHex = signatureHex.substring(2); + + int messageLength = siweMessage.getBytes(CHARSET).length; + String prefixedMessage = PREFIX + messageLength + siweMessage; + byte[] messageHash = keccak256(prefixedMessage.getBytes(CHARSET)); + 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); + + ECPoint qPoint = ecRecover(messageHash, v, r, s); + if (qPoint == null) + return null; + return toAddress(qPoint); + } + + public static ECPoint 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; + ECPoint rPoint = decodePoint(x, v); + if (!rPoint.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); + return ECAlgorithms.sumOfTwoMultiplies(DOMAIN_PARAMS.getG(), u1, rPoint, u2); + } + + public static String toAddress(ECPoint point) + { + // Remove the 1-byte prefix and return the public key as an ethereum address. + byte[] qBytes = point.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 ECPoint decodePoint(BigInteger p, int v) + { + byte[] encodedPoint = INT_CONVERTER.integerToBytes(p, 1 + INT_CONVERTER.getByteLength(DOMAIN_PARAMS.getCurve())); + encodedPoint[0] = (byte)((v % 2) == 0 ? 0x02 : 0x03); + return DOMAIN_PARAMS.getCurve().decodePoint(encodedPoint); + } + + 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(); + } + + 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-integrations/jetty-ethereum/src/main/java/org/eclipse/jetty/security/siwe/internal/SignInWithEthereumToken.java b/jetty-integrations/jetty-ethereum/src/main/java/org/eclipse/jetty/security/siwe/internal/SignInWithEthereumToken.java new file mode 100644 index 00000000000..a4e2a2780e1 --- /dev/null +++ b/jetty-integrations/jetty-ethereum/src/main/java/org/eclipse/jetty/security/siwe/internal/SignInWithEthereumToken.java @@ -0,0 +1,144 @@ +// +// ======================================================================== +// 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 java.util.regex.Matcher; +import java.util.regex.Pattern; + +import org.eclipse.jetty.security.ServerAuthException; +import org.eclipse.jetty.security.siwe.EthereumAuthenticator; +import org.eclipse.jetty.util.IncludeExcludeSet; +import org.eclipse.jetty.util.StringUtil; + +/** + * Record representing a parsed SIWE message defined by EIP4361. + * @param scheme the URI scheme of the origin of the request. + * @param domain the domain that is requesting the signing. + * @param address the Ethereum address performing the signing. + * @param statement a human-readable ASCII assertion that the user will sign. + * @param uri an RFC 3986 URI referring to the resource that is the subject of the signing. + * @param version the version of the SIWE Message. + * @param chainId the Chain ID to which the session is bound. + * @param nonce a random string used to prevent replay attacks. + * @param issuedAt time when the message was generated. + * @param expirationTime time when the signed authentication message is no longer valid. + * @param notBefore time when the signed authentication message will become valid. + * @param requestId a system-specific request identifier. + * @param resources list of resources the user wishes to have resolved as part of authentication. + */ +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) +{ + 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 + ); + + /** + * Parses a SIWE Message into a {@link SignInWithEthereumToken}, + * based off the ABNF Message Format from EIP-4361. + * @param message the SIWE message to parse. + * @return the {@link SignInWithEthereumToken} or null if it was not a valid SIWE message. + */ + public static SignInWithEthereumToken from(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")); + } + + /** + * @param signedMessage the {@link EthereumAuthenticator.SignedMessage}. + * @param validateNonce a {@link Predicate} used to validate the nonce. + * @param domains the {@link IncludeExcludeSet} used to validate the domain. + * @param chainIds the {@link IncludeExcludeSet} used to validate the chainId. + * @throws ServerAuthException if the {@link EthereumAuthenticator.SignedMessage} fails validation. + */ + public void validate(EthereumAuthenticator.SignedMessage signedMessage, Predicate validateNonce, + IncludeExcludeSet domains, + IncludeExcludeSet chainIds) throws ServerAuthException + { + if (validateNonce != null && !validateNonce.test(nonce())) + throw new ServerAuthException("invalid nonce " + nonce); + + if (!StringUtil.asciiEqualsIgnoreCase(signedMessage.recoverAddress(), address())) + throw new ServerAuthException("signature verification failed"); + + if (!"1".equals(version())) + throw new ServerAuthException("unsupported version " + 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 (domains != null && !domains.test(domain())) + throw new ServerAuthException("unregistered domain: " + domain()); + if (chainIds != null && !chainIds.test(chainId())) + throw new ServerAuthException("unregistered chainId: " + chainId()); + } +} diff --git a/jetty-integrations/jetty-ethereum/src/main/resources/META-INF/services/org.eclipse.jetty.security.Authenticator$Factory b/jetty-integrations/jetty-ethereum/src/main/resources/META-INF/services/org.eclipse.jetty.security.Authenticator$Factory new file mode 100644 index 00000000000..5e568c70188 --- /dev/null +++ b/jetty-integrations/jetty-ethereum/src/main/resources/META-INF/services/org.eclipse.jetty.security.Authenticator$Factory @@ -0,0 +1 @@ +org.eclipse.jetty.security.siwe.EthereumAuthenticatorFactory diff --git a/jetty-integrations/jetty-ethereum/src/test/java/org/eclipse/jetty/security/siwe/SignInWithEthereumParserTest.java b/jetty-integrations/jetty-ethereum/src/test/java/org/eclipse/jetty/security/siwe/SignInWithEthereumParserTest.java new file mode 100644 index 00000000000..1b24651f642 --- /dev/null +++ b/jetty-integrations/jetty-ethereum/src/test/java/org/eclipse/jetty/security/siwe/SignInWithEthereumParserTest.java @@ -0,0 +1,157 @@ +// +// ======================================================================== +// 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.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 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 = SignInWithEthereumToken.from(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 = SignInWithEthereumToken.from(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-integrations/jetty-ethereum/src/test/java/org/eclipse/jetty/security/siwe/SignInWithEthereumTest.java b/jetty-integrations/jetty-ethereum/src/test/java/org/eclipse/jetty/security/siwe/SignInWithEthereumTest.java new file mode 100644 index 00000000000..9707c51d0e3 --- /dev/null +++ b/jetty-integrations/jetty-ethereum/src/test/java/org/eclipse/jetty/security/siwe/SignInWithEthereumTest.java @@ -0,0 +1,277 @@ +// +// ======================================================================== +// 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 ("/error".equals(pathInContext)) + { + response.write(true, BufferUtil.toBuffer("ERROR"), callback); + return true; + } + 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(); + + SecurityHandler.PathMapped securityHandler = new SecurityHandler.PathMapped(); + securityHandler.setAuthenticator(_authenticator); + securityHandler.setHandler(handler); + securityHandler.setParameter(EthereumAuthenticator.LOGIN_PATH_PARAM, "/login"); + 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); + EthereumAuthenticator.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); + EthereumAuthenticator.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 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(EthereumAuthenticator.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-integrations/jetty-ethereum/src/test/java/org/eclipse/jetty/security/siwe/SignInWithEthereumTokenTest.java b/jetty-integrations/jetty-ethereum/src/test/java/org/eclipse/jetty/security/siwe/SignInWithEthereumTokenTest.java new file mode 100644 index 00000000000..a04a6d5b3c6 --- /dev/null +++ b/jetty-integrations/jetty-ethereum/src/test/java/org/eclipse/jetty/security/siwe/SignInWithEthereumTokenTest.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.internal.EthereumUtil; +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 + ); + + EthereumAuthenticator.SignedMessage signedMessage = credentials.signMessage(message); + SignInWithEthereumToken siwe = SignInWithEthereumToken.from(message); + assertNotNull(siwe); + + Throwable error = assertThrows(Throwable.class, () -> + siwe.validate(signedMessage, 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 + ); + + EthereumAuthenticator.SignedMessage signedMessage = credentials.signMessage(message); + SignInWithEthereumToken siwe = SignInWithEthereumToken.from(message); + assertNotNull(siwe); + + Throwable error = assertThrows(Throwable.class, () -> + siwe.validate(signedMessage, 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 + ); + + EthereumAuthenticator.SignedMessage signedMessage = credentials.signMessage(message); + SignInWithEthereumToken siwe = SignInWithEthereumToken.from(message); + assertNotNull(siwe); + + Throwable error = assertThrows(Throwable.class, () -> + siwe.validate(signedMessage, 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 + ); + + EthereumAuthenticator.SignedMessage signedMessage = credentials.signMessage(message); + SignInWithEthereumToken siwe = SignInWithEthereumToken.from(message); + assertNotNull(siwe); + + IncludeExcludeSet domains = new IncludeExcludeSet<>(); + domains.include("example.org"); + + Throwable error = assertThrows(Throwable.class, () -> + siwe.validate(signedMessage, null, domains, null)); + assertThat(error.getMessage(), containsString("unregistered domain")); + } + + @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 + ); + + EthereumAuthenticator.SignedMessage signedMessage = credentials.signMessage(message); + SignInWithEthereumToken siwe = SignInWithEthereumToken.from(message); + assertNotNull(siwe); + + IncludeExcludeSet chainIds = new IncludeExcludeSet<>(); + chainIds.include("1337"); + + Throwable error = assertThrows(Throwable.class, () -> + siwe.validate(signedMessage, 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 + ); + + EthereumAuthenticator.SignedMessage signedMessage = credentials.signMessage(message); + SignInWithEthereumToken siwe = SignInWithEthereumToken.from(message); + assertNotNull(siwe); + + Predicate nonceValidation = nonce -> false; + Throwable error = assertThrows(Throwable.class, () -> + siwe.validate(signedMessage, nonceValidation, 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 + ); + + EthereumAuthenticator.SignedMessage signedMessage = credentials.signMessage(message); + SignInWithEthereumToken siwe = SignInWithEthereumToken.from(message); + assertNotNull(siwe); + + Predicate nonceValidation = nonce -> true; + assertDoesNotThrow(() -> + siwe.validate(signedMessage, nonceValidation, null, null)); + } +} diff --git a/jetty-integrations/jetty-ethereum/src/test/java/org/eclipse/jetty/security/siwe/SignatureVerificationTest.java b/jetty-integrations/jetty-ethereum/src/test/java/org/eclipse/jetty/security/siwe/SignatureVerificationTest.java new file mode 100644 index 00000000000..6a2d3811915 --- /dev/null +++ b/jetty-integrations/jetty-ethereum/src/test/java/org/eclipse/jetty/security/siwe/SignatureVerificationTest.java @@ -0,0 +1,35 @@ +// +// ======================================================================== +// 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.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"; + EthereumAuthenticator.SignedMessage signedMessage = credentials.signMessage(siweMessage); + String address = credentials.getAddress(); + String recoveredAddress = signedMessage.recoverAddress(); + assertThat(recoveredAddress, equalToIgnoringCase(address)); + } +} diff --git a/jetty-integrations/jetty-ethereum/src/test/java/org/eclipse/jetty/security/siwe/util/EthereumCredentials.java b/jetty-integrations/jetty-ethereum/src/test/java/org/eclipse/jetty/security/siwe/util/EthereumCredentials.java new file mode 100644 index 00000000000..b2b07b91e64 --- /dev/null +++ b/jetty-integrations/jetty-ethereum/src/test/java/org/eclipse/jetty/security/siwe/util/EthereumCredentials.java @@ -0,0 +1,129 @@ +// +// ======================================================================== +// 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.Signature; +import java.security.spec.ECGenParameterSpec; +import java.util.Arrays; + +import org.bouncycastle.jcajce.provider.asymmetric.ec.BCECPublicKey; +import org.bouncycastle.jce.provider.BouncyCastleProvider; +import org.bouncycastle.math.ec.ECPoint; +import org.bouncycastle.util.encoders.Hex; +import org.eclipse.jetty.security.siwe.EthereumAuthenticator; +import org.eclipse.jetty.security.siwe.internal.EthereumUtil; + +import static org.eclipse.jetty.security.siwe.internal.EthereumUtil.keccak256; + +/** + * Test utility to generate an ethereum address and use it to sign messages. + */ +public class EthereumCredentials +{ + private final PrivateKey privateKey; + private final PublicKey publicKey; + private final String address; + private final BouncyCastleProvider provider = new BouncyCastleProvider(); + + public EthereumCredentials() + { + try + { + KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("EC", provider); + ECGenParameterSpec ecGenParameterSpec = new ECGenParameterSpec("secp256k1"); + keyPairGenerator.initialize(ecGenParameterSpec, new SecureRandom()); + KeyPair keyPair = keyPairGenerator.generateKeyPair(); + this.privateKey = keyPair.getPrivate(); + this.publicKey = keyPair.getPublic(); + this.address = EthereumUtil.toAddress(((BCECPublicKey)publicKey).getQ()); + } + catch (Exception e) + { + throw new RuntimeException(e); + } + } + + public String getAddress() + { + return address; + } + + public EthereumAuthenticator.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", provider); + ecdsaSign.initSign(privateKey); + ecdsaSign.update(messageHash); + byte[] encodedSignature = ecdsaSign.sign(); + byte[] r = getR(encodedSignature); + byte[] s = getS(encodedSignature); + + byte[] signature = new byte[65]; + System.arraycopy(r, 0, signature, 0, 32); + System.arraycopy(s, 0, signature, 32, 32); + signature[64] = (byte)(calculateV(messageHash, r, s) + 27); + return new EthereumAuthenticator.SignedMessage(message, Hex.toHexString(signature)); + } + + private byte[] getR(byte[] encodedSignature) + { + int rLength = encodedSignature[3]; + byte[] r = Arrays.copyOfRange(encodedSignature, 4, 4 + rLength); + return ensure32Bytes(r); + } + + private byte[] getS(byte[] encodedSignature) + { + int rLength = encodedSignature[3]; + int sLength = encodedSignature[5 + rLength]; + byte[] s = Arrays.copyOfRange(encodedSignature, 6 + rLength, 6 + rLength + sLength); + return ensure32Bytes(s); + } + + private byte[] ensure32Bytes(byte[] bytes) + { + if (bytes.length == 32) + return bytes; + if (bytes.length > 32) + return Arrays.copyOfRange(bytes, bytes.length - 32, bytes.length); + else + { + byte[] padded = new byte[32]; + System.arraycopy(bytes, 0, padded, 32 - bytes.length, bytes.length); + return padded; + } + } + + private byte calculateV(byte[] hash, byte[] r, byte[] s) + { + ECPoint publicKeyPoint = ((BCECPublicKey)publicKey).getQ(); + for (int v = 0; v < 4; v++) + { + ECPoint qPoint = EthereumUtil.ecRecover(hash, v, new BigInteger(1, r), new BigInteger(1, s)); + if (qPoint != null && qPoint.equals(publicKeyPoint)) + return (byte)v; + } + throw new RuntimeException("Could not recover public key from signature"); + } +} diff --git a/jetty-integrations/jetty-ethereum/src/test/java/org/eclipse/jetty/security/siwe/util/SignInWithEthereumGenerator.java b/jetty-integrations/jetty-ethereum/src/test/java/org/eclipse/jetty/security/siwe/util/SignInWithEthereumGenerator.java new file mode 100644 index 00000000000..dc52f9121de --- /dev/null +++ b/jetty-integrations/jetty-ethereum/src/test/java/org/eclipse/jetty/security/siwe/util/SignInWithEthereumGenerator.java @@ -0,0 +1,107 @@ +// +// ======================================================================== +// 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; + +/** + * A utility to generate Sign-In with Ethereum message to be used for testing. + */ +public class SignInWithEthereumGenerator +{ + private SignInWithEthereumGenerator() + { + } + + 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-integrations/jetty-ethereum/src/test/resources/jetty-logging.properties b/jetty-integrations/jetty-ethereum/src/test/resources/jetty-logging.properties new file mode 100755 index 00000000000..a5c0825874c --- /dev/null +++ b/jetty-integrations/jetty-ethereum/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-integrations/pom.xml b/jetty-integrations/pom.xml index 2761a5fa8b1..c1785dba714 100644 --- a/jetty-integrations/pom.xml +++ b/jetty-integrations/pom.xml @@ -12,6 +12,7 @@ Integrations + jetty-ethereum jetty-gcloud jetty-hazelcast jetty-infinispan diff --git a/pom.xml b/pom.xml index 79fbcf407e7..69bc91da4f7 100644 --- a/pom.xml +++ b/pom.xml @@ -169,6 +169,7 @@ 9.7 4.2.1 7.0.0 + 1.78.1 3.6.0 1.5 3.2.0 @@ -700,6 +701,22 @@ awaitility ${awaitility.version} + + + org.bouncycastle + bcpkix-jdk15to18 + ${bouncycastle.version} + + + org.bouncycastle + bcprov-jdk15to18 + ${bouncycastle.version} + + + org.bouncycastle + bcutil-jdk15to18 + ${bouncycastle.version} + org.codehaus.plexus plexus-classworlds @@ -775,6 +792,11 @@ jetty-ee ${project.version} + + org.eclipse.jetty + jetty-ethereum + ${project.version} + org.eclipse.jetty jetty-hazelcast diff --git a/tests/test-distribution/test-ee10-distribution/pom.xml b/tests/test-distribution/test-ee10-distribution/pom.xml index d320f923ff5..dd0d2528018 100644 --- a/tests/test-distribution/test-ee10-distribution/pom.xml +++ b/tests/test-distribution/test-ee10-distribution/pom.xml @@ -32,6 +32,11 @@ jetty-client test + + org.eclipse.jetty + jetty-ethereum + test + org.eclipse.jetty jetty-openid diff --git a/tests/test-distribution/test-ee10-distribution/src/test/java/org/eclipse/jetty/ee10/tests/distribution/SiweTests.java b/tests/test-distribution/test-ee10-distribution/src/test/java/org/eclipse/jetty/ee10/tests/distribution/SiweTests.java new file mode 100644 index 00000000000..387d5797c26 --- /dev/null +++ b/tests/test-distribution/test-ee10-distribution/src/test/java/org/eclipse/jetty/ee10/tests/distribution/SiweTests.java @@ -0,0 +1,153 @@ +// +// ======================================================================== +// 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.ee10.tests.distribution; + +import java.io.FileWriter; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Map; +import java.util.concurrent.TimeUnit; + +import org.eclipse.jetty.client.ContentResponse; +import org.eclipse.jetty.client.FormRequestContent; +import org.eclipse.jetty.ee10.tests.distribution.siwe.EthereumCredentials; +import org.eclipse.jetty.ee10.tests.distribution.siwe.SignInWithEthereumGenerator; +import org.eclipse.jetty.http.HttpHeader; +import org.eclipse.jetty.http.HttpStatus; +import org.eclipse.jetty.security.siwe.EthereumAuthenticator; +import org.eclipse.jetty.tests.distribution.AbstractJettyHomeTest; +import org.eclipse.jetty.tests.testers.JettyHomeTester; +import org.eclipse.jetty.tests.testers.Tester; +import org.eclipse.jetty.util.Fields; +import org.eclipse.jetty.util.ajax.JSON; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.parallel.Isolated; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.is; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +@Isolated +public class SiweTests extends AbstractJettyHomeTest +{ + private final EthereumCredentials _credentials = new EthereumCredentials(); + + @Test + public void testSiwe() throws Exception + { + Path jettyBase = newTestJettyBaseDirectory(); + String jettyVersion = System.getProperty("jettyVersion"); + JettyHomeTester distribution = JettyHomeTester.Builder.newInstance() + .jettyVersion(jettyVersion) + .jettyBase(jettyBase) + .build(); + + String[] args1 = { + "--create-startd", + "--approve-all-licenses", + "--add-to-start=http,ee10-webapp,ee10-deploy,ee10-annotations,ethereum" + }; + + try (JettyHomeTester.Run run1 = distribution.start(args1)) + { + assertTrue(run1.awaitFor(START_TIMEOUT, TimeUnit.SECONDS)); + assertEquals(0, run1.getExitValue()); + + Path webApp = distribution.resolveArtifact("org.eclipse.jetty.ee10:jetty-ee10-test-siwe-webapp:war:" + jettyVersion); + distribution.installWar(webApp, "test"); + Files.createDirectory(jettyBase.resolve("etc")); + Path realmProperties = Files.createFile(jettyBase.resolve("etc/realm.properties")); + try (FileWriter fw = new FileWriter(realmProperties.toFile())) + { + fw.write(_credentials.getAddress() + ":,admin\n"); + } + + int port = Tester.freePort(); + String[] args2 = { + "jetty.http.port=" + port, + "jetty.ssl.port=" + port, + "jetty.server.dumpAfterStart=true", + }; + +// System.setProperty("distribution.debug.port", "5005"); + try (JettyHomeTester.Run run2 = distribution.start(args2)) + { + assertTrue(run2.awaitConsoleLogsFor("Started oejs.Server@", START_TIMEOUT, TimeUnit.SECONDS)); + startHttpClient(false); + String uri = "http://localhost:" + port + "/test"; + + // Initially not authenticated. + ContentResponse response = client.GET(uri + "/"); + assertThat(response.getStatus(), is(HttpStatus.OK_200)); + String content = response.getContentAsString(); + assertThat(content, containsString("not authenticated")); + + // Request to /admin redirects to loginPage. + client.setFollowRedirects(false); + response = client.GET(uri + "/admin"); + assertThat(response.getStatus(), is(HttpStatus.FOUND_302)); + assertThat(response.getHeaders().get(HttpHeader.LOCATION), + containsString(uri + "/login.html")); + + // Fetch a nonce from the server. + response = client.GET(uri + "/auth/nonce"); + String nonce = parseNonce(response.getContentAsString()); + assertThat(nonce.length(), equalTo(8)); + + // Request to authenticate redirects to /admin page. + FormRequestContent authRequestContent = getAuthRequestContent(port, nonce); + response = client.POST(uri + "/auth/login").body(authRequestContent).send(); + assertThat(response.getStatus(), is(HttpStatus.SEE_OTHER_303)); + assertThat(response.getHeaders().get(HttpHeader.LOCATION), + containsString(uri + "/admin")); + + // We can access /admin as user has the admin role. + response = client.GET(uri + "/admin"); + assertThat(response.getStatus(), is(HttpStatus.OK_200)); + content = response.getContentAsString(); + assertThat(content, containsString("adminPage userPrincipal: " + _credentials.getAddress())); + + // We can't access /forbidden as user does not have the correct role. + response = client.GET(uri + "/forbidden"); + assertThat(response.getStatus(), is(HttpStatus.FORBIDDEN_403)); + + // Logout and we can no longer get the userPrincipal. + client.setFollowRedirects(true); + response = client.GET(uri + "/logout"); + assertThat(response.getStatus(), is(HttpStatus.OK_200)); + content = response.getContentAsString(); + assertThat(content, containsString("not authenticated")); + } + } + } + + private FormRequestContent getAuthRequestContent(int port, String nonce) throws Exception + { + EthereumAuthenticator.SignedMessage signedMessage = _credentials.signMessage( + SignInWithEthereumGenerator.generateMessage(port, _credentials.getAddress(), nonce)); + Fields fields = new Fields(); + fields.add("signature", signedMessage.signature()); + fields.add("message", signedMessage.message()); + return new FormRequestContent(fields); + } + + @SuppressWarnings("rawtypes") + private String parseNonce(String responseContent) + { + return (String)((Map)new JSON().parse(new JSON.StringSource(responseContent))).get("nonce"); + } +} diff --git a/tests/test-distribution/test-ee10-distribution/src/test/java/org/eclipse/jetty/ee10/tests/distribution/siwe/EthereumCredentials.java b/tests/test-distribution/test-ee10-distribution/src/test/java/org/eclipse/jetty/ee10/tests/distribution/siwe/EthereumCredentials.java new file mode 100644 index 00000000000..1b79bb299a1 --- /dev/null +++ b/tests/test-distribution/test-ee10-distribution/src/test/java/org/eclipse/jetty/ee10/tests/distribution/siwe/EthereumCredentials.java @@ -0,0 +1,126 @@ +// +// ======================================================================== +// 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.ee10.tests.distribution.siwe; + +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.Signature; +import java.security.spec.ECGenParameterSpec; +import java.util.Arrays; + +import org.bouncycastle.jcajce.provider.asymmetric.ec.BCECPublicKey; +import org.bouncycastle.jce.provider.BouncyCastleProvider; +import org.bouncycastle.math.ec.ECPoint; +import org.bouncycastle.util.encoders.Hex; +import org.eclipse.jetty.security.siwe.EthereumAuthenticator; +import org.eclipse.jetty.security.siwe.internal.EthereumUtil; + +import static org.eclipse.jetty.security.siwe.internal.EthereumUtil.keccak256; + +public class EthereumCredentials +{ + private final PrivateKey privateKey; + private final PublicKey publicKey; + private final String address; + private final BouncyCastleProvider provider = new BouncyCastleProvider(); + + public EthereumCredentials() + { + try + { + KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("EC", provider); + ECGenParameterSpec ecGenParameterSpec = new ECGenParameterSpec("secp256k1"); + keyPairGenerator.initialize(ecGenParameterSpec, new SecureRandom()); + KeyPair keyPair = keyPairGenerator.generateKeyPair(); + this.privateKey = keyPair.getPrivate(); + this.publicKey = keyPair.getPublic(); + this.address = EthereumUtil.toAddress(((BCECPublicKey)publicKey).getQ()); + } + catch (Exception e) + { + throw new RuntimeException(e); + } + } + + public String getAddress() + { + return address; + } + + public EthereumAuthenticator.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", provider); + ecdsaSign.initSign(privateKey); + ecdsaSign.update(messageHash); + byte[] encodedSignature = ecdsaSign.sign(); + byte[] r = getR(encodedSignature); + byte[] s = getS(encodedSignature); + + byte[] signature = new byte[65]; + System.arraycopy(r, 0, signature, 0, 32); + System.arraycopy(s, 0, signature, 32, 32); + signature[64] = (byte)(calculateV(messageHash, r, s) + 27); + return new EthereumAuthenticator.SignedMessage(message, Hex.toHexString(signature)); + } + + private byte[] getR(byte[] encodedSignature) + { + int rLength = encodedSignature[3]; + byte[] r = Arrays.copyOfRange(encodedSignature, 4, 4 + rLength); + return ensure32Bytes(r); + } + + private byte[] getS(byte[] encodedSignature) + { + int rLength = encodedSignature[3]; + int sLength = encodedSignature[5 + rLength]; + byte[] s = Arrays.copyOfRange(encodedSignature, 6 + rLength, 6 + rLength + sLength); + return ensure32Bytes(s); + } + + private byte[] ensure32Bytes(byte[] bytes) + { + if (bytes.length == 32) + return bytes; + if (bytes.length > 32) + return Arrays.copyOfRange(bytes, bytes.length - 32, bytes.length); + else + { + byte[] padded = new byte[32]; + System.arraycopy(bytes, 0, padded, 32 - bytes.length, bytes.length); + return padded; + } + } + + private byte calculateV(byte[] hash, byte[] r, byte[] s) + { + ECPoint publicKeyPoint = ((BCECPublicKey)publicKey).getQ(); + for (int v = 0; v < 4; v++) + { + ECPoint qPoint = EthereumUtil.ecRecover(hash, v, new BigInteger(1, r), new BigInteger(1, s)); + if (qPoint != null && qPoint.equals(publicKeyPoint)) + return (byte)v; + } + throw new RuntimeException("Could not recover public key from signature"); + } +} diff --git a/tests/test-distribution/test-ee10-distribution/src/test/java/org/eclipse/jetty/ee10/tests/distribution/siwe/SignInWithEthereumGenerator.java b/tests/test-distribution/test-ee10-distribution/src/test/java/org/eclipse/jetty/ee10/tests/distribution/siwe/SignInWithEthereumGenerator.java new file mode 100644 index 00000000000..8f507b5f813 --- /dev/null +++ b/tests/test-distribution/test-ee10-distribution/src/test/java/org/eclipse/jetty/ee10/tests/distribution/siwe/SignInWithEthereumGenerator.java @@ -0,0 +1,104 @@ +// +// ======================================================================== +// 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.ee10.tests.distribution.siwe; + +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; + +public class SignInWithEthereumGenerator +{ + private SignInWithEthereumGenerator() + { + } + + 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(); + } +}