Next.js, SIWEでMetaMaskを用いたユーザー認証をする
はじめに
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
実装
"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接続
...
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取得
...
const nonceResponse = await fetch("/api/nonce", {
method: "POST",
});
const { nonce } = await nonceResponse.json();
...
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を削除などで無効にすることで、認証情報の使い回しを防ぐことができます。
署名
...
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 の各項目を見ていくと、
- domain
- ユーザーが開いてるサービスのドメインとコードで指定するdomainが違うと以下のように警告が出ます
- https://docs.login.xyz/general-information/siwe-overview/eip-4361#wallet-implementer-guidelines
- address
- wallet address
- uri
- 署名の対象をURIで指定するようです
- version
-
1
で固定
-
- chainId
- チェーンのIDを指定。Ethererumは1
- nonce
- 上述
検証
...
const verifyResponse = await fetch("/api/verify", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ message: preparedMessage, signature, nonce }),
});
...
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はデータストアに保存されているかをチェックして、その後使われないように削除します。
おわり
今回のコードは、
においています。MetaMaskはシークレットリカバリーフレーズの保存がちょっと不安ですが、認証を実装する側も使う側も他の認証方法より簡単な気がするのでいいですね。
Discussion