文档
开发指南认证系统

认证系统

🚀 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',
}

📎 延伸阅读

技术文档:

安全最佳实践:

社交登录配置:


下一步: 认证系统配置完成后,查看 [权限管理]./organizations) 了解如何实现基于角色的访问控制。