认证与数据
认证模式
本指南涵盖认证模式,并展示如何用 TanStack Start 实现你自己的认证系统。
开始之前
先看看我们的认证总览,了解所有可选方案,包括合作伙伴方案和托管服务。
认证方案
在 TanStack Start 应用中有几种认证选项:
托管方案:
- Clerk——带 UI 组件的完整认证平台
- WorkOS——专注企业,带 SSO 和合规特性
- Better Auth——开源的 TypeScript 库
- Auth.js——支持 80+ OAuth 服务商的开源库
DIY 自建的好处:
- 完全掌控:对认证流程的完全定制
- 无厂商锁定:拥有你自己的认证逻辑和用户数据
- 自定义需求:实现特定的业务逻辑或合规要求
- 成本可控:没有按用户计费或用量限制
认证涉及许多考量,包括密码安全、会话管理、限流、CSRF 保护和各种攻击向量。
核心概念
认证与授权
- 认证(Authentication):这个用户是谁?(登录/登出)
- 授权(Authorization):这个用户能做什么?(权限/角色)
TanStack Start 通过服务器函数、会话和路由保护,为两者都提供了工具。
注意
先保护数据/API 边界。任何返回或修改私有数据的服务器函数、服务器路由或其他 API 端点,都必须由端点自己授权请求。beforeLoad 对路由 UX 很有用:它让用户无法进入用不了的页面,并避免触发注定会失败的工作。但它不是数据的安全边界。服务端模式见认证服务器原语。
核心构件
1. 用于认证的服务器函数
服务器函数在服务器上安全地处理敏感的认证逻辑:
import { createServerFn } from '@tanstack/react-start'
import { redirect } from '@tanstack/react-router'
// Login server function
export const loginFn = createServerFn({ method: 'POST' })
.validator((data: { email: string; password: string }) => data)
.handler(async ({ data }) => {
// Verify credentials (replace with your auth logic)
const user = await authenticateUser(data.email, data.password)
if (!user) {
return { error: 'Invalid credentials' }
}
// Create session
const session = await useAppSession()
await session.update({
userId: user.id,
email: user.email,
})
// Redirect to protected area
throw redirect({ to: '/dashboard' })
})
// Logout server function
export const logoutFn = createServerFn({ method: 'POST' }).handler(async () => {
const session = await useAppSession()
await session.clear()
throw redirect({ to: '/' })
})
// Get current user
export const getCurrentUserFn = createServerFn({ method: 'GET' }).handler(
async () => {
const session = await useAppSession()
const userId = session.data.userId
if (!userId) {
return null
}
return await getUserById(userId)
},
)2. 会话管理
TanStack Start 提供安全的 HTTP-only cookie 会话:
// utils/session.ts
import { useSession } from '@tanstack/react-start/server'
type SessionData = {
userId?: string
email?: string
role?: string
}
export function useAppSession() {
return useSession<SessionData>({
// Session configuration
name: 'app-session',
password: process.env.SESSION_SECRET!, // At least 32 characters
// Optional: customize cookie settings
cookie: {
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
httpOnly: true,
},
})
}3. 认证上下文
在你的应用中共享认证状态:
// contexts/auth.tsx
import { createContext, useContext, ReactNode } from 'react'
import { useServerFn } from '@tanstack/react-start'
import { getCurrentUserFn } from '../server/auth'
type User = {
id: string
email: string
role: string
}
type AuthContextType = {
user: User | null
isLoading: boolean
refetch: () => void
}
const AuthContext = createContext<AuthContextType | undefined>(undefined)
export function AuthProvider({ children }: { children: ReactNode }) {
const { data: user, isLoading, refetch } = useServerFn(getCurrentUserFn)
return (
<AuthContext.Provider value={{ user, isLoading, refetch }}>
{children}
</AuthContext.Provider>
)
}
export function useAuth() {
const context = useContext(AuthContext)
if (!context) {
throw new Error('useAuth must be used within AuthProvider')
}
return context
}4. 路由保护
用 beforeLoad 保护路由:
// routes/_authed.tsx - Layout route for protected pages
import { createFileRoute, redirect } from '@tanstack/react-router'
import { getCurrentUserFn } from '../server/auth'
export const Route = createFileRoute('/_authed')({
beforeLoad: async ({ location }) => {
const user = await getCurrentUserFn()
if (!user) {
throw redirect({
to: '/login',
search: { redirect: location.href },
})
}
// Pass user to child routes
return { user }
},
})// routes/_authed/dashboard.tsx - Protected route
import { createFileRoute } from '@tanstack/react-router'
export const Route = createFileRoute('/_authed/dashboard')({
component: DashboardComponent,
})
function DashboardComponent() {
const { user } = Route.useRouteContext()
return (
<div>
<h1>Welcome, {user.email}!</h1>
{/* Dashboard content */}
</div>
)
}实现模式
基础的邮箱/密码认证
// server/auth.ts
import bcrypt from 'bcryptjs'
import { createServerFn } from '@tanstack/react-start'
// User registration
export const registerFn = createServerFn({ method: 'POST' })
.validator((data: { email: string; password: string; name: string }) => data)
.handler(async ({ data }) => {
// Check if user exists
const existingUser = await getUserByEmail(data.email)
if (existingUser) {
return { error: 'User already exists' }
}
// Hash password
const hashedPassword = await bcrypt.hash(data.password, 12)
// Create user
const user = await createUser({
email: data.email,
password: hashedPassword,
name: data.name,
})
// Create session
const session = await useAppSession()
await session.update({ userId: user.id })
return { success: true, user: { id: user.id, email: user.email } }
})
async function authenticateUser(email: string, password: string) {
const user = await getUserByEmail(email)
if (!user) return null
const isValid = await bcrypt.compare(password, user.password)
return isValid ? user : null
}基于角色的访问控制(RBAC)
// utils/auth.ts
export const roles = {
USER: 'user',
ADMIN: 'admin',
MODERATOR: 'moderator',
} as const
type Role = (typeof roles)[keyof typeof roles]
export function hasPermission(userRole: Role, requiredRole: Role): boolean {
const hierarchy = {
[roles.USER]: 0,
[roles.MODERATOR]: 1,
[roles.ADMIN]: 2,
}
return hierarchy[userRole] >= hierarchy[requiredRole]
}
// Protected route with role check
export const Route = createFileRoute('/_authed/admin/')({
beforeLoad: async ({ context }) => {
if (!hasPermission(context.user.role, roles.ADMIN)) {
throw redirect({ to: '/unauthorized' })
}
},
})社交认证集成
// Example with OAuth providers
export const authProviders = {
google: {
clientId: process.env.GOOGLE_CLIENT_ID!,
redirectUri: `${process.env.APP_URL}/auth/google/callback`,
},
github: {
clientId: process.env.GITHUB_CLIENT_ID!,
redirectUri: `${process.env.APP_URL}/auth/github/callback`,
},
}
export const initiateOAuthFn = createServerFn({ method: 'POST' })
.validator((data: { provider: 'google' | 'github' }) => data)
.handler(async ({ data }) => {
const provider = authProviders[data.provider]
const state = generateRandomState()
// Store state in session for CSRF protection
const session = await useAppSession()
await session.update({ oauthState: state })
// Generate OAuth URL
const authUrl = generateOAuthUrl(provider, state)
throw redirect({ href: authUrl })
})密码重置流程
// Password reset request
export const requestPasswordResetFn = createServerFn({ method: 'POST' })
.validator((data: { email: string }) => data)
.handler(async ({ data }) => {
const user = await getUserByEmail(data.email)
if (!user) {
// Don't reveal if email exists
return { success: true }
}
const token = generateSecureToken()
const expires = new Date(Date.now() + 60 * 60 * 1000) // 1 hour
await savePasswordResetToken(user.id, token, expires)
await sendPasswordResetEmail(user.email, token)
return { success: true }
})
// Password reset confirmation
export const resetPasswordFn = createServerFn({ method: 'POST' })
.validator((data: { token: string; newPassword: string }) => data)
.handler(async ({ data }) => {
const resetToken = await getPasswordResetToken(data.token)
if (!resetToken || resetToken.expires < new Date()) {
return { error: 'Invalid or expired token' }
}
const hashedPassword = await bcrypt.hash(data.newPassword, 12)
await updateUserPassword(resetToken.userId, hashedPassword)
await deletePasswordResetToken(data.token)
return { success: true }
})安全最佳实践
1. 密码安全
// Use strong hashing (bcrypt, scrypt, or argon2)
import bcrypt from 'bcryptjs'
const saltRounds = 12 // Adjust based on your security needs
const hashedPassword = await bcrypt.hash(password, saltRounds)2. 会话安全
// Use secure session configuration
export function useAppSession() {
return useSession({
name: 'app-session',
password: process.env.SESSION_SECRET!, // 32+ characters
cookie: {
secure: process.env.NODE_ENV === 'production', // HTTPS only in production
sameSite: 'lax', // CSRF protection
httpOnly: true, // XSS protection
maxAge: 7 * 24 * 60 * 60, // 7 days
},
})
}3. 限流
// Simple in-memory rate limiting (use Redis in production)
const loginAttempts = new Map<string, { count: number; resetTime: number }>()
export const rateLimitLogin = (ip: string): boolean => {
const now = Date.now()
const attempts = loginAttempts.get(ip)
if (!attempts || now > attempts.resetTime) {
loginAttempts.set(ip, { count: 1, resetTime: now + 15 * 60 * 1000 }) // 15 min
return true
}
if (attempts.count >= 5) {
return false // Too many attempts
}
attempts.count++
return true
}4. 输入校验
import { z } from 'zod'
const loginSchema = z.object({
email: z.string().email().max(255),
password: z.string().min(8).max(100),
})
export const loginFn = createServerFn({ method: 'POST' })
.validator((data) => loginSchema.parse(data))
.handler(async ({ data }) => {
// data is now validated
})测试认证
服务器函数的单元测试
// __tests__/auth.test.ts
import { describe, it, expect, beforeEach } from 'vitest'
import { loginFn } from '../server/auth'
describe('Authentication', () => {
beforeEach(async () => {
await setupTestDatabase()
})
it('should login with valid credentials', async () => {
const result = await loginFn({
data: { email: 'test@example.com', password: 'password123' },
})
expect(result.error).toBeUndefined()
expect(result.user).toBeDefined()
})
it('should reject invalid credentials', async () => {
const result = await loginFn({
data: { email: 'test@example.com', password: 'wrongpassword' },
})
expect(result.error).toBe('Invalid credentials')
})
})集成测试
// __tests__/auth-flow.test.tsx
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
import { RouterProvider, createMemoryHistory } from '@tanstack/react-router'
import { router } from '../router'
describe('Authentication Flow', () => {
it('should redirect to login when accessing protected route', async () => {
const history = createMemoryHistory()
history.push('/dashboard') // Protected route
render(<RouterProvider router={router} history={history} />)
await waitFor(() => {
expect(screen.getByText('Login')).toBeInTheDocument()
})
})
})常见模式
加载状态
function LoginForm() {
const [isLoading, setIsLoading] = useState(false)
const loginMutation = useServerFn(loginFn)
const handleSubmit = async (data: LoginData) => {
setIsLoading(true)
try {
await loginMutation.mutate(data)
} catch (error) {
// Handle error
} finally {
setIsLoading(false)
}
}
return (
<form onSubmit={handleSubmit}>
{/* Form fields */}
<button disabled={isLoading}>
{isLoading ? 'Logging in...' : 'Login'}
</button>
</form>
)
}记住我功能
export const loginFn = createServerFn({ method: 'POST' })
.validator(
(data: { email: string; password: string; rememberMe?: boolean }) => data,
)
.handler(async ({ data }) => {
const user = await authenticateUser(data.email, data.password)
if (!user) return { error: 'Invalid credentials' }
const session = await useAppSession()
await session.update(
{ userId: user.id },
{
// Extend session if remember me is checked
maxAge: data.rememberMe ? 30 * 24 * 60 * 60 : undefined, // 30 days vs session
},
)
return { success: true }
})可运行的示例
研究这些实现,理解不同的认证模式:
- 带 Prisma 的基础认证——带数据库和会话的完整 DIY 实现
- Supabase 集成——第三方服务集成示例
- 客户端上下文认证——仅客户端认证模式
从其他方案迁移
从客户端认证迁移
如果你正在从客户端认证(localStorage、仅 context)迁移:
- 把认证逻辑移到服务器函数
- 用服务器会话替换 localStorage
- 把路由保护改为使用
beforeLoad - 添加正确的安全请求头和 CSRF 保护
从其他框架迁移
- Next.js:把 API 路由替换成服务器函数,迁移 NextAuth 会话
- Remix:把 loaders/actions 转换为服务器函数,适配会话模式
- SvelteKit:把表单 action 移到服务器函数,更新路由保护
生产考量
选择认证方案时,考虑这些因素:
托管 vs DIY 对比
托管方案(Clerk、WorkOS、Better Auth):
- 预制安全措施和定期更新
- UI 组件和用户管理功能
- 合规认证和审计跟踪
- 支持和文档
- 按用户或订阅定价
DIY 实现:
- 对实现和数据的完全控制
- 没有持续的订阅费用
- 自定义业务逻辑和工作流
- 对安全更新和监控负责
- 需要处理边界情况和攻击向量
安全考量
认证系统需要处理各种安全方面:
- 密码哈希和时序攻击防护
- 会话管理和固定攻击防护
- CSRF 和 XSS 防护
- 限流和暴力破解防护
- OAuth 流程安全
- 合规要求(GDPR、CCPA 等)
下一步
实现认证时,考虑:
- 安全审查:按安全最佳实践审查你的实现
- 性能:为用户查找和会话校验添加缓存
- 监控:为认证事件添加日志和监控
- 合规:如果存储个人数据,确保符合相关法规