本文的介紹遵循EIP-4361 Sign-In with Ethereum 規則
SIWE(Sign-In with Ethereum),是一種在Ethereum 上對用戶身份的一種驗證方式,和錢包發起一筆交易類似,表明用戶對該錢包有控制權。
目前的身份驗證方式已經非常簡單,只需要在錢包插件中對資訊進行簽名即可,常見的錢包插件都已經支援。
本文考慮的簽名場景是在Ethereum 上,其他的像Solana、SUI 等不在本文的討論範圍內。
你的專案需要SIWE 嗎
SIWE 是為了解決錢包位址的身份驗證問題,所以如果你有需求,可以考慮使用SWIE:
- 你的Dapp 有自己的使用者係統;
- 需要查詢的資訊和用戶隱私相關;
但如果你的Dapp 是查詢為主的功能,例如像etherscan 這類應用,沒有SIWE 也是可以的。
可能你會有一個疑問,在Dapp 上我透過錢包進行連線之後,不就代表了我有錢包的所有權了嗎。
對,又不完全對。對於前端來說,確實你通過錢包連接的操作之後,你表明了你的身份,但是對於一些需要後端支持的接口調用,你是沒有辦法表明自己的身份的,如果只是在接口中傳你的地址的話,那麼誰都可以「借用」你的身分了,畢竟地址是公開的資訊。
SIWE 的原理和流程
SIWE 的流程總結起來就是三個步驟:連結錢包-- 簽名-- 取得身分識別識別。我們對這三個步驟展開詳細介紹。
連接錢包
連接錢包是一個常見的WEB3 操作,透過錢包插件的方式可以在Dapp 中連接你的錢包。
簽名
在SIWE 中,簽署的步驟包括了取得Nonce 值,錢包簽章以及後端簽章校驗。
取得Nonce 值應該是參考了ETH 交易中的Nonce 值的設計,也是需要呼叫後端的介面來取得。後端在接受到請求之後,回生成隨機的Nonce 值,並和當前的地址進行關聯,為後面的簽名做準備。
前端取得到Nonce 值之後,就需要建構簽章內容,SIWE 可以設計的簽章內容包含取得到的Nonce 值、網域名稱、鏈ID、簽章的內容等,我們一般會使用錢包提供的簽章方法來對內容進行簽名。
在建置完簽名之後,最後將簽名傳送給後端。
取得取得身分標識
後端在校驗完簽名並且通過之後,會返回對應的用戶身份標識,可以是JWT,前端後續在發送後端請求時帶上對應的地址和身份標識,就可以表明自己對錢包的所有權了。
實踐一下
目前已經有很多的元件、庫支援開發者快速的存取錢包連接和SIWE 了,我們可以實際操作一下,實踐的目標,是能夠讓你的Dapp 能夠返回JWT 用於用戶身份校驗。
注意,這個DEMO 只是用來介紹SIWE 的基本流程,使用在生產環境可能會有安全問題。
事先準備
本文採用nextjs 的方式開發應用,因此需要開發者準備好nodejs 的環境。採用nextjs 的一個好處在於,我們可以直接開發全端的項目,不需要分割成前後端兩個項目。
安裝依賴
首先我們安裝nextjs,在你的專案目錄裡,用命令列輸入:
npx create-next-app@14
依照指示安裝好nextjs,可以看到下面的內容:
進入到專案目錄之後,可以看到nextjs 鷹架已經幫我們做了很多的工作了。我們可以在專案目錄裡面將項目跑起來:
npm run dev
之後根據終端機的提示,進入到localhost: 3000
就可以看到一個基本的nextjs 專案已經跑起來了。
安裝SIWE 相關依賴
根據先前的介紹,SIWE 需要依賴登入體系,因此需要將我們的專案連接上錢包,這裡我們使用Ant Design Web3( https://web3.ant.design/ ),因為:
- 它完全免費,目前仍在積極維護中
- 作為WEB3 元件庫,它的使用體驗和普通元件庫類似,沒有額外的心智負擔
- 並且支援SIWE。
我們需要在終端輸入:
npm install antd @ant-design/web3 @ant-design/web3-wagmi wagmi viem @tanstack/react-query --save
引入Wagmi
Ant Design Web3 的SIWE 是依賴Wagmi 函式庫來實現的,所以在專案中需要引入相關的元件。我們在layout.tsx
中引入對應的Provider,這樣整個專案都可以使用Wagmi 提供的Hooks。
我們先定義WagmiProvider 的配置,程式碼如下:
"use client"; import { getNonce, verifyMessage } from "@/app/api"; import { Mainnet, MetaMask, OkxWallet, TokenPocket, WagmiWeb3ConfigProvider, WalletConnect, } from "@ant-design/web3-wagmi"; import { QueryClient } from "@tanstack/react-query"; import React from "react"; import { createSiweMessage } from "viem/siwe"; import { http } from "wagmi"; import { JwtProvider } from "./JwtProvider"; const YOUR_WALLET_CONNECT_PROJECT_ID = "c07c0051c2055890eade3556618e38a6"; const queryClient = new QueryClient(); const WagmiProvider: React.FC = ({ children }) => { const [jwt, setJwt] = React.useState(null); return ( (await getNonce(address)).data, createMessage: (props) => { return createSiweMessage({ ...props, statement: "Ant Design Web3" }); }, verifyMessage: async (message, signature) => { const jwt = (await verifyMessage(message, signature)).data; setJwt(jwt); return !!jwt; }, }} chains={[Mainnet]} transports={{ [Mainnet.id]: http(), }} walletConnect={{ projectId: YOUR_WALLET_CONNECT_PROJECT_ID, }} wallets={[ MetaMask(), WalletConnect(), TokenPocket({ group: "Popular", }), OkxWallet(), ]} queryClient={queryClient} > {children} ); }; export default WagmiProvider;
我們使用了Ant Design Web3 提供的Provider,並對SIWE 的一些介面做了定義,具體介面的實作我們在後續會介紹。
之後我們再引入連接錢包的按鈕,這樣就可以在前端中加入了一個連接的入口。
至此位置就算已經接上了SIWE,步驟非常簡單。
之後我們需要定義一個連接的按鈕,來實現連接錢包和簽名,程式碼如下:
"use client"; import type { Account } from "@ant-design/web3"; import { ConnectButton, Connector } from "@ant-design/web3"; import { Flex, Space } from "antd"; import React from "react"; import { JwtProvider } from "./JwtProvider"; export default function App() { const jwt = React.useContext(JwtProvider); const renderSignBtnText = ( defaultDom: React.ReactNode, account?: Account ) => { const { address } = account ?? {}; const ellipsisAddress = address ? `${address.slice(0, 6)}...${address.slice(-6)}` : ""; return `Sign in as ${ellipsisAddress}`; }; return ( <>
{jwt}
); }
這樣子我們就實作了一個最簡單的SIWE 登入框架。
介面實現
根據上文的介紹,SIWE 需要一些的介面來幫助後端校驗使用者的身分。現在我們來簡單實作一下。
Nonce
Nonce 的是為了讓錢包在簽名時每次產生的簽名內容變化,提高簽名的可靠性。這個Nonce 的產生需要和用戶傳入的address 產生關聯,提高驗證的準確性。
Nonce 的實作非常直接,首先我們產生一個隨機的字串(由字母和數字產生),之後再將這個nonce 和address 建立聯繫即可,程式碼如下:
import { randomBytes } from "crypto"; import { addressMap } from "../cache"; export async function GET(request: Request) { const { searchParams } = new URL(request.url); const address = searchParams.get("address"); if (!address) { throw new Error("Invalid address"); } const nonce = randomBytes(16).toString("hex"); addressMap.set(address, nonce); return Response.json({ data: nonce, }); }
signMessage
signMessage 的功能是簽名內容,這部分功能一般是透過錢包插件完成,我們一般不需要做配置,只需要指定方法即可,在本Demo 中使用的是Wagmi 的簽名方法。
verifyMessage
在使用者對內容進行簽署之後,需要將簽名前的內容和簽名一同發給後端進行校驗,後端從簽名中解析出對應的內容進行比較,一致則表示驗證通過。
此外,對於簽署的內容還需要再做一些安全性的校驗,例如簽名內容中的Nonce 值是否和我們派發給使用者的一致等。在驗證通過之後,需要傳回對應的使用者JWT 用於後續的權限校驗,範例程式碼如下:
import { createPublicClient, http } from "viem"; import { mainnet } from "viem/chains"; import jwt from "jsonwebtoken"; import { parseSiweMessage } from "viem/siwe"; import { addressMap } from "../cache"; const JWT_SECRET = "your-secret-key"; // 请使用更安全的密钥,并添加对应的过期校验等const publicClient = createPublicClient({ chain: mainnet, transport: http(), }); export async function POST(request: Request) { const { signature, message } = await request.json(); const { nonce, address = "0x" } = parseSiweMessage(message); console.log("nonce", nonce, address, addressMap); // 校验nonce 值是否一致if (!nonce || nonce !== addressMap.get(address)) { throw new Error("Invalid nonce"); } // 校验签名内容const valid = await publicClient.verifySiweMessage({ message, address, signature, }); if (!valid) { throw new Error("Invalid signature"); } // 生成jwt 并返回const token = jwt.sign({ address }, JWT_SECRET, { expiresIn: "1h" }); return Response.json({ data: token, }); }
至此,一個基本實作SIWE 登入的Dapp 就開發完成了。
一些優化項
現在我們在進行SIWE 登入時,如果我們使用預設的RPC 節點的話,驗證的過程將會花費近30s 的時間,所以這裡強烈建議使用專門的節點服務來提升介面的回應時間。本文所使用的是ZAN 的節點服務( https://zan.top/home/node-service?chInfo=ch_WZ ),可以前往ZAN 節點服務控制台取得對應的RPC 連線。
我們在取得到到以太坊主網的HTTPS RPC 連線之後,在程式碼中替換掉publicClient
的預設RPC:
const publicClient = createPublicClient({ chain: mainnet, transport: http('https://api.zan.top/node/v1/eth/mainnet/xxxx'), //获取到的ZAN 节点服务RPC });
替換之後,驗證的時間可以顯著減少,介面的速度顯著加快。