Firebase Auth SignIn with MetaMask and React
基本的に以下の記事がベース。
ただしこの記事では Angular を使っていたので、axiosを導入したりと少しカスタマイズを要した。
- firebase-tools
- React
- axios
今回は Firebase の Atuhentication, FireStore, Cloud Functions の3つの機能を使います。認証の流れなどは上記の参考元に任せます。
大まかな構成
まず前提として、Next.js x TypeScript x FireStore を使ったプロジェクトがあるうえで、メールアドレスやTwitterログインでなくどうやったら MetaMesk を使った Web2.5 的なログインができるか、という部分だけ記述しています。状態管理ライブラリは Recoil を使ってますが、SignIn自体は関係しないかと。
# Frontend = Next.js
src
L pages/index.tsx
L components/ConnectWalletButton.tsx
L lib/firebaseConfig.ts
L models/FirebaseUser.ts
L hooks
L AuthService.ts // ホントは hook 化したいけど現状 class
L useFirebaseUser.ts
# Backend = Cloud Functions
functions
L index.ts // getNonceToSign, verifySignedMessage
Firebase の設定
割愛。基本的な Firebase Authentication と FireStore のアプリを作成していれば、あとは「匿名ログイン」を有効にするくらいです。
Frontend
今回フロントエンドは Next.js です。
package installation
yarn add @metamask/detect-provider rxjs axios
Firebase Config
これは今回に限らず、FireStoreやFirebase Authentication を使うならいる設定。Next.jsを使っている関係もあり少しだけ他の人の書き方とは違うかも。
// firebaseConfig.ts
import firebase from 'firebase/app';
import 'firebase/auth';
import 'firebase/firestore';
const config = {
apiKey: process.env.NEXT_PUBLIC_FIREBASE_API_KEY,
authDomain: process.env.NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN,
databaseURL: process.env.NEXT_PUBLIC_FIREBASE_DATABASE_URL,
projectId: process.env.NEXT_PUBLIC_FIREBASE_PROJECT_ID,
storageBucket: process.env.NEXT_PUBLIC_FIREBASE_STORAGE_BUCKET,
messagingSenderId: process.env.NEXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID,
appId: process.env.NEXT_PUBLIC_FIREBASE_APP_ID,
};
!firebase.apps.length ? firebase.initializeApp(config) : firebase.app();
const auth = firebase.auth();
const db = firebase.firestore();
export { firebase, auth, db }
AuthService.ts
ここが今回のフロント側の肝。hooks 化したかったが取り敢えずクラスの形で実装した。@metamask/detect-provider
は web3.js とか ethers.js とか、AlchemySDKとかで代用できるはずだけど、今回は参考元に従って入れました。
// AtuhService.ts
import detectEthereumProvider from '@metamask/detect-provider';
import axios from 'axios';
import firebase from 'firebaseConfig';
import { from } from 'rxjs';
import { switchMap } from 'rxjs/operators';
interface NonceResponse {
nonce: string;
}
interface VerifyResponse {
token: string;
}
export class AuthService {
constructor(private auth: firebase.auth.Auth) { }
public signOut() {
return firebase.auth().signOut();
}
public signInWithMetaMask() {
let ethereum: any;
return from(detectEthereumProvider()).pipe(
// Step 1: Request (limited) access to users ethereum account
switchMap(async (provider) => {
if (!provider) {
throw new Error('Please install MetaMask');
}
ethereum = provider;
return await ethereum.request({ method: 'eth_requestAccounts' });
}),
// Step 2: Retrieve the current nonce for the requested address
switchMap(async () => {
const response = await axios.post<NonceResponse>(
`${process.env.NEXT_PUBLIC_FIREBASE_FUNCTIONS_ENDPOINT}/getNonceToSign`,
{
address: ethereum.selectedAddress,
}
)
return response;
}
),
// Step 3: Get the user to sign the nonce with their private key
switchMap(
async (response) =>
await ethereum.request({
method: 'personal_sign',
params: [
`0x${this.toHex(response.data.nonce)}`,
ethereum.selectedAddress,
],
})
),
// Step 4: If the signature is valid, retrieve a custom auth token for Firebase
switchMap(async (sig) => {
const response = await axios.post<VerifyResponse>(
`${process.env.NEXT_PUBLIC_FIREBASE_FUNCTIONS_ENDPOINT}/verifySignedMessage`,
{ address: ethereum.selectedAddress, signature: sig }
).catch(err=>console.error(err))
return response;
}
),
// Step 5: Use the auth token to auth with Firebase
switchMap(
async (response) => {
firebase.auth().signInWithCustomToken(response.data.token)
.then((userCredential) => {
// Signed in
var user = userCredential.user;
console.log('Successfully signed in')
console.log('user: ', user)
// ...
})
.catch((error) => {
var errorCode = error.code;
var errorMessage = error.message;
console.log('errorCode: ', errorCode)
console.log('errorMessage: ', errorMessage)
// ...
});
}
)
);
}
private toHex(stringToConvert: string) {
return stringToConvert
.split('')
.map((c) => c.charCodeAt(0).toString(16).padStart(2, '0'))
.join('');
}
}
Component
// ConnectWalletButton.tsx
import { Login } from "../hooks/useFirebaseUser";
export default function ConnectWalletButton() {
return (
<button
onClick={() => Login()}
>
Sign in with MetaMask
</button>
)
}
hooks
今回は Firebase のログイン中ユーザーを返してくれる hook, useFirebaseUser.ts を用意しています。ステート管理ライブラリとして Recoil を使っていますが、これはもっと上手いやり方があるはず。
// useFirebaseUser.ts
import router from 'next/router';
import { useEffect } from 'react';
import { atom, useRecoilState } from 'recoil';
import { firebase, auth, db } from '../lib/firebase';
import { FirebaseUser } from '../models/FirebaseUser';
import { AuthService } from './AuthService';
const userState = atom<FirebaseUser>({ // Recoil ゆえの定義
key: 'firebaseUser',
default: null,
});
export function useFirebaseUser() {
const [firebaseUser, setFirebaseUser] = useRecoilState(userState);
useEffect(() => {
const unsubscribe = auth.onAuthStateChanged(function (firebaseUser) {
if (firebaseUser) {
console.log('Firebase auth state changed');
console.log('Set firebaseUser');
const loginUser: FirebaseUser = {
uid: firebaseUser.uid,
providerId: firebaseUser.providerId,
displayName: firebaseUser.displayName, // 匿名ログインなので undefined になる
photoURL: firebaseUser.photoURL, // 匿名ログインなので undefined になる
};
setFirebaseUser(loginUser);
} else {
console.log('User is signed out');
setFirebaseUser(null);
}
});
return () => unsubscribe();
}, [setFirebaseUser]);
return firebaseUser;
}
export const Login = () => {
console.log('Login..')
const authService = new AuthService(auth);
authService.signInWithMetaMask().subscribe({
next() {
// Handle success
// router.push('/dashboard')
},
error(err) {
console.error(err)
},
})
};
export const Logout = () => {
auth
.signOut()
.then(() => {
window.location.reload();
});
};
Backend (Cloud Functions)
まず npm, firebase-tools を最新の安定版にアップデートしておいた方が良いと思う。自分は最初バージョンが古く firebase init functions
で入るデフォオルトの firebase-tools
, firebase-admin
のバージョンが食い違い、deploy時にエラーが出たりしました。最終的には以下で安定した。
- Node.js: v14.19.0
- npm: 8.19.2
- firebase-tools: 11.14.4
公式サイトはこちら
Function を作っていく
firebase init functions
// 省略。流れに従う
// 今回は TypeScript を使ってます
Install library..
npm install @metamask/eth-sig-util cors
// index.ts
import * as functions from "firebase-functions";
import * as firebaseAdmin from "firebase-admin";
import * as corsLib from "cors";
import {recoverPersonalSignature} from "@metamask/eth-sig-util";
import {randomUUID} from "crypto";
const admin = firebaseAdmin.initializeApp();
const cors = corsLib({
origin: true,
});
export const getNonceToSign = functions.https.onRequest((request, response) =>
cors(request, response, async () => {
try {
if (request.method !== "POST") {
return response.sendStatus(403);
}
if (!request.body.address) {
return response.sendStatus(400);
}
// Get the user document for that address
const userDoc: FirebaseFirestore.DocumentSnapshot = await admin
.firestore()
.collection("users")
.doc(request.body.address)
.get();
if (userDoc.exists) {
// The user document exists already, so just return the nonce
const existingNonce = userDoc.data()?.nonce;
return response.status(200).json({nonce: existingNonce});
} else {
// The user document does not exist, create it first
const generatedNonce = randomUUID(); // 元記事の Math.random() * 1000000 から変更
// Create an Auth user
const createdUser = await admin.auth().createUser({
uid: request.body.address,
});
// Associate the nonce with that user
await admin.firestore().collection("users").doc(createdUser.uid).set({
nonce: generatedNonce,
});
return response.status(200).json({nonce: generatedNonce});
}
} catch (err) {
console.log(err);
return response.sendStatus(500);
}
})
);
export const verifySignedMessage = functions.https.onRequest(
(request, response) =>
cors(request, response, async () => {
try {
if (request.method !== "POST") {
return response.sendStatus(403);
}
if (!request.body.address || !request.body.signature) {
return response.sendStatus(400);
}
const address = request.body.address;
const sig = request.body.signature;
// Get the nonce for this address
const userDocRef = admin.firestore().collection("users").doc(address);
const userDoc = await userDocRef.get();
if (userDoc.exists) {
const existingNonce = userDoc.data()?.nonce;
// Recover the address of the account
// used to create the given Ethereum signature.
const recoveredAddress = recoverPersonalSignature({
data: `0x${toHex(existingNonce)}`,
signature: sig,
});
// See if that matches the address
// the user is claiming the signature is from
if (recoveredAddress === address) {
// The signature was verified - update the nonce to prevent replay attacks
// update nonce
await userDocRef.update({
nonce: randomUUID(), // 元記事の Math.random() * 1000000 から変更
});
// Create a custom token for the specified address
const firebaseToken = await admin.auth().createCustomToken(address);
// Return the token
return response.status(200).json({token: firebaseToken});
} else {
// The signature could not be verified
return response.sendStatus(401);
}
} else {
console.log("user doc does not exist");
return response.sendStatus(500);
}
} catch (err) {
console.log(err);
return response.sendStatus(500);
}
})
);
const toHex = (stringToConvert: string) =>
stringToConvert
.split("")
.map((c) => c.charCodeAt(0).toString(16).padStart(2, "0"))
.join("");
And deploy..
firebase deploy --only functions --project [your_project_id]
ハマりどころ
Cloud Functions for Firebase は Admin SDK を実行する時は以下のプリンシパル(≒サービスアカウント?)を使うようで、それにロール2つを付与しないといけなかった。
## Cloud Functions が使うプリンシパル
- App Engine default service account
- [project_Id]@appspot.gserviceaccount.com
## 付与するロール
- Firebase Admin SDK 管理者サービス エージェント
- サービス アカウント トークン作成者
ロールを付与して変更が保存されても、実際に Cloud Functions の挙動に反映されるまでは5分くらいかかるので注意(Firebase あるある)。
注意点
UID は小文字アドレスとなる
この方法で Firebase Authentication に Ethereum Address をUIDとするユーザーアカウントが、FireStore の /users
配下に ethereum address を Document ID とするドキュメントが作成されますが、これらは小文字になってます。Ethereumではアドレスの大文字小文字を区別しないそうですが、何かデータの Update などをする際の Path 指定では、(本来のアドレスの形式である)大文字混在ではなく、全て小文字にしないと FireStore はエラーを吐くのでご注意ください。
FireStore内の nonce は消しちゃダメ
/users
配下にできたユーザーのドキュメントに nonce: string
のフィールドが入ってますが、このフィールド値は消してはダメです。僕もまだ正確に説明しきれないので理由は省きますが、この FireStore のデータを削除すると2回目以降の SignIn ができなくなります。
もしデータを上書きなどして消してしまって、nonce値も忘れてしまった場合は Authentication からユーザーアカウントを削除し、改めてフロントエンドから SignIn し直すことで同じ Ethereum Address を UID としてアカウントを作成できます。
終わりに (Bug Bounty)
思ったより難しく、またネット上にも定番のフレームワーク React を使っている知見が少なかったので書きました。DApps ではブロックチェーンを DB として活用したり(フルオンチェーン)、Moralis などの Web3.0 版Firebase を使っているからかもしれません。
今回 RxJs なども入ってきたので正直自分も挙動に自身がなく、もし「この書き方はセキュリティーリスクを孕んでるよ」「これは不具合を起こすよ」などありましたら、コメントか @maztak_ までご連絡いただけると幸いですm(__)m
typoとかはあれですが、重大なものには Bug Bounty として 0.01 ETH をお支払いさせていただきます!
参考リンク
公式のカスタムトークンによる認証
FireStoreから取得したデータを型付けようという話
Discussion
OpenSeaのログイン画面はこんな感じになってました。隠してますが MetaMesk のメッセージ欄の一番下に nonce の表示もありました。
seriuntius さんにご指摘いただき、
Math.random() * 1000000
ではなくcrypto.randomUUID()
を使う形に変更しました。nonce と ハッシュ について