티스토리 뷰
개발 부스러기
Next.js App Router + RainbowKit + SIWE + NextAuth.js를 사용해 지갑으로 로그인하는 기능 구현하기
piatoss 2024. 7. 31. 19:54구현 및 동작이 가능한지 여부를 확인하기 위한 간단한 튜토리얼. 상세한 설명은 없습니다.
프로젝트 초기화
Next.js 프로젝트 생성
$ yarn create next-app
yarn create v1.22.22
[1/4] Resolving packages...
[2/4] Fetching packages...
[3/4] Linking dependencies...
[4/4] Building fresh packages...
success Installed "create-next-app@14.2.5" with binaries:
- create-next-app
✔ What is your project named? … my-app
✔ 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
...
Success! Created my-app at /home/piatoss/my-app
Done in 17.89s
Chakra UI 설치
$ yarn add @chakra-ui/react @emotion/react @emotion/styled framer-motion
RainbowKit + Wagmi 설치
$ yarn add @rainbow-me/rainbowkit wagmi viem @tanstack/react-query
SIWE + NextAuth.js 설치
$ yarn add next-auth siwe ethers@^5
- ethers.js는 siwe의 종속성으로 필요하므로 함께 설치
불필요한 파일, 코드 제거
export default function Home() {
return <div></div>;
}
- page.tsx에 작성된 코드 지우기
import type { Metadata } from "next";
import { Inter } from "next/font/google";
- layout.tsx에서 global.css 파일 임포트 구문 지우기
.env.local 파일 생성
NEXT_PUBLIC_WALLET_CONNECT_PROJECT_ID=
NEXTAUTH_URL=
NEXTAUTH_SECRET=
- NEXT_PUBLIC_WALLET_CONNECT_PROJECT_ID: Wallet Connect Cloud에서 프로젝트 생성하고 하나 받아오기
- NEXTAUTH_URL: NextAuth.js를 사용해 로그인할 때 사용자의 도메인을 검사하기 위해 사용
- NEXTAUTH_SECRET: 로그인한 사용자의 토큰을 생성할 때 사용하는 비밀키
Providers, Context 생성
providers.tsx 파일 생성
"use client";
import { ChakraProvider } from "@chakra-ui/react";
import { getDefaultConfig, RainbowKitProvider } from "@rainbow-me/rainbowkit";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { useEffect, useState } from "react";
import { zkSync, zkSyncSepoliaTestnet } from "viem/zksync";
import { WagmiProvider } from "wagmi";
import { SessionProvider } from "next-auth/react";
import { AuthProvider } from "@/context";
const config = getDefaultConfig({
appName: "Your Project Name",
projectId: process.env.NEXT_PUBLIC_WALLET_CONNECT_PROJECT_ID || "",
chains: [zkSync, zkSyncSepoliaTestnet],
ssr: true,
});
const queryClient = new QueryClient();
const Providers = ({ children }: { children: React.ReactNode }) => {
const [mounted, setMounted] = useState<boolean>(false);
useEffect(() => {
setMounted(true);
}, []);
return (
<ChakraProvider>
<WagmiProvider config={config}>
<QueryClientProvider client={queryClient}>
<SessionProvider refetchInterval={0}>
<RainbowKitProvider
showRecentTransactions={true}
coolMode
initialChain={zkSyncSepoliaTestnet}
>
<AuthProvider>{mounted && children}</AuthProvider>
</RainbowKitProvider>
</SessionProvider>
</QueryClientProvider>
</WagmiProvider>
</ChakraProvider>
);
};
export default Providers;
- ChakraProvider: 스타일을 적용하기 위해 가장 상위에 배치
- WagmiProvider, QueryClientProvider: 공식 문서에 명시된 순서로 배치
- SessionProvider: QueryClientProvider에 의존하므로 하위에 배치
- RainbowKitProvider: 공식 문서에 따라 WagmiProvider, QueryClientProvider 하위에 배치
- AuthProvider: 사용자 정의 콘텍스트를 제공하기 위한 프로바이더로, WagmiProvider, SessionProvider에 의존하므로 하위에 배치, RainbowKitProvider와 순서는 상관없음
layout.tsx 파일 수정
import type { Metadata } from "next";
import { Inter } from "next/font/google";
import Providers from "./providers";
import "@rainbow-me/rainbowkit/styles.css";
const inter = Inter({ subsets: ["latin"] });
export const metadata: Metadata = {
title: "Create Next App",
description: "Generated by create next app",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en">
<body className={inter.className}>
<Providers>{children}</Providers>
</body>
</html>
);
}
- import "@rainbow-me/rainbowkit/styles.css"로 RainbowKit css 파일 임포트
- <Providers>{children}</Providers>로 모든 페이지에서 프로바이더를 통해 제공되는 값, 기능에 접근가능하게 하기
AuthProvider 생성하기
"use client";
import { createContext, useCallback, useEffect, useState } from "react";
import { getCsrfToken, signIn, signOut, useSession } from "next-auth/react";
import { SiweMessage } from "siwe";
import { useAccount, useSignMessage, useSwitchChain } from "wagmi";
import { zkSync, zkSyncSepoliaTestnet } from "viem/zksync";
import { useToast } from "@chakra-ui/react";
import { Session } from "next-auth";
interface AuthContextValues {
address: `0x${string}`;
session: Session | null;
isLoading: boolean;
isWalletConnected: boolean;
isSignedIn: boolean;
handleSignIn: () => void;
handleSignOut: () => void;
}
const AuthContext = createContext({
address: `0x0`,
session: null,
isLoading: false,
isWalletConnected: false,
isSignedIn: false,
handleSignIn: async () => {},
handleSignOut: async () => {},
} as AuthContextValues);
const AuthProvider = ({ children }: { children: React.ReactNode }) => {
const toast = useToast();
const { data: session, status } = useSession();
const {
address,
chainId,
isConnected,
isDisconnected,
isConnecting,
isReconnecting,
} = useAccount();
const { signMessageAsync } = useSignMessage();
const { switchChain } = useSwitchChain();
const isLoading = status === "loading" || isConnecting || isReconnecting;
const isWalletConnected = isConnected;
const isSignedIn = !!session && status === "authenticated";
const handleSignIn = useCallback(async () => {
try {
if (!address || !isConnected) {
throw new Error("Please connect your wallet first.");
}
const callbackUrl = "/";
const nonce = await getCsrfToken();
const message = new SiweMessage({
domain: window.location.host,
uri: window.location.origin,
version: "1",
address: address,
statement: "Sign in with Ethereum to Fuzion",
nonce: nonce,
chainId: chainId,
});
const signature = await signMessageAsync({
message: message.prepareMessage(),
});
const response = await signIn("ethereum", {
message: JSON.stringify(message),
signature,
redirect: false,
callbackUrl,
});
if (!response) {
throw new Error("Failed to sign in.");
}
if (response.ok) {
toast({
title: "Success",
description: "You have successfully signed in.",
status: "success",
duration: 5000,
isClosable: true,
});
}
if (response.error) {
throw new Error(response.error);
}
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : "Unknown error";
toast({
title: "Error",
description: errorMessage,
status: "error",
duration: 5000,
isClosable: true,
});
}
}, [address, chainId, isConnected, signMessageAsync, toast]);
const handleSignOut = useCallback(async () => {
// Do nothing if the user is not signed in
if (!isSignedIn) {
return;
}
// Sign out and redirect to the home page
await signOut({ callbackUrl: "/" });
}, [isSignedIn, isConnected]);
useEffect(() => {
// chainId is not available yet
if (!chainId) {
return;
}
// Switch to zkSync Sepolia Testnet if the chain is not zkSync or zkSync Sepolia Testnet
if (chainId != zkSync.id && chainId != zkSyncSepoliaTestnet.id) {
console.log("Switching chain to zkSync Sepolia Testnet...");
switchChain({
chainId: zkSyncSepoliaTestnet.id,
});
}
}, [chainId]);
useEffect(() => {
// Sign out if the user is signed in but disconnected or the address is different
if (isSignedIn) {
if (isDisconnected || (session as any).user.address !== address) {
handleSignOut();
}
}
}, [address, isDisconnected, isSignedIn, session]);
return (
<AuthContext.Provider
value={{
address: address ? address : `0x0`,
session,
isLoading,
isWalletConnected,
isSignedIn,
handleSignIn,
handleSignOut,
}}
>
{children}
</AuthContext.Provider>
);
};
export { AuthProvider, AuthContext };
콘텍스트에서 제공되는 값
- address: 연결된 지갑 주소
- session: 세션 데이터
- isLoading: 지갑 연결 또는 로그인 중인지 여부
- isWalletConnected: 지갑 연결 여부
- isSignedIn: 로그인 여부
- handleSignIn: 로그인 핸들러
- handleSignOut: 로그아웃 핸들러
AuthProvider 예쁘게 내보내기
import { AuthProvider, AuthContext } from "./Auth";
export { AuthProvider, AuthContext };
- context.index.ts 파일을 생성하고 그 안에서 AuthProvider와 AuthContext 불러오기
- 이렇게 하면 '@/context'로 접근이 가능
useAuth 커스텀 훅 생성하기
import { AuthContext } from "@/context";
import { useContext } from "react";
const useAuth = () => {
return useContext(AuthContext);
};
export default useAuth;
- hooks 디렉토리를 생성하고 useAuth.ts 파일을 작성
import useAuth from "./useAuth";
export { useAuth };
- 컨텍스트와 마찬가지로 index.ts 파일을 생성하여 예쁘게 내보낸다.
로그인, 로그아웃을 위한 API Routes 구현
AuthOptions 구성
import { AuthOptions } from "next-auth";
import CredentialsProvider, {
CredentialInput,
} from "next-auth/providers/credentials";
import { getCsrfToken } from "next-auth/react";
import { SiweMessage } from "siwe";
export const authOptions: AuthOptions = {
providers: [
CredentialsProvider({
id: "ethereum",
name: "Ethereum",
credentials: {
message: {
label: "Message",
type: "text",
placeholder: "0x0",
} as CredentialInput,
signature: {
label: "Signature",
type: "text",
placeholder: "0x0",
},
},
async authorize(credentials, req) {
try {
if (!(credentials?.message && credentials?.signature)) {
throw new Error("Missing Credentials");
}
const siwe = new SiweMessage(JSON.parse(credentials.message));
const nextAuthUrl = new URL(
process.env.NEXTAUTH_URL || "http://localhost:3000/"
);
const csrf = await getCsrfToken({ req: { headers: req.headers } });
const result = await siwe.verify({
signature: credentials.signature,
domain: nextAuthUrl.host,
nonce: csrf,
});
if (result.success) {
return {
id: siwe.address,
};
}
return null;
} catch (error) {
console.log(error);
return null;
}
},
}),
],
session: { strategy: "jwt" },
debug: process.env.NODE_ENV === "development",
secret: process.env.NEXTAUTH_SECRET,
callbacks: {
async session({ session, token }: { session: any; token: any }) {
session.user.address = token.sub;
session.user.name = token.sub;
return session;
},
},
pages: {
signIn: "/login",
},
};
로그인, 로그아웃을 위한 API Routes 생성
import { authOptions } from "@/libs/auth";
import NextAuth from "next-auth/next";
const handler = NextAuth(authOptions);
export { handler as GET, handler as POST };
- app/api/auth/[...nextauth] 디렉터리에 route.ts 파일을 생성
로그인한 사용자만 사용 가능한 API Route 생성
import { authOptions } from "@/libs/auth";
import { getServerSession } from "next-auth";
import { NextRequest, NextResponse } from "next/server";
const GET = async (req: NextRequest) => {
try {
const session = await getServerSession(authOptions);
console.log("session", session);
if (session) {
return NextResponse.json(
{
message: "This is a protected route. You are signed in.",
},
{ status: 200 }
);
}
return NextResponse.json(
{
message: "You must be signed in to access this route.",
},
{ status: 401 }
);
} catch (error) {
const message =
error instanceof Error ? error.message : "Internal Server Error";
return NextResponse.json({ message }, { status: 500 });
}
};
export { GET };
지갑 연결, 로그인, 로그아웃을 위한 컴포넌트 구현
page.tsx 파일 수정
"use client";
import { useAuth } from "@/hooks";
import { Box, Button, Center, Stack, Text } from "@chakra-ui/react";
import { ConnectButton } from "@rainbow-me/rainbowkit";
import { useSession } from "next-auth/react";
import { useState } from "react";
export default function Home() {
const { data: session } = useSession();
const {
isWalletConnected,
isSignedIn,
isLoading,
handleSignIn,
handleSignOut,
} = useAuth();
const [message, setMessage] = useState<string | null>(null);
const handleProtected = async () => {
try {
const response = await fetch("/api/protected");
const data = await response.json();
const message = data.message as string;
setMessage(message);
} catch (error) {
console.error(error);
setMessage("An error occurred.");
}
};
return (
<Box display="flex" flexDirection="column" minH="100vh" p={4} bg="gray.100">
<Center flexGrow={1}>
<Stack spacing={4} align="center">
<ConnectButton />
{isWalletConnected && (
<>
<Box>
<Button
colorScheme="blue"
onClick={isSignedIn ? handleSignOut : handleSignIn}
isLoading={isLoading}
>
{isSignedIn ? "Sign out" : "Sign in"}
</Button>
</Box>
{session ? (
<Box>
<p>Signed in as {session.user!.name}</p>
<p>Expires: {session.expires}</p>
</Box>
) : (
<Box>
<p>Not signed in</p>
</Box>
)}
<Stack spacing={4} align="center">
<Button colorScheme="blue" onClick={handleProtected}>
Protected
</Button>
{message && <Text>{message}</Text>}
</Stack>
</>
)}
</Stack>
</Center>
</Box>
);
}
기능 시연
지갑 연결 및 로그인
보호된 경로로 요청
참고
'개발 부스러기' 카테고리의 다른 글
Yarn을 사용한 모노레포에서 cannot find module 문제 해결하기 (0) | 2024.09.11 |
---|---|
[Solidity] 함수 시그니처를 사용해 동적으로 데이터를 인코딩하는 폼 만들기 (1) | 2024.09.06 |
Damn Vulnerable DeFi Foundry V3 업데이트 작업 (0) | 2024.02.28 |
Solidity 런타임 바이트코드 분해하기 (0) | 2024.02.13 |
Hyperlane V3를 사용한 Crosschain NFT (0) | 2024.02.12 |