网站太多,各种用户名/密码实在记不住。所以我们逐渐接受了BAT账号的授权登录功能。在以太坊DAPP应用中,也可以使用MetaMask实现授权后一键登录功能。MetaMask是去中心化钱包,授权信息不会如BAT中心一样存在被收集利用的问题。
本文从技术层面讲清楚原理,并结合代码说明如何实现。
我们往往被自己的密码难住,越来越抵制传统的电子邮件/密码注册流程。通过微信,QQ,支付宝,Facebook,Google或GitHub一键式社交登录功能可以省去记住密码或者密码泄露的而风险。当然,它也需要权衡利弊。
社交媒体登录集成的优点:
社交媒体登录集成的缺点:
加密猫(https://www.cryptokitties.co/)游戏中,用户不需要输入用户名,密码就可以建立自己的账户体系,进行登录交易。
本文介绍下这个方法的原理和代码实现,使用MetaMask扩展的一键式加密安全登录流程,所有数据都存储在我们自己的后端。我们称为“使用MetaMask登录”。
一张价值千言万语的图片,这里是我们要构建的登录流程的演示:
看起来不错?让我们开始吧!
一键式登录流程的基本思想是,通过使用私钥对一段数据进行签名,可以很容易地通过加密方式证明帐户的所有权。如果您设法签署由我们的后端生成的精确数据,那么后端将认为您是该钱包地址的所有者。因此,我们可以构建基于消息签名的身份验证机制,并将用户的钱包地址作为其标识符。
如果它看起来不太清楚,那就没问题了,因为我们会逐一解释它:
请注意,虽然我们将使用连接到以太坊区块链的工具(MetaMask,以太坊钱包地址),但此登录过程实际上并不需要区块链:它只需要其加密功能。话虽如此,随着MetaMask成为如此受欢迎的扩展,现在似乎是介绍此登录流程的好时机。
如果您已经知道MetaMask是什么,请跳过本节。
MetaMask是一个浏览器插件,可作为MetaMask Chrome扩展或Firefox附加组件使用。它的核心是它作为以太坊钱包:通过安装它,您将可以访问一个独特的以太坊钱包地址,您可以使用它开始发送和接收以太币或ERC20通证。
但MetaMask不仅仅是以太坊钱包。作为浏览器扩展,它可以与您正在浏览的当前网页进行交互。它通过在您访问的每个网页中注入一个名为web3.js的JavaScript库来实现。注入后,web3
将通过window.web3
的JavaScript代码为你访问的每个网页提供一个对象。要查看此对象,只需在Chrome或Firefox DevTools控制台键入window.web3
(如果已安装MetaMask),结果如下图。
Web3.js是以太坊区块链的JavaScript接口。有以下功能:
web3.eth.getBlockNumber
)web3.eth.coinbase
)web3.eth.getBalance
)web3.eth.sendTransaction
)web3.personal.sign
)安装MetaMask时,任何前端代码都可以访问所有这些功能,并与区块链进行交互。他们被称为dapps或DApps(去中心化的应用程序,有时甚至写成“ĐApps”)。
与DApp开发相关: 时间锁定钱包:以太坊智能合约简介
web3.js中的大多数函数都是读函数(get block, get balance, etc.),web3
立即给出响应。但是,某些功能(如web3.eth.sendTransaction
和web3.personal.sign
)需要当前帐户使用其私钥对某些数据进行签名。这些函数触发MetaMask显示确认弹窗,以仔细检查用户是否知道他或她正在签名的内容。
让我们看看如何使用MetaMask。要进行简单测试,请在DevTools控制台中粘贴以下行:
web3.personal.sign(web3.fromUtf8("你好,我是辉哥!!"), web3.eth.coinbase, console.log);
此命令表示:使用coinbase帐户(即当前帐户)将我的消息(从utf8转换为十六进制)进行签名,并以打印作为回调函数打印出签名。输入回车后,将出现MetaMask弹窗,如果点击签名按钮,将打印签名的消息。
我们将web3.personal.sign
在登录流程中使用。
关于这一部分的最后一点说明:MetaMask将web3.js注入到您当前的浏览器中,但实际上还有其他独立的浏览器也会注入web3.js,例如Mist。但是,在我看来,MetaMask为普通用户提供了探索dapps的最佳用户体验和最简单的转换。
这是如何做到的呢?这部分内容讲说服你,证明这种方式是安全的。所以为什么部分的介绍就比较短了。
如前面所述,我们将忘记区块链。我们有一个传统的Web 2.0客户端 - 服务器RESTful架构。我们将做出一个假设:访问我们的前端网页的所有用户都安装了MetaMask。有了这个假设,我们将展示无密码加密安全登录流程的工作原理。
首先,我们的User
模型需要有两个新的必填字段:publicAddress
和nonce
。此外,publicAddress
需要具有唯一性。你可以保持平常username
,email
和password
字段,特别是如果你想平行实现您MetaMask登录电子邮件/密码登录,但它们是可选的。
如果用户希望使用MetaMask登录,则注册过程也会略有不同,因为注册时publicAddress
将是必填字段。不过请放心,用户永远不需要手动输入publicAddress
钱包地址,因为它可以通过web3.eth.coinbase
变量来提取。
对于数据库中的每个用户,在nonce
字段中生成随机字符串。例如,nonce
可以是一个大的随机整数。
在我们的前端JavaScript代码中,假设存在MetaMask,我们可以访问window.web3
。因此,我们可以通知web3.eth.coinbase
获取当前MetaMask帐户的钱包地址。
当用户单击登录按钮时,我们向后端发出API调用以检索与其钱包地址关联的随机数。像带参数获取例如GET /api/users?publicAddress=${publicAddress}
应该做的事情那样。当然,由于这是一个未经身份验证的API调用,因此后端应配置为仅显示此路由上的公共信息包括nonce
。
如果先前的请求未返回任何结果,则表示当前钱包地址尚未注册。我们需要先通过POST /users
传递publicAddress
请求消息体来创建一个新帐户。另一方面,如果有结果,那么我们存储它的nonce
。
一旦前端接收nonce
到先前API调用的响应,它将运行以下代码:
web3.personal.sign(nonce, web3.eth.coinbase, callback);
这将提示MetaMask显示用于签名消息的确认弹出窗口。随机数将显示在此弹出窗口中,以便用户知道她或他有没有签署某些恶意数据。
当她或他接受签名时,将使用带签名的消息(称为signature
)作为参数调用回调函数。然后前端进行另一个API调用POST /api/authentication
,传递一个带有signature
和publicAddress
的消息体。
当后端收到POST /api/authentication
请求时,它首先根据请求消息体中publicAddress
获取数据库中的对应用户,特别是它相关的随机数nonce
。
具有随机数,钱包地址和签名后,后端可以加密地验证用户已正确签署了随机数。如果确认是这种情况,那么用户已经证明了拥有钱包地址的所有权,我们可以考虑对她或他进行身份验证。然后可以将JWT或会话标识符返回到前端。
为了防止用户使用相同的签名再次登录(如果它被泄露),我们确保下次同一用户想要登录时,她或他需要签署一个新的nonce。这是通过nonce
为该用户生成另一个随机数并将其持久保存到数据库来实现的。
这就是我们管理nonce签名无密码登录流程的方法。
根据定义,身份验证实际上只是帐户所有权的证明。如果您使用钱包地址唯一地标识您的帐户,那么证明您加密方式拥有该帐户就非常简单。
为了防止黑客获取某个特定邮件及其签名(但不是您的实际私钥),我们会强制需要签名的消息满足以下条件:
在我们的demo样例中,每次成功登录后我们都改变了它,但也可以设想基于时间戳的机制。
在本节中,我将逐一完成上述六个步骤。我将展示一些代码片段,以便我们如何从头开始构建此登录流,或者将其集成到现有的后端,而不需要太多努力。
为了本文的目的,我创建了一个小型演示应用程序。我正在使用的堆栈如下:
我尝试使用尽可能少的库。我希望代码足够简单,以便您可以轻松地将其移植到其他技术堆栈。
访问https://login-with-metamask.firebaseapp.com/可以获得一个演示,也可以参考步骤搭建自己的本地工程。
欢迎加入辉哥的知识星球,从中下载本案例代码工程,也可加专门微信群交流技术问题。
需要两个字段:publicAddress
和nonce
。我们初始化nonce
为随机大数。每次成功登录后都应更改此号码。我还在username
这里添加了一个可选字段,用户可以更改。
.\backend\src\models\user.model.js
const User = sequelize.define('User', {
nonce: {
allowNull: false,
type: Sequelize.INTEGER.UNSIGNED,
defaultValue: () => Math.floor(Math.random() * 1000000) // Initialize with a random nonce
},
publicAddress: {
allowNull: false,
type: Sequelize.STRING,
unique: true,
validate: { isLowercase: true }
},
username: {
type: Sequelize.STRING,
unique: true
}
});
为简单起见,我将publicAddress
字段设置为小写。更严格的检查地址是否是有效的以太坊地址的方法参考链接:https://ethereum.stackexchange.com/questions/1374/how-can-i-check-if-an-ethereum-address-is-valid)。
这是在defaultValue()
上面的模型定义中的函数中完成的。
下一步是在后端添加一些样板代码来处理User
模型上的CRUD方法,我们在这里不做。
切换到前端代码,当用户单击登录按钮时,我们的handleClick
处理程序执行以下操作:
.\frontend\src\Login\Login.js
class Login extends Component {
handleClick = () => {
// --snip--
const publicAddress = web3.eth.coinbase.toLowerCase();
// Check if user with current publicAddress is already present on back end
fetch(`${process.env.REACT_APP_BACKEND_URL}/users?publicAddress=${publicAddress}`)
.then(response => response.json())
// If yes, retrieve it. If no, create it.
.then(
users => (users.length ? users[0] : this.handleSignup(publicAddress))
)
// --snip--
};
handleSignup = publicAddress =>
fetch(`${process.env.REACT_APP_BACKEND_URL}/users`, {
body: JSON.stringify({ publicAddress }),
headers: {
'Content-Type': 'application/json'
},
method: 'POST'
}).then(response => response.json());
}
在这里,我们正在检索MetaMask活动帐户web3.eth.coinbase
。然后我们检查publicAddress
后端是否已经存在。如果用户已经存在,我们就获取用户信息。要么就是在handleSignup
方法中创建一个新帐户。
让我们继续我们的handleClick
方法。我们现在拥有一个由后端给出的用户(无论是检索还是新创建)。特别是我们有他们的nonce
和publicAddress
。因此,我们准备publicAddress
使用与此相关联的私钥对nonce进行签名web3.personal.sign
。这是在handleSignMessage
函数中完成的。
请注意,web3.personal.sign
将字符串的十六进制表示作为其第一个参数。我们需要使用UTF-8编码的字符串转换为十六进制格式web3.fromUtf8
。此外,我决定签署一个更加用户友好的句子,而不是仅签署nonce,因为它将显示在MetaMask确认弹出窗口中:I am signing my once-time nonce: ${nonce}
。
class Login extends Component {
handleClick = () => {
// --snip--
fetch(`${process.env.REACT_APP_BACKEND_URL}/users?publicAddress=${publicAddress}`)
.then(response => response.json())
// If yes, retrieve it. If no, create it.
.then(
users => (users.length ? users[0] : this.handleSignup(publicAddress))
)
// Popup MetaMask confirmation modal to sign message
.then(this.handleSignMessage)
// Send signature to back end on the /auth route
.then(this.handleAuthenticate)
// --snip--
};
handleSignMessage = ({ publicAddress, nonce }) => {
return new Promise((resolve, reject) =>
web3.personal.sign(
web3.fromUtf8(`I am signing my one-time nonce: ${nonce}`),
publicAddress,
(err, signature) => {
if (err) return reject(err);
return resolve({ publicAddress, signature });
}
)
);
};
handleAuthenticate = ({ publicAddress, signature }) =>
fetch(`${process.env.REACT_APP_BACKEND_URL}/auth`, {
body: JSON.stringify({ publicAddress, signature }),
headers: {
'Content-Type': 'application/json'
},
method: 'POST'
}).then(response => response.json());
}
当用户成功签署消息后,我们将转到该handleAuthenticate
方法。我们只是向/auth
后端的路由发送请求,发送我们publicAddress
以及signature
用户刚签名的消息。
这是稍微复杂一点的部分。后端在/auth
包含一个 publicAddress
和一个路由上接收请求签名signature
,并且需要验证钱包地址publicAddress
是否已签名正确的随机数nonce
。
第一步是从数据库中检索用户所说的publicAddress
; 只有一个因为我们publicAddress
在数据库中定义为唯一字段。然后,我们将消息设置msg
为“I am signing my one-time nonce…”,与步骤4中的前端完全相同,使用此用户的随机数。
下一个块是验证本身。有一些加密涉及。如果您喜欢研究,我建议您阅读有关椭圆曲线签名算法以获得更多信息。
总结这部分的作用,对于给出的msg
(包含nonce
)和signature
信息,ecrecover
函数输出用于签名msg
的钱包地址。如果它与我们请求消息体的publicAddress
一致,则证明了他们拥有publicAddress
的所有权。经过这个过程,我们认为他们经过身份验证的。
User.findOne({ where: { publicAddress } })
// --snip--
.then(user => {
const msg = `I am signing my one-time nonce: ${user.nonce}`;
// We now are in possession of msg, publicAddress and signature. We
// can perform an elliptic curve signature verification with ecrecover
const msgBuffer = ethUtil.toBuffer(msg);
const msgHash = ethUtil.hashPersonalMessage(msgBuffer);
const signatureBuffer = ethUtil.toBuffer(signature);
const signatureParams = ethUtil.fromRpcSig(signatureBuffer);
const publicKey = ethUtil.ecrecover(
msgHash,
signatureParams.v,
signatureParams.r,
signatureParams.s
);
const addressBuffer = ethUtil.publicToAddress(publicKey);
const address = ethUtil.bufferToHex(addressBuffer);
// The signature verification is successful if the address found with
// ecrecover matches the initial publicAddress
if (address.toLowerCase() === publicAddress.toLowerCase()) {
return user;
} else {
return res
.status(401)
.send({ error: 'Signature verification failed' });
}
})
成功验证后,后端生成JWT并将其发送回客户端。这是一种经典的身份验证方案,所以我不会在这里放置代码。
出于安全原因,最后一步是更改nonce。在成功验证后的某处,添加以下代码:
// --snip--
.then(user => {
user.nonce = Math.floor(Math.random() * 1000000);
return user.save();
})
// --snip--
虽然区块链可能有其缺陷并且仍处于早期阶段,但我无法强调如何在今天的任何现有网站上实施此登录流程的重要性。以下是为什么此登录流程优先于电子邮件/密码和社交登录的参数列表:
当然,MetaMask登录流程可以很好地与其他传统登录方法并行使用。需要在每个帐户与其拥有的钱包地址之间进行映射。
但是这个登录流程并不适合所有人:
web3
的浏览器,此登录流程显然无效。如果您的受众对加密货币不感兴趣,他们甚至会考虑安装MetaMask。随着最近的通证热潮,让我们希望我们正在走向Web 3.0互联网。正如我们所见,这web3
是此登录流程的先决条件。在桌面浏览器上,MetaMask会注入它。但是,移动浏览器没有扩展程序,因此此登录流程无法在移动版Safari,Chrome或Firefox上开箱即用。有一些独立的移动浏览器注入了web3
基于MetaMask的浏览器。在撰写本文时,它们还处于早期阶段,但如果您有兴趣,请查看Cipher,Status和Toshi。“使用MetaMask登录”适用于这些移动浏览器。
关于移动应用程序,答案是肯定的,登录流程有效,但需要有很多准备工作的作为基础。作为基本准备工作,您需要自己重建一个简单的以太坊钱包。这包括钱包地址生成,种子文字恢复和安全私钥存储,以及web3.personal.sign
确认弹出窗口。幸运的是,有library可以帮助您。人们关心的关键信息是安全的,因为应用程序本身拥有私钥。在桌面浏览器上,我们将此任务委托给MetaMask。
所以我认为答案是否定的,这个登录流程今天不适用于移动设备。但它正朝着这个方向努力,今天简单的解决方案仍然是移动用户的并行传统登录方法。
【辉哥备注】虽然主流的移动端浏览器APP还不支持MetaMask插件,但是包括TrustWallet, AlphaWallet等钱包自带的DAPP浏览器支持支付功能,也就不需要MetaMask钱包用于支付了。手机端的一键登录问题转换为别的实现方案的问题。
##1). 修改IP地址
辉哥采用Windows 环境下搭建Ubuntu Linux环境的方式,在Windows环境访问目标测试程序,所以需要修改前后端调用的IP地址为本地地址。就是http://192.168.0.103为Ubuntu服务器的IP地址,如果调用前端也在linux下运行则可使用http://127.0.0.1地址。
.\login-with-metamask-demo\frontend.env.development
REACT_APP_BACKEND_URL=http://192.168.0.103:8000/api
.\login-with-metamask-demo\frontend\src\registerServiceWorker.js
window.location.hostname === 'http://192.168.0.103' ||
修改后,然后把完整的login-with-metamask-demo工程上传至linux工作目录下。
##2). 安装依赖并运行后端服务器
在新的命令窗口运行以下命令,完成安装和服务器运行:
npm install -g yarn
yarn
yarn dev
安装运行成功的输出内容:
duncanwang@ubuntu:~/work/login-with-metamask-demo/backend$ yarn dev
yarn run v1.10.1
$ nodemon --exec babel-node src/
[nodemon] 1.17.2
[nodemon] to restart at any time, enter `rs`
[nodemon] watching: *.*
[nodemon] starting `babel-node src/`
sequelize deprecated String based operators are now deprecated. Please use Symbol based operators for better security, read more at http://docs.sequelizejs.com/manual/tutorial/querying.html#operators node_modules/sequelize/lib/sequelize.js:242:13
Express app listening on localhost:8000
^C
duncanwang@ubuntu:~/work/login-with-metamask-demo/backend$ ls
Dockerfile node_modules package.json src yarn.lock
duncanwang@ubuntu:~/work/login-with-metamask-demo/backend$ rm -r -f node_modules
duncanwang@ubuntu:~/work/login-with-metamask-demo/backend$ ls
Dockerfile package.json src yarn.lock
duncanwang@ubuntu:~/work/login-with-metamask-demo/backend$ npm install -g yarn
/home/duncanwang/.nvm/versions/node/v8.11.4/bin/yarn -> /home/duncanwang/.nvm/versions/node/v8.11.4/lib/node_modules/yarn/bin/yarn.js
/home/duncanwang/.nvm/versions/node/v8.11.4/bin/yarnpkg -> /home/duncanwang/.nvm/versions/node/v8.11.4/lib/node_modules/yarn/bin/yarn.js
+ [email protected]
updated 1 package in 4.201s
duncanwang@ubuntu:~/work/login-with-metamask-demo/backend$ yarn
yarn install v1.10.1
[1/4] Resolving packages...
[2/4] Fetching packages...
info [email protected]: The platform "linux" is incompatible with this module.
info "[email protected]" is an optional dependency and failed compatibility check. Excluding it from installation.
[3/4] Linking dependencies...
[4/4] Building fresh packages...
Done in 173.70s.
duncanwang@ubuntu:~/work/login-with-metamask-demo/backend$ yarn dev
yarn run v1.10.1
$ nodemon --exec babel-node src/
[nodemon] 1.17.2
[nodemon] to restart at any time, enter `rs`
[nodemon] watching: *.*
[nodemon] starting `babel-node src/`
sequelize deprecated String based operators are now deprecated. Please use Symbol based operators for better security, read more at http://docs.sequelizejs.com/manual/tutorial/querying.html#operators node_modules/sequelize/lib/sequelize.js:242:13
Express app listening on localhost:8000
##3). 安装依赖并运行前端服务器
在前端程序根目录下
yarn
yarn start
安装运行成功的输出内容:
duncanwang@ubuntu:~/work/login-with-metamask-demo/frontend$ yarn
yarn install v1.10.1
warning package-lock.json found. Your project contains lock files generated by tools other than Yarn. It is advised not to mix package managers in order to avoid resolution inconsistencies caused by unsynchronized lock files. To clear this warning, remove package-lock.json.
[1/4] Resolving packages...
[2/4] Fetching packages...
info There appears to be trouble with your network connection. Retrying...
info There appears to be trouble with your network connection. Retrying...
info [email protected]: The platform "linux" is incompatible with this module.
info "[email protected]" is an optional dependency and failed compatibility check. Excluding it from installation.
[3/4] Linking dependencies...
[4/4] Building fresh packages...
success Saved lockfile.
Done in 266.85s.
duncanwang@ubuntu:~/work/login-with-metamask-demo/frontend$ yarn start
yarn run v1.10.1
$ react-scripts start
Starting the development server...
Compiled successfully!
You can now view frontend in the browser.
Local: http://localhost:3000/
On Your Network: http://192.168.0.103:3000/
Note that the development build is not optimized.
To create a production build, use yarn build.
在Windows浏览器运行客户端程序,点击完成SIGN签名授权:
不需要输入密码,完成了duncanwang和钱包地址0xD1F7922e8b78cBEB182250753ade8379d1E09949的关联和一键登录功能。
我们在本文中介绍了一键式,加密安全的登录流程,没有涉及第三方,称为“使用MetaMask登录”。我们解释了后端生成的随机数的数字签名如何证明帐户的所有权,从而提供身份验证。我们还探讨了这种登录机制与传统电子邮件/密码或社交登录相比的权衡,无论是在桌面还是在移动设备上。
即使今天这样的登录流程的目标受众仍然很小,我真诚地希望你们中的一些人感到鼓舞,在你自己的网络应用程序中提供与MetaMask一起登录,与传统登录流程并行。
本文由辉哥翻译自《One-click Login with Blockchain: A MetaMask Tutorial》,并做适当的修改以便中国用户可以更好的理解。