ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

使用 Refine + Supabase Auth 与 Twilio 构建 React OTP 短信登录

2026/9/10 6:14:24 拓冰建站 浏览量
使用 Refine + Supabase Auth 与 Twilio 构建 React OTP 短信登录 使用 Refine Supabase Auth 与 Twilio 构建 React OTP 短信登录【免费下载链接】refineA React Framework for building internal tools, admin panels, dashboards B2B apps with unmatched flexibility.项目地址: https://gitcode.com/GitHub_Trending/re/refine导读本指南基于 Refine 仓库中的完整示例项目 examples/blog-refine-supabase-auth手把手实现一套基于 OTP短信一次性密码的免密码登录流程前端使用 Refine 框架与 shadcn/ui 组件库后端认证与数据层全部交由 Supabase Auth 承担短信下发通过 Twilio Verify 完成。读完本文你将掌握supabase.auth.signInWithOtp()与supabase.auth.verifyOtp()的完整调用链、如何编写面向 OTP 场景的 RefineauthProvider、如何使用 Refine 内置的 Supabase data provider 与 live provider以及如何用AuthenticatedNavigateToResource搭建受保护的路由树。为什么选择 OTP 认证密码是目前最主流的用户认证方式但它有两个显著短板一是容易遭受暴力破解与数据泄露攻击二是用户需要在不同站点之间维护大量密码记忆负担很重。OTPOne Time Password一次性密码正是针对这些问题的一种补充方案——当用户访问应用时系统通过自动语音或短信下发一个六位数字验证码用户输入后即可完成登录全程不涉及密码的创建与保管。在 Refine 中接入 OTP 认证尤其自然Refine 内置了对 Supabase 的开箱即用支持既包括数据层data provider也包括实时层live provider再加上 Refine 的authProvider抽象认证逻辑可以被收敛到少数几个方法中业务页面则通过useLogin、useLogout等 hook 无感知地消费。前置条件开始之前请确认以下条件系统已安装最新版本的 Node.js熟悉 React 与 TypeScript 基础概念拥有一个 Twilio 账户用于实际发送 OTP 短信拥有一个 GitHub 账户用于注册 SupabaseSupabase 支持 GitHub 登录注册。创建 Refine 应用使用官方脚手架工具create refine-app快速生成项目骨架npm create refine-applatest refine-supabase-auth在交互式提示中按如下方式选择✔ Downloaded remote source successfully. ✔ Choose a project template · Refine(Vite) ✔ What would you like to name your project?: · refine-supabase-auth ✔ Choose your backend service to connect: · Supabase ✔ Do you want to use a UI Framework?: · shadcn/ui ✔ Do you want to add example pages?: · no ✔ Choose a package manager: · npm关键选择说明Backend service选Supabase脚手架会自动安装refinedev/supabase包并生成dataProvider、liveProvider与supabaseClient的接线代码UI Framework选shadcn/ui提供本项目需要的Card、Input、Button、Table、Skeleton等基础组件Example pages选no示例页面会干扰我们即将手动编写的登录页与资源页。项目就绪后进入目录并启动开发服务器cd refine-supabase-auth npm run dev脚手架已经替你接好了路由、主题、通知notification provider、Devtools 与实时liveprovider 等基础设施你只需要在既定结构上补充业务代码。配置 Supabase 项目与 Twilio Verify获取 Supabase 凭据登录 Supabase// Supabase Dashboard - Integrations - Data API export const SUPABASE_URL YOUR SUPABASE URL; // Supabase Dashboard - Project Settings - API Keys - Publishable key export const SUPABASE_KEY YOUR SUPABASE KEY;提示仓库中的示例文件保留了一组演示凭据constants.ts实际开发时请务必替换为你自己项目的 URL 与 Key。启用 Phone Auth 并接入 Twilio进入Authentication → Sign In / Auth providers找到Phone Auth选项并启用在 SMS provider 中选择 Twilio。务必使用 Twilio Verify 配置而不是通用的 Twilio Messaging 配置——Supabase 的 Phone Auth 依赖 Verify API 来完成验证码的创建、发送与校验闭环。Supabase 需要你提供以下三个来自 Twilio Verify 的值配置项获取方式格式特征Twilio Account SIDTwilio Console 项目凭据区域以AC开头Twilio Auth Token同一凭据区域与 Account SID 配套Twilio Verify Service SIDTwilio Console → Verify → Services创建 Verify 服务后复制以VA开头将三个值填入 Supabase 的 Twilio 字段后其余 OTP 设置如验证码有效期、码长、短信模板可按需调整本指南保持默认值即可。客户端初始化脚手架生成的 Supabase 客户端位于 src/providers/supabase-client.tsimport { createClient } from refinedev/supabase; import { SUPABASE_KEY, SUPABASE_URL } from /providers/constants; export const supabaseClient createClient(SUPABASE_URL, SUPABASE_KEY, { db: { schema: public, }, auth: { persistSession: true, }, });注意这里导入的createClient来自refinedev/supabase包而非supabase/supabase-js。从 packages/supabase/src/index.ts 的源码可以看到该包重新导出了supabase/supabase-js的createClient因此你既可以使用标准 supabase-js 的全部能力也能在同一个客户端实例上无缝衔接 Refine 的 data provider 与 live provider// packages/supabase/src/index.ts import { createClient } from supabase/supabase-js; import {} from supabase/supabase-js/dist/main/lib/types; export * from ./types/index.js; export * from ./utils/index.js; export * from ./dataProvider/index.js; export * from ./liveProvider/index.js; export { createClient };创建登录页两步 OTP 表单登录页由两个步骤组成发送验证码用户输入带国家区号的手机号点击Send OTP触发短信下发输入验证码用户输入收到的六位 OTP点击Login完成登录若验证码无效或已过期页面展示错误信息。完整实现见 src/pages/login.tsximport { useLogin } from refinedev/core; import { type FormEvent, useRef, useState } from react; import { Button } from /components/ui/button; import { Card, CardContent, CardDescription, CardHeader, CardTitle, } from /components/ui/card; import { Input } from /components/ui/input; import { Label } from /components/ui/label; import { supabaseClient } from /providers/supabase-client; let persistedLoginError: string | null null; let persistedMobileNo ; let persistedFormState: SEND_OTP | LOGIN SEND_OTP; export const LoginPage () { const mobileNoRef useRef(persistedMobileNo); const otpRef useRef(); const [error, setError] useStatestring | null(() persistedLoginError); const [formState, setFormState] useStateSEND_OTP | LOGIN( () persistedFormState, ); const { mutate: login, isPending } useLogin(); const setFormError (message: string | null) { persistedLoginError message; setError(message); }; const setPersistedFormState (nextState: SEND_OTP | LOGIN) { persistedFormState nextState; setFormState(nextState); }; const backToSendOtp () { otpRef.current ; setFormError(null); setPersistedFormState(SEND_OTP); }; const onLogin () { login( { mobileNo: mobileNoRef.current, otp: otpRef.current }, { onSuccess: ({ success, error }) { if (!success) { setFormError(error?.message ?? error?.name ?? Login failed); return; } setFormError(null); persistedMobileNo ; setPersistedFormState(SEND_OTP); }, onError: (error) setFormError(error.message), }, ); }; const onSubmit (event: FormEventHTMLFormElement) { event.preventDefault(); if (formState SEND_OTP) { void onSendOtp(); return; } onLogin(); }; const mobileFormRender () ( div classNamespace-y-4 div classNamespace-y-2 Label htmlFormobileEnter your mobile number/Label Input idmobile onChange{(e) { mobileNoRef.current e.target.value; persistedMobileNo e.target.value; }} namemobile typetel defaultValue{mobileNoRef.current} placeholder14155552671 / /div Button classNamew-full typesubmit Send OTP /Button /div ); const otpFormRender () ( div classNamespace-y-4 div classNamerounded-md border border-border bg-muted/40 px-3 py-2 text-sm text-muted-foreground Code sent to{ } span classNamefont-medium text-foreground {mobileNoRef.current} /span /div div classNamespace-y-2 Label htmlForotpEnter OTP/Label Input idotp onChange{(e) { otpRef.current e.target.value; }} nameotp defaultValue{otpRef.current} placeholder123456 / /div div classNameflex gap-2 Button classNameflex-1 typesubmit disabled{isPending} Login /Button Button classNameflex-1 typebutton variantoutline disabled{isPending} onClick{backToSendOtp} Change Number /Button /div /div ); const onSendOtp async () { const mobileNo mobileNoRef.current || ; if (!/^\[1-9]{1}[0-9]{3,14}$/.test(mobileNo)) { setFormError(Please enter a valid mobile number); return; } const { error } await supabaseClient.auth.signInWithOtp({ phone: mobileNo, }); if (error) { setFormError(error.message); return; } setFormError(null); setPersistedFormState(LOGIN); }; return ( div classNamerelative flex min-h-screen items-center justify-center overflow-hidden bg-background px-4 py-10 div classNamepointer-events-none absolute inset-0 bg-gradient-to-b from-zinc-100 via-background to-background dark:from-zinc-900/60 dark:via-background dark:to-background / Card classNamerelative z-10 w-full max-w-md border-border/70 bg-card/95 shadow-xl shadow-zinc-950/5 backdrop-blur dark:shadow-black/25 CardHeader classNamespace-y-1 CardTitleSign In/CardTitle CardDescription {formState SEND_OTP ? Enter your phone number to receive a one-time password. : Enter the one-time password sent to your phone.} /CardDescription /CardHeader CardContent classNamespace-y-4 {error ? ( div classNamerounded-md border border-destructive/20 bg-destructive/10 px-3 py-2 text-sm text-destructive {error} /div ) : null} form classNamespace-y-4 onSubmit{onSubmit} {formState SEND_OTP mobileFormRender()} {formState LOGIN otpFormRender()} /form /CardContent /Card /div ); };实现要点formState驱动双阶段渲染SEND_OTP阶段渲染手机号输入框LOGIN阶段渲染 OTP 输入框两个阶段的输入值分别保存在mobileNoRef与otpRef两个 ref 中避免因受控输入引发不必要的重渲染模块级持久化变量persistedLoginError、persistedMobileNo、persistedFormState三个顶层变量用于在路由切换例如输入错误后跳转再返回时保留表单状态减少用户重复输入手机号校验正则^\[1-9]{1}[0-9]{3,14}$要求号码以开头并包含国家区号总长度为 415 位数字。若需更友好的体验可以替换为带国家区号下拉框和内置校验的第三方手机号输入组件发送验证码直接调用supabaseClient.auth.signInWithOtp({ phone })由 Supabase 后台对接 Twilio Verify 完成短信下发请求失败时把error.message渲染在卡片内useLogin的isPending登录请求进行中时禁用登录与换号按钮避免重复提交。数据提供层Refine 内置的 Supabase data providerdataProvider是应用的数据层负责发起 HTTP 请求并封装数据的获取方式通过create()、getList()、getMany()等预定义方法对外提供服务。Refine 的数据 hooks如useList内部会调用 data provider 的对应方法——例如使用useList时Refine 会调用getList()。本项目的数据提供层由 src/providers/data.ts 创建import { dataProvider as supabaseDataProvider } from refinedev/supabase; import { supabaseClient } from /providers/supabase-client; export const dataProvider supabaseDataProvider(supabaseClient);sudabaseDataProvider是 Refine 对 Supabase 的开箱即用实现位于 packages/supabase/src/dataProvider/index.ts。从源码结构看getList的实现逻辑如下支持meta.schema指定数据库 schema未指定时默认使用客户端配置的 schema服务端分页模式pagination.mode server下通过query.range()映射 Refine 的currentPage/pageSize到 Supabase 的行区间排序通过query.order()完成还支持foreignTable.field形式的关联表字段排序过滤条件由 utils/generateFilter.ts 转换为 Supabase 的eq、in、gte、contains等查询操作符错误统一经handleError处理后再抛给 Refine 数据层。换言之data provider 内部实际调用的是 supabase-js 的数据库方法如select()、insert()、update()、delete()Refine 的useList、useCreate等 hooks 让你无需关心这些底层调用。更完整的 data provider 概念说明可参见仓库中的 data 文档目录。认证提供层面向 OTP 流程的 authProviderauthProvider是 Refine 用于认证与授权的核心对象。本应用最关键的方法是login、logout、check、getIdentity与onError它们返回 Promise并被useLogin()、useLogout()等 hooks 内部调用。选择 Supabase 模板时脚手架已经生成了 src/providers/auth.ts我们只需要针对 OTP 流程定制其中的login方法import type { AuthProvider } from refinedev/core; import { supabaseClient } from /providers/supabase-client; const authProvider: AuthProvider { login: async ({ mobileNo, otp }) { const { data, error } await supabaseClient.auth.verifyOtp({ phone: mobileNo, token: otp, type: sms, }); if (error) { return { success: false, error: error || { message: Login failed, name: Invalid OTP, }, }; } if (data.session) { return { success: true, redirectTo: /, }; } return { success: false, error: { message: Login failed, name: Invalid OTP, }, }; }, logout: async () { const { error } await supabaseClient.auth.signOut(); if (error) { return { success: false, error: error || { message: Logout failed, name: Unexpected error, }, }; } return { success: true, redirectTo: /login, }; }, onError: async (error) { if (error?.code PGRST301 || error?.code 401) { return { logout: true, }; } return { error }; }, check: async () { const { data, error } await supabaseClient.auth.getSession(); const session data.session; if (!session) { return { authenticated: false, error: error || { message: Check failed, name: Session not found, }, redirectTo: /login, }; } return { authenticated: true, }; }, getPermissions: async () { const { data } await supabaseClient.auth.getUser(); return data.user?.role ?? null; }, getIdentity: async () { const { data } await supabaseClient.auth.getUser(); const user data.user; if (!user) { return null; } return { ...user, name: user.phone ?? user.email, }; }, }; export default authProvider;各方法职责梳理login接收登录页传入的{ mobileNo, otp }调用supabaseClient.auth.verifyOtp({ phone, token, type: sms })校验验证码。type: sms告诉 Supabase 校验的是短信通道下发的验证码。校验成功后拿到data.sessionSupabase 会自动写入并持久化会话返回{ success: true, redirectTo: / }logout调用supabase.auth.signOut()清除本地会话并重定向回/logincheck通过getSession()判断当前是否已登录这是Authenticated组件与路由守卫的核心依据onError当遇到 Supabase 的PGRST301JWT 过期或 HTTP 401 时返回{ logout: true }触发 Refine 自动登出流程getIdentity把 Supabase 用户对象映射为 Refine 身份信息并优先以手机号作为显示名称。回到登录页的onLogin()它把手机号与 OTP 一并交给useLogin返回的login函数Refine 内部会调用上述authProvider.login并将结果透传给onSuccess/onError回调。验证码无效或过期时authProvider返回success: false与错误对象登录页据此渲染错误提示。关于 authProvider 的完整方法约定可进一步阅读仓库中的 authentication 文档目录。配置路由用 Authenticated 保护业务页面在 src/App.tsx 中引入LoginPage /并为其创建/login路由。同时借助Authenticated /、Outlet /、CatchAllNavigate /与NavigateToResource /实现未登录跳转登录页、已登录自动跳转资源页的路由守卫import { Authenticated, ErrorComponent, GitHubBanner, Refine, } from refinedev/core; import { DevtoolsPanel, DevtoolsProvider } from refinedev/devtools; import { RefineKbar, RefineKbarProvider } from refinedev/kbar; import routerProvider, { CatchAllNavigate, DocumentTitleHandler, NavigateToResource, UnsavedChangesNotifier, } from refinedev/react-router; import { liveProvider } from refinedev/supabase; import { BrowserRouter, Routes, Route, Outlet } from react-router; import { Toaster } from /components/refine-ui/notification/toaster; import { useNotificationProvider } from /components/refine-ui/notification/use-notification-provider; import { ThemeProvider } from /components/refine-ui/theme/theme-provider; import { Colors } from /pages/colors; import { Layout } from /pages/layout; import { LoginPage } from /pages/login; import authProvider from /providers/auth; import { dataProvider } from /providers/data; import { supabaseClient } from /providers/supabase-client; import ./App.css; function App() { return ( BrowserRouter {/* GitHubBanner / */} RefineKbarProvider ThemeProvider DevtoolsProvider Refine routerProvider{routerProvider} dataProvider{dataProvider} liveProvider{liveProvider(supabaseClient)} authProvider{authProvider} notificationProvider{useNotificationProvider()} resources{[ { name: colors, list: /colors, }, ]} options{{ syncWithLocation: true, warnWhenUnsavedChanges: true, }} Routes Route element{ Authenticated keyauthenticated-routes fallback{CatchAllNavigate to/login /} Layout Outlet / /Layout /Authenticated } Route index element{NavigateToResource resourcecolors /} / Route path/colors element{Colors /} / /Route Route element{ Authenticated keyauth-pages fallback{Outlet /} NavigateToResource resourcecolors / /Authenticated } Route path/login element{LoginPage /} / /Route Route element{ Authenticated keycatch-all fallback{CatchAllNavigate to/login /} Layout Outlet / /Layout /Authenticated } Route path* element{ErrorComponent /} / /Route /Routes Toaster / RefineKbar / UnsavedChangesNotifier / DocumentTitleHandler / /Refine DevtoolsPanel / /DevtoolsProvider /ThemeProvider /RefineKbarProvider /BrowserRouter ); } export default App;路由树的三层结构解读受保护路由组/重定向到colors资源与/colors被Authenticated包裹未登录时经CatchAllNavigate跳转/login登录路由组/login的守卫逻辑相反——已登录用户访问登录页会被NavigateToResource直接送回colors兜底路由组未匹配路径统一渲染ErrorComponent同样要求登录。注意liveProvider{liveProvider(supabaseClient)}Refine 的 Supabase live provider 会让依赖数据的组件订阅 Supabase 的实时变更这是脚手架免费附带的能力之一。创建 colors 资源页与布局认证流程完成后剩下的工作是把colors资源页做出来。先在 Supabase 的SQL Editor中打开 Quick start 里的Colors示例脚本并运行它会创建一张colors表作为应用读取的数据源。数据展示页在 src/pages/colors.tsx 中用useListhook 从 Supabase 拉取颜色数据并用 shadcn 的Table组件渲染import { useList } from refinedev/core; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow, } from /components/ui/table; import { Skeleton } from /components/ui/skeleton; type Color { id: number; name: string | null; hex: string; red: number | null; green: number | null; blue: number | null; hue: number | null; sat_hsl: number | null; light_hsl: number | null; sat_hsv: number | null; val_hsv: number | null; source: string | null; }; const formatValue (value: number | null, suffix ) { if (value null) { return -; } return ${value}${suffix}; }; const loadingRows Array.from({ length: 5 }, (_, index) index); export const Colors () { const { result, query } useListColor({ resource: colors, pagination: { mode: off }, sorters: [{ field: id, order: asc }], }); const colors result?.data ?? []; const isLoading query.isLoading; return ( div classNameoverflow-hidden rounded-xl border border-border/70 bg-card shadow-sm shadow-zinc-950/5 dark:shadow-black/20 Table TableHeader TableRow TableHeadPreview/TableHead TableHeadName/TableHead TableHeadRGB/TableHead TableHeadHSL/TableHead TableHeadHSV/TableHead TableHeadSource/TableHead /TableRow /TableHeader TableBody {isLoading ? loadingRows.map((row) ( TableRow key{loading-${row}} TableCell div classNameflex items-center gap-3 Skeleton classNamesize-4 rounded-full / Skeleton classNameh-4 w-20 / /div /TableCell TableCell Skeleton classNameh-4 w-24 / /TableCell TableCell Skeleton classNameh-4 w-24 / /TableCell TableCell Skeleton classNameh-4 w-24 / /TableCell TableCell Skeleton classNameh-4 w-24 / /TableCell TableCell Skeleton classNameh-4 w-16 / /TableCell /TableRow )) : colors.map((color) ( TableRow key{color.id} TableCell div classNameflex items-center gap-3 span aria-hiddentrue classNamesize-4 rounded-full border border-border shadow-sm style{{ backgroundColor: color.hex }} / span classNamefont-mono text-xs uppercase text-muted-foreground {color.hex} /span /div /TableCell TableCell{color.name ?? Untitled}/TableCell TableCell {[ formatValue(color.red), formatValue(color.green), formatValue(color.blue), ].join( / )} /TableCell TableCell {[ formatValue(color.hue), formatValue(color.sat_hsl, %), formatValue(color.light_hsl, %), ].join( / )} /TableCell TableCell {[ formatValue(color.hue), formatValue(color.sat_hsv, %), formatValue(color.val_hsv, %), ].join( / )} /TableCell TableCell{color.source ?? -}/TableCell /TableRow ))} /TableBody /Table /div ); };这段代码演示了 Refine v5 风格的数据消费方式useList返回{ result, query }其中result.data是经过规范化后的数据数组query暴露 TanStack Query 层面的isLoading等状态pagination: { mode: off }表示关闭服务端分页一次取全量数据sorters按id升序排列。上述选项会原样传递到 data provider 的getList实现最终映射为对 Supabase 的select()、order()查询。布局与登出按钮接着创建 src/pages/layout.tsx提供一个带登出按钮的顶部应用栏import { type LayoutProps, useLogout } from refinedev/core; import { Button } from /components/ui/button; export const Layout: React.FCLayoutProps ({ children }) { const { mutate: logout, isPending } useLogout(); return ( div classNamemin-h-screen bg-background text-foreground header classNamesticky top-0 z-10 border-b border-border/60 bg-background/90 backdrop-blur div classNamemx-auto flex max-w-5xl items-center justify-between px-4 py-3 div h1 classNametext-sm font-semibold tracking-tightColors/h1 p classNametext-xs text-muted-foreground Browse the color records fetched from Supabase. /p /div Button variantoutline sizesm disabled{isPending} onClick{() logout()} Logout /Button /div /header main classNamemx-auto w-full max-w-5xl px-4 py-6{children}/main /div ); };useLogout内部调用authProvider.logout即supabase.auth.signOut()登出成功后 Refine 会把用户重定向回/login。最后把Colors /与Layout /引入 src/App.tsx并在Refine /中通过resources声明colors资源。完成后即可启动应用完整跑通输入手机号 → 接收短信验证码 → 输入 OTP → 登录并浏览 colors 表格的闭环。小结OTP 认证为应用增加了一层不依赖密码的验证机制能有效降低密码泄露与撞库风险。本文完整走通了以下链路使用create refine-app创建以 Supabase 为后端、shadcn/ui 为 UI 的 Refine 应用在 Supabase 控制台启用 Phone Auth 并接入 Twilio VerifyAccount SID、Auth Token、Verify Service SID通过supabaseClient.auth.signInWithOtp({ phone })发送验证码通过verifyOtp({ phone, token, type: sms })校验验证码编写定制化的authProviderlogin、logout、check、getIdentity、onError配合useLogin/useLogouthooks 组织登录登出用Authenticated、CatchAllNavigate、NavigateToResource搭建受保护的路由树借助 Refine 内置的 Supabase data provider 与useList渲染colors资源。仓库中完整可运行的示例位于 examples/blog-refine-supabase-auth相关的 data provider / live provider 实现位于 packages/supabase。如果你还想探索 Refine 与 Supabase 的更多组合方式可以参考仓库中另一篇 基于 Refine 与 Supabase 构建可定制问题追踪器 的实战指南。【免费下载链接】refineA React Framework for building internal tools, admin panels, dashboards B2B apps with unmatched flexibility.项目地址: https://gitcode.com/GitHub_Trending/re/refine创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考