开发指南认证系统
认证系统
🚀 Cheatsheet (快速执行)
目标: 15分钟内配置完整的用户认证系统,支持邮箱登录和社交登录
最快上手方式:
# 1. 已经安装了 Better Auth,配置认证
# 编辑 lib/auth/index.ts
# 2. 添加环境变量
echo 'AUTH_SECRET=your-secret-key' >> .env.local
echo 'GOOGLE_CLIENT_ID=your-google-id' >> .env.local
echo 'GOOGLE_CLIENT_SECRET=your-google-secret' >> .env.local
# 3. 测试登录
bun dev
# 访问 http://localhost:3000/auth/login
立即生效: 用户可以通过邮箱密码和 Google 账号登录,支持注册、密码重置
❓ 为什么要完善认证系统
对 MVP 的核心价值:
- 用户留存: 用户需要登录才能保存数据和个性化设置
- 数据安全: 保护用户隐私和敏感信息
- 功能解锁: 某些高级功能需要登录后使用
- 商业化基础: 付费功能需要用户身份验证
真实场景举例: 你的 AI 写作工具,用户需要登录才能保存文章草稿、使用高级AI模型、管理个人设置。没有认证系统,用户每次刷新页面都会丢失数据。
🤔 为什么选择 Better Auth
我们的实战经验:
- 类型安全: 完整的 TypeScript 支持,编译时就能发现错误
- 现代化: 支持最新的认证方式如 Passkeys
- 零配置: 开箱即用,不需要复杂配置
- 插件生态: 丰富的插件满足各种需求
对比其他方案:
NextAuth.js: 功能强大但配置复杂
Auth0: 收费且数据不在自己手里
Supabase Auth: 和数据库绑定,不够灵活
Better Auth: ✅ 现代、简单、类型安全
🧠 简要原理 & 最简案例
认证流程: 用户输入凭据 → 验证身份 → 创建会话 → 返回认证状态 → 后续请求携带会话
最简认证示例:
// lib/auth/index.ts
import { betterAuth } from "better-auth"
import { prismaAdapter } from "better-auth/adapters/prisma"
import { db } from "@/lib/database"
export const auth = betterAuth({
database: prismaAdapter(db, {
provider: "postgresql"
}),
emailAndPassword: {
enabled: true,
minPasswordLength: 8,
},
socialProviders: {
google: {
clientId: process.env.GOOGLE_CLIENT_ID!,
clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
},
},
})
// 在组件中使用
import { useSession } from "@/lib/auth/client"
export default function Dashboard() {
const { data: session, isPending } = useSession()
if (isPending) return <div>Loading...</div>
if (!session) return <div>Please login</div>
return <div>Welcome {session.user.name}!</div>
}
🛠️ 实际操作步骤
1. 配置社交登录 (10分钟)
Google OAuth 设置:
1. 前往 https://console.cloud.google.com/
2. 创建新项目或选择现有项目
3. 启用 Google+ API
4. 创建 OAuth 2.0 客户端凭据
5. 添加授权重定向 URI: http://localhost:3000/api/auth/callback/google
6. 复制客户端 ID 和密钥
GitHub OAuth 设置:
1. 前往 https://github.com/settings/applications/new
2. 填写应用信息
- Homepage URL: http://localhost:3000
- Authorization callback URL: http://localhost:3000/api/auth/callback/github
3. 复制 Client ID 和 Client Secret
环境变量配置:
# .env.local
# 必需配置
AUTH_SECRET=your-secret-key-here # 运行: openssl rand -base64 32
DATABASE_URL=your-database-url
# Google OAuth
GOOGLE_CLIENT_ID=your-google-client-id
GOOGLE_CLIENT_SECRET=your-google-client-secret
# GitHub OAuth
GITHUB_CLIENT_ID=your-github-client-id
GITHUB_CLIENT_SECRET=your-github-client-secret
2. 增强认证配置 (10分钟)
完整认证配置:
// lib/auth/index.ts
import { betterAuth } from "better-auth"
import { prismaAdapter } from "better-auth/adapters/prisma"
import { db } from "@/lib/database"
export const auth = betterAuth({
database: prismaAdapter(db, {
provider: "postgresql"
}),
// 邮箱密码登录
emailAndPassword: {
enabled: true,
minPasswordLength: 8,
maxPasswordLength: 128,
requireEmailVerification: true,
},
// 社交登录
socialProviders: {
google: {
clientId: process.env.GOOGLE_CLIENT_ID!,
clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
},
github: {
clientId: process.env.GITHUB_CLIENT_ID!,
clientSecret: process.env.GITHUB_CLIENT_SECRET!,
},
},
// 会话配置
session: {
expiresIn: 60 * 60 * 24 * 7, // 7天
updateAge: 60 * 60 * 24, // 1天更新一次
cookieName: "better-auth.session_token",
},
// 邮件配置
emailVerification: {
sendOnSignUp: true,
expiresIn: 60 * 60 * 24, // 24小时
sendVerificationEmail: async ({ user, url }) => {
// 使用你的邮件服务发送验证邮件
await sendVerificationEmail(user.email, url)
},
},
// 密码重置
passwordReset: {
expiresIn: 60 * 60, // 1小时
sendResetPasswordEmail: async ({ user, url }) => {
await sendPasswordResetEmail(user.email, url)
},
},
// 安全设置
security: {
rateLimit: {
window: 60 * 1000, // 1分钟
max: 5, // 最多5次尝试
},
csrf: {
protection: true,
},
},
// 用户模型扩展
user: {
additionalFields: {
role: {
type: "string",
defaultValue: "user",
},
avatar: {
type: "string",
required: false,
},
},
},
})
export type Session = typeof auth.$Infer.Session
3. 创建认证组件 (15分钟)
登录表单组件:
// components/auth/LoginForm.tsx
"use client"
import { useState } from 'react'
import { signIn } from '@/lib/auth/client'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
export function LoginForm() {
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
const handleEmailLogin = async (e: React.FormEvent) => {
e.preventDefault()
setLoading(true)
setError('')
try {
const result = await signIn.email({
email,
password,
})
if (result.error) {
setError(result.error.message)
} else {
// 登录成功,重定向到 dashboard
window.location.href = '/dashboard'
}
} catch (err) {
setError('登录失败,请重试')
} finally {
setLoading(false)
}
}
const handleSocialLogin = async (provider: 'google' | 'github') => {
setLoading(true)
try {
await signIn.social({
provider,
callbackURL: '/dashboard',
})
} catch (err) {
setError('社交登录失败')
setLoading(false)
}
}
return (
<Card className="w-full max-w-md mx-auto">
<CardHeader>
<CardTitle>登录账号</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{error && (
<div className="p-3 text-sm text-red-600 bg-red-50 rounded-md">
{error}
</div>
)}
<form onSubmit={handleEmailLogin} className="space-y-4">
<div>
<Input
type="email"
placeholder="邮箱地址"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
/>
</div>
<div>
<Input
type="password"
placeholder="密码"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
/>
</div>
<Button
type="submit"
className="w-full"
disabled={loading}
>
{loading ? '登录中...' : '邮箱登录'}
</Button>
</form>
<div className="relative">
<div className="absolute inset-0 flex items-center">
<span className="w-full border-t" />
</div>
<div className="relative flex justify-center text-xs uppercase">
<span className="bg-background px-2 text-muted-foreground">
或者
</span>
</div>
</div>
<div className="space-y-2">
<Button
type="button"
variant="outline"
className="w-full"
onClick={() => handleSocialLogin('google')}
disabled={loading}
>
使用 Google 登录
</Button>
<Button
type="button"
variant="outline"
className="w-full"
onClick={() => handleSocialLogin('github')}
disabled={loading}
>
使用 GitHub 登录
</Button>
</div>
<div className="text-center text-sm">
<a
href="/auth/forgot-password"
className="text-blue-600 hover:underline"
>
忘记密码?
</a>
</div>
<div className="text-center text-sm">
还没有账号?{' '}
<a
href="/auth/signup"
className="text-blue-600 hover:underline"
>
立即注册
</a>
</div>
</CardContent>
</Card>
)
}
注册表单组件:
// components/auth/SignupForm.tsx
"use client"
import { useState } from 'react'
import { signUp } from '@/lib/auth/client'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
export function SignupForm() {
const [formData, setFormData] = useState({
name: '',
email: '',
password: '',
confirmPassword: '',
})
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setLoading(true)
setError('')
if (formData.password !== formData.confirmPassword) {
setError('密码确认不匹配')
setLoading(false)
return
}
try {
const result = await signUp.email({
email: formData.email,
password: formData.password,
name: formData.name,
})
if (result.error) {
setError(result.error.message)
} else {
// 注册成功,显示验证邮件提示
alert('注册成功!请检查邮箱验证邮件')
window.location.href = '/auth/login'
}
} catch (err) {
setError('注册失败,请重试')
} finally {
setLoading(false)
}
}
return (
<Card className="w-full max-w-md mx-auto">
<CardHeader>
<CardTitle>创建账号</CardTitle>
</CardHeader>
<CardContent>
{error && (
<div className="p-3 mb-4 text-sm text-red-600 bg-red-50 rounded-md">
{error}
</div>
)}
<form onSubmit={handleSubmit} className="space-y-4">
<Input
type="text"
placeholder="姓名"
value={formData.name}
onChange={(e) => setFormData(prev => ({ ...prev, name: e.target.value }))}
required
/>
<Input
type="email"
placeholder="邮箱地址"
value={formData.email}
onChange={(e) => setFormData(prev => ({ ...prev, email: e.target.value }))}
required
/>
<Input
type="password"
placeholder="密码 (至少8位)"
value={formData.password}
onChange={(e) => setFormData(prev => ({ ...prev, password: e.target.value }))}
required
minLength={8}
/>
<Input
type="password"
placeholder="确认密码"
value={formData.confirmPassword}
onChange={(e) => setFormData(prev => ({ ...prev, confirmPassword: e.target.value }))}
required
/>
<Button
type="submit"
className="w-full"
disabled={loading}
>
{loading ? '注册中...' : '创建账号'}
</Button>
</form>
<div className="mt-4 text-center text-sm">
已有账号?{' '}
<a
href="/auth/login"
className="text-blue-600 hover:underline"
>
立即登录
</a>
</div>
</CardContent>
</Card>
)
}
4. 路由保护中间件 (10分钟)
页面级权限保护:
// lib/auth/middleware.ts
import { auth } from './index'
import { NextRequest, NextResponse } from 'next/server'
export async function authMiddleware(request: NextRequest) {
const session = await auth.api.getSession({
headers: request.headers,
})
const { pathname } = request.nextUrl
// 需要登录的路由
const protectedRoutes = ['/dashboard', '/settings', '/api/user']
const isProtectedRoute = protectedRoutes.some(route =>
pathname.startsWith(route)
)
// 需要管理员权限的路由
const adminRoutes = ['/admin']
const isAdminRoute = adminRoutes.some(route =>
pathname.startsWith(route)
)
if (isProtectedRoute && !session) {
// 未登录,重定向到登录页
return NextResponse.redirect(new URL('/auth/login', request.url))
}
if (isAdminRoute && (!session || session.user.role !== 'admin')) {
// 不是管理员,返回403
return NextResponse.redirect(new URL('/403', request.url))
}
return NextResponse.next()
}
// middleware.ts
import { authMiddleware } from '@/lib/auth/middleware'
export function middleware(request: NextRequest) {
return authMiddleware(request)
}
export const config = {
matcher: [
'/((?!_next/static|_next/image|favicon.ico).*)',
],
}
5. 自定义Hook和工具 (10分钟)
认证状态Hook:
// hooks/useAuth.ts
import { useSession } from '@/lib/auth/client'
import { signOut } from '@/lib/auth/client'
export function useAuth() {
const { data: session, isPending, error } = useSession()
const logout = async () => {
await signOut()
window.location.href = '/'
}
const isAuthenticated = !!session
const isLoading = isPending
const user = session?.user
return {
user,
isAuthenticated,
isLoading,
logout,
error,
}
}
权限检查Hook:
// hooks/usePermissions.ts
import { useAuth } from './useAuth'
export function usePermissions() {
const { user, isAuthenticated } = useAuth()
const hasRole = (role: string) => {
return isAuthenticated && user?.role === role
}
const isAdmin = () => hasRole('admin')
const isUser = () => hasRole('user')
const can = (permission: string) => {
// 实现更复杂的权限检查逻辑
switch (permission) {
case 'admin:users:read':
return isAdmin()
case 'user:profile:edit':
return isAuthenticated
default:
return false
}
}
return {
hasRole,
isAdmin,
isUser,
can,
}
}
💡 常用认证组件
用户头像菜单
// components/auth/UserMenu.tsx
import { useAuth } from '@/hooks/useAuth'
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
export function UserMenu() {
const { user, logout } = useAuth()
if (!user) return null
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Avatar className="cursor-pointer">
<AvatarImage src={user.image} alt={user.name} />
<AvatarFallback>
{user.name?.slice(0, 2).toUpperCase()}
</AvatarFallback>
</Avatar>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem asChild>
<a href="/settings">设置</a>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<a href="/profile">个人资料</a>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={logout}>
退出登录
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
}
权限保护组件
// components/auth/ProtectedComponent.tsx
import { usePermissions } from '@/hooks/usePermissions'
interface ProtectedComponentProps {
permission?: string
role?: string
fallback?: React.ReactNode
children: React.ReactNode
}
export function ProtectedComponent({
permission,
role,
fallback = null,
children,
}: ProtectedComponentProps) {
const { hasRole, can } = usePermissions()
const hasAccess = () => {
if (role && !hasRole(role)) return false
if (permission && !can(permission)) return false
return true
}
if (!hasAccess()) {
return <>{fallback}</>
}
return <>{children}</>
}
// 使用示例
<ProtectedComponent role="admin" fallback={<div>权限不足</div>}>
<AdminPanel />
</ProtectedComponent>
🆘 常见问题解决
问题1: Google登录失败
检查清单:
1. Google Client ID 和 Secret 是否正确?
2. 重定向URI是否在Google控制台中配置?
3. 是否启用了Google+ API?
解决方案:
重新检查Google OAuth配置,确保重定向URI匹配
问题2: 邮件验证不工作
// 确保邮件服务配置正确
// lib/auth/index.ts
emailVerification: {
sendVerificationEmail: async ({ user, url }) => {
// 检查邮件服务是否正确配置
console.log('发送验证邮件给:', user.email)
console.log('验证链接:', url)
// 使用你的邮件服务
await sendEmail({
to: user.email,
subject: '验证您的邮箱',
html: `<a href="${url}">点击验证</a>`
})
},
}
问题3: 会话不持久
// 检查cookie设置
session: {
expiresIn: 60 * 60 * 24 * 7, // 7天
updateAge: 60 * 60 * 24, // 1天更新一次
cookieName: "auth-session",
// 确保在生产环境中设置secure
secure: process.env.NODE_ENV === 'production',
}
📎 延伸阅读
技术文档:
- Better Auth 官方文档 - 完整功能文档
- OAuth 2.0 规范 - OAuth 标准文档
安全最佳实践:
- OWASP 认证指南 - 认证安全规范
- 密码安全指南 - 密码存储最佳实践
社交登录配置:
- Google OAuth 配置 - Google 登录设置
- GitHub OAuth 配置 - GitHub 登录设置
下一步: 认证系统配置完成后,查看 [权限管理]./organizations) 了解如何实现基于角色的访问控制。