add siwe.mod, distribution tests and documentation
Signed-off-by: Lachlan Roberts <lachlan.p.roberts@gmail.com>
This commit is contained in:
parent
aa945d5bd1
commit
52c6c88de6
|
@ -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>
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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[]
|
||||
|
|
|
@ -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
|
|
@ -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.
|
|
@ -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}"]
|
||||
Add script to generate and sign the SIWE message when the sign-in button is pressed.
|
||||
[,html,indent=0]
|
||||
----
|
||||
Authenticator authenticator = new OpenIdAuthenticator(openIdConfig, "/error");
|
||||
securityHandler.setAuthenticator(authenticator);
|
||||
servletContextHandler.setSecurityHandler(securityHandler);
|
||||
<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>
|
||||
----
|
||||
|
||||
===== 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.
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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.
|
||||
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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/bouncycastle/bcprov-jdk15to18-${bouncycastle.version}.jar
|
||||
|
||||
[ini]
|
||||
bouncycastle.version?=@bouncycastle.version@
|
||||
jetty.webapp.addHiddenClasses+=,${jetty.base.uri}/lib/bouncycastle/
|
|
@ -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;
|
||||
|
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
{
|
||||
|
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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();
|
||||
}
|
||||
|
||||
|
|
|
@ -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>
|
|
@ -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());
|
||||
}
|
||||
}
|
|
@ -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>");
|
||||
}
|
||||
}
|
|
@ -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>");
|
||||
}
|
||||
}
|
|
@ -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");
|
||||
}
|
||||
}
|
|
@ -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());
|
||||
}
|
||||
}
|
|
@ -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>
|
|
@ -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 |
|
@ -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>
|
|
@ -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>
|
||||
|
|
|
@ -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
17
pom.xml
|
@ -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>
|
||||
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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");
|
||||
}
|
||||
}
|
|
@ -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");
|
||||
}
|
||||
}
|
|
@ -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();
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue