DZone
Thanks for visiting DZone today,
Edit Profile
  • Manage Email Subscriptions
  • How to Post to DZone
  • Article Submission Guidelines
Sign Out View Profile
  • Post an Article
  • Manage My Drafts
Over 2 million developers have joined DZone.
Log In / Join
Please enter at least three characters to search
Refcards Trend Reports
Events Video Library
Refcards
Trend Reports

Events

View Events Video Library

Zones

Culture and Methodologies Agile Career Development Methodologies Team Management
Data Engineering AI/ML Big Data Data Databases IoT
Software Design and Architecture Cloud Architecture Containers Integration Microservices Performance Security
Coding Frameworks Java JavaScript Languages Tools
Testing, Deployment, and Maintenance Deployment DevOps and CI/CD Maintenance Monitoring and Observability Testing, Tools, and Frameworks
Culture and Methodologies
Agile Career Development Methodologies Team Management
Data Engineering
AI/ML Big Data Data Databases IoT
Software Design and Architecture
Cloud Architecture Containers Integration Microservices Performance Security
Coding
Frameworks Java JavaScript Languages Tools
Testing, Deployment, and Maintenance
Deployment DevOps and CI/CD Maintenance Monitoring and Observability Testing, Tools, and Frameworks

Because the DevOps movement has redefined engineering responsibilities, SREs now have to become stewards of observability strategy.

Apache Cassandra combines the benefits of major NoSQL databases to support data management needs not covered by traditional RDBMS vendors.

The software you build is only as secure as the code that powers it. Learn how malicious code creeps into your software supply chain.

Generative AI has transformed nearly every industry. How can you leverage GenAI to improve your productivity and efficiency?

Related

  • MuleSoft OAuth 2.0 Provider: Password Grant Type
  • Spring OAuth Server: Authenticate User With user-details Service
  • Basic Authentication Using Spring Boot Security: A Step-By-Step Guide
  • OAuth2/OpenID for Spring Boot 3 and SPA

Trending

  • After 9 Years, Microsoft Fulfills This Windows Feature Request
  • Unlocking the Potential of Apache Iceberg: A Comprehensive Analysis
  • Comparing Managed Postgres Options on The Azure Marketplace
  • How To Build AI-Powered Prompt Templates Using the Salesforce Prompt Builder
  1. DZone
  2. Software Design and Architecture
  3. Security
  4. Spring Authentication With MetaMask

Spring Authentication With MetaMask

Learn how to develop an authentication mechanism for Spring Security with the MetaMask extension using asymmetric encryption and providing data privacy.

By 
Alexander Makeev user avatar
Alexander Makeev
·
Sep. 12, 23 · Tutorial
Likes (12)
Comment
Save
Tweet
Share
9.0K Views

Join the DZone community and get the full member experience.

Join For Free

When choosing a user authentication method for your application, you usually have several options: develop your own system for identification, authentication, and authorization, or use a ready-made solution. A ready-made solution means that the user already has an account on an external system such as Google, Facebook, or GitHub, and you use the appropriate mechanism, most likely OAuth, to provide limited access to the user’s protected resources without transferring the username and password to it. The second option with OAuth is easier to implement, but there is a risk for your user if the user's account is blocked and the user will lose access to your site. Also, if I, as a user, want to enter a site that I do not trust, I have to provide my personal information, such as my email and full name, sacrificing my anonymity.

In this article, we’ll build an alternative login method for Spring using the MetaMask browser extension. MetaMask is a cryptocurrency wallet used to manage Ethereum assets and interact with the Ethereum blockchain. Unlike the OAuth provider, only the necessary set of data can be stored on the Ethereum network. We must take care not to store secret information in the public data, but since any wallet on the Ethereum network is in fact a cryptographic strong key pair, in which the public key determines the wallet address and the private key is never transmitted over the network and is known only by the owner, we can use asymmetric encryption to authenticate users.

Authentication Flow

Authentication Flow

  1. Connect to MetaMask and receive the user’s address.
  2. Obtain a one-time code (nonce) for a user address.
  3. Sign a message containing nonce with a private key using MetaMask.
  4. Authenticate the user by validating the user's signature on the back end.
  5. Generate a new nonce to prevent your signature from being compromised.

Step 1: Project Setup

To quickly build a project, we can use Spring Initializr. Let’s add the following dependencies:

  • Spring Web
  • Spring Security
  • Thymeleaf
  • Lombok

Download the generated project and open it with a convenient IDE. In the pom.xml, we add the following dependency to verify the Ethereum signature:

XML
 
<dependency>
	<groupId>org.web3j</groupId>
	<artifactId>core</artifactId>
	<version>4.10.2</version>
</dependency>


Step 2: User Model

Let’s create a simple User model containing the following fields: address and nonce. The nonce, or one-time code, is a random number we will use for authentication to ensure the uniqueness of each signed message.

Java
 
public class User {
    private final String address;
    private Integer nonce;

    public User(String address) {
        this.address = address;
        this.nonce = (int) (Math.random() * 1000000);
    }

    // getters
}


To store users, for simplicity, I’ll be using an in-memory Map with a method to retrieve User by address, creating a new User instance in case the value is missing:

Java
 
@Repository
public class UserRepository {
    private final Map<String, User> users = new ConcurrentHashMap<>();

    public User getUser(String address) {
        return users.computeIfAbsent(address, User::new);
    }
}


Let's define a controller allowing users to fetch nonce by their public address:

Java
 
@RestController
public class NonceController {
    @Autowired
    private UserRepository userRepository;

    @GetMapping("/nonce/{address}")
    public ResponseEntity<Integer> getNonce(@PathVariable String address) {
        User user = userRepository.getUser(address);
        return ResponseEntity.ok(user.getNonce());
    }
}


Step 3: Authentication Filter

To implement a custom authentication mechanism with Spring Security, first, we need to define our AuthenticationFilter.  Spring filters are designed to intercept requests for certain URLs and perform some actions. Each filter in the chain can process the request, pass it to the next filter in the chain, or not pass it, immediately sending a response to the client.

Java
 
public class MetaMaskAuthenticationFilter extends AbstractAuthenticationProcessingFilter {
    protected MetaMaskAuthenticationFilter() {
        super(new AntPathRequestMatcher("/login", "POST"));
    }


    @Override
    public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response)
            throws AuthenticationException {
        UsernamePasswordAuthenticationToken authRequest = getAuthRequest(request);
        authRequest.setDetails(this.authenticationDetailsSource.buildDetails(request));
        return this.getAuthenticationManager().authenticate(authRequest);
    }


    private UsernamePasswordAuthenticationToken getAuthRequest(HttpServletRequest request) {
        String address = request.getParameter("address");
        String signature = request.getParameter("signature");
        return new MetaMaskAuthenticationRequest(address, signature);
    }
}


Our MetaMaskAuthenticationFilter will intercept requests with the POST "/login" pattern. In the attemptAuthentication(HttpServletRequest request, HttpServletResponse response) method, we extract address and signature parameters from the request. Next, these values are used to create an instance of MetaMaskAuthenticationRequest, which we pass as a login request to the authentication manager:

Java
 
public class MetaMaskAuthenticationRequest extends UsernamePasswordAuthenticationToken {
    public MetaMaskAuthenticationRequest(String address, String signature) {
        super(address, signature);
        super.setAuthenticated(false);
    }

    public String getAddress() {
        return (String) super.getPrincipal();
    }

    public String getSignature() {
        return (String) super.getCredentials();
    }
}


Step 4: Authentication Provider

Our MetaMaskAuthenticationRequest should be processed by a custom AuthenticationProvider, where we can validate the user's signature and return a fully authenticated object. Let’s create an implementation of AbstractUserDetailsAuthenticationProvider, which is designed to work with UsernamePasswordAuthenticationToken instances:

Java
 
@Component
public class MetaMaskAuthenticationProvider extends AbstractUserDetailsAuthenticationProvider {
    @Autowired
    private UserRepository userRepository;

    @Override
    protected UserDetails retrieveUser(String username, UsernamePasswordAuthenticationToken authentication)
            throws AuthenticationException {
        MetaMaskAuthenticationRequest auth = (MetaMaskAuthenticationRequest) authentication;
        User user = userRepository.getUser(auth.getAddress());
        return new MetaMaskUserDetails(auth.getAddress(), auth.getSignature(), user.getNonce());
    }

    @Override
    protected void additionalAuthenticationChecks(UserDetails userDetails, UsernamePasswordAuthenticationToken authentication)
            throws AuthenticationException {
        MetaMaskAuthenticationRequest metamaskAuthenticationRequest = (MetaMaskAuthenticationRequest) authentication;
        MetaMaskUserDetails metamaskUserDetails = (MetaMaskUserDetails) userDetails;

        if (!isSignatureValid(authentication.getCredentials().toString(),
                metamaskAuthenticationRequest.getAddress(), metamaskUserDetails.getNonce())) {
            logger.debug("Authentication failed: signature is not valid");
            throw new BadCredentialsException("Signature is not valid");
        }
    }

    ...
}


The first method, retrieveUser(String username, UsernamePasswordAuthenticationToken authentication) should load the User entity from our UserRepository and compose the UserDetails instance containing address, signature, and nonce:

Java
 
public class MetaMaskUserDetails extends User {
    private final Integer nonce;

    public MetaMaskUserDetails(String address, String signature, Integer nonce) {
        super(address, signature, Collections.emptyList());
        this.nonce = nonce;
    }

    public String getAddress() {
        return getUsername();
    }

    public Integer getNonce() {
        return nonce;
    }
}


The second method, additionalAuthenticationChecks(UserDetails userDetails, UsernamePasswordAuthenticationToken authentication) will do the signature verification using the Elliptic Curve Digital Signature Algorithm (ECDSA). The idea of this algorithm is to recover the wallet address from a given message and signature. If the recovered address matches our address from MetaMaskUserDetails, then the user can be authenticated.

1. Get the message hash by adding a prefix to make the calculated signature recognizable as an Ethereum signature:

Java
 
String prefix = "\u0019Ethereum Signed Message:\n" + message.length();
byte[] msgHash = Hash.sha3((prefix + message).getBytes());


2. Extract the r, s and v components from the Ethereum signature and create a SignatureData instance:

Java
 
byte[] signatureBytes = Numeric.hexStringToByteArray(signature);
byte v = signatureBytes[64];
if (v < 27) {v += 27;}
byte[] r = Arrays.copyOfRange(signatureBytes, 0, 32);
byte[] s = Arrays.copyOfRange(signatureBytes, 32, 64);
Sign.SignatureData data = new Sign.SignatureData(v, r, s);


3. Using the method Sign.recoverFromSignature(), retrieve the public key from the signature:

Java
 
BigInteger publicKey = Sign.signedMessageHashToKey(msgHash, sd);


4. Finally, get the wallet address and compare it with the initial address:

Java
 
String recoveredAddress = "0x" + Keys.getAddress(publicKey);
if (address.equalsIgnoreCase(recoveredAddress)) {
    // Signature is valid.
} else {
    // Signature is not valid.
}


There is a complete implementation of isSignatureValid(String signature, String address, Integer nonce) method with nonce:

Java
 
public boolean isSignatureValid(String signature, String address, Integer nonce) {
    // Compose the message with nonce
    String message = "Signing a message to login: %s".formatted(nonce);

    // Extract the ‘r’, ‘s’ and ‘v’ components
    byte[] signatureBytes = Numeric.hexStringToByteArray(signature);
    byte v = signatureBytes[64];
    if (v < 27) {
        v += 27;
    }
    byte[] r = Arrays.copyOfRange(signatureBytes, 0, 32);
    byte[] s = Arrays.copyOfRange(signatureBytes, 32, 64);
    Sign.SignatureData data = new Sign.SignatureData(v, r, s);

    // Retrieve public key
    BigInteger publicKey;
    try {
        publicKey = Sign.signedPrefixedMessageToKey(message.getBytes(), data);
    } catch (SignatureException e) {
        logger.debug("Failed to recover public key", e);
        return false;
    }

    // Get recovered address and compare with the initial address
    String recoveredAddress = "0x" + Keys.getAddress(publicKey);
    return address.equalsIgnoreCase(recoveredAddress);
}


Step 5: Security Configuration

In the Security Configuration, besides the standard formLogin setup, we need to insert our MetaMaskAuthenticationFilter into the filter chain before the default:

Java
 
@Bean
public SecurityFilterChain filterChain(HttpSecurity http, AuthenticationManager authenticationManager) throws Exception {
    return http
            .authorizeHttpRequests(customizer -> customizer
                    .requestMatchers(HttpMethod.GET, "/nonce/*").permitAll()
                    .anyRequest().authenticated())
            .formLogin(customizer -> customizer.loginPage("/login")
                    .failureUrl("/login?error=true")
                    .permitAll())
            .logout(customizer -> customizer.logoutUrl("/logout"))
            .csrf(AbstractHttpConfigurer::disable)
            .addFilterBefore(authenticationFilter(authenticationManager), UsernamePasswordAuthenticationFilter.class)
            .build();
}

private MetaMaskAuthenticationFilter authenticationFilter(AuthenticationManager authenticationManager) {
    MetaMaskAuthenticationFilter filter = new MetaMaskAuthenticationFilter();
    filter.setAuthenticationManager(authenticationManager);
    filter.setAuthenticationSuccessHandler(new MetaMaskAuthenticationSuccessHandler(userRepository));
    filter.setAuthenticationFailureHandler(new SimpleUrlAuthenticationFailureHandler("/login?error=true"));
    filter.setSecurityContextRepository(new HttpSessionSecurityContextRepository());
    return filter;
}


To prevent replay attacks in case the user’s signature gets compromised, we will create the AuthenticationSuccessHandler implementation, in which we change the user’s nonce and make the user sign the message with a new nonce next login:

Java
 
public class MetaMaskAuthenticationSuccessHandler extends SimpleUrlAuthenticationSuccessHandler {
    private final UserRepository userRepository;

    public MetaMaskAuthenticationSuccessHandler(UserRepository userRepository) {
        super("/");
        this.userRepository = userRepository;
    }

    @Override
    public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication)
            throws ServletException, IOException {
        super.onAuthenticationSuccess(request, response, authentication);
        MetaMaskUserDetails principal = (MetaMaskUserDetails) authentication.getPrincipal();
        User user = userRepository.getUser(principal.getAddress());
        user.changeNonce();
    }
}
Java
 
public class User {
    ...    

    public void changeNonce() {
        this.nonce = (int) (Math.random() * 1000000);
    }
}


We also need to configure the AuthenticationManager bean injecting our MetaMaskAuthenticationProvider:

Java
 
@Bean
public AuthenticationManager authenticationManager(List<AuthenticationProvider> authenticationProviders) {
    return new ProviderManager(authenticationProviders);
}


Step 6: Templates

Java
 
@Controller
public class WebController {
    @RequestMapping("/")
    public String root() {
        return "index";
    }


    @RequestMapping("/login")
    public String login() {
        return "login";
    }
}


Our WebController contains two templates: login.html and index.html:

1. The first template will be used to authenticate with MetaMask.

To prompt a user to connect to MetaMask and receive a wallet address, we can use the eth_requestAccounts method:

JavaScript
 
const accounts = await window.ethereum.request({method: 'eth_requestAccounts'});
const address = accounts[0];


Connect with MetaMask prompt

Next, having connected the MetaMask and received the nonce from the back end, we request the MetaMask to sign a message using the personal_sign method:

JavaScript
 
const nonce = await getNonce(address);
const message = `Signing a message to login: ${nonce}`;
const signature = await window.ethereum.request({method: 'personal_sign', params: [message, address]});


Signature request screen

Finally, we send the calculated signature with the address to the back end. There is a complete template templates/login.html:

HTML
 
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org" lang="en">
<head>
    <title>Login page</title>
    <meta charset="utf-8"/>
    <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
    <link href="https://maxcdn.bootstrapcdn.com/bootstrap/4.0.0-beta/css/bootstrap.min.css" rel="stylesheet"
          integrity="sha384-/Y6pD6FV/Vv2HJnA6t+vslU6fwYXjCFtcEpHbNJ0lyAFsXTsjBbfaDjzALeQsN6M" crossorigin="anonymous">
    <link href="https://getbootstrap.com/docs/4.0/examples/signin/signin.css" rel="stylesheet" crossorigin="anonymous"/>
</head>
<body>
<div class="container">
    <div class="form-signin">
        <h3 class="form-signin-heading">Please sign in</h3>
        <p th:if="${param.error}" class="text-danger">Invalid signature</p>
        <button class="btn btn-lg btn-primary btn-block" type="submit" onclick="login()">Login with MetaMask</button>
    </div>
</div>
<script th:inline="javascript">
    async function login() {
        if (!window.ethereum) {
            console.error('Please install MetaMask');
            return;
        }

        // Prompt user to connect MetaMask
        const accounts = await window.ethereum.request({method: 'eth_requestAccounts'});
        const address = accounts[0];

        // Receive nonce and sign a message
        const nonce = await getNonce(address);
        const message = `Signing a message to login: ${nonce}`;
        const signature = await window.ethereum.request({method: 'personal_sign', params: [message, address]});

        // Login with signature
        await sendLoginData(address, signature);
    }

    async function getNonce(address) {
        return await fetch(`/nonce/${address}`)
            .then(response => response.text());
    }

    async function sendLoginData(address, signature) {
        return fetch('/login', {
            method: 'POST',
            headers: {'content-type': 'application/x-www-form-urlencoded'},
            body: new URLSearchParams({
                address: encodeURIComponent(address),
                signature: encodeURIComponent(signature)
            })
        }).then(() => window.location.href = '/');
    }
</script>
</body>
</html>


2. The second templates/index.html template will be protected by our Spring Security configuration, displaying the Principal name as the wallet address after the person gets signed up:

HTML
 
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org"
      xmlns:sec="http://www.thymeleaf.org/extras/spring-security" lang="en">
<head>
    <title>Spring Authentication with MetaMask</title>
    <meta charset="utf-8"/>
    <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
    <link href="https://maxcdn.bootstrapcdn.com/bootstrap/4.0.0-beta/css/bootstrap.min.css" rel="stylesheet"
          integrity="sha384-/Y6pD6FV/Vv2HJnA6t+vslU6fwYXjCFtcEpHbNJ0lyAFsXTsjBbfaDjzALeQsN6M" crossorigin="anonymous">
    <link href="https://getbootstrap.com/docs/4.0/examples/signin/signin.css" rel="stylesheet" crossorigin="anonymous"/>
</head>
<body>
<div class="container" sec:authorize="isAuthenticated()">
    <form class="form-signin" method="post" th:action="@{/logout}">
        <h3 class="form-signin-heading">This is a secured page!</h3>
        <p>Logged in as: <span sec:authentication="name"></span></p>
        <button class="btn btn-lg btn-secondary btn-block" type="submit">Logout</button>
    </form>
</div>
</body>
</html>


The full source code is provided on GitHub.

In this article, we developed an alternative authentication mechanism with Spring Security and MetaMask using asymmetric encryption. This method can fit into your application, but only if your target audience is using cryptocurrency and has the MetaMask extension installed in their browser.

Ethereum Spring Security authentication

Opinions expressed by DZone contributors are their own.

Related

  • MuleSoft OAuth 2.0 Provider: Password Grant Type
  • Spring OAuth Server: Authenticate User With user-details Service
  • Basic Authentication Using Spring Boot Security: A Step-By-Step Guide
  • OAuth2/OpenID for Spring Boot 3 and SPA

Partner Resources

×

Comments
Oops! Something Went Wrong

The likes didn't load as expected. Please refresh the page and try again.

ABOUT US

  • About DZone
  • Support and feedback
  • Community research
  • Sitemap

ADVERTISE

  • Advertise with DZone

CONTRIBUTE ON DZONE

  • Article Submission Guidelines
  • Become a Contributor
  • Core Program
  • Visit the Writers' Zone

LEGAL

  • Terms of Service
  • Privacy Policy

CONTACT US

  • 3343 Perimeter Hill Drive
  • Suite 100
  • Nashville, TN 37211
  • support@dzone.com

Let's be friends:

Likes
There are no likes...yet! 👀
Be the first to like this post!
It looks like you're not logged in.
Sign in to see who liked this post!