🌱

Next.js, SIWEでMetaMaskを用いたユーザー認証をする

2023/09/08に公開

はじめに

MetaMaskを用いたユーザー認証について書いていきます。
SampleとしてNext.jsを使っています。

install

以下3つをinstallします

npx create-next-app@latest
yarn add siwe 
yarn add ethers
yarn add utf-8-validate bufferutil

使用したバージョンは以下です。

  • next 13.4.9
  • siwe 2.1.4
  • ethers 6.71

utf-8-validate, bufferutilに関して、下記のエラーが発生するのでinstallしています。

- warn ./node_modules/ws/lib/buffer-util.js
Module not found: Can't resolve 'bufferutil' in '/Users/hide/hid3h/siwe-nextjs-sample/node_modules/ws/lib'

Import trace for requested module:
./node_modules/ws/lib/buffer-util.js
./node_modules/ws/lib/receiver.js
./node_modules/ws/wrapper.mjs
./node_modules/ethers/lib.esm/providers/ws.js
./node_modules/ethers/lib.esm/providers/provider-websocket.js
./node_modules/ethers/lib.esm/providers/index.js
./node_modules/ethers/lib.esm/ethers.js
./node_modules/ethers/lib.esm/index.js
./app/page.tsx

./node_modules/ws/lib/validation.js
Module not found: Can't resolve 'utf-8-validate' in '/Users/hide/hid3h/siwe-nextjs-sample/node_modules/ws/lib'

Import trace for requested module:
./node_modules/ws/lib/validation.js
./node_modules/ws/lib/receiver.js
./node_modules/ws/wrapper.mjs
./node_modules/ethers/lib.esm/providers/ws.js
./node_modules/ethers/lib.esm/providers/provider-websocket.js
./node_modules/ethers/lib.esm/providers/index.js
./node_modules/ethers/lib.esm/ethers.js
./node_modules/ethers/lib.esm/index.js
./app/page.tsx

実装

app/page.tsx
"use client";

import { BrowserProvider } from "ethers";
import { useState } from "react";
import { SiweMessage } from "siwe";

declare global {
  interface Window {
    ethereum: any;
  }
}

export default function Home() {
  const [verifiedAddress, setVerifiedAddress] = useState("");

  const signInWithEthereum = async () => {
    const provider = new BrowserProvider(window.ethereum);
    const domain = window.location.host;

    const signer = await provider.getSigner();

    const nonceResponse = await fetch("/api/nonce", {
      method: "POST",
    });
    const { nonce } = await nonceResponse.json();

    const message = new SiweMessage({
      domain,
      address: signer.address,
      uri: origin,
      version: "1",
      chainId: 1,
      nonce,
    });
    const preparedMessage = message.prepareMessage();
    const signature = await signer.signMessage(preparedMessage);

    const verifyResponse = await fetch("/api/verify", {
      method: "POST",
      headers: {
        "Content-Type": "application/json",
      },
      body: JSON.stringify({ message: preparedMessage, signature, nonce }),
    });
    const { address } = await verifyResponse.json();
    setVerifiedAddress(address);
  };

  return (
    <>
      <button onClick={signInWithEthereum}>Sign-in with Ethereum</button>
      <p>VerifiedAddress: {verifiedAddress}</p>
    </>
  );
}

Window interfaceに要素を追加

const provider = new BrowserProvider(window.ethereum);

上記のままだとProperty 'ethereum' does not exist on type 'Window & typeof globalThis'.ts(2339)という型エラーが発生します。

以下のようにinterfaceを拡張することによって対応しました。

declare global {
  interface Window {
    ethereum: any;
  }
}

参考: https://dev.classmethod.jp/articles/typings-of-window-object/

Wallet接続

app/page.tsx
...
const signInWithEthereum = async () => {
  const provider = new BrowserProvider(window.ethereum);
  const domain = window.location.host;

  const signer = await provider.getSigner();
...

await provider.getSigner()の処理で、ブラウザ拡張機能のMetaMaskにEthereumアカウントの情報取得の許可を求めます。


接続することで、Ethereumアカウントの情報、例えばwallet addressなどが取得できるようになります。

ただ、ここで取得したwallet addressをそのままサーバーに送ってユーザー登録・ログイン処理をすることはできません。
適当なwallet addressや他人のwallet addressを直接サーバーにリクエストすることができるので。
そのため、メッセージと署名をサーバーに送りサーバー側で検証する必要があります。

メッセージと署名をサーバーに送りサーバー側で検証

nonce取得

app/page.tsx
...
const nonceResponse = await fetch("/api/nonce", {
  method: "POST",
});
const { nonce } = await nonceResponse.json();
...
app/api/nonce/routes.ts
import { NextResponse } from "next/server";
import { generateNonce } from "siwe";

export async function POST() {
  const nonce = generateNonce();

  // TODO: nonceをRDBやKVSなどのデータストアに保存する

  return NextResponse.json({ nonce });
}

nonceとは使い捨てのランダムな値のことで、リプレイ攻撃を防ぐために使います。

nonceを生成し、なにかしらのデータストアに保存しておきます。
署名検証時に対象のnonceを削除などで無効にすることで、認証情報の使い回しを防ぐことができます。

署名

app/page.tsx
...
const message = new SiweMessage({
  domain,
  address: signer.address,
  uri: origin,
  version: "1",
  chainId: 1,
  nonce,
});
const preparedMessage = message.prepareMessage();
const signature = await signer.signMessage(preparedMessage);
...

walletの接続とは別で、署名のリクエストがされます。

参考: https://docs.login.xyz/sign-in-with-ethereum/quickstart-guide/creating-siwe-messages

https://docs.login.xyz/general-information/siwe-overview/eip-4361#message-field-descriptions の各項目を見ていくと、

検証

app/page.tsx
...
const verifyResponse = await fetch("/api/verify", {
  method: "POST",
  headers: {
    "Content-Type": "application/json",
  },
  body: JSON.stringify({ message: preparedMessage, signature, nonce }),
});
...
app/api/verify/route.ts
import { NextResponse } from "next/server";
import { SiweMessage } from "siwe";

export async function POST(request: Request) {
  const { message, signature, nonce } = await request.json();
  const siweMessage = new SiweMessage(message);

  const domain = "localhost:3000";

  try {
    // TODO: データストアに保存しているか確認して、nonceを削除する

    const result = await siweMessage.verify({ signature, domain, nonce });

    if (!result.success) {
      return NextResponse.json({ error: "TODO: エラー" }, { status: 401 });
    }

    // ユーザー登録処理や、ログイン処理をしたり
    // 例えば、walletAddressでDBからuserを取得したり
    // const address = result.data.address;
    // const user = ...

    return NextResponse.json(result.data);
  } catch (error) {
    console.log("error", error);
    return NextResponse.json({ error: "TODO: エラー" }, { status: 401 });
  }
}

参考: https://docs.login.xyz/sign-in-with-ethereum/quickstart-guide/implement-the-backend

siweMessage.verify()signature, domain, nonceを渡して検証します。
検証に成功したことで、得られるwallet addressが持ち主によって正しく署名されたものであることがわかり、wallet addressを使ってユーザー登録やユーザーを取得してからのログイン処理を行うことができます。

nonceはデータストアに保存されているかをチェックして、その後使われないように削除します。

おわり

今回のコードは、
https://github.com/hid3h/siwe-nextjs-sample
においています。

MetaMaskはシークレットリカバリーフレーズの保存がちょっと不安ですが、認証を実装する側も使う側も他の認証方法より簡単な気がするのでいいですね。

Discussion