当前位置:Java -> 使用 MetaMask 进行 Spring 认证

使用 MetaMask 进行 Spring 认证

在为你的应用程序选择用户认证方法时,通常有几种选择:开发自己的身份识别、认证和授权系统,或者使用现成的解决方案。现成的解决方案意味着用户已经在外部系统(如Google、Facebook或GitHub)上有一个账户,并且通过适当的机制(大多数情况下是OAuth)提供有限访问权限给用户的受保护资源,而无需将用户名和密码传输给它。使用OAuth的第二种选择更容易实现,但如果用户的账户被封锁,用户将失去对你网站的访问权限。另外,如果作为用户,我希望进入一个我不信任的站点,我不得不提供我的个人信息,比如我的邮箱和全名,从而牺牲了我的匿名性。

在本文中,我们将使用MetaMask浏览器扩展程序为Spring构建一种替代登录方法。MetaMask是一种加密货币钱包,用于管理以太坊资产并与以太坊区块链进行交互。与OAuth提供程序不同,以太坊网络上只能存储必要的数据集。我们必须注意不要将秘密信息存储在公共数据中,但由于以太坊网络上的任何钱包实际上都是一个加密强大的密钥对,其中公钥确定钱包地址,私钥永远不会通过网络传输,并且仅由所有者知道,我们可以使用非对称加密来认证用户。

认证流程

Authentication Flow

  1. 连接到MetaMask并接收用户的地址。
  2. 为用户地址获取一次性代码(nonce)。
  3. 使用MetaMask使用私钥对包含nonce的消息进行签名。
  4. 通过验证后端上用户的签名来认证用户。
  5. 生成一个新的nonce以防止您的签名被破坏。

步骤1:项目设置

Spring Initializr。让我们添加以下依赖项:
  • Spring Web
  • Spring Security
  • Thymeleaf
  • Lombok
pom.xml中,我们添加以下依赖项来验证以太坊签名:
<dependency>
	<groupId>org.web3j</groupId>
	<artifactId>core</artifactId>
	<version>4.10.2</version>
</dependency>


步骤2:用户模型

让我们创建一个简单的User模型,其中包含以下字段:地址nonce。一次性代码(nonce)是我们将用于认证的随机数,以确保每个签名消息的唯一性。

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

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

    // getters
}


Map,并添加一个方法来通过地址检索User,在值缺失的情况下创建一个新的User实例:
@Repository
public class UserRepository {
    private final Map<String, User> users = new ConcurrentHashMap<>();

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


让我们定义一个控制器,允许用户通过他们的公共地址获取nonce

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


步骤3:认证过滤器

AuthenticationFilter。Spring过滤器旨在拦截对某些URL的请求并执行一些操作。链中的每个过滤器都可以处理请求,将其传递给链中的下一个过滤器,或者不通过,立即向客户端发送响应。
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);
    }
}


MetaMaskAuthenticationFilter将拦截模式为POST "/login"的请求。在attemptAuthentication(HttpServletRequest request, HttpServletResponse response)方法中,我们从请求中提取地址签名参数。接下来,这些值将用于创建MetaMaskAuthenticationRequest的实例,我们将其作为登录请求传递给认证管理器:
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();
    }
}


步骤4:认证提供程序

MetaMaskAuthenticationRequest应通过自定义的AuthenticationProvider进行处理,在那里我们可以验证用户的签名并返回完全认证的对象。让我们创建一个AbstractUserDetailsAuthenticationProvider的实现,它被设计用于处理UsernamePasswordAuthenticationToken实例:
@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");
        }
    }

    ...
}


retrieveUser(String username, UsernamePasswordAuthenticationToken authentication)应该从我们的UserRepository中加载User实体,并组成包含地址签名nonceUserDetails实例:
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;
    }
}


第二种方法,additionalAuthenticationChecks(UserDetails userDetails, UsernamePasswordAuthenticationToken authentication)将使用椭圆曲线数字签名算法(ECDSA)进行签名验证。该算法的理念是从给定的消息和签名中恢复钱包地址。如果恢复的地址与我们在MetaMaskUserDetails中的地址匹配,则用户可以被认证。

1. 通过添加前缀来获取消息哈希,使计算得到的签名可识别为以太坊签名:

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


2. 从以太坊签名中提取rsv组件,并创建一个SignatureData实例:

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. 使用方法Sign.recoverFromSignature(),从签名中恢复公钥:

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


4. 最后,获取钱包地址并将其与初始地址进行比较:

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


有一个带有nonce的isSignatureValid(String signature, String address, Integer nonce)方法的完整实现:

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


步骤5:安全配置

在安全配置中,除了标准的formLogin设置之外,我们还需要将我们的MetaMaskAuthenticationFilter插入到默认过滤器链中:

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


为了防止重放攻击,如果用户的签名被篡改,我们将创建AuthenticationSuccessHandler实现,在其中更改用户的nonce,并使用户在下次登录时使用新的nonce签名消息:

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();
    }
}
public class User {
    ...    

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


我们还需要配置AuthenticationManager bean,注入我们的MetaMaskAuthenticationProvider

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


步骤6:模板

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


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


我们的WebController包含两个模板:login.html index.html

1. 第一个模板将用于与MetaMask进行身份验证。

为了提示用户连接到MetaMask并接收钱包地址,我们可以使用eth_requestAccounts方法:

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


Connect with MetaMask prompt

接下来,连接MetaMask并从后端接收nonce后,我们请求MetaMask使用personal_sign方法对消息进行签名:

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

最后,将计算得到的签名与地址发送到后端。有一个完整的模板templates/login.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. 第二个templates/index.html模板将受我们的Spring Security配置保护,显示注册后的Principal姓名作为钱包地址:

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


完整的源代码可以在GitHub上找到。

在这篇文章中,我们使用了Spring Security和MetaMask开发了一种替代的身份验证机制,使用了非对称加密。这种方法可以适用于你的应用程序,但前提是你的目标用户正在使用加密货币并在其浏览器中安装了MetaMask扩展。

推荐阅读: 国内程序员VS国外程序员

本文链接: 使用 MetaMask 进行 Spring 认证