modernライブラリを駆使して認証つきDappsを構築してみた

2023/10/27に公開

0.はじめに

Next.js 13.4 から、App Router が正式リリースとなりました。この新しいバージョンでは、これまでの Pages Router とは大幅に異なる変更が加えられており、より強力で柔軟なルーティングオプションを提供しています。

wagmi,viem,siwe,NextAuth を用いて、ログイン機能付きの Dapps を作りたいと思います。

この記事では、実際にアプリケーションを開発していきます。

1. Nextjs

まずは、nextjsのインストールから

npx create-next-app@latest
✔ What is your project named? … modern-web3-frontend
✔ Would you like to use TypeScript? … No / Yes
✔ Would you like to use ESLint? … No / Yes
✔ Would you like to use Tailwind CSS? … No / Yes
✔ Would you like to use `src/` directory? … No / Yes
✔ Would you like to use App Router? (recommended) … No / Yes
✔ Would you like to customize the default import alias (@/*)? … No / Yes
✔ What import alias would you like configured? … @/*

2. ウォレット接続ボタン

ウォレットを接続するためのボタンを実装していきます。
まずは、ライブラリのインストールを行います。

npm install @web3modal/wagmi wagmi viem

Wallet Connect

Wallet Connect を使うと簡単にブロックチェーンに接続できます。まずは、Wallet Connect のサイトで projectID を作りましょう。

https://walletconnect.com/

公式サイトでログインしたら、新しいプロジェクトを作っていきます。

Wallet Connect
Wallet Connect

.env.development.localNEXT_PUBLIC_WC_PROJECT_IDを追加します。

NEXT_PUBLIC_WC_PROJECT_ID = "..."

このままだと、typescript のエラーがちょこちょこ出て鬱陶しいので、global.d.tsを追加します。

global.d.ts
declare global {
  namespace NodeJS {
    interface ProcessEnv {
      NEXT_PUBLIC_WC_PROJECT_ID: string;
    }
  }
}

export {};

ちなみに、nextjs の page router で良ければ、下記のコードからスタートするのが早いです。

Wallet Connect

npx create-wc-dapp@latest web3modal-quickstart -id ${projectID} -y

WalletConnect の関係で、下記エラーが出ますので、臭いものには蓋をしましょう。

Module not found: Can't resolve 'pino-pretty','lokijs','encoding'
next.config.js
/** @type {import('next').NextConfig} */
const nextConfig = {
  webpack: (config) => {
    config.externals.push("pino-pretty", "lokijs", "encoding");
    return config;
  },
};

module.exports = nextConfig;

次に、Provider の設定を行っていきます。React では複数 Provider を使う可能性が高いので、ひとまとめにできるように構築していきます。

src/providers/WagmiProvider.tsx
import { createWeb3Modal, defaultWagmiConfig } from "@web3modal/wagmi/react";

import { WagmiConfig } from "wagmi";
import { useEffect, useState } from "react";
import {
  arbitrum,
  avalanche,
  bsc,
  fantom,
  gnosis,
  mainnet,
  optimism,
  polygon,
} from "wagmi/chains";

const chains = [
  mainnet,
  polygon,
  avalanche,
  arbitrum,
  bsc,
  optimism,
  gnosis,
  fantom,
];

// 1. Get projectID at https://cloud.walletconnect.com
const projectId = process.env.NEXT_PUBLIC_WC_PROJECT_ID;

const metadata = {
  name: "Next Starter Template",
  description: "A Next.js starter template with Web3Modal v3 + Wagmi",
  url: "https://web3modal.com",
  icons: ["https://avatars.githubusercontent.com/u/37784886"],
};

const wagmiConfig = defaultWagmiConfig({ chains, projectId, metadata });

createWeb3Modal({ wagmiConfig, projectId, chains });

type ProviderType = {
  children: React.ReactNode;
};

export default function WagmiProvider({ children }: ProviderType) {
  const [ready, setReady] = useState(false);

  useEffect(() => {
    setReady(true);
  }, []);
  return (
    <>
      {ready ? (
        <WagmiConfig config={wagmiConfig}>{children}</WagmiConfig>
      ) : null}
    </>
  );
}

*chains は使うものに限定することをお勧めします!

その後、Provider をひとまとめにするために、

src/providers/Providers.tsx
"use client";
import React from "react";
import WagmiProvider from "./WagmiProvider";

type ProviderType = {
  children: React.ReactNode;
};

const Providers = ({ children }: ProviderType) => {
  return <WagmiProvider>{children}</WagmiProvider>;
};

export default Providers;

プロバイダーを挿入します。

src/app/layout.tsx
export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="en">
      <body className={inter.className}>
-        {children}
+        <Providers>{children}</Providers>
      </body>
    </html>
  );
}

あとは、フロントに下記ボタンを埋め込めば、とりあえず完成!

src/app/page.tsx
// ネットワーク切り替えボタン
<w3m-network-button />

// 接続ボタン
<w3m-button />

ちなみに、色んなボタンがあるので、下記を参照してみてくださいね!
https://docs.walletconnect.com/web3modal/react/components

カスタム1:より強力なクライアント

WalletConnect では、1 日 100,000 リクエストまで使用することができます。

より強力な Dapps にしたい場合は、プロバイダーの追加を行います。というのも、別のプロバイダーを追加しておくと、リクエストが失敗した場合、リスト内の次のプロバイダーにフォールバックしてくれます。

これは、Viem のフォールバック機能を Wagmi が内部的に使用しているためです。より強力なクライアントを作成することができますね!

src/providers/WagmiProvider.tsx
+ const alchemyId = process.env.NEXT_PUBLIC_ALCHEMY_APIKEY;
+ const infuraId = process.env.NEXT_PUBLIC_INFURA_APIKEY;

- const wagmiConfig = defaultWagmiConfig({ chains, projectId, metadata });
+ const { publicClient } = configureChains(chains, [
+   alchemyProvider({ apiKey: alchemyId }),
+   infuraProvider({ apiKey: infuraId }),
+   jsonRpcProvider({
+     rpc: (chain) => ({
+       http: `https://${chain.id}.example.com`,
+       webSocket: `wss://${chain.id}.example.com`,
+     }),
+   }),
+   publicProvider(),
+ ]);

+ const wagmiConfig = createConfig({
+   autoConnect: true,
+   connectors: [
+     new WalletConnectConnector({
+       chains,
+       options: { projectId, showQrModal: false, metadata },
+     }),
+     new InjectedConnector({ chains, options: { shimDisconnect: true } }),
+     new CoinbaseWalletConnector({
+       chains,
+       options: { appName: metadata.name },
+     }),
+   ],
+   publicClient,
+ });

カスタム 2:オリジナル接続ボタン

さらにカスタマイズしたい場合は、下記のような感じで組み込めば可能です。

// WagmiProvider.tsx
export const connectors = [
  new InjectedConnector({ chains }),
  new WalletConnectConnector({
    chains,
    options: {
      projectId,
      metadata,
    },
  }),
  new CoinbaseWalletConnector({
    chains,
    options: {
      appName: "wagmi.sh",
      // jsonRpcUrl: `https://eth-mainnet.alchemyapi.io/v2/${ALCHEMY_APIKEY}`,
    },
  }),
  new MetaMaskConnector({
    chains,
  }),
];

const wagmiConfig = createConfig({
  autoConnect: true,
  publicClient,
  connectors,
});

// Client components
const { connect } = useConnect();
const { disconnect } = useDisconnect();

<button
  onClick={() => {
    connect({ connector: connectors[0] });
  }}
>
  connect button
</button>;
<button onClick={() => disconnect()}>Disconnect</button>;

3.ユーザー認証機能

次は、Sign-In With Ethereum及びNextAuthを用いてユーザー認証機能を追加していきます。

Sign-In With Ethereum

イーサリアムのウォレットアドレスでユーザー認証・認可などの仕組みを統一・標準化を行っているのが、Sign-In With Ethereumです。

SIWEと略されます。Web3 におけるユーザー認証機能を実装する際は、ぜひこのライブラリを使いましょう。

https://login.xyz/

NextAuth

NextAuth.js は、Next.js とサーバーレスをサポートするためにゼロから設計されている認証のためのライブラリです。

https://next-auth.js.org/getting-started/introduction

これらを併用して、ユーザー認証を実装していきたいと思います。

ユーザー認証機能の実装

まずは、使用するライブラリをインストールします。

npm install next-auth siwe

次に環境変数を追加しましょう

NEXTAUTH_URL = "http://localhost:3000"
NEXTAUTH_SECRET = "secret"

NEXT_PUBLIC_SIGNIN_MESSAGE = "Sign in with Ethereum to the app."

NEXTAUTH_SECRETはシークレットな値を、NEXT_PUBLIC_SIGNIN_MESSAGEはウォレットでサインする文字を設定します。

今はまだ provider に siwe がないので、自分で provider を構築する必要があります。そこで、CredentialsProvider を用いて、カスタム provider を作っていきます。

src/app/api/auth/[...nextauth]/route.ts
import { AuthOptions } from "next-auth";
import NextAuth from "next-auth/next";
import CredentialsProvider from "next-auth/providers/credentials";
import { getCsrfToken } from "next-auth/react";
import { SiweMessage } from "siwe";

export const authOptions: AuthOptions = {
  providers: [
    CredentialsProvider({
      id: "siwe",
      name: "siwe",
      credentials: {
        message: { label: "Message", type: "text", placeholder: "0x0" },
        signature: { label: "Signature", type: "text", placeholder: "0x0" },
      },
      async authorize(credentials, req) {
        try {
          const siwe = new SiweMessage(
            JSON.parse(credentials?.message || "{}")
          );
          const nextAuthUrl = new URL(process.env.NEXTAUTH_URL);

          const result = await siwe.verify({
            signature: credentials?.signature || "",
            domain: nextAuthUrl.host,
            nonce: await getCsrfToken({ req: { headers: req.headers } }),
          });

          if (!result.success) throw new Error("Invalid Signature");

          if (result.data.statement !== process.env.NEXT_PUBLIC_SIGNIN_MESSAGE)
            throw new Error("Invalid Message");

          return {
            id: siwe.address,
          };
        } catch (error) {
          console.log(error);
          return null;
        }
      },
    }),
  ],
  session: { strategy: "jwt" },
  secret: process.env.NEXTAUTH_SECRET,
  callbacks: {
    async session({ session, token }: { session: any; token: any }) {
      session.address = token.sub;
      session.user.name = token.sub;
      return session;
    },
  },
  pages: {
    signIn: "/auth",
  },
};

const handler = NextAuth(authOptions);

export { handler as GET, handler as POST };

ここでは、フロントから送られてくるSiweMessageを検証しています。

注意点としては、仮に署名が同じでも、署名されたメッセージが異なる可能性があるため、署名したメッセージが意図したメッセージかどうかのチェックを行っています。

次に、NextAuthProviderを設定していきます。

src/providers/NextAuthProvider.tsx
"use client";

import { SessionProvider } from "next-auth/react";

type ProviderType = {
  children: React.ReactNode;
};

export default function NextAuthProvider({ children }: ProviderType) {
  return <SessionProvider>{children}</SessionProvider>;
}
src/providers/Providers.tsx
"use client";
import React from "react";
import WagmiProvider from "./WagmiProvider";
import NextAuthProvider from "./NextAuthProvider";

type ProviderType = {
  children: React.ReactNode;
};

const Providers = ({ children }: ProviderType) => {
  return (
    <WagmiProvider>
      <NextAuthProvider>{children}</NextAuthProvider>
    </WagmiProvider>
  );
};

export default Providers;

これで設定はおしまい!
あとは、フロントを作っていきます。まずは、ウォレットでログインする画面から。

src/app/auth/page.tsx
"use client";
import { useEffect, useState } from "react";
import { SiweMessage } from "siwe";
import { useAccount, useNetwork, useSignMessage } from "wagmi";
import { getCsrfToken, signIn } from "next-auth/react";

export default function Auth() {
  const [mounted, setMounted] = useState(false);
  const { address, isConnected } = useAccount();
  const { chain } = useNetwork();
  const { signMessageAsync } = useSignMessage();

  useEffect(() => setMounted(true), []);
  if (!mounted) return <></>;

  const handleLogin = async () => {
    try {
      const callbackUrl = "/protected";
      const message = new SiweMessage({
        domain: window.location.host,
        address: address as `0x${string}`,
        statement: process.env.NEXT_PUBLIC_SIGNIN_MESSAGE,
        uri: window.location.origin,
        version: "1",
        chainId: chain?.id,
        nonce: await getCsrfToken(),
      });

      const signature = await signMessageAsync({
        message: message.prepareMessage(),
      });

      const response = await signIn("siwe", {
        message: JSON.stringify(message),
        redirect: true,
        signature,
        callbackUrl,
      });
      if (response?.error) {
        console.log("Error occured:", response.error);
      }
    } catch (error) {
      console.log("Error Occured", error);
    }
  };

  return (
    <main className="flex min-h-screen flex-col items-center justify-center">
      {!isConnected && <w3m-button />}
      {isConnected && (
        <button
          onClick={handleLogin}
          className="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded"
        >
          Sign Message to Login
        </button>
      )}
    </main>
  );
}

handleLoginでは、siweを用いてサインするメッセージを作っています。その後、さっき作った NextAuth の認証機能で認証を行っています。

フロントはボタンだけですね。後述しますが、認証されていない人が直接アクセスした時に、まずはウォレット接続してもらうために、<w3m-button />を設置しています。

src/app/protected/page.tsx
"use client";
import { signOut } from "next-auth/react";
import React from "react";
import { useDisconnect } from "wagmi";

export default function HiddenPage() {
  const { disconnectAsync } = useDisconnect();
  const handleSignout = async () => {
    disconnectAsync();
    signOut({ callbackUrl: "/" });
  };
  return (
    <div className="flex min-h-screen flex-col items-center justify-center">
      <button
        className="bg-red-500 hover:bg-red-700 text-white font-bold py-2 px-4 rounded"
        onClick={handleSignout}
      >
        Sign Out
      </button>
    </div>
  );
}

次に認証後の画面です。Sign Outボタンのみ配置されていて、押すとトップ画面に戻ります。

トップ画面から認証画面いアクセスするためのボタンを設置しましょう。

src/app/page.tsx
<Link href="/auth">
   <button className="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded">
     Sign In
   </button>
</Link>

最後に、認証していない人が勝手にアクセスできないように保護しましょう。

src/middleware.ts
export { default } from "next-auth/middleware";

export const config = {
  matcher: ["/protected"],
};

サイト全体に認証を要求する場合は、config を消せば OK です。
こうすることで、ユーザーがログインしていない場合、デフォルトの動作ではサインインページにリダイレクトされます。

今回は、src/app/api/auth/[...nextauth]/route.tsにて、サインインページを/authに設定したため、authページにリダイレクトします。

ブロックチェーンとの対話はこの次に!お疲れ様でした。


いかがだったでしょうか?

どんどん刷新していくので、ついていくのがたいへんですよね〜。一方で、実装がかなり楽な方向に進んでいってるのは嬉しい限りです。

もし記事があなたのお役に立ったなら、ぜひ「いいね!」ボタンをクリックしてくださいね。

Discussion