add siwe.mod, distribution tests and documentation

Signed-off-by: Lachlan Roberts <lachlan.p.roberts@gmail.com>
This commit is contained in:
Lachlan Roberts 2024-07-23 11:11:18 +10:00
parent aa945d5bd1
commit 52c6c88de6
No known key found for this signature in database
GPG Key ID: 5663FB7A8FF7E348
34 changed files with 1098 additions and 337 deletions

View File

@ -54,6 +54,10 @@
<groupId>org.eclipse.jetty</groupId>
<artifactId>jetty-session</artifactId>
</dependency>
<dependency>
<groupId>org.eclipse.jetty</groupId>
<artifactId>jetty-siwe</artifactId>
</dependency>
<dependency>
<groupId>org.eclipse.jetty</groupId>
<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

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

View File

@ -0,0 +1,16 @@
//
// ========================================================================
// 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
// ========================================================================
//
= Jetty Security
TODO: introduction

View File

@ -1,134 +0,0 @@
//
// ========================================================================
// 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
// ========================================================================
//
[[openid-support]]
=== OpenID Support
==== External Setup
===== Registering an App with OpenID Provider
You must register the app with an OpenID Provider such as link:https://developers.google.com/identity/protocols/OpenIDConnect#authenticatingtheuser[Google] or link:https://images-na.ssl-images-amazon.com/images/G/01/lwa/dev/docs/website-developer-guide._TTH_.pdf[Amazon.]
This will give you a Client ID and Client Secret.
Once set up you must also register all the possible URI's for your webapp with the path `/j_security_check` so that the OpenId Provider will allow redirection back to the webapp.
These may look like
* `http://localhost:8080/openid-webapp/j_security_check`
* `https://example.com/j_security_check`
==== Distribution Configuration
===== OpenID Provider Configuration
To enable OpenID support, you first need to activate the `openid` module in your implementation.
[source, screen, subs="{sub-order}"]
----
$ java -jar $JETTY_HOME/start.jar --add-to-start=openid
----
To configure OpenID Authentication with Jetty you will need to specify the OpenID Provider's issuer identifier (case sensitive URL using the `https` scheme) and the OAuth 2.0 Client ID and Client Secret.
If the OpenID Provider does not allow metadata discovery you will also need to specify the token endpoint and authorization endpoint of the OpenID Provider.
These can be set as properties in the `start.ini` or `start.d/openid.ini` files.
===== WebApp Specific Configuration in web.xml
The `web.xml` file needs some specific configuration to use OpenID.
There must be a `login-config` element with an `auth-method` value of `OPENID`, and a `realm-name` value of the exact URL string used to set the OpenID Provider.
To set the error page, an init param is set at `"org.eclipse.jetty.security.openid.error_page"`, its value should be a path relative to the webapp where authentication errors should be redirected.
Example:
[source, xml, subs="{sub-order}"]
----
<login-config>
<auth-method>OPENID</auth-method>
<realm-name>https://accounts.google.com</realm-name>
</login-config>
<context-param>
<param-name>org.eclipse.jetty.security.openid.error_page</param-name>
<param-value>/error</param-value>
</context-param>
----
==== Embedded Configuration
===== Define the `OpenIdConfiguration` for a specific OpenID Provider.
If the OpenID Provider allows metadata discovery then you can use.
[source, java, subs="{sub-order}"]
----
OpenIdConfiguration openIdConfig = new OpenIdConfiguration(ISSUER, CLIENT_ID, CLIENT_SECRET);
----
Otherwise you can manually enter the necessary information:
[source, java, subs="{sub-order}"]
----
OpenIdConfiguration openIdConfig = new OpenIdConfiguration(ISSUER, TOKEN_ENDPOINT, AUTH_ENDPOINT, CLIENT_ID, CLIENT_SECRET);
----
===== Configuring an `OpenIdLoginService`
[source, java, subs="{sub-order}"]
----
LoginService loginService = new OpenIdLoginService(openIdConfig);
securityHandler.setLoginService(loginService);
----
===== Configuring an `OpenIdAuthenticator` with `OpenIdConfiguration` and Error Page Redirect
[source, java, subs="{sub-order}"]
----
Authenticator authenticator = new OpenIdAuthenticator(openIdConfig, "/error");
securityHandler.setAuthenticator(authenticator);
servletContextHandler.setSecurityHandler(securityHandler);
----
===== Usage
====== Claims and Access Token
Claims about the user can be found using attributes on the session attribute `"org.eclipse.jetty.security.openid.claims"`, and the full response containing the OAuth 2.0 Access Token can be found with the session attribute `"org.eclipse.jetty.security.openid.response"`.
Example:
[source, java, subs="{sub-order}"]
----
Map<String, Object> claims = (Map)request.getSession().getAttribute("org.eclipse.jetty.security.openid.claims");
String userId = claims.get("sub");
Map<String, Object> response = (Map)request.getSession().getAttribute("org.eclipse.jetty.security.openid.response");
String accessToken = response.get("access_token");
----
==== Scopes
The OpenID scope is always used but additional scopes can be requested which can give you additional resources or privileges.
For the Google OpenID Provider it can be useful to request the scopes `profile` and `email` which will give you additional user claims.
Additional scopes can be requested through the `start.ini` or `start.d/openid.ini` files, or with `OpenIdConfiguration.addScopes(...);` in embedded code.
==== Roles
If security roles are required they can be configured through a wrapped `LoginService` which is deferred to for role information by the `OpenIdLoginService`.
This can be configured in XML through `etc/openid-baseloginservice.xml` in the Distribution, or in embedded code using the constructor for the `OpenIdLoginService`.
[source, java, subs="{sub-order}"]
----
LoginService wrappedLoginService = ...; // Optional LoginService for Roles
LoginService loginService = new OpenIdLoginService(openIdConfig, wrappedLoginService);
----
When using authorization roles, the setting `authenticateNewUsers` becomes significant.
If set to `true` users not found by the wrapped `LoginService` will still be authenticated but will have no roles.
If set to `false` those users will be not be allowed to authenticate and are redirected to the error page.
This setting is configured through the property `jetty.openid.authenticateNewUsers` in the `start.ini` or `start.d/openid.ini` file, or with `OpenIdLoginService.setAuthenticateNewUsers(...);` in embedded code.

View File

@ -11,124 +11,170 @@
// ========================================================================
//
[[openid-support]]
=== OpenID Support
[[siwe-support]]
= SIWE Support
==== External Setup
== Introduction
===== Registering an App with OpenID Provider
You must register the app with an OpenID Provider such as link:https://developers.google.com/identity/protocols/OpenIDConnect#authenticatingtheuser[Google] or link:https://images-na.ssl-images-amazon.com/images/G/01/lwa/dev/docs/website-developer-guide._TTH_.pdf[Amazon.]
This will give you a Client ID and Client Secret.
Once set up you must also register all the possible URI's for your webapp with the path `/j_security_check` so that the OpenId Provider will allow redirection back to the webapp.
Sign-In with Ethereum (SIWE) is a decentralized authentication protocol that allows users to authenticate using their Ethereum account.
These may look like
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.
* `http://localhost:8080/openid-webapp/j_security_check`
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.
* `https://example.com/j_security_check`
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.
==== Distribution Configuration
=== Support
===== OpenID Provider Configuration
To enable OpenID support, you first need to activate the `openid` module in your implementation.
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.
[source, screen, subs="{sub-order}"]
== 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-to-start=openid
$ java -jar $JETTY_HOME/start.jar --add-modules=siwe
----
To configure OpenID Authentication with Jetty you will need to specify the OpenID Provider's issuer identifier (case sensitive URL using the `https` scheme) and the OAuth 2.0 Client ID and Client Secret.
If the OpenID Provider does not allow metadata discovery you will also need to specify the token endpoint and authorization endpoint of the OpenID Provider.
These can be set as properties in the `start.ini` or `start.d/openid.ini` files.
If using embedded Jetty you must add the `EthereumAuthenticator` to your `SecurityHandler`.
===== WebApp Specific Configuration in web.xml
=== Configuration
The `web.xml` file needs some specific configuration to use OpenID.
There must be a `login-config` element with an `auth-method` value of `OPENID`, and a `realm-name` value of the exact URL string used to set the OpenID Provider.
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.
To set the error page, an init param is set at `"org.eclipse.jetty.security.openid.error_page"`, its value should be a path relative to the webapp where authentication errors should be redirected.
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.
Example:
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.
[source, xml, subs="{sub-order}"]
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]
----
<login-config>
<auth-method>OPENID</auth-method>
<realm-name>https://accounts.google.com</realm-name>
</login-config>
<context-param>
<param-name>org.eclipse.jetty.security.openid.error_page</param-name>
<param-value>/error</param-value>
</context-param>
<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>
----
==== Embedded Configuration
=== 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.
===== Define the `OpenIdConfiguration` for a specific OpenID Provider.
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.
If the OpenID Provider allows metadata discovery then you can use.
The nonce endpoint provided by the `EthereumAuthenticator` returns a response with `application/json` format, with a single key of `nonce`.
[source, java, subs="{sub-order}"]
=== Configuring Security Handler
[,java,indent=0]
----
OpenIdConfiguration openIdConfig = new OpenIdConfiguration(ISSUER, CLIENT_ID, CLIENT_SECRET);
include::code:example$src/main/java/org/eclipse/jetty/docs/programming/security/siwe/SignInWithEthereum.java[tags=configureSecurityHandler]
----
Otherwise you can manually enter the necessary information:
=== Login Page Example
[source, java, subs="{sub-order}"]
Include the `Web3.js` library to interact with the users Ethereum wallet.
[,html,indent=0]
----
OpenIdConfiguration openIdConfig = new OpenIdConfiguration(ISSUER, TOKEN_ENDPOINT, AUTH_ENDPOINT, CLIENT_ID, CLIENT_SECRET);
<script src="https://cdn.jsdelivr.net/npm/web3@1.6.1/dist/web3.min.js"></script>
----
===== Configuring an `OpenIdLoginService`
[source, java, subs="{sub-order}"]
HTML form to submit the sign in request.
[,html,indent=0]
----
LoginService loginService = new OpenIdLoginService(openIdConfig);
securityHandler.setLoginService(loginService);
<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>
----
===== Configuring an `OpenIdAuthenticator` with `OpenIdConfiguration` and Error Page Redirect
[source, java, subs="{sub-order}"]
----
Authenticator authenticator = new OpenIdAuthenticator(openIdConfig, "/error");
securityHandler.setAuthenticator(authenticator);
servletContextHandler.setSecurityHandler(securityHandler);
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;
===== Usage
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];
====== Claims and Access Token
Claims about the user can be found using attributes on the session attribute `"org.eclipse.jetty.security.openid.claims"`, and the full response containing the OAuth 2.0 Access Token can be found with the session attribute `"org.eclipse.jetty.security.openid.response"`.
// Fetch nonce from the server.
const nonceResponse = await fetch('/auth/nonce');
const nonceData = await nonceResponse.json();
const nonce = nonceData.nonce;
Example:
[source, java, subs="{sub-order}"]
----
Map<String, Object> claims = (Map)request.getSession().getAttribute("org.eclipse.jetty.security.openid.claims");
String userId = claims.get("sub");
Map<String, Object> response = (Map)request.getSession().getAttribute("org.eclipse.jetty.security.openid.response");
String accessToken = response.get("access_token");
----
==== Scopes
The OpenID scope is always used but additional scopes can be requested which can give you additional resources or privileges.
For the Google OpenID Provider it can be useful to request the scopes `profile` and `email` which will give you additional user claims.
Additional scopes can be requested through the `start.ini` or `start.d/openid.ini` files, or with `OpenIdConfiguration.addScopes(...);` in embedded code.
==== Roles
If security roles are required they can be configured through a wrapped `LoginService` which is deferred to for role information by the `OpenIdLoginService`.
This can be configured in XML through `etc/openid-baseloginservice.xml` in the Distribution, or in embedded code using the constructor for the `OpenIdLoginService`.
[source, java, subs="{sub-order}"]
----
LoginService wrappedLoginService = ...; // Optional LoginService for Roles
LoginService loginService = new OpenIdLoginService(openIdConfig, wrappedLoginService);
----
When using authorization roles, the setting `authenticateNewUsers` becomes significant.
If set to `true` users not found by the wrapped `LoginService` will still be authenticated but will have no roles.
If set to `false` those users will be not be allowed to authenticate and are redirected to the error page.
This setting is configured through the property `jetty.openid.authenticateNewUsers` in the `start.ini` or `start.d/openid.ini` file, or with `OpenIdLoginService.setAuthenticateNewUsers(...);` in embedded code.
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

@ -133,7 +133,7 @@
<dependency>
<groupId>org.eclipse.jetty</groupId>
<artifactId>jetty-siwe</artifactId>
<version>12.0.11-SNAPSHOT</version>
<version>12.0.12-SNAPSHOT</version>
</dependency>
<dependency>
<groupId>org.eclipse.jetty</groupId>

View File

@ -12,7 +12,6 @@
<description>Test keystore with self-signed SSL Certificate.</description>
<properties>
<bouncycastle.version>1.78.1</bouncycastle.version>
<bundle-symbolic-name>${project.groupId}.keystore</bundle-symbolic-name>
</properties>
@ -20,17 +19,14 @@
<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>
<groupId>org.eclipse.jetty</groupId>

View File

@ -313,6 +313,8 @@ public abstract class SecurityHandler extends Handler.Wrapper implements Configu
protected void doStart()
throws Exception
{
Context context1 = ContextHandler.getCurrentContext();
// complicated resolution of login and identity service to handle
// many different ways these can be constructed and injected.

View File

@ -12,20 +12,13 @@
<description>Jetty Sign-In with Ethereum</description>
<properties>
<bouncycastle.version>1.78.1</bouncycastle.version>
<bundle-symbolic-name>${project.groupId}.siwe</bundle-symbolic-name>
</properties>
<dependencies>
<dependency>
<groupId>org.bouncycastle</groupId>
<artifactId>bcpkix-jdk15to18</artifactId>
<version>${bouncycastle.version}</version>
</dependency>
<dependency>
<groupId>org.bouncycastle</groupId>
<artifactId>bcprov-jdk15to18</artifactId>
<version>${bouncycastle.version}</version>
</dependency>
<dependency>
<groupId>org.eclipse.jetty</groupId>

View File

@ -6,5 +6,13 @@ 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/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

@ -56,34 +56,38 @@ 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
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 MAX_MESSAGE_SIZE_PARAM = "org.eclipse.jetty.security.siwe.max_message_size";
public static final String LOGOUT_REDIRECT_PARAM = "org.eclipse.jetty.security.siwe.logout_redirect_path";
public static final String DISPATCH_PARAM = "org.eclipse.jetty.security.siwe.dispatch";
public static final String ERROR_PAGE = "org.eclipse.jetty.security.siwe.error_page";
public static final String J_URI = "org.eclipse.jetty.security.siwe.URI";
public static final String J_POST = "org.eclipse.jetty.security.siwe.POST";
public static final String J_METHOD = "org.eclipse.jetty.security.siwe.METHOD";
public static final String ERROR_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> _schemes = new IncludeExcludeSet<>();
private final IncludeExcludeSet<String, String> _domains = new IncludeExcludeSet<>();
private String _loginPath;
@ -91,11 +95,10 @@ public class EthereumAuthenticator extends LoginAuthenticator
private String _noncePath = DEFAULT_NONCE_PATH;
private int _maxMessageSize = 4 * 1024;
private String _logoutRedirectPath;
private String _errorPage;
private String _errorPath;
private String _errorQuery;
private boolean _dispatch;
private boolean authenticateNewUsers = true;
private boolean _authenticateNewUsers = true;
public EthereumAuthenticator()
{
@ -107,11 +110,6 @@ public class EthereumAuthenticator extends LoginAuthenticator
_domains.include(domains);
}
public void includeSchemes(String... schemes)
{
_schemes.include(schemes);
}
public void includeChainIds(String... chainIds)
{
_chainIds.include(chainIds);
@ -140,7 +138,7 @@ public class EthereumAuthenticator extends LoginAuthenticator
if (logout != null)
setLogoutRedirectPath(logout);
String error = authConfig.getParameter(ERROR_PAGE);
String error = authConfig.getParameter(ERROR_PATH_PARAM);
if (error != null)
setErrorPage(error);
@ -148,7 +146,19 @@ public class EthereumAuthenticator extends LoginAuthenticator
if (dispatch != null)
setDispatch(Boolean.parseBoolean(dispatch));
if (authenticateNewUsers)
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)
@ -161,6 +171,8 @@ public class EthereumAuthenticator extends LoginAuthenticator
};
}
if (_loginPath == null)
throw new IllegalStateException("No loginPath");
super.setConfiguration(authConfig);
}
@ -172,7 +184,7 @@ public class EthereumAuthenticator extends LoginAuthenticator
public boolean isAuthenticateNewUsers()
{
return authenticateNewUsers;
return _authenticateNewUsers;
}
/**
@ -186,7 +198,7 @@ public class EthereumAuthenticator extends LoginAuthenticator
*/
public void setAuthenticateNewUsers(boolean authenticateNewUsers)
{
this.authenticateNewUsers = authenticateNewUsers;
this._authenticateNewUsers = authenticateNewUsers;
}
public void setLoginPath(String loginPath)
@ -263,7 +275,6 @@ public class EthereumAuthenticator extends LoginAuthenticator
if (path == null || path.trim().isEmpty())
{
_errorPath = null;
_errorPage = null;
}
else
{
@ -272,15 +283,14 @@ public class EthereumAuthenticator extends LoginAuthenticator
LOG.warn("error-page must start with /");
path = "/" + path;
}
_errorPage = path;
_errorPath = path;
_errorQuery = "";
int queryIndex = _errorPath.indexOf('?');
if (queryIndex > 0)
{
_errorPath = _errorPage.substring(0, queryIndex);
_errorQuery = _errorPage.substring(queryIndex + 1);
_errorPath = path.substring(0, queryIndex);
_errorQuery = path.substring(queryIndex + 1);
}
}
}
@ -417,6 +427,8 @@ public class EthereumAuthenticator extends LoginAuthenticator
return Constraint.Authorization.ANY_USER;
if (isLoginPage(pathInContext) || isErrorPage(pathInContext))
return Constraint.Authorization.ALLOWED;
if (isNonceRequest(pathInContext))
return Constraint.Authorization.ANY_USER;
return existing;
}
@ -433,7 +445,7 @@ public class EthereumAuthenticator extends LoginAuthenticator
break;
totalRead += len;
if (totalRead > _maxMessageSize)
if (_maxMessageSize >= 0 && totalRead > _maxMessageSize)
throw new BadMessageException("SIWE Message Too Large");
out.append(buffer, 0, len);
}
@ -494,6 +506,7 @@ public class EthereumAuthenticator extends LoginAuthenticator
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;
@ -510,7 +523,7 @@ public class EthereumAuthenticator extends LoginAuthenticator
try
{
siwe.validate(signedMessage, nonce -> redeemNonce(session, nonce), _schemes, _domains, _chainIds);
siwe.validate(signedMessage, nonce -> redeemNonce(session, nonce), _domains, _chainIds);
}
catch (Throwable t)
{
@ -580,9 +593,6 @@ public class EthereumAuthenticator extends LoginAuthenticator
return formAuth;
}
// not authenticated
if (LOG.isDebugEnabled())
LOG.debug("auth failed {}=={}", address, _errorPage);
sendError(request, response, callback, "auth failed");
return AuthenticationState.SEND_FAILURE;
}
@ -661,9 +671,9 @@ public class EthereumAuthenticator extends LoginAuthenticator
private void sendError(Request request, Response response, Callback callback, String message)
{
if (LOG.isDebugEnabled())
LOG.debug("OpenId authentication FAILED: {}", message);
LOG.debug("Authentication FAILED: {}", message);
if (_errorPage == null)
if (_errorPath == null)
{
if (LOG.isDebugEnabled())
LOG.debug("auth failed 403");
@ -673,10 +683,10 @@ public class EthereumAuthenticator extends LoginAuthenticator
else
{
if (LOG.isDebugEnabled())
LOG.debug("auth failed {}", _errorPage);
LOG.debug("auth failed {}", _errorPath);
String contextPath = Request.getContextPath(request);
String redirectUri = URIUtil.addPaths(contextPath, _errorPage);
String redirectUri = URIUtil.addPaths(contextPath, _errorPath);
if (message != null)
{
String query = URIUtil.addQueries(ERROR_PARAMETER + "=" + UrlEncoded.encodeString(message), _errorQuery);
@ -732,6 +742,8 @@ public class EthereumAuthenticator extends LoginAuthenticator
public boolean isErrorPage(String pathInContext)
{
if (_errorPath == null)
return false;
return pathInContext != null && (pathInContext.equals(_errorPath));
}
@ -762,6 +774,24 @@ public class EthereumAuthenticator extends LoginAuthenticator
}
}
@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;

View File

@ -56,24 +56,22 @@ public record SignInWithEthereumToken(String scheme,
/**
* @param signedMessage the {@link SignedMessage}.
* @param validateNonce a {@link Predicate} used to validate the nonce.
* @param schemes the {@link IncludeExcludeSet} used to validate the scheme.
* @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 SignedMessage} fails validation.
*/
public void validate(SignedMessage signedMessage, Predicate<String> validateNonce,
IncludeExcludeSet<String, String> schemes,
IncludeExcludeSet<String, String> domains,
IncludeExcludeSet<String, String> chainIds) throws ServerAuthException
{
if (validateNonce != null && !validateNonce.test(nonce()))
throw new ServerAuthException("invalid nonce");
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");
throw new ServerAuthException("unsupported version " + version);
LocalDateTime now = LocalDateTime.now();
if (StringUtil.isNotBlank(expirationTime()))
@ -90,11 +88,9 @@ public record SignInWithEthereumToken(String scheme,
throw new ServerAuthException("SIWE message not yet valid");
}
if (schemes != null && !schemes.test(scheme()))
throw new ServerAuthException("unregistered scheme");
if (domains != null && !domains.test(domain()))
throw new ServerAuthException("unregistered domain");
throw new ServerAuthException("unregistered domain: " + domain());
if (chainIds != null && !chainIds.test(chainId()))
throw new ServerAuthException("unregistered chainId");
throw new ServerAuthException("unregistered chainId: " + chainId());
}
}

View File

@ -70,6 +70,11 @@ public class SignInWithEthereumTest
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);
@ -89,11 +94,11 @@ public class SignInWithEthereumTest
};
_authenticator = new EthereumAuthenticator();
_authenticator.setLoginPath("/login");
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();
@ -224,26 +229,6 @@ public class SignInWithEthereumTest
assertThat(response.getContentAsString(), containsString("UserPrincipal: " + _credentials.getAddress()));
}
@Test
public void testEnforceScheme() throws Exception
{
_authenticator.includeSchemes("https");
// Test login with invalid scheme.
String nonce = getNonce();
String siweMessage = SignInWithEthereumGenerator.generateMessage("http", "localhost", _credentials.getAddress(), nonce);
ContentResponse response = sendAuthRequest(_credentials.signMessage(siweMessage));
assertThat(response.getStatus(), equalTo(HttpStatus.FORBIDDEN_403));
assertThat(response.getContentAsString(), containsString("unregistered scheme"));
// Test login with valid scheme.
nonce = getNonce();
siweMessage = SignInWithEthereumGenerator.generateMessage("https", "localhost", _credentials.getAddress(), nonce);
response = sendAuthRequest(_credentials.signMessage(siweMessage));
assertThat(response.getStatus(), equalTo(HttpStatus.OK_200));
assertThat(response.getContentAsString(), containsString("UserPrincipal: " + _credentials.getAddress()));
}
@Test
public void testEnforceChainId() throws Exception
{

View File

@ -55,7 +55,7 @@ public class SignInWithEthereumTokenTest
assertNotNull(siwe);
Throwable error = assertThrows(Throwable.class, () ->
siwe.validate(signedMessage, null, null, null, null));
siwe.validate(signedMessage, null, null, null));
assertThat(error.getMessage(), containsString("unsupported version"));
}
@ -84,7 +84,7 @@ public class SignInWithEthereumTokenTest
assertNotNull(siwe);
Throwable error = assertThrows(Throwable.class, () ->
siwe.validate(signedMessage, null, null, null, null));
siwe.validate(signedMessage, null, null, null));
assertThat(error.getMessage(), containsString("expired SIWE message"));
}
@ -114,7 +114,7 @@ public class SignInWithEthereumTokenTest
assertNotNull(siwe);
Throwable error = assertThrows(Throwable.class, () ->
siwe.validate(signedMessage, null, null, null, null));
siwe.validate(signedMessage, null, null, null));
assertThat(error.getMessage(), containsString("SIWE message not yet valid"));
}
@ -144,40 +144,10 @@ public class SignInWithEthereumTokenTest
domains.include("example.org");
Throwable error = assertThrows(Throwable.class, () ->
siwe.validate(signedMessage, null, null, domains, null));
siwe.validate(signedMessage, null, domains, null));
assertThat(error.getMessage(), containsString("unregistered domain"));
}
@Test
public void testInvalidScheme() throws Exception
{
EthereumCredentials credentials = new EthereumCredentials();
LocalDateTime issuedAt = LocalDateTime.now();
String message = SignInWithEthereumGenerator.generateMessage(
"https",
"example.com",
credentials.getAddress(),
"hello this is the statement",
"https://example.com",
"1",
"1",
EthereumUtil.createNonce(),
issuedAt,
null, null, null, null
);
SignedMessage signedMessage = credentials.signMessage(message);
SignInWithEthereumToken siwe = SignInWithEthereumParser.parse(message);
assertNotNull(siwe);
IncludeExcludeSet<String, String> schemes = new IncludeExcludeSet<>();
schemes.include("wss");
Throwable error = assertThrows(Throwable.class, () ->
siwe.validate(signedMessage, null, schemes, null, null));
assertThat(error.getMessage(), containsString("unregistered scheme"));
}
@Test
public void testInvalidChainId() throws Exception
{
@ -204,7 +174,7 @@ public class SignInWithEthereumTokenTest
chainIds.include("1337");
Throwable error = assertThrows(Throwable.class, () ->
siwe.validate(signedMessage, null, null, null, chainIds));
siwe.validate(signedMessage, null, null, chainIds));
assertThat(error.getMessage(), containsString("unregistered chainId"));
}
@ -232,7 +202,7 @@ public class SignInWithEthereumTokenTest
Predicate<String> nonceValidation = nonce -> false;
Throwable error = assertThrows(Throwable.class, () ->
siwe.validate(signedMessage, nonceValidation, null, null, null));
siwe.validate(signedMessage, nonceValidation, null, null));
assertThat(error.getMessage(), containsString("invalid nonce"));
}
@ -260,6 +230,6 @@ public class SignInWithEthereumTokenTest
Predicate<String> nonceValidation = nonce -> true;
assertDoesNotThrow(() ->
siwe.validate(signedMessage, nonceValidation, null, null, null));
siwe.validate(signedMessage, nonceValidation, null, null));
}
}

View File

@ -79,13 +79,7 @@ public class SignInWithEthereumEmbeddedExample
}
};
EthereumAuthenticator authenticator = new EthereumAuthenticator();
authenticator.setLoginPath("/login.html");
SecurityHandler.PathMapped securityHandler = new SecurityHandler.PathMapped();
securityHandler.setAuthenticator(authenticator);
securityHandler.setHandler(handler);
securityHandler.put("/*", Constraint.ANY_USER);
SecurityHandler securityHandler = createSecurityHandler(handler);
SessionHandler sessionHandler = new SessionHandler();
sessionHandler.setHandler(securityHandler);
@ -98,4 +92,24 @@ public class SignInWithEthereumEmbeddedExample
System.err.println(resourceHandler.getBaseResource());
server.join();
}
public static SecurityHandler createSecurityHandler(Handler handler)
{
// 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");
return securityHandler;
}
}

View File

@ -17,6 +17,7 @@ import java.io.IOException;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.Enumeration;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
@ -29,6 +30,7 @@ import java.util.stream.Stream;
import jakarta.servlet.HttpConstraintElement;
import jakarta.servlet.HttpMethodConstraintElement;
import jakarta.servlet.ServletContext;
import jakarta.servlet.ServletSecurityElement;
import jakarta.servlet.annotation.ServletSecurity.EmptyRoleSemantic;
import jakarta.servlet.annotation.ServletSecurity.TransportGuarantee;
@ -40,6 +42,7 @@ import org.eclipse.jetty.http.pathmap.PathSpec;
import org.eclipse.jetty.security.Constraint;
import org.eclipse.jetty.security.Constraint.Transport;
import org.eclipse.jetty.security.SecurityHandler;
import org.eclipse.jetty.server.Context;
import org.eclipse.jetty.server.Request;
import org.eclipse.jetty.server.Server;
import org.eclipse.jetty.server.handler.ContextHandler;
@ -373,6 +376,20 @@ 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
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();
}

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.12-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

@ -0,0 +1,59 @@
<!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)
console.log("siweMessage: " + siweMessage)
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

@ -27,6 +27,7 @@
<module>jetty-ee10-test-log4j2-webapp</module>
<module>jetty-ee10-test-loginservice</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-quickstart</module>
<module>jetty-ee10-test-sessions</module>

View File

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

17
pom.xml
View File

@ -169,6 +169,7 @@
<asciidoctorj.version>3.0.0-alpha.2</asciidoctorj.version>
<asm.version>9.7</asm.version>
<awaitility.version>4.2.1</awaitility.version>
<bouncycastle.version>1.78.1</bouncycastle.version>
<bndlib.version>7.0.0</bndlib.version>
<build-helper.maven.plugin.version>3.6.0</build-helper.maven.plugin.version>
<build-support.version>1.5</build-support.version>
@ -1303,6 +1304,22 @@
<artifactId>wildfly-elytron-sasl-scram</artifactId>
<version>${wildfly.elytron.version}</version>
</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>
</dependencies>
</dependencyManagement>

View File

@ -37,6 +37,11 @@
<artifactId>jetty-openid</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.eclipse.jetty</groupId>
<artifactId>jetty-siwe</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.eclipse.jetty</groupId>
<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.SignedMessage;
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
{
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.SignedMessage;
import org.eclipse.jetty.security.siwe.internal.EthereumSignatureVerifier;
import static org.eclipse.jetty.security.siwe.internal.EthereumSignatureVerifier.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 = EthereumSignatureVerifier.toAddress(((BCECPublicKey)publicKey).getQ());
}
catch (Exception e)
{
throw new RuntimeException(e);
}
}
public String getAddress()
{
return address;
}
public SignedMessage signMessage(String message) throws Exception
{
byte[] messageBytes = message.getBytes(StandardCharsets.ISO_8859_1);
String prefix = "\u0019Ethereum Signed Message:\n" + messageBytes.length + message;
byte[] messageHash = keccak256(prefix.getBytes(StandardCharsets.ISO_8859_1));
Signature ecdsaSign = Signature.getInstance("NONEwithECDSA", 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 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 = EthereumSignatureVerifier.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();
}
}