Merge remote-tracking branch 'origin/jetty-12.0.x-SignInWithEthereum' into jetty-12.1.x

This commit is contained in:
Lachlan Roberts 2024-08-22 13:19:51 +10:00
commit 25cf822e3a
No known key found for this signature in database
GPG Key ID: 5663FB7A8FF7E348
44 changed files with 3486 additions and 5 deletions

View File

@ -54,6 +54,14 @@
<groupId>org.eclipse.jetty</groupId> <groupId>org.eclipse.jetty</groupId>
<artifactId>jetty-session</artifactId> <artifactId>jetty-session</artifactId>
</dependency> </dependency>
<dependency>
<groupId>org.eclipse.jetty</groupId>
<artifactId>jetty-siwe</artifactId>
</dependency>
<dependency>
<groupId>org.eclipse.jetty</groupId>
<artifactId>jetty-slf4j-impl</artifactId>
</dependency>
<dependency> <dependency>
<groupId>org.eclipse.jetty</groupId> <groupId>org.eclipse.jetty</groupId>
<artifactId>jetty-unixdomain-server</artifactId> <artifactId>jetty-unixdomain-server</artifactId>

View File

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

View File

@ -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("<br><a href=\"/logout\">Logout</a>");
}
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;
}
}

View File

@ -0,0 +1,58 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Sign-In with Ethereum</title>
<script src="https://cdn.jsdelivr.net/npm/web3@1.6.1/dist/web3.min.js"></script>
</head>
<body>
<h4>Sign-In with Ethereum</h4>
<button id="siwe">Sign-In with Ethereum</button>
<form id="loginForm" action="/auth/login" method="POST" style="display: none;">
<input type="hidden" id="signatureField" name="signature">
<input type="hidden" id="messageField" name="message">
</form>
<p class="alert" style="display: none;">Result: <span id="siweResult"></span></p>
<script>
let provider = window.ethereum;
let accounts;
if (!provider) {
document.getElementById('siweResult').innerText = 'MetaMask is not installed. Please install MetaMask to use this feature.';
} else {
document.getElementById('siwe').addEventListener('click', async () => {
try {
// Request account access if needed
accounts = await provider.request({ method: 'eth_requestAccounts' });
const domain = window.location.host;
const from = accounts[0];
// Fetch nonce from the server
const nonceResponse = await fetch('/auth/nonce');
const nonceData = await nonceResponse.json();
const nonce = nonceData.nonce;
const siweMessage = `${domain} wants you to sign in with your Ethereum account:\n${from}\n\nI accept the MetaMask Terms of Service: https://community.metamask.io/tos\n\nURI: https://${domain}\nVersion: 1\nChain ID: 1\nNonce: ${nonce}\nIssued At: ${new Date().toISOString()}`;
const signature = await provider.request({
method: 'personal_sign',
params: [siweMessage, from]
});
console.log("signature: " + signature)
console.log("nonce: " + nonce)
console.log("length: " + length)
document.getElementById('signatureField').value = signature;
document.getElementById('messageField').value = siweMessage;
document.getElementById('loginForm').submit();
} catch (error) {
console.error('Error during login:', error);
document.getElementById('siweResult').innerText = `Error: ${error.message}`;
document.getElementById('siweResult').parentElement.style.display = 'block';
}
});
}
</script>
</body>
</html>

View File

@ -43,6 +43,8 @@
** xref:troubleshooting/state-tracking.adoc[] ** xref:troubleshooting/state-tracking.adoc[]
** xref:troubleshooting/component-dump.adoc[] ** xref:troubleshooting/component-dump.adoc[]
** xref:troubleshooting/debugging.adoc[] ** xref:troubleshooting/debugging.adoc[]
* Jetty Security
** xref:security/siwe-support.adoc[]
* Migration Guides * Migration Guides
** xref:migration/94-to-10.adoc[] ** xref:migration/94-to-10.adoc[]
** xref:migration/11-to-12.adoc[] ** xref:migration/11-to-12.adoc[]

View File

@ -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.0+ 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]
----
<Configure id="wac" class="org.eclipse.jetty.ee10.webapp.WebAppContext">
<Call id="ResourceFactory" class="org.eclipse.jetty.util.resource.ResourceFactory" name="of">
<Arg><Ref refid="Server"/></Arg>
<Call id="realmResource" name="newResource">
<Arg><SystemProperty name="jetty.base" default="."/>/etc/realm.properties</Arg>
</Call>
</Call>
<Call name="getSecurityHandler">
<Set name="loginService">
<New class="org.eclipse.jetty.security.HashLoginService">
<Set name="name">myRealm</Set>
<Set name="config"><Ref refid="realmResource"/></Set>
</New>
</Set>
</Call>
</Configure>
----
=== 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]
----
<script src="https://cdn.jsdelivr.net/npm/web3@1.6.1/dist/web3.min.js"></script>
----
HTML form to submit the sign in request.
[,html,indent=0]
----
<button id="siwe">Sign-In with Ethereum</button>
<form id="loginForm" action="/auth/login" method="POST" style="display: none;">
<input type="hidden" id="signatureField" name="signature">
<input type="hidden" id="messageField" name="message">
</form>
<p class="alert" style="display: none;">Result: <span id="siweResult"></span></p>
----
Add script to generate and sign the SIWE message when the sign-in button is pressed.
[,html,indent=0]
----
<script>
let provider = window.ethereum;
let accounts;
if (!provider) {
document.getElementById('siweResult').innerText = 'MetaMask is not installed. Please install MetaMask to use this feature.';
} else {
document.getElementById('siwe').addEventListener('click', async () => {
try {
accounts = await provider.request({ method: 'eth_requestAccounts' });
const domain = window.location.host;
const from = accounts[0];
// Fetch nonce from the server.
const nonceResponse = await fetch('/auth/nonce');
const nonceData = await nonceResponse.json();
const nonce = nonceData.nonce;
const siweMessage = `${domain} wants you to sign in with your Ethereum account:\n${from}\n\nI accept the MetaMask Terms of Service: https://community.metamask.io/tos\n\nURI: https://${domain}\nVersion: 1\nChain ID: 1\nNonce: ${nonce}\nIssued At: ${new Date().toISOString()}`;
document.getElementById('signatureField').value = await provider.request({
method: 'personal_sign',
params: [siweMessage, from]
});
document.getElementById('messageField').value = siweMessage;
document.getElementById('loginForm').submit();
} catch (error) {
console.error('Error during login:', error);
document.getElementById('siweResult').innerText = `Error: ${error.message}`;
document.getElementById('siweResult').parentElement.style.display = 'block';
}
});
}
</script>
----

View File

@ -130,6 +130,11 @@
<artifactId>jetty-session</artifactId> <artifactId>jetty-session</artifactId>
<version>12.1.0-SNAPSHOT</version> <version>12.1.0-SNAPSHOT</version>
</dependency> </dependency>
<dependency>
<groupId>org.eclipse.jetty</groupId>
<artifactId>jetty-siwe</artifactId>
<version>12.0.13-SNAPSHOT</version>
</dependency>
<dependency> <dependency>
<groupId>org.eclipse.jetty</groupId> <groupId>org.eclipse.jetty</groupId>
<artifactId>jetty-slf4j-impl</artifactId> <artifactId>jetty-slf4j-impl</artifactId>

View File

@ -12,7 +12,6 @@
<description>Test keystore with self-signed SSL Certificate.</description> <description>Test keystore with self-signed SSL Certificate.</description>
<properties> <properties>
<bouncycastle.version>1.77</bouncycastle.version>
<bundle-symbolic-name>${project.groupId}.keystore</bundle-symbolic-name> <bundle-symbolic-name>${project.groupId}.keystore</bundle-symbolic-name>
</properties> </properties>
@ -20,17 +19,14 @@
<dependency> <dependency>
<groupId>org.bouncycastle</groupId> <groupId>org.bouncycastle</groupId>
<artifactId>bcpkix-jdk15to18</artifactId> <artifactId>bcpkix-jdk15to18</artifactId>
<version>${bouncycastle.version}</version>
</dependency> </dependency>
<dependency> <dependency>
<groupId>org.bouncycastle</groupId> <groupId>org.bouncycastle</groupId>
<artifactId>bcprov-jdk15to18</artifactId> <artifactId>bcprov-jdk15to18</artifactId>
<version>${bouncycastle.version}</version>
</dependency> </dependency>
<dependency> <dependency>
<groupId>org.bouncycastle</groupId> <groupId>org.bouncycastle</groupId>
<artifactId>bcutil-jdk15to18</artifactId> <artifactId>bcutil-jdk15to18</artifactId>
<version>${bouncycastle.version}</version>
</dependency> </dependency>
<dependency> <dependency>
<groupId>org.eclipse.jetty</groupId> <groupId>org.eclipse.jetty</groupId>

View File

@ -42,6 +42,7 @@ public interface Authenticator
String SPNEGO_AUTH = "SPNEGO"; String SPNEGO_AUTH = "SPNEGO";
String NEGOTIATE_AUTH = "NEGOTIATE"; String NEGOTIATE_AUTH = "NEGOTIATE";
String OPENID_AUTH = "OPENID"; String OPENID_AUTH = "OPENID";
String SIWE_AUTH = "SIWE";
/** /**
* Configure the Authenticator * Configure the Authenticator

View File

@ -176,7 +176,7 @@ public class FormFields extends ContentSourceCompletableFuture<Fields>
* @param maxLength The maximum total size of the fields * @param maxLength The maximum total size of the fields
* @return A {@link CompletableFuture} that will provide the {@link Fields} or a failure. * @return A {@link CompletableFuture} that will provide the {@link Fields} or a failure.
*/ */
static CompletableFuture<Fields> from(Content.Source source, Attributes attributes, Charset charset, int maxFields, int maxLength) public static CompletableFuture<Fields> from(Content.Source source, Attributes attributes, Charset charset, int maxFields, int maxLength)
{ {
Object attr = attributes.getAttribute(FormFields.class.getName()); Object attr = attributes.getAttribute(FormFields.class.getName());
if (attr instanceof FormFields futureFormFields) if (attr instanceof FormFields futureFormFields)

View File

@ -0,0 +1,82 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.eclipse.jetty</groupId>
<artifactId>jetty-core</artifactId>
<version>12.0.13-SNAPSHOT</version>
</parent>
<artifactId>jetty-siwe</artifactId>
<name>Core :: Sign-In with Ethereum</name>
<description>Jetty Sign-In with Ethereum</description>
<properties>
<bundle-symbolic-name>${project.groupId}.siwe</bundle-symbolic-name>
</properties>
<dependencies>
<dependency>
<groupId>org.bouncycastle</groupId>
<artifactId>bcprov-jdk15to18</artifactId>
</dependency>
<dependency>
<groupId>org.eclipse.jetty</groupId>
<artifactId>jetty-client</artifactId>
</dependency>
<dependency>
<groupId>org.eclipse.jetty</groupId>
<artifactId>jetty-security</artifactId>
</dependency>
<dependency>
<groupId>org.eclipse.jetty</groupId>
<artifactId>jetty-server</artifactId>
</dependency>
<dependency>
<groupId>org.eclipse.jetty</groupId>
<artifactId>jetty-util-ajax</artifactId>
</dependency>
<dependency>
<groupId>org.slf4j</groupId>
<artifactId>slf4j-api</artifactId>
</dependency>
<dependency>
<groupId>org.eclipse.jetty</groupId>
<artifactId>jetty-session</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.eclipse.jetty</groupId>
<artifactId>jetty-slf4j-impl</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.eclipse.jetty.toolchain</groupId>
<artifactId>jetty-test-helper</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.felix</groupId>
<artifactId>maven-bundle-plugin</artifactId>
<extensions>true</extensions>
<executions>
<execution>
<goals>
<goal>manifest</goal>
</goals>
<configuration>
<instructions>
<Require-Capability>osgi.extender; filter:="(osgi.extender=osgi.serviceloader.registrar)"</Require-Capability>
<Provide-Capability>osgi.serviceloader;osgi.serviceloader=org.eclipse.jetty.security.Authenticator$Factory</Provide-Capability>
</instructions>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
</build>
</project>

View File

@ -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-siwe-${jetty.version}.jar
lib/bouncycastle/bcprov-jdk15to18-${bouncycastle.version}.jar
[ini]
bouncycastle.version?=@bouncycastle.version@
jetty.webapp.addHiddenClasses+=,${jetty.base.uri}/lib/bouncycastle/

View File

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

View File

@ -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<String, String> _chainIds = new IncludeExcludeSet<>();
private final IncludeExcludeSet<String, String> _domains = new IncludeExcludeSet<>();
private String _loginPath;
private String _authenticationPath = DEFAULT_AUTHENTICATION_PATH;
private String _noncePath = DEFAULT_NONCE_PATH;
private int _maxMessageSize = 4 * 1024;
private String _logoutRedirectPath;
private String _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}.
* <p>
* This setting is only meaningful if a wrapped {@link LoginService} has been set.
* </p>
* <p>
* 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.
* </p>
*
* @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);
}
}
/**
* <p>This will attempt to redirect the request to the {@link #_logoutRedirectPath}.</p>
*
* @param request the request to redirect.
*/
private void attemptLogoutRedirect(Request request, Response response)
{
try
{
String redirectUri = null;
if (_logoutRedirectPath != null)
{
HttpURI.Mutable httpURI = HttpURI.build()
.scheme(request.getHttpURI().getScheme())
.host(Request.getServerName(request))
.port(Request.getServerPort(request))
.path(URIUtil.compactPath(Request.getContextPath(request) + _logoutRedirectPath));
redirectUri = httpURI.toString();
}
Session session = request.getSession(false);
if (session == null)
{
if (redirectUri != null)
sendRedirect(request, response, redirectUri);
}
}
catch (Throwable t)
{
LOG.warn("failed to redirect to end_session_endpoint", t);
}
}
private void sendRedirect(Request request, Response response, String location) throws IOException
{
try (Blocker.Callback callback = Blocker.callback())
{
Response.sendRedirect(request, response, callback, location);
callback.block();
}
}
@Override
public Request prepareRequest(Request request, AuthenticationState authenticationState)
{
// if this is a request resulting from a redirect after auth is complete
// (ie its from a redirect to the original request uri) then due to
// browser handling of 302 redirects, the method may not be the same as
// that of the original request. Replace the method and original post
// params (if it was a post).
if (authenticationState instanceof AuthenticationState.Succeeded)
{
Session session = request.getSession(false);
if (session == null)
return request; //not authenticated yet
// Remove the nonce set used for authentication.
session.removeAttribute(NONCE_SET_ATTR);
HttpURI juri = (HttpURI)session.getAttribute(J_URI);
HttpURI uri = request.getHttpURI();
if ((uri.equals(juri)))
{
session.removeAttribute(J_URI);
Fields fields = (Fields)session.removeAttribute(J_POST);
if (fields != null)
request.setAttribute(FormFields.class.getName(), fields);
String method = (String)session.removeAttribute(J_METHOD);
if (method != null && request.getMethod().equals(method))
{
return new Request.Wrapper(request)
{
@Override
public String getMethod()
{
return method;
}
};
}
}
}
return request;
}
@Override
public Constraint.Authorization getConstraintAuthentication(String pathInContext, Constraint.Authorization existing, Function<Boolean, Session> getSession)
{
if (isAuthenticationRequest(pathInContext))
return Constraint.Authorization.ANY_USER;
if (isLoginPage(pathInContext) || isErrorPage(pathInContext))
return Constraint.Authorization.ALLOWED;
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<String> attribute = (Set<String>)session.getAttribute(NONCE_SET_ATTR);
if (attribute == null)
session.setAttribute(NONCE_SET_ATTR, attribute = new FixedSizeSet<>(5));
if (!attribute.add(nonce))
throw new IllegalStateException("Nonce already in use");
}
return nonce;
}
protected boolean redeemNonce(Session session, String nonce)
{
synchronized (session)
{
@SuppressWarnings("unchecked")
Set<String> attribute = (Set<String>)session.getAttribute(NONCE_SET_ATTR);
if (attribute == null)
return false;
return attribute.remove(nonce);
}
}
@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<T> extends LinkedHashSet<T>
{
private final int maxSize;
public FixedSizeSet(int maxSize)
{
super(maxSize);
this.maxSize = maxSize;
}
@Override
public boolean add(T element)
{
if (size() >= maxSize)
{
Iterator<T> it = iterator();
if (it.hasNext())
{
it.next();
it.remove();
}
}
return super.add(element);
}
}
public record SignedMessage(String message, String signature)
{
public String recoverAddress()
{
return EthereumUtil.recoverAddress(this);
}
}
}

View File

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

View File

@ -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.
* <p>
* 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)}.
* </p>
*/
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<Boolean, Session> 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);
}
}

View File

@ -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}.
* <p>
* This uses algorithms and terminology defined in <a href="https://eips.ethereum.org/EIPS/eip-191">EIP-191</a> and
* <a href="https://en.wikipedia.org/wiki/Elliptic_Curve_Digital_Signature_Algorithm">ECDSA</a>.
* </p>
* @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();
}
}

View File

@ -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 <a href="https://eips.ethereum.org/EIPS/eip-4361">EIP4361</a>.
* @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>" + SCHEME_PATTERN + ")://)?(?<domain>" + DOMAIN_PATTERN + ") wants you to sign in with your Ethereum account:\n" +
"(?<address>" + ADDRESS_PATTERN + ")\n\n" +
"(?<statement>" + STATEMENT_PATTERN + ")?\n\n" +
"URI: (?<uri>" + URI_PATTERN + ")\n" +
"Version: (?<version>" + VERSION_PATTERN + ")\n" +
"Chain ID: (?<chainId>" + CHAIN_ID_PATTERN + ")\n" +
"Nonce: (?<nonce>" + NONCE_PATTERN + ")\n" +
"Issued At: (?<issuedAt>" + DATE_TIME_PATTERN + ")" +
"(?:\nExpiration Time: (?<expirationTime>" + DATE_TIME_PATTERN + "))?" +
"(?:\nNot Before: (?<notBefore>" + DATE_TIME_PATTERN + "))?" +
"(?:\nRequest ID: (?<requestId>" + REQUEST_ID_PATTERN + "))?" +
"(?:\nResources:(?<resources>" + RESOURCES_PATTERN + "))?$",
Pattern.DOTALL
);
/**
* Parses a SIWE Message into a {@link SignInWithEthereumToken},
* based off the ABNF Message Format from <a href="https://eips.ethereum.org/EIPS/eip-4361">EIP-4361</a>.
* @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<String> validateNonce,
IncludeExcludeSet<String, String> domains,
IncludeExcludeSet<String, String> 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());
}
}

View File

@ -0,0 +1 @@
org.eclipse.jetty.security.siwe.EthereumAuthenticatorFactory

View File

@ -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<Arguments> specExamples()
{
List<Arguments> data = new ArrayList<>();
data.add(Arguments.of("""
example.com wants you to sign in with your Ethereum account:
0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2
I accept the ExampleOrg Terms of Service: https://example.com/tos
URI: https://example.com/login
Version: 1
Chain ID: 1
Nonce: 32891756
Issued At: 2021-09-30T16:25:24Z
Resources:
- ipfs://bafybeiemxf5abjwjbikoz4mc3a3dla6ual3jsgpdr4cjr3oz3evfyavhwq/
- https://example.com/my-web2-claim.json""",
null, "example.com"
));
data.add(Arguments.of("""
example.com:3388 wants you to sign in with your Ethereum account:
0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2
I accept the ExampleOrg Terms of Service: https://example.com/tos
URI: https://example.com/login
Version: 1
Chain ID: 1
Nonce: 32891756
Issued At: 2021-09-30T16:25:24Z
Resources:
- ipfs://bafybeiemxf5abjwjbikoz4mc3a3dla6ual3jsgpdr4cjr3oz3evfyavhwq/
- https://example.com/my-web2-claim.json""",
null, "example.com:3388"
));
data.add(Arguments.of("""
https://example.com wants you to sign in with your Ethereum account:
0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2
I accept the ExampleOrg Terms of Service: https://example.com/tos
URI: https://example.com/login
Version: 1
Chain ID: 1
Nonce: 32891756
Issued At: 2021-09-30T16:25:24Z
Resources:
- ipfs://bafybeiemxf5abjwjbikoz4mc3a3dla6ual3jsgpdr4cjr3oz3evfyavhwq/
- https://example.com/my-web2-claim.json""",
"https", "example.com"
));
return data.stream();
}
@ParameterizedTest
@MethodSource("specExamples")
public void testSpecExamples(String message, String scheme, String domain)
{
SignInWithEthereumToken siwe = 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));
}
}

View File

@ -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<String, Object> parsed = (Map<String, Object>)new JSON().parse(new JSON.StringSource(response.getContentAsString()));
String nonce = (String)parsed.get("nonce");
assertThat(nonce.length(), equalTo(8));
return nonce;
}
}

View File

@ -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<String, String> 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<String, String> 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<String> 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<String> nonceValidation = nonce -> true;
assertDoesNotThrow(() ->
siwe.validate(signedMessage, nonceValidation, null, null));
}
}

View File

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

View File

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

View File

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

View File

@ -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

View File

@ -37,6 +37,7 @@
<module>jetty-security</module> <module>jetty-security</module>
<module>jetty-server</module> <module>jetty-server</module>
<module>jetty-session</module> <module>jetty-session</module>
<module>jetty-siwe</module>
<module>jetty-slf4j-impl</module> <module>jetty-slf4j-impl</module>
<module>jetty-start</module> <module>jetty-start</module>
<module>jetty-tests</module> <module>jetty-tests</module>

View File

@ -17,6 +17,7 @@ import java.io.IOException;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Arrays; import java.util.Arrays;
import java.util.Collections; import java.util.Collections;
import java.util.Enumeration;
import java.util.HashMap; import java.util.HashMap;
import java.util.HashSet; import java.util.HashSet;
import java.util.List; import java.util.List;
@ -29,6 +30,7 @@ import java.util.stream.Stream;
import jakarta.servlet.HttpConstraintElement; import jakarta.servlet.HttpConstraintElement;
import jakarta.servlet.HttpMethodConstraintElement; import jakarta.servlet.HttpMethodConstraintElement;
import jakarta.servlet.ServletContext;
import jakarta.servlet.ServletSecurityElement; import jakarta.servlet.ServletSecurityElement;
import jakarta.servlet.annotation.ServletSecurity.EmptyRoleSemantic; import jakarta.servlet.annotation.ServletSecurity.EmptyRoleSemantic;
import jakarta.servlet.annotation.ServletSecurity.TransportGuarantee; import jakarta.servlet.annotation.ServletSecurity.TransportGuarantee;
@ -40,6 +42,7 @@ import org.eclipse.jetty.http.pathmap.PathSpec;
import org.eclipse.jetty.security.Constraint; import org.eclipse.jetty.security.Constraint;
import org.eclipse.jetty.security.Constraint.Transport; import org.eclipse.jetty.security.Constraint.Transport;
import org.eclipse.jetty.security.SecurityHandler; import org.eclipse.jetty.security.SecurityHandler;
import org.eclipse.jetty.server.Context;
import org.eclipse.jetty.server.Request; import org.eclipse.jetty.server.Request;
import org.eclipse.jetty.server.Server; import org.eclipse.jetty.server.Server;
import org.eclipse.jetty.server.handler.ContextHandler; import org.eclipse.jetty.server.handler.ContextHandler;
@ -373,6 +376,20 @@ public class ConstraintSecurityHandler extends SecurityHandler implements Constr
//Servlet Spec 3.1 pg 147 sec 13.8.4.2 log paths for which there are uncovered http methods //Servlet Spec 3.1 pg 147 sec 13.8.4.2 log paths for which there are uncovered http methods
checkPathsWithUncoveredHttpMethods(); checkPathsWithUncoveredHttpMethods();
Context context = ContextHandler.getCurrentContext();
if (context instanceof ServletContextHandler.ServletScopedContext servletScopedContext)
{
ServletContext servletContext = servletScopedContext.getServletContext();
Enumeration<String> 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(); super.doStart();
} }

View File

@ -0,0 +1,27 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.eclipse.jetty.ee10</groupId>
<artifactId>jetty-ee10-tests</artifactId>
<version>12.0.13-SNAPSHOT</version>
</parent>
<artifactId>jetty-ee10-test-siwe-webapp</artifactId>
<packaging>war</packaging>
<name>EE10 :: Tests :: SIWE WebApp</name>
<dependencies>
<dependency>
<groupId>org.eclipse.jetty</groupId>
<artifactId>jetty-slf4j-impl</artifactId>
<scope>compile</scope>
</dependency>
<dependency>
<groupId>jakarta.servlet</groupId>
<artifactId>jakarta.servlet-api</artifactId>
<scope>provided</scope>
</dependency>
</dependencies>
</project>

View File

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

View File

@ -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("<h1>error: not authorized</h1>");
response.getWriter().println("<p>" + request.getUserPrincipal() + "</p>");
response.getWriter().println("<p>" + request.getParameter("error_description_jetty") + "</p>");
String home = request.getContextPath().isEmpty() ? "/" : request.getContextPath();
response.getWriter().println("<a href=\"" + home + "\">Home</a><br>");
}
}

View File

@ -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("<p>Not authorized to access this page.</p>");
response.getWriter().println("<a href=\"" + home + "\">Home</a><br>");
}
}

View File

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

View File

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

View File

@ -0,0 +1,20 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE Configure PUBLIC "-//Jetty//Configure//EN" "https://jetty.org/configure_12_0.dtd">
<Configure id="wac" class="org.eclipse.jetty.ee10.webapp.WebAppContext">
<Call id="ResourceFactory" class="org.eclipse.jetty.util.resource.ResourceFactory" name="of">
<Arg><Ref refid="Server"/></Arg>
<Call id="realmResource" name="newResource">
<Arg><SystemProperty name="jetty.base" default="."/>/etc/realm.properties</Arg>
</Call>
</Call>
<Call name="getSecurityHandler">
<Set name="loginService">
<New class="org.eclipse.jetty.security.HashLoginService">
<Set name="name">myRealm</Set>
<Set name="config"><Ref refid="realmResource"/></Set>
</New>
</Set>
</Call>
</Configure>

View File

@ -0,0 +1,69 @@
<?xml version="1.0" encoding="UTF-8"?>
<web-app xmlns="http://xmlns.jcp.org/xml/ns/javaee"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://xmlns.jcp.org/xml/ns/javaee http://xmlns.jcp.org/xml/ns/javaee/web-app_3_1.xsd"
version="3.1">
<display-name>SIWE Authentication Webapp</display-name>
<login-config>
<auth-method>SIWE</auth-method>
<realm-name>myRealm</realm-name>
</login-config>
<context-param>
<param-name>org.eclipse.jetty.security.siwe.login_path</param-name>
<param-value>/login.html</param-value>
</context-param>
<context-param>
<param-name>org.eclipse.jetty.security.siwe.error_path</param-name>
<param-value>/error?foobar=3</param-value>
</context-param>
<context-param>
<param-name>org.eclipse.jetty.security.siwe.chainIds</param-name>
<param-value>2,3,4,1,5,7</param-value>
</context-param>
<security-role>
<role-name>admin</role-name>
</security-role>
<security-role>
<role-name>forbidden</role-name>
</security-role>
<security-role>
<role-name>**</role-name>
</security-role>
<security-constraint>
<web-resource-collection>
<web-resource-name>User Pages</web-resource-name>
<url-pattern>/login</url-pattern>
<url-pattern>/expiry</url-pattern>
</web-resource-collection>
<auth-constraint>
<role-name>**</role-name>
</auth-constraint>
</security-constraint>
<security-constraint>
<web-resource-collection>
<web-resource-name>Admin Page</web-resource-name>
<url-pattern>/admin</url-pattern>
</web-resource-collection>
<auth-constraint>
<role-name>admin</role-name>
</auth-constraint>
</security-constraint>
<security-constraint>
<web-resource-collection>
<web-resource-name>Forbidden Page</web-resource-name>
<url-pattern>/forbidden</url-pattern>
</web-resource-collection>
<auth-constraint>
<role-name>forbidden</role-name>
</auth-constraint>
</security-constraint>
</web-app>

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@ -27,6 +27,7 @@
<module>jetty-ee10-test-log4j2-webapp</module> <module>jetty-ee10-test-log4j2-webapp</module>
<module>jetty-ee10-test-loginservice</module> <module>jetty-ee10-test-loginservice</module>
<module>jetty-ee10-test-openid-webapp</module> <module>jetty-ee10-test-openid-webapp</module>
<module>jetty-ee10-test-siwe-webapp</module>
<module>jetty-ee10-test-owb-cdi-webapp</module> <module>jetty-ee10-test-owb-cdi-webapp</module>
<module>jetty-ee10-test-quickstart</module> <module>jetty-ee10-test-quickstart</module>
<module>jetty-ee10-test-sessions</module> <module>jetty-ee10-test-sessions</module>

View File

@ -104,6 +104,10 @@
<groupId>org.eclipse.jetty</groupId> <groupId>org.eclipse.jetty</groupId>
<artifactId>jetty-security</artifactId> <artifactId>jetty-security</artifactId>
</dependency> </dependency>
<dependency>
<groupId>org.eclipse.jetty</groupId>
<artifactId>jetty-siwe</artifactId>
</dependency>
<dependency> <dependency>
<groupId>org.eclipse.jetty</groupId> <groupId>org.eclipse.jetty</groupId>
<artifactId>jetty-start</artifactId> <artifactId>jetty-start</artifactId>

22
pom.xml
View File

@ -169,6 +169,7 @@
<asm.version>9.7</asm.version> <asm.version>9.7</asm.version>
<awaitility.version>4.2.1</awaitility.version> <awaitility.version>4.2.1</awaitility.version>
<bndlib.version>7.0.0</bndlib.version> <bndlib.version>7.0.0</bndlib.version>
<bouncycastle.version>1.78.1</bouncycastle.version>
<build-helper.maven.plugin.version>3.6.0</build-helper.maven.plugin.version> <build-helper.maven.plugin.version>3.6.0</build-helper.maven.plugin.version>
<build-support.version>1.5</build-support.version> <build-support.version>1.5</build-support.version>
<buildnumber.maven.plugin.version>3.2.0</buildnumber.maven.plugin.version> <buildnumber.maven.plugin.version>3.2.0</buildnumber.maven.plugin.version>
@ -700,6 +701,22 @@
<artifactId>awaitility</artifactId> <artifactId>awaitility</artifactId>
<version>${awaitility.version}</version> <version>${awaitility.version}</version>
</dependency> </dependency>
<dependency>
<groupId>org.bouncycastle</groupId>
<artifactId>bcpkix-jdk15to18</artifactId>
<version>${bouncycastle.version}</version>
</dependency>
<dependency>
<groupId>org.bouncycastle</groupId>
<artifactId>bcprov-jdk15to18</artifactId>
<version>${bouncycastle.version}</version>
</dependency>
<dependency>
<groupId>org.bouncycastle</groupId>
<artifactId>bcutil-jdk15to18</artifactId>
<version>${bouncycastle.version}</version>
</dependency>
<dependency> <dependency>
<groupId>org.codehaus.plexus</groupId> <groupId>org.codehaus.plexus</groupId>
<artifactId>plexus-classworlds</artifactId> <artifactId>plexus-classworlds</artifactId>
@ -898,6 +915,11 @@
<artifactId>jetty-session</artifactId> <artifactId>jetty-session</artifactId>
<version>${project.version}</version> <version>${project.version}</version>
</dependency> </dependency>
<dependency>
<groupId>org.eclipse.jetty</groupId>
<artifactId>jetty-siwe</artifactId>
<version>${project.version}</version>
</dependency>
<dependency> <dependency>
<groupId>org.eclipse.jetty</groupId> <groupId>org.eclipse.jetty</groupId>
<artifactId>jetty-slf4j-impl</artifactId> <artifactId>jetty-slf4j-impl</artifactId>

View File

@ -37,6 +37,11 @@
<artifactId>jetty-openid</artifactId> <artifactId>jetty-openid</artifactId>
<scope>test</scope> <scope>test</scope>
</dependency> </dependency>
<dependency>
<groupId>org.eclipse.jetty</groupId>
<artifactId>jetty-siwe</artifactId>
<scope>test</scope>
</dependency>
<dependency> <dependency>
<groupId>org.eclipse.jetty</groupId> <groupId>org.eclipse.jetty</groupId>
<artifactId>jetty-slf4j-impl</artifactId> <artifactId>jetty-slf4j-impl</artifactId>

View File

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

View File

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

View File

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