Posts solana DApp 인증 구현
Post
Cancel

solana DApp 인증 구현

개요

환경

  • next.js로 DApp을 구현하였다.
  • 인증 시 사용하는 api는 next.js의 API 기능을 사용한다.
  • NextAuth.js를 이용하여 인증을 구현한다.

인증 원리

  • 지갑의 전자서명(signMessage(data)) 기능을 이용한다.
  • 지갑에는 유저의 공개키와 비밀키가 저장되어있다.
  • 지갑에 저장되어 있는 비밀키로 메세지를 암호화한다.
    이를 서명(signature)이라고 한다.
  • 유저는 (메세지, 서명, 공개키)를 인증 서버에 전달한다.
  • 인증서버는 서명을 공개키로 복호화한다.
  • 복호화된 메세지가 전달 받은 메세지와 일치하면
    인증을 요청한 지갑이 전달 받은 공개키 지갑이라는 것을 확인할 수 있다.

SigninMessage.ts

  • 설명
    • 인증 시 필요한 정보를 담고 있는 클래스이다.
  • 프로퍼티 설명
    • domain
      • 인증 요청 URL, 허가된 곳에서 요청했는지 확인할때 사용한다.
    • publicKey
      • 지갑의 공개키
    • nonce:
      • 랜덤 문자열로 메세지가 중복되는 것을 막는다.
    • statement
      • 인증 시 사용하는 문자열
        아무 문자나 적어도 된다.
  • prepare 메서드 설명
    • statement와 nonce를 이어붙여 메세지를 만든다.
  • validate 메서드 설명
  • 코드
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    
                
    /* Reference by : https://www.quicknode.com/guides/solana-development/dapps/how-to-authenticate-users-with-a-solana-wallet#create-a-sign-in-message-class */  
                
    import bs58 from "bs58";  
    import nacl from "tweetnacl";  
    type SignMessage = {  
      domain: string;  
      publicKey: string;  
      nonce: string;  
      statement: string;  
    };  
                
    export class SigninMessage {  
      domain: any;  
      publicKey: any;  
      nonce: any;  
      statement: any;  
                
      constructor({ domain, publicKey, nonce, statement }: SignMessage) {  
        this.domain = domain;  
        this.publicKey = publicKey;  
        this.nonce = nonce;  
        this.statement = statement;  
      }  
                
      prepare() {  
        return `${this.statement}${this.nonce}`;  
      }  
                
      async validate(signature: string) {  
        const msg = this.prepare();  
        const signatureUint8 = bs58.decode(signature);  
        const msgUint8 = new TextEncoder().encode(msg);  
        const pubKeyUint8 = bs58.decode(this.publicKey);  
                
        return nacl.sign.detached.verify(msgUint8, signatureUint8, pubKeyUint8);  
      }  
                
    }  
                
    

Solana Wallet Adapter 추가

  • 설명
    • next.js 앱에서 SessionProvider를 추가한다.
    • 나머지는 solana 지갑관련된 것으로
      기호에 따라 변경할 수 있다.
      (예시에서는 PhantomWallet만 지원하고 있다.)
  • 코드
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    
                
    /* Reference by: https://www.quicknode.com/guides/solana-development/dapps/how-to-authenticate-users-with-a-solana-wallet#add-the-solana-wallet-adapter */  
                
    export default function App({ Component, pageProps }: AppProps) {  
      const network = WalletAdapterNetwork.Devnet;  
      const endpoint = useMemo(() => clusterApiUrl(network), [network]);  
                  
      const wallets = useMemo(  
        () => [  
          new PhantomWalletAdapter(),  
        ],  
        []  
      );  
                
      return (  
        <ConnectionProvider endpoint={endpoint}>  
          <WalletProvider wallets={wallets} autoConnect>  
            <WalletModalProvider>  
              // 추가 시작  
              <SessionProvider session={pageProps.session} refetchInterval={0}>  
              // 추가 끝  
                <Component {...pageProps} />  
              // 추가 시작  
              </SessionProvider>  
              // 추가 끝  
            </WalletModalProvider>  
          </WalletProvider>  
        </ConnectionProvider>  
      );  
    }  
                
    

프론트엔드

  • 설명
    • 인증 요청 버튼을 클릭하면
      domain, publicKey, statement, nonce를 이용하여
      SigninMessage 인스턴스를 생성한다.
    • nonce는 csrf 토큰으로 랜덤하다.
    • SingingMessage 인스턴스에서 prepare 메서드를 실행하여
      메시지를 만든다.
    • wallet.signMessage(메세지)를 실행하여 서명을 만든다.
    • SigningMessage 인스턴스를 json으로 바꾸고, 서명과 함께
      인증 서버로 요청한다.
  • 코드
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    64
    65
    66
    67
    68
    69
    70
    71
    72
    73
    74
    75
    76
    77
    78
    79
    80
    81
    82
    83
    84
    85
    86
    87
    88
    89
    90
    91
    92
    93
    94
    95
    96
    97
    98
    99
    100
    101
    102
    103
    104
    105
    106
    107
    108
    109
    110
    111
    112
    113
    114
    115
    116
    117
    118
    119
    120
    121
    122
    123
    124
    125
    126
    127
    128
    129
    130
    
                
    /* Reference by: https://www.quicknode.com/guides/solana-development/dapps/how-to-authenticate-users-with-a-solana-wallet#update-front-end */  
                
    import Link from "next/link";  
    import { getCsrfToken, signIn, signOut, useSession } from "next-auth/react";  
    import styles from "./header.module.css";  
    import { useWalletModal } from "@solana/wallet-adapter-react-ui";  
    import { useWallet } from "@solana/wallet-adapter-react";  
    import { SigninMessage } from "../utils/SigninMessage";  
    import bs58 from "bs58";  
    import { useEffect } from "react";  
                
    export default function Header() {  
      const { data: session, status } = useSession();  
      const loading = status === "loading";  
                
      const wallet = useWallet();  
      const walletModal = useWalletModal();  
                
      const handleSignIn = async () => {  
        try {  
          if (!wallet.connected) {  
            walletModal.setVisible(true);  
          }  
                
          // csrf 토큰 생성  
          const csrf = await getCsrfToken();  
          if (!wallet.publicKey || !csrf || !wallet.signMessage) return;  
                
          // SigninMessage 인스턴스 생성  
          const message = new SigninMessage({  
            domain: window.location.host,  
            publicKey: wallet.publicKey?.toBase58(),  
            statement: `Sign this message to sign in to the app.`,  
            nonce: csrf,  
          });  
                
          // 서명 생성  
          const data = new TextEncoder().encode(message.prepare());  
          const signature = await wallet.signMessage(data);  
          const serializedSignature = bs58.encode(signature);  
                
          // 인증서버로 요청  
          signIn("credentials", {  
            message: JSON.stringify(message),  
            redirect: false,  
            signature: serializedSignature,  
          });  
        } catch (error) {  
          console.log(error);  
        }  
      };  
                
      useEffect(() => {  
        if (wallet.connected && status === "unauthenticated") {  
          handleSignIn();  
        }  
      }, [wallet.connected]);  
                
      return (  
        <header>  
          <noscript>  
            <style>{`.nojs-show { opacity: 1; top: 0; }`}</style>  
          </noscript>  
          <div className={styles.signedInStatus}>  
            <p  
              className={`nojs-show ${  
                !session && loading ? styles.loading : styles.loaded  
              }`}  
            >  
              {!session && (  
                <>  
                  <span className={styles.notSignedInText}>  
                    You are not signed in  
                  </span>  
                  <span className={styles.buttonPrimary} onClick={handleSignIn}>  
                    Sign in  
                  </span>  
                </>  
              )}  
              {session?.user && (  
                <>  
                  {session.user.image && (  
                    <span  
                      style={{ backgroundImage: `url('${session.user.image}')`}}  
                      className={styles.avatar}  
                    />  
                  )}  
                  <span className={styles.signedInText}>  
                    <small>Signed in as</small>  
                    <br />  
                    <strong>{session.user.email ?? session.user.name}</strong>  
                  </span>  
                  <a  
                    href={`/api/auth/signout`}  
                    className={styles.button}  
                    onClick={(e) => {  
                      e.preventDefault();  
                      signOut();  
                    }}  
                  >  
                    Sign out  
                  </a>  
                </>  
              )}  
            </p>  
          </div>  
          <nav>  
            <ul className={styles.navItems}>  
              <li className={styles.navItem}>  
                <Link legacyBehavior href="/">  
                  <a>Home</a>  
                </Link>  
              </li>  
              <li className={styles.navItem}>  
                <Link legacyBehavior href="/api/examples/protected">  
                  <a>Protected API Route</a>  
                </Link>  
              </li>  
              <li className={styles.navItem}>  
                <Link legacyBehavior href="/me">  
                  <a>Me</a>  
                </Link>  
              </li>  
            </ul>  
          </nav>  
        </header>  
      );  
    }  
                
    

백엔드

  • 설명
    • 프론트엔드에서 인증요청을 받아서
      인증여부를 처리하는 부분이다.
    • 유저가 전달한 (메세지, 서명, 공개키)를 이용하여
      인증 요청자 지갑 소유 여부를 확인한다.
    • 인증에 성공하면 jwt를 세션에 담아 응답을 보낸다.
      세션 생성 함수 NextAuth(req, res, configuration) 문서
  • 코드
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    64
    65
    66
    67
    68
    69
    70
    71
    72
    73
    74
    75
    76
    77
    78
    79
    80
    81
    82
    83
    84
    85
    86
    87
    88
    89
    
                
    /* Reference by: https://www.quicknode.com/guides/solana-development/dapps/how-to-authenticate-users-with-a-solana-wallet#implement-backend-api */  
                
    import { NextApiRequest, NextApiResponse } from "next";  
    import NextAuth from "next-auth";  
    import CredentialsProvider from "next-auth/providers/credentials";  
    import { getCsrfToken } from "next-auth/react";  
    import { SigninMessage } from "../../../utils/SigninMessage";  
                
    export default async function auth(req: NextApiRequest, res: NextApiResponse) {  
      const providers = [  
        CredentialsProvider({  
          name: "Solana",  
          credentials: {  
            message: {  
              label: "Message",  
              type: "text",  
            },  
            signature: {  
              label: "Signature",  
              type: "text",  
            },  
          },  
          async authorize(credentials, req) {  
            try {  
              const signinMessage = new SigninMessage(  
                JSON.parse(credentials?.message || "{}")  
              );  
                
              // domain 유효성 확인  
              const nextAuthUrl = new URL(process.env.NEXTAUTH_URL);  
              if (signinMessage.domain !== nextAuthUrl.host) {  
                return null;  
              }  
                
              // csrf 토큰 유효성 확인  
              const csrfToken = await getCsrfToken({ req: { ...req, body: null } });  
                
              if (signinMessage.nonce !== csrfToken) {  
                return null;  
              }  
                
              // 서명 유효성 확인  
              const validationResult = await signinMessage.validate(  
                credentials?.signature || ""  
              );  
                
              if (!validationResult)  
                throw new Error("Could not validate the signed message");  
                
              return {  
                id: signinMessage.publicKey,  
              };  
            } catch (e) {  
              return null;  
            }  
          },  
        }),  
      ];  
                
      const isDefaultSigninPage =  
        req.method === "GET" && req.query.nextauth?.includes("signin");  
                
      // Hides Sign-In with Solana from the default sign page  
      if (isDefaultSigninPage) {  
        providers.pop();  
      }  
                
      // 세션 생성  
      return await NextAuth(req, res, {  
        providers,  
        session: {  
          strategy: "jwt",  
        },  
        secret: process.env.NEXTAUTH_SECRET,  
        callbacks: {  
          async session({ session, token }) {  
            // @ts-ignore  
            session.publicKey = token.sub;  
            if (session.user) {  
              session.user.name = token.sub;  
              session.user.image = `https://ui-avatars.com/api/?name=${token.sub}&background=random`;  
            }  
            return session;  
          },  
        },  
      });  
    }  
                
    

참고

This post is licensed under CC BY 4.0 by the author.

certbot, DNS 인증 시 자동 갱신하게 만들기(AWS Route 53)

Support for the experimental syntax 'decorators-legacy' isn't currently enabled