ビットコインやイーサリアムのトランザクション署名に用いられるECDSA(Elliptic Curve Digital Signature Algorithm:楕円曲線デジタル署名アルゴリズム)は、トランザクション作成者や取引の正当性を証明する役割を持っています。送金に用いられる技術ですが、”公開鍵(に紐づくアカウント)を持っている証明"が可能であることを応用して、Webアプリのユーザ認証が可能です。
本記事では、web3モジュールのweb3.eth.personal.sign メソッドによるECDSA署名と、eth-sig-utilモジュールのecrecoverメソッドによる検証を用いたWebアプリのユーザ認証の実装例をReact(Firebase hosting) + Express(Vercel Now) + MongoDB(Atlas)のサーバレス構成によるデモサイトとともにご紹介します。
そもそもDAppsとは・・という方はこちらの記事をご覧ください
[入門編]ÐAppsってどんなもの?《前篇》
暗号通貨とそのバックグラウンドにある技術に興味があれば「楕円曲線暗号」という言葉には馴染み深いかもしれません。数学的説明を排除した説明がこちらの記事によってされております。また、ECDSAによる署名と検証については以下の記事が非常に分かりやすいです。
ECDSAによる署名生成と検証の仕組みを分かりやすく解説
本記事では、実装例として以下の記事をほぼそのまま用いています。
One-click Login with Blockchain: A MetaMask Tutorial
React+Express+SQLiteの構成によるデモサイトもあり、記事も非常に丁寧ですので、英語に抵抗のない方はこちらを読んでいただければ十分だと思います。本記事では、処理の流れをなるべく丁寧に追えるようにしています。上に挙げた記事と合わせて本記事を読んでいただくことで、理解の一助になれば幸いです。なお、本記事では自分が個人開発でよく使う構成で書き直していますが、処理の流れは同じです。
ECDSAを用いた署名とecrecoverによる検証を用いたDAppsのユーザー認証の設計については、↓の記事にも説明があります。
DApps のユーザー認証に web3.eth.personal.sign を使おう!
まずはじめに、前述の参考文献をもとに作成したECDSAを用いた署名と検証のフローの図を示します。Userはアプリを使用するユーザを、FrontendはReactを、WalletはMetamaskを、BackendはNode.js(Vercel Now)を、Databaseはmongodbをそれぞれ表しています。
上から流れに沿ってコードの説明をします。ソースコードはこちらです。
まず、ログインボタンのコードが含まれるフロントエンドのLogin.jsで、ボタンのonClick動作時に実行されるhandleClick関数の実装をみてみましょう。全体フロー中で以下の図の部分に相当します。
// Check if MetaMask is installed
if (!window.ethereum) {
window.alert(`MetaMaskをインストールして下さい`);
return;
}
if (!web3) {
try {
// Request account access if needed
await window.ethereum.enable();
// We don't know window.web3 version, so we use our own instance of Web3
// with the injected provider given by MetaMask
web3 = new Web3(window.ethereum);
} catch {
window.alert(`MetaMaskを認可して下さい`);
return;
}
}
const coinbase = await web3.eth.getCoinbase();
if (!coinbase) {
window.alert(`MetaMaskをアクティベートして下さい`)
return;
}
const publicAddress = coinbase.toLowerCase();
ここではMetamaskがアクティブになっているかチェックし、アカウントから公開鍵(publicAddress)を取得しています。なお、MetaMask 5.0以降ではプライバシーモードが導入され、アカウントから公開鍵を取得するためにユーザのアクセス許可が必要になっています。そのため、DApps側でethereum.enable()を呼び出す必要があります。
次に、この公開鍵をURLパラメータに含んで、バックエンドの/api/usersを叩きます。(リンク)
// Look if user with current publicAddress is already present on backend
fetch(
`${process.env.REACT_APP_BACKEND_URL}/api/users?publicAddress=${publicAddress}`,{
mode: 'cors'
}
)
呼び出し先であるバックエンドの/api/usersの実装をみてみましょう。全体フローのうちの以下の図の部分に対応しています。
コードは以下の通りです。(リンク)
const app = require("../../util/app");
const User = require('../../util/models/user-model');
app.get("*", (req, res) => {
User.findOne({ publicAddress: req.query.publicAddress })
.then((user) => {
res.end(JSON.stringify({user}));
})
});
app.post("*", (req, res) => {
User.create(req.body)
.then((user) => {
res.end(JSON.stringify(user))
})
})
module.exports = app;
データベースから、URLパラメータで与えたpublicAddressと一致するユーザ情報を取得し、フロントエンドに返します。ユーザー情報はuser-model.jsに以下のように定義しています。(リンク)
const mongoose = require('mongoose');
mongoose.connect(process.env.MONGODB_URI, { useNewUrlParser: true })
const UserSchema = new mongoose.Schema({
id: Number,
nonce: {
type: Number,
default: Math.floor(Math.random() * 10000)
},
publicAddress: String,
})
module.exports = mongoose.model('User', UserSchema);
ユーザーごとにnonce(ランダムな値)が定義されており、これを含んだオブジェクトをフロントエンドに返します。新規ユーザの場合にはuserがundefinedになりますので、this.handleSignupの処理を行います。(リンク)
// Look if user with current publicAddress is already present on backend
fetch(
`${process.env.REACT_APP_BACKEND_URL}/api/users?publicAddress=${publicAddress}`,{
mode: 'cors'
}
)
.then(response => response.json())
// If yes, resrieve it. if no, create it.
.then(({user}) => {
return user ? user : this.handleSignup(publicAddress)
})
handleSignupは/api/usersにPOSTし、新規ユーザーをデータベース上に作っています。(リンク)
handleSignup = (publicAddress) => {
return fetch(`${process.env.REACT_APP_BACKEND_URL}/api/users`, {
body: JSON.stringify({ publicAddress }),
headers: {
'Content-Type': 'application/json'
},
method: 'POST'
}).then(response => response.json());
};
次に、画面に署名に関するメッセージを表示し、ユーザーに承認してもらうことでウォレットからECDSAにより生成されるsignatureを取得します。全体フローの中では下の図に相当します。
handleSignMessage関数は以下のような実装になっています。/api/usersの返し値に含まれるnonceを用いて署名メッセージを作成し、 web3.eth.personal.signメソッドを呼び出します。ユーザにより署名がされるとsignatureが返されます。(リンク)
handleSignMessage = async ({
publicAddress,
nonce
}) => {
try {
const signature = await web3.eth.personal.sign(
`〇〇にログインします。ワンタイムトークン: ${nonce}`,
publicAddress,
'' // Metamask will ignore the password argument here
);
return { publicAddress, signature };
} catch (err) {
throw new Error('You need to sign the message to be able to log in.');
}
};
次に、バックエンド(api/auth)にsignatureをPOSTし、ecrecoverを用いて署名者(ユーザ)のアドレスを取得します。signatureから取得したアドレス(図中ではverify_addressとしています)とpublicAddressが一致していれば、そのpublicAddressをユーザが所有しているという証明になります。チェックしたあとはnonceをアップデートし、JWTを生成してフロントエンドに返したら、ログイン処理は完了です。この部分のシークエンスは以下の図の通りです。
signatureとメッセージをもとにverify_addressを取得するコードはバックエンドのapi/aithの以下の部分です。sigUtilのrecoverPersonalSignatureメソッド内でecrecoverが走り、公開鍵が返ってきますので、publicAddressと比較することでユーザの認証を行います。(リンク)
const msg = `〇〇にログインします。ワンタイムトークン: ${nonce}`;
// We now are in possession of msg, publicAddress and signature.
// We will use a helper from eth-sig-util to extract the address from the signature
const msgBufferHex = ethUtil.bufferToHex(Buffer.from(msg, 'utf8'));
const verify_address = sigUtil.recoverPersonalSignature({
data: msgBufferHex,
sig: signature
})
// The signature verification is successful if the adress found
// sigUtil.recoverPersonalSignature matches the initial publicAddress
if (verify_address.toLowerCase() === publicAddress.toLowerCase()){
return user;
} else {
res.status(401).send({ error: 'Signature verification failed.'});
}
認証が成功した後は、データベースのnonceをアップデートし、アクセストークン(JWT)を生成しフロントエンドに返します。(リンク)
// Generate a new nonce for the user
.then((user) => {
const nonce = Math.floor(Math.random() * 10000);
return User.findOneAndUpdate({ publicAddress }, {$set: {nonce: nonce}})
})
// Create JWT
.then((user) => {
return jwt.sign(
{
id: user._id,
publicAddress
},
config.secret,
{})
})
.then((accessToken) => {
res.end(JSON.stringify({ accessToken }));
});
ログイン処理の流れは以上です。JWTを使って認証付きAPIを使用する流れは通常のWebアプリと変わりませんので省略します。
それでは、実際の動作を確認してみましょう。今回作成したデモサイトはこちらです。
デモサイトにアクセスすると、下のような画面になります。
Loginをクリックすると、window.ethereum.enable()によりアクセス許可の画面がMetaMaskに表示されますので「Connect」をクリックします。
画面が出ない場合、MetaMaskのアイコンに通知がついていると思いますのでクリックしてください。
次に、署名を促す画面が表示されますので、「署名」をクリックします。
しばらくするとログイン処理が行われ、公開鍵が表示されたら成功です。
事前準備として、Vercel NowとAtlasにユーザー登録が必要です。登録してから先にお進みください。
デモサイトのソースコードはこちらに公開しています。
metamask-login-sample/packagesにfrontendとbackendディレクトリがありますので、それぞれパッケージをインストールします。
npm i
(WSL の場合は npm i --save)
次に、backend内で.envファイルを作成し、mongodbのURI、SECRET、PORTを指定します。
MONGODB_URI="mongodb+srv://userid:password@cluster0-aaaaa.mongodb.net/bbbbb"
SECRET="secret-key"
PORT=3000
MONGODB_URIはAtlasで確認してください。SECRETもハッシュ値を用いるなど適切に設定してください。nowコマンドを実行すると必要項目の入力が求められるので、終わりましたらnow devコマンドを実行します。以下のような表示が出たらOKです。
次に、frontendディレクトリ内で.envを作成し、実行時ポート番号とバックエンドのURLを指定します。
PORT=4000
REACT_APP_BACKEND_URL="http://localhost:3000"
npm startするとブラウザで新しい窓が立ち上がるので、
にアクセスするとデモサイトと同じ画面になるはずです。
ブロックチェーンに用いられているECDSAによるパスワード不要のログイン認証について紹介し、Amaury Martinyさんによる記事およびコードをもとに、処理のシークエンスに関して捕捉して記事にしました。また、React(Firebase hosting)+Express(Vercel Now)+MongoDB(Atlas)のサーバレス構成でデモサイトを作成し公開しました。
ALISユーザには馴染み深い人も多いMetaMaskですが、一般に浸透してませんので通常のWebサイトのログイン方法にはなり得ません。ただ、DAppsのログイン方法としては非常にシンプルで扱いやすいです。今回のMetaMaskログインは、ブラウザゲームの個人開発にトライされているメンティーさん向けに調べて実装したものでして(初心者向けに個人開発お助けマンやってます)、DAppsの個人開発では選択肢になるかと思います。ご興味ありましたら是非お試しください。
クレジット(画像素材)