如何将登录认证与 Solana 钱包集成

  • QuickNode
  • 发布于 2024-08-13 22:59
  • 阅读 49

本文介绍了如何在使用Next.js和NextAuth.js的React应用中集成Solana Wallet Adapter进行用户认证,包括项目设置、依赖安装、前端和后端的实现步骤。

与某些版本的 Next.js 和 NextAuth.js 不兼容

由于最近对 Next.js 和 NextAuth.js 库的更新,本指南可能与某些版本不兼容。本指南中使用的版本如下,使用其他版本可能会导致错误。

依赖 版本
Next.js 13.3.0
NextAuth.js 4.22.0
@solana/wallet-adapter-base ^0.9.22
@solana/wallet-adapter-react ^0.15.32
@solana/web3.js ^1.75.0

概述

Web3 钱包认证提供了一种独特的用户认证方法,允许用户控制自己的数据。这种方法消除了传统电子邮件登录的需要,并为开发者提供了一种安全的、私密的方式来在其平台上认证用户。作为开发者,将 web3 钱包整合到你的认证过程中,可以为去中心化应用创造新的机会,并创造一种更具用户赋权的体验。在本指南中,我们将使用 Solana Wallet Adapter 在你的去中心化应用程序(Web3 SSO)上认证用户!

你将要完成的工作

在本指南中,你将创建一个使用 Next.js 和 NextAuth.js 的简单 React 应用程序,该程序允许你认证用户(感谢 Blocksmith Labs 提供这个很棒的 示例仓库)。具体来说,你将:

你的浏览器不支持视频标签。

你将需要的工具

要跟随本指南,你将需要以下工具:

  • Solana Wallet Adapter 的基本经验
  • 具备 JavaScript、TypeScript 和 React 编程语言的基本知识
  • 安装良好的 Nodejs(版本 16.15 或更高)
  • 安装 npm 或 yarn(我们将使用 yarn 来初始化我们的项目并安装必要的包。如果你更喜欢使用 npm,请随意使用)
  • 有 TypeScript 经验并安装 ts-node
  • 对身份验证或 NextAuth.js 的基本了解会有帮助,但不是必需的
  • 安装了 Solana 钱包扩展的现代浏览器(例如 Phantom

设置你的项目

在终端中创建一个新的项目目录:

mkdir solana-auth
cd solana-auth

克隆 NextAuthJS 示例仓库

NextAuth.js 是一个开源的认证和授权库,适用于 Next.js,这是一个流行的基于 React 的构建 web 应用程序的框架。它为开发者提供了一种简单安全的方式来处理 Next.js 应用程序中的用户认证和授权。NextAuth.js 通常被开发者用于轻松整合多个认证提供商,例如电子邮件/密码、Google 和 Facebook。

将 NextAuth.js 示例仓库克隆到你的项目文件夹中。在终端中输入:

git clone https://github.com/nextauthjs/next-auth-example.git .

输入以下命令来安装仓库中的依赖:

yarn
#或
npm install

安装 Solana 和钱包适配器依赖项

我们需要添加 Solana Web3 库以及一些其他关键依赖项。Solana Wallet Adapter 是一个库,用于将 Solana 钱包集成到 web 应用程序中。它提供了一个统一的 API,用于与不同的 Solana 钱包进行交互,使构建可与任何 Solana 钱包一起使用的去中心化应用更容易。

在终端中输入:

yarn add @solana/web3.js@1 @solana-mobile/wallet-adapter-mobile @solana/wallet-adapter-base @solana/wallet-adapter-react @solana/wallet-adapter-react-ui @solana/wallet-adapter-wallets bs58 tweetnacl
#或
npm install @solana/web3.js@1 @solana-mobile/wallet-adapter-mobile @solana/wallet-adapter-base @solana/wallet-adapter-react @solana/wallet-adapter-react-ui @solana/wallet-adapter-wallets bs58 tweetnacl

除了 Solana-Web3.js 库和 Solana Wallet Adapter 依赖项,我们还添加了:

  • bs58 是一种 Base58 编码方案。Base58 是一种以比更常见的 Base64 编码更短且更用户友好的格式编码数据的方法,例如比特币地址。它使用一组包含数字 0-9 并省略了容易混淆的字符(如字母 "O" 和数字 "0")的 58 个字符。因此,用于手动输入或由人类读取的数据编码,例如比特币地址时,它更有用。
  • tweetnacl 是一个用于特定加密操作的库,包括公钥加密和数字签名功能,以及对称密钥加密和认证。

创建身份验证的秘密

NextAuth 使用 .env 变量 NEXTAUTH_SECRET 来确保 NextAuth.js 库中客户端(浏览器)和服务器之间的通信安全。它是一个字符串,作为密钥用于加密和解密在客户端和服务器之间交换的 JSON Web Tokens (JWT)。JWT 包含用户的认证信息,例如用户 ID 和会话数据。如果没有有效的 NEXTAUTH_SECRET,客户端将无法解密 JWT 并且无法认证用户。在此过程中保持 NEXTAUTH_SECRET 的值私密并永远不与任何人分享是至关重要的。我们将通过 .env 实现这一点。

在你选择的 IDE 中打开你的项目目录,并查找一个名为 .env.local.example 的文件。将其重命名为 .env.local,并用以下内容替换其内容:

NEXTAUTH_URL=http://localhost:3000
NEXTAUTH_SECRET=ENTER_A_SECRET_KEY

你可以为密钥使用任何字符串——通过在终端中输入 openssl rand -hex 32 可以生成安全密钥(或者你也可以使用 solana-keygen grind --starts-with x:1 并使用返回的公钥值)。确保将 NEXTAUTH_SECRET 更新为该值。

生成一个密钥

创建登录消息类

在你的主项目目录中,创建一个 utils 文件夹:

mkdir utils

并在该目录中创建一个新文件 SigninMessage.ts

cd utils
echo > SigninMessage.ts

SigninMessage.ts 中粘贴以下代码:

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);
  }
}

这段代码定义了一个名为 "SigninMessage" 的类,用于验证消息的签名。

该类具有四个属性:

  • domain
  • publicKey
  • nonce
  • statement

注意: nonce(一次性数字的缩写)是一个唯一值,仅生成一次,以防止重放攻击。重放攻击是一种网络攻击,其中攻击者拦截并重新发送有效的数据传输,诱使系统接受相同的数据多次。

prepare() 函数连接 statement 和 nonce 属性,并返回结果。

validate() 函数接受一个签名作为输入,使用 prepare() 函数获得消息,使用 bs58 库解码签名和 publicKey,然后使用 nacl 库验证签名的有效性,采用 nacl.sign.detached.verify 方法。该函数返回一个布尔值,指示签名是否有效。

我们将在身份验证 API 中使用此类。

添加 Solana Wallet Adapter

要使用 Solana Wallet Adapter,我们需要向我们的应用程序添加三个包装器:

  • ConnectionProvider 用于跨应用共享我们的 Solana Connection
  • WalletProvider 用于跨应用共享我们的钱包上下文
  • WalletModalProvider 使我们能够在应用程序中使用钱包适配器的模态 UI

为此,打开 pages/_app.tsx 并用以下内容替换导入内容:

import { SessionProvider } from "next-auth/react";
import React, { useMemo } from "react";
import { ConnectionProvider, WalletProvider, } from "@solana/wallet-adapter-react";
import { WalletAdapterNetwork } from "@solana/wallet-adapter-base";
import { PhantomWalletAdapter } from "@solana/wallet-adapter-wallets";
import { WalletModalProvider } from "@solana/wallet-adapter-react-ui";
import { clusterApiUrl } from "@solana/web3.js";
import type { AppProps } from "next/app";

require("@solana/wallet-adapter-react-ui/styles.css");
import "./styles.css";

并将你的 App 函数更新为:

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>
  );
}

注意: 为了简化演示,我们仅使用了 Phantom。欢迎你添加所选择的其他适配器。

我们在这里所做的只是将我们的 SessionProvider 包装在钱包和连接包装器中。我们可以使用默认的 devnet 和公共 RPC,因为在此示例中,我们不会实际发出任何网络请求。

现在我们的钱包适配器已设置,让我们更新前端以连接我们的钱包并进行身份验证!

更新前端

为了这个示例,我们将保持模板主体不变,但我们需要更新我们的 Header 以添加与我们的钱包适配器的功能。打开 components/header.tsx

将整个文件的内容替换为以下内容:

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);
      }

      const csrf = await getCsrfToken();
      if (!wallet.publicKey || !csrf || !wallet.signMessage) return;

      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}>
                你尚未登录
              </span>
              <span className={styles.buttonPrimary} onClick={handleSignIn}>
                登录
              </span>
            </>
          )}
          {session?.user && (
            <>
              {session.user.image && (
                <span
                  style={{ backgroundImage: `url('${session.user.image}')` }}
                  className={styles.avatar}
                />
              )}
              <span className={styles.signedInText}>
                <small>已登录为</small>
                <br />
                <strong>{session.user.email ?? session.user.name}</strong>
              </span>
              <a
                href={`/api/auth/signout`}
                className={styles.button}
                onClick={(e) => {
                  e.preventDefault();
                  signOut();
                }}
              >
                登出
              </a>
            </>
          )}
        </p>
      </div>
      <nav>
        <ul className={styles.navItems}>
          <li className={styles.navItem}>
            <Link legacyBehavior href="/">
              <a>主页</a>
            </Link>
          </li>
          <li className={styles.navItem}>
            <Link legacyBehavior href="/api/examples/protected">
              <a>受保护的 API 路由</a>
            </Link>
          </li>
          <li className={styles.navItem}>
            <Link legacyBehavior href="/me">
              <a>我</a>
            </Link>
          </li>
        </ul>
      </nav>
    </header>
  );
}

让我们逐步解析这段代码。此代码导出了一个 React 组件 Header,它渲染一个包含导航栏的 header 元素。该组件使用来自 next-auth/react 库的 useSession 钩子获取当前会话数据和状态,同时使用来自 @solana/wallet-adapter-react@solana/wallet-adapter-react-ui 库的 useWalletuseWalletModal 钩子与 Solana 钱包进行交互。

该组件具有一个登录按钮,单击后将启动 handleSignIn 登录过程:

  • 首先,它检查钱包是否连接。如果未连接,则显示钱包模态,以便用户选择他们的钱包。
  • 接下来,它调用 getCsrfToken() 函数,该函数返回一个承诺,解决为跨站请求伪造Token(一个密码学安全的随机值)。该Token用作 nonce 以帮助防止跨站请求伪造攻击。
  • 之后,代码检查钱包是否有公钥,以及 csrf Token和钱包的签名消息功能是否定义。如果任何一个条件不满足,函数将退出。
  • 然后,它创建一个新的 SigninMessage 对象,正是我们在本指南中之前定义的对象。
  • 一旦创建了 message,它将被编码为字节并由用户签名。
  • 然后使用 bs58 编码 signature 并与登录消息和 csrf Token一起传递给 signIn 函数。
  • signIn 函数由 next-auth 包提供,处理服务器端的认证过程。它接收一个会话类型(在本例中为 "credentials")和一个选项对象,其中包含 messagesignature 和重定向选项(在本例中,重定向设置为 false,这意味着用户在登录后将停留在当前页面)。

太棒了,几乎完成了。让我们更新我们的 API!

实现后端 API

到目前为止,我们已经建立了一个登录函数并将我们的应用程序连接到 Solana 钱包适配器。现在我们需要实现后端验证。我们需要更新 API 目录中的两个文件:pages/api

  • pages/api/auth/[...nextauth].ts,允许我们定义自定义路由以进行认证和授权逻辑
  • pages/api/examples/protected.ts,我们将作为示例路由,以提醒用户他们是否已被认证

打开 pages/api/auth/[...nextauth].ts 并用以下内容替换文件内容:

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 || "{}")\
          );\
          const nextAuthUrl = new URL(process.env.NEXTAUTH_URL);\
          if (signinMessage.domain !== nextAuthUrl.host) {\
            return null;\
          }\
\
          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("无法验证签名消息");\
\
          return {\
            id: signinMessage.publicKey,\
          };\
        } catch (e) {\
          return null;\
        }\
      },\
    }),\
  ];

  const isDefaultSigninPage =
    req.method === "GET" && req.query.nextauth?.includes("signin");

  // 从默认登录页面隐藏 Solana 登录
  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;
      },
    },
  });
}

让我们逐步分析代码。

  • 首先,我们定义一个 auth 函数,接受 NextApiRequestNextApiResponse 作为参数。
  • 该函数创建一个身份验证提供程序数组,此例中仅有一个名为 "Solana" 的提供程序,该提供程序使用来自 next-authCredentialsProvider(你可以使用其他提供商,例如 Google、Facebook 等,只需对这段代码进行一些修改即可)。
  • CredentialsProvider 配置了用于身份验证凭证的 messagesignature 字段。
  • CredentialsProvider 的 authorize 函数通过:
    • 从 JSON 解析的 credentials.message 创建新的 SigninMessage 对象
    • 验证消息的 domain 是否与 NextAuth URL 的域匹配
    • 验证消息的 nonce 是否与由 getCsrfToken 函数生成的 nonce 匹配
    • 使用 SigninMessage 的 validate 方法验证 message 的签名
  • 如果验证成功,函数将返回一个包含用户公钥作为 ID 的对象。
  • 还检查请求是否为一个 GET 请求,以及 nextauth 参数中是否包含 "signin";如果是,则将 Solana 提供程序从提供程序数组中移除。这只是包含在示例中,因为我们没有为此示例构建任何登录重定向。你可以尝试去掉此代码,查看它如何影响你未登录时的 /me 页面。
  • 最后,NextAuth 使用提供程序数组、会话策略 "jwt" 和一个设置了 publicKey 和用户在会话中的图像 URL 的会话回调进行调用。

我们现在只需要创建一个仅授权用户才能查看的受保护页面。让我们修改默认的 pages/api/examples/protected.ts。打开文件并用以下内容替换其内容:

import type { NextApiRequest, NextApiResponse } from "next";
import { getToken } from "next-auth/jwt";

const secret = process.env.NEXTAUTH_SECRET;

export default async function handler(
  req: NextApiRequest,
  res: NextApiResponse
) {
  const token = await getToken({ req, secret });

  if (!token || !token.sub)
    return res.send({
      error: "用户钱包未认证",
    });

  if (token) {
    return res.send({
      content:
        "这是受保护的内容。你可以访问该内容,因为你已使用 Solana 钱包登录。",
    });
  }

  res.send({
    error: "你必须使用 Solana 钱包登录才能查看此页面上的受保护内容。",
  });
}

这是一个 Next.js API 端点,用于保护特定页面或资源,只有经过身份验证的用户才能访问。

  • next-auth/jwt 包中导入 getToken 函数并用于从请求中提取 JWT。
  • 然后使用密钥验证该 JWT,该密钥由 process.env.NEXTAUTH_SECRET 环境变量读取。NEXTAUTH_SECRET 用于对在客户端和服务器之间发送的 JWT 进行签名和验证。客户端使用此密钥签署Token,服务器使用相同的密钥进行验证。保持密钥私密至关重要,因为如果攻击者获取到它,他们可以用它伪造 JWT Token并冒充任何用户。
  • 如果Token不存在或不包含 sub 字段,则表示用户未经过身份验证,API 端点将返回错误。
  • 如果Token存在并且包含 sub 字段,则表示用户已认证,API 端点将返回一条消息,表示用户可以访问受保护的内容。
  • 如果以上两种条件均不满足,则 API 端点将返回另一个错误消息。

哇!这真是太多内容了。我们应该准备开始测试了。

运行你的去中心化应用程序

好了,让我们开始吧!打开你的终端,输入:

yarn dev

你应该会看到如下页面:

演示网站

点击 受保护的 API 路由。你应该会看到一个错误:

{"error":"用户钱包未认证"}

返回主页,然后点击 登录。你将被提示连接钱包并签名消息。

现在再试一次——点击 受保护的 API 路由。你应该会看到一条受保护的消息:

{"content":"这是受保护的内容。你可以访问该内容,因为你已使用 Solana 钱包登录。"}

太棒了!恭喜你,做得好!

总结

NextAuth 是一个强大的库,可以轻松为你的 Next.js 应用程序添加身份验证和授权功能。通过实现 Solana Wallet Adapter,你可以轻松认证和授权用户,保护页面和路由,甚至定制用户体验……所有这些都是使用他们的 Solana 钱包实现的!

想到你可能如何利用这项技术真是令人兴奋。我们非常想听听你的想法。在 Discord 上和我们分享你的项目,或者在 Twitter 上关注我们,及时了解最新信息。

我们 ❤️ 反馈!

如果你对本指南有任何反馈,请告诉我们。我们很想听到你的声音。

点赞 0
收藏 0
分享
本文参与登链社区写作激励计划 ,好文好收益,欢迎正在阅读的你也加入。

0 条评论

请先 登录 后评论
QuickNode
QuickNode
江湖只有他的大名,没有他的介绍。