当前位置:Java -> 使用 MetaMask 进行 Spring 认证
在为你的应用程序选择用户认证方法时,通常有几种选择:开发自己的身份识别、认证和授权系统,或者使用现成的解决方案。现成的解决方案意味着用户已经在外部系统(如Google、Facebook或GitHub)上有一个账户,并且通过适当的机制(大多数情况下是OAuth)提供有限访问权限给用户的受保护资源,而无需将用户名和密码传输给它。使用OAuth的第二种选择更容易实现,但如果用户的账户被封锁,用户将失去对你网站的访问权限。另外,如果作为用户,我希望进入一个我不信任的站点,我不得不提供我的个人信息,比如我的邮箱和全名,从而牺牲了我的匿名性。
在本文中,我们将使用MetaMask浏览器扩展程序为Spring构建一种替代登录方法。MetaMask是一种加密货币钱包,用于管理以太坊资产并与以太坊区块链进行交互。与OAuth提供程序不同,以太坊网络上只能存储必要的数据集。我们必须注意不要将秘密信息存储在公共数据中,但由于以太坊网络上的任何钱包实际上都是一个加密强大的密钥对,其中公钥确定钱包地址,私钥永远不会通过网络传输,并且仅由所有者知道,我们可以使用非对称加密来认证用户。
Spring Initializr。让我们添加以下依赖项:
pom.xml中,我们添加以下依赖项来验证以太坊签名:
<dependency>
<groupId>org.web3j</groupId>
<artifactId>core</artifactId>
<version>4.10.2</version>
</dependency>
让我们创建一个简单的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());
}
}
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();
}
}
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
实体,并组成包含地址
、签名
和nonce
的UserDetails
实例:
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. 从以太坊签名中提取r
、s
和v
组件,并创建一个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);
}
在安全配置中,除了标准的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);
}
@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];
接下来,连接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]});
最后,将计算得到的签名与地址发送到后端。有一个完整的模板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 认证