クリプト

【DApps】MetaMaskを使ったパスワード不要のユーザ認証(React+Node.js+MongoDB)

ホーさん's icon'
  • ホーさん
  • 2020/05/03 01:40
Content image

はじめに

ビットコインやイーサリアムのトランザクション署名に用いられる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を用いた署名と検証のフロー

まずはじめに、前述の参考文献をもとに作成したECDSAを用いた署名と検証のフローの図を示します。Userはアプリを使用するユーザを、FrontendはReactを、WalletはMetamaskを、BackendはNode.js(Vercel Now)を、Databaseはmongodbをそれぞれ表しています。

Content image

上から流れに沿ってコードの説明をします。ソースコードはこちらです。

まず、ログインボタンのコードが含まれるフロントエンドのLogin.jsで、ボタンのonClick動作時に実行されるhandleClick関数の実装をみてみましょう。全体フロー中で以下の図の部分に相当します。

Content image
// 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の実装をみてみましょう。全体フローのうちの以下の図の部分に対応しています。

Content image

コードは以下の通りです。(リンク

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を取得します。全体フローの中では下の図に相当します。

Content image

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を生成してフロントエンドに返したら、ログイン処理は完了です。この部分のシークエンスは以下の図の通りです。

Content image

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アプリと変わりませんので省略します。

 

デモサイト

それでは、実際の動作を確認してみましょう。今回作成したデモサイトはこちらです。

デモサイトにアクセスすると、下のような画面になります。

Content image

Loginをクリックすると、window.ethereum.enable()によりアクセス許可の画面がMetaMaskに表示されますので「Connect」をクリックします。

Content image

画面が出ない場合、MetaMaskのアイコンに通知がついていると思いますのでクリックしてください。

Content image

次に、署名を促す画面が表示されますので、「署名」をクリックします。

Content image

しばらくするとログイン処理が行われ、公開鍵が表示されたら成功です。

Content image

 

ローカル実行のチュートリアル

事前準備として、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です。

Content image

次に、frontendディレクトリ内で.envを作成し、実行時ポート番号とバックエンドのURLを指定します。

PORT=4000 
REACT_APP_BACKEND_URL="http://localhost:3000"

npm startするとブラウザで新しい窓が立ち上がるので、

http://localhost:4000

にアクセスするとデモサイトと同じ画面になるはずです。

 

まとめ

ブロックチェーンに用いられているECDSAによるパスワード不要のログイン認証について紹介し、Amaury Martinyさんによる記事およびコードをもとに、処理のシークエンスに関して捕捉して記事にしました。また、React(Firebase hosting)+Express(Vercel Now)+MongoDB(Atlas)のサーバレス構成でデモサイトを作成し公開しました。

ALISユーザには馴染み深い人も多いMetaMaskですが、一般に浸透してませんので通常のWebサイトのログイン方法にはなり得ません。ただ、DAppsのログイン方法としては非常にシンプルで扱いやすいです。今回のMetaMaskログインは、ブラウザゲームの個人開発にトライされているメンティーさん向けに調べて実装したものでして(初心者向けに個人開発お助けマンやってます)、DAppsの個人開発では選択肢になるかと思います。ご興味ありましたら是非お試しください。

 

クレジット(画像素材)

 

Supporter profile iconSupporter profile iconSupporter profile iconSupporter profile icon
Article tip 6人がサポートしています
獲得ALIS: Article like 78.35 ALIS Article tip 1.52k ALIS
ホーさん's icon'
  • ホーさん
  • @fukurou
Python/JavaScript

投稿者の人気記事
コメントする
コメントする
こちらもおすすめ!
Eye catch
クリプト

ジョークコインとして出発したDogecoin(ドージコイン)の誕生から現在まで。注目される非証券性🐶

Like token Tip token
38.21 ALIS
Eye catch
クリプト

Polygon(Matic)で、よく使うサイト(DeFi,Dapps)をまとめてみた

Like token Tip token
235.30 ALIS
Eye catch
クリプト

CoinList(コインリスト)の登録方法

Like token Tip token
15.55 ALIS
Eye catch
クリプト

2021年1月以降バイナンスに上場した銘柄を140文字以内でざっくりレビュー(Twitter向け情報まとめ)

Like token Tip token
38.10 ALIS
Eye catch
クリプト

ゼロから始める暗号資産

Like token Tip token
37.25 ALIS
Eye catch
クリプト

Bitcoin史 〜0.00076ドルから6万ドルへの歩み〜

Like token Tip token
946.13 ALIS
Eye catch
クリプト

ブロックチェーンの51%攻撃ってなに

Like token Tip token
0.00 ALIS
Eye catch
クリプト

【初心者向け】$MCHCの基本情報と獲得方法

Like token Tip token
16.32 ALIS
Eye catch
クリプト

スーパーコンピュータ「京」でマイニングしたら

Like token Tip token
1.01k ALIS
Eye catch
クリプト

17万円のPCでTwitterやってるのはもったいないのでETHマイニングを始めた話

Like token Tip token
46.50 ALIS
Eye catch
クリプト

バイナンスの信用取引(マージン取引)を徹底解説~アカウントの開設方法から証拠金計算例まで~

Like token Tip token
3.50 ALIS
Eye catch
クリプト

UNISWAPでALISをETHに交換してみた

Like token Tip token
18.40 ALIS