登录注册与权限体系

认证(Authentication)回答"你是谁",授权(Authorization)回答"你能做什么"。SaaS 产品的认证系统比个人项目复杂得多——需要支持多种登录方式、组织/团队管理、角色权限、邀请制度、SSO 企业集成。本章系统讲解 Next.js SaaS 中的认证授权体系设计。

1. 认证架构

1.1 认证 vs 授权

概念问题时机数据
认证(AuthN)你是谁?登录时身份信息(email、ID)
授权(AuthZ)你能做什么?每次请求权限信息(角色、权限)

1.2 Next.js 中的认证层级

请求到达
├── Edge Middleware → 快速认证检查(JWT 验证)
│   ├── 未认证 → 重定向到登录页
│   └── 已认证 → 继续
├── Server Component → 读取会话,获取用户信息
├── Server Action → 验证权限后执行操作
└── API Route → 验证 Token 后返回数据

每一层都可以做认证检查——但策略不同:

层级认证方式场景
MiddlewareJWT 验证(快,无数据库查询)路由保护
Server ComponentSession 查询(完整用户信息)页面渲染
Server ActionSession + 权限检查数据变更
API RouteBearer Token / API Key外部调用

1.3 会话管理策略

策略机制优势劣势
JWT(无状态)Token 存储在 Cookie无需数据库查询,Edge 可用无法即时撤销
Session(有状态)Session ID → 数据库查询可即时撤销、存储任意数据每次请求查数据库
混合JWT 短期 + Refresh Token兼顾性能和安全实现复杂

SaaS 推荐:混合方案——JWT 用于 Middleware 快速验证,Session 用于 Server Component 获取完整权限。

实践:Middleware 路由保护(以 Better Auth 为例):

// middleware.ts
import { NextRequest, NextResponse } from 'next/server'
import { betterFetch } from '@better-fetch/fetch'
 
const publicPaths = ['/', '/pricing', '/blog', '/login', '/signup']
 
export async function middleware(request: NextRequest) {
  const { pathname } = request.nextUrl
 
  // 公开路由直接放行
  if (publicPaths.some((p) => pathname === p || pathname.startsWith(`${p}/`))) {
    return NextResponse.next()
  }
 
  // 验证 session cookie(快速检查,不查数据库全量信息)
  const sessionCookie = request.cookies.get('session_token')?.value
  if (!sessionCookie) {
    return NextResponse.redirect(new URL('/login', request.url))
  }
 
  // 可选:验证 JWT 有效期(无需数据库查询)
  try {
    const { data: session } = await betterFetch('/api/auth/get-session', {
      headers: { cookie: request.headers.get('cookie') || '' },
      baseURL: request.nextUrl.origin,
    })
    if (!session) throw new Error('Invalid session')
  } catch {
    return NextResponse.redirect(new URL('/login', request.url))
  }
 
  return NextResponse.next()
}
 
export const config = { matcher: ['/((?!api|_next/static|favicon.ico).*)'] }

实践:Server Component 获取完整用户信息

// lib/auth/session.ts
import { auth } from '@/lib/auth'
import { headers } from 'next/headers'
import { cache } from 'react'
 
// cache() 确保同一请求内只调用一次
export const getSession = cache(async () => {
  const session = await auth.api.getSession({ headers: await headers() })
  return session
})
 
// app/(dashboard)/page.tsx
export default async function DashboardPage() {
  const session = await getSession()
  if (!session) redirect('/login')
 
  return <h1>Welcome, {session.user.name}</h1>
}

2. 多方式登录

2.1 登录方式选择

方式用户体验安全性实现复杂度推荐
OAuth(Google/GitHub)⭐⭐⭐⭐⭐低(第三方处理)✅ 必备
Magic Link⭐⭐⭐⭐✅ 推荐
邮箱 + 密码⭐⭐⭐中(依赖密码强度)⚠️ 可选
手机 + 验证码⭐⭐⭐⭐中(需要 SMS 服务)看市场
Passkey⭐⭐⭐⭐⭐最高✅ 趋势

2.2 OAuth 登录流程

用户点击"用 Google 登录"
    ↓
重定向到 Google 授权页面
    ↓
用户在 Google 授权
    ↓
Google 回调到你的应用(带 code)
    ↓
应用用 code 换取 access_token
    ↓
用 access_token 获取用户信息(email、name、avatar)
    ↓
查找或创建本地用户
    ↓
创建会话,重定向到 Dashboard

Magic Link 是无密码登录——用户输入邮箱,收到一封包含登录链接的邮件:

优势劣势
无需记密码依赖邮箱(邮件可能延迟)
安全(一次性 Token)用户需要切换到邮箱
注册和登录合一不适合频繁登录场景

2.4 Passkey(WebAuthn)

Passkey 是 2025 年的认证趋势——用指纹/Face ID 替代密码:

特性说明
无密码生物识别 / 设备 PIN
防钓鱼绑定域名,无法在假网站使用
跨设备iCloud Keychain / Google Password Manager 同步
支持度Chrome、Safari、Edge 已支持

3. 注册与用户生命周期

3.1 注册流程设计

注册入口
├── OAuth 注册 → 自动获取信息 → 创建账户 → 引导
├── Magic Link → 输入邮箱 → 发送链接 → 点击链接 → 创建账户 → 引导
└── 邮箱密码 → 输入信息 → 发送验证邮件 → 验证 → 创建账户 → 引导

3.2 用户引导(Onboarding)

步骤说明技术实现
1. 创建组织设置组织名称Server Action
2. 邀请成员输入团队成员邮箱邮件发送
3. 配置偏好选择使用场景/主题用户设置
4. 体验核心功能引导完成一个关键操作产品 Tour

3.3 邮箱验证

方案说明
注册后立即可用先让用户体验,后台要求验证
注册后必须验证防止垃圾注册,但增加流失
关键操作时验证平衡体验和安全

推荐:注册后立即可用,但在关键操作(发布、邀请成员)时要求验证邮箱。

4. RBAC 权限模型

4.1 权限模型选择

模型说明复杂度适用场景
ACL访问控制列表简单文件权限
RBAC基于角色的访问控制大部分 SaaS
ABAC基于属性的访问控制复杂企业系统
ReBAC基于关系的访问控制Google Docs 类共享

RBAC 适合绝大多数 SaaS——简单、直观、易维护。

实践:类型安全的权限定义

// lib/auth/permissions.ts
 
// 所有可用权限,集中管理
const PERMISSIONS = {
  'project:read':    ['owner', 'admin', 'member', 'viewer'],
  'project:create':  ['owner', 'admin', 'member'],
  'project:delete':  ['owner', 'admin'],
  'member:invite':   ['owner', 'admin'],
  'member:remove':   ['owner', 'admin'],
  'billing:manage':  ['owner', 'admin'],
  'org:delete':      ['owner'],
  'org:transfer':    ['owner'],
} as const
 
type Permission = keyof typeof PERMISSIONS
type Role = 'owner' | 'admin' | 'member' | 'viewer'
 
export function hasPermission(role: Role, permission: Permission): boolean {
  return PERMISSIONS[permission].includes(role)
}
 
// 用于 Server Action 的权限守卫——不满足直接抛错
export async function requirePermission(permission: Permission) {
  const session = await getSession()
  if (!session) throw new Error('Unauthorized')
 
  const membership = await getCurrentMembership(session.user.id)
  if (!membership) throw new Error('Not a member of this organization')
 
  if (!hasPermission(membership.role as Role, permission)) {
    throw new Error(`Forbidden: requires ${permission}`)
  }
 
  return { session, membership }
}

4.2 RBAC 设计

角色层级:
  Owner → 最高权限(不可删除、不可转让)
  Admin → 管理权限(成员管理、计费、设置)
  Member → 标准权限(使用核心功能)
  Viewer → 只读权限(查看数据)

4.3 权限矩阵

操作OwnerAdminMemberViewer
查看数据
创建/编辑
删除⚠️ 仅自己的
邀请成员
管理角色
管理计费
删除组织
转让所有权

4.4 权限检查的实现层级

层级方式说明
UI 层隐藏/禁用按钮体验优化(不是安全手段)
Server Component条件渲染不返回无权限的内容
Server Action权限校验后执行数据变更的安全关卡
数据库RLS 策略最后的安全防线

关键原则:永远不要只在 UI 层做权限控制——UI 可以被绕过,Server Action 和数据库是真正的安全层。

实践:Server Action 权限检查

// app/(dashboard)/projects/actions.ts
'use server'
 
import { requirePermission } from '@/lib/auth/permissions'
import { db } from '@/lib/db'
import { projects } from '@/lib/db/schema'
import { revalidatePath } from 'next/cache'
 
export async function deleteProject(projectId: string) {
  // 1. 权限守卫——不满足直接抛错,后续代码不会执行
  const { membership } = await requirePermission('project:delete')
 
  // 2. 业务逻辑
  await db.delete(projects).where(
    and(eq(projects.id, projectId), eq(projects.tenantId, membership.tenantId))
  )
 
  revalidatePath('/projects')
}

实践:Server Component 条件渲染

// components/dashboard/project-actions.tsx
import { getSession } from '@/lib/auth/session'
import { getCurrentMembership } from '@/lib/auth/membership'
import { hasPermission } from '@/lib/auth/permissions'
 
export async function ProjectActions({ projectId }: { projectId: string }) {
  const session = await getSession()
  const membership = await getCurrentMembership(session!.user.id)
  const role = membership!.role as Role
 
  return (
    <div>
      {hasPermission(role, 'project:create') && (
        <Button>Edit</Button>
      )}
      {hasPermission(role, 'project:delete') && (
        <Button variant="destructive">Delete</Button>
      )}
    </div>
  )
}

5. 组织与团队

5.1 组织模型

概念说明示例
Organization顶级租户,计费单位"Acme Inc"
Team组织内的分组(可选)"Engineering"
Member组织中的用户"[email protected]"
Invitation待接受的邀请邀请链接/邮件

5.2 邀请制度

管理员输入被邀请人邮箱
    ↓
系统发送邀请邮件(包含一次性 Token)
    ↓
被邀请人点击链接
├── 已有账户 → 加入组织
└── 无账户 → 注册 → 加入组织
    ↓
设置角色(默认 Member)

实践:邀请 Server Action

// app/(dashboard)/settings/members/actions.ts
'use server'
 
import { requirePermission } from '@/lib/auth/permissions'
import { db } from '@/lib/db'
import { invitations } from '@/lib/db/schema'
import { sendEmail } from '@/lib/email'
import { nanoid } from 'nanoid'
 
export async function inviteMember(formData: FormData) {
  const { membership } = await requirePermission('member:invite')
  const email = formData.get('email') as string
  const role = (formData.get('role') as string) || 'member'
 
  // 生成一次性邀请 Token(72 小时过期)
  const token = nanoid(32)
  const expiresAt = new Date(Date.now() + 72 * 60 * 60 * 1000)
 
  await db.insert(invitations).values({
    tenantId: membership.tenantId,
    email,
    role,
    token,
    expiresAt,
    invitedBy: membership.userId,
  })
 
  // 发送邀请邮件
  await sendEmail({
    to: email,
    subject: `You're invited to join ${membership.tenant.name}`,
    template: 'invitation',
    props: {
      inviteUrl: `${process.env.NEXT_PUBLIC_APP_URL}/invite/${token}`,
      orgName: membership.tenant.name,
    },
  })
 
  revalidatePath('/settings/members')
}

5.3 邀请的安全考虑

风险对策
邀请链接被转发绑定邮箱地址
邀请链接过期设置有效期(72h)
批量邀请滥用限制邀请频率
邀请到错误邮箱允许撤销邀请

5.4 组织切换

一个用户可能属于多个组织——需要支持组织切换:

用户登录 → 默认进入上次使用的组织
    ↓
切换组织(下拉菜单 / 组织列表页)
    ↓
更新 Cookie/Session 中的 current_org_id
    ↓
所有数据查询基于新的 org_id

实践:组织切换 Server Action + Cookie 持久化

// app/(dashboard)/actions.ts
'use server'
 
import { cookies } from 'next/headers'
import { redirect } from 'next/navigation'
import { db } from '@/lib/db'
import { memberships } from '@/lib/db/schema'
import { and, eq } from 'drizzle-orm'
import { getSession } from '@/lib/auth/session'
 
const ORG_COOKIE = 'current_org_id'
 
export async function switchOrganization(orgId: string) {
  const session = await getSession()
  if (!session) throw new Error('Unauthorized')
 
  // 验证用户确实属于该组织(防止伪造 orgId)
  const [membership] = await db
    .select()
    .from(memberships)
    .where(and(eq(memberships.userId, session.user.id), eq(memberships.tenantId, orgId)))
 
  if (!membership) throw new Error('Not a member of this organization')
 
  // 写入 Cookie,后续请求自动携带
  const cookieStore = await cookies()
  cookieStore.set(ORG_COOKIE, orgId, {
    httpOnly: true,
    secure: process.env.NODE_ENV === 'production',
    sameSite: 'lax',
    maxAge: 60 * 60 * 24 * 365, // 1 年
  })
 
  redirect('/')
}
 
// 读取当前组织 ID(供其他模块使用)
export async function getCurrentOrgId(): Promise<string | null> {
  const cookieStore = await cookies()
  return cookieStore.get(ORG_COOKIE)?.value ?? null
}

6. SSO 企业集成

6.1 SSO 的必要性

客户规模SSO 需求
个人/小团队不需要
中型企业(50+)期望有
大型企业(200+)必须有

SSO 是 SaaS 的"Enterprise Feature"——它是企业客户的采购门槛。

6.2 SAML vs OIDC

协议年代特点企业使用率
SAML 2.02005XML,复杂,但企业 IdP 普遍支持
OIDC2014JSON,简单,现代 IdP 支持增长中

如果只支持一种,选 SAML——Okta、Azure AD、OneLogin 等企业 IdP 都支持 SAML。

6.3 SSO 实现方案

方案说明推荐
Clerk内置 SAML SSO✅ Enterprise 计划
WorkOS专注企业 SSO 集成✅ 专业方案
BoxyHQ开源 SAML SSO 中间件✅ 自托管
自建saml2-js 等库❌ 非常复杂

7. 安全最佳实践

7.1 认证安全清单

检查项说明
✅ HTTPS only所有认证相关请求走 HTTPS
✅ CSRF 保护Server Actions 自动保护
✅ HttpOnly CookieSession Token 不暴露给 JS
✅ Secure + SameSiteCookie 安全属性
✅ 密码哈希bcrypt/argon2,不存明文
✅ 限流登录失败次数限制
✅ Token 过期Session/JWT 有过期时间
✅ 敏感操作二次验证删除账户、更改邮箱

实践:登录限流(基于 IP + Email 的滑动窗口):

// lib/auth/rate-limit.ts
import { Ratelimit } from '@upstash/ratelimit'
import { Redis } from '@upstash/redis'
 
const redis = Redis.fromEnv()
 
// 同一 IP 每分钟最多 10 次登录尝试
const ipLimiter = new Ratelimit({
  redis,
  limiter: Ratelimit.slidingWindow(10, '60 s'),
  prefix: 'login:ip',
})
 
// 同一邮箱每小时最多 5 次失败尝试
const emailLimiter = new Ratelimit({
  redis,
  limiter: Ratelimit.slidingWindow(5, '3600 s'),
  prefix: 'login:email',
})
 
export async function checkLoginRateLimit(ip: string, email: string) {
  const [ipResult, emailResult] = await Promise.all([
    ipLimiter.limit(ip),
    emailLimiter.limit(email),
  ])
 
  if (!ipResult.success) {
    throw new Error('Too many login attempts from this IP. Try again later.')
  }
  if (!emailResult.success) {
    throw new Error('Too many failed attempts for this email. Try again later.')
  }
}

7.2 常见攻击与防护

攻击防护
暴力破解登录限流 + 账户锁定
Session 劫持HttpOnly + Secure Cookie
CSRFSameSite Cookie + CSRF Token
XSSCSP + React 自动转义
账户枚举统一的错误提示(不区分"邮箱不存在"和"密码错误")
Token 泄漏短期 JWT + Refresh Token 轮转

7.3 数据合规

法规要求影响
GDPR数据可导出、可删除欧盟用户
CCPA隐私声明、不出售数据加州用户
SOC 2安全控制审计企业客户要求

本章小结

  • 认证层级:Middleware(快速 JWT 验证)→ Server Component(完整会话)→ Server Action(权限检查)→ 数据库(RLS)
  • 登录方式:OAuth + Magic Link 是最佳组合,Passkey 是趋势
  • RBAC:Owner/Admin/Member/Viewer 四级角色,Server Action 层强制权限检查
  • 组织管理:User-Tenant 多对多,邀请制加入,支持组织切换
  • SSO:企业客户的采购门槛,SAML 协议优先,推荐 Clerk/WorkOS
  • 安全:HttpOnly Cookie、限流、CSRF 保护、密码哈希、敏感操作二次验证
  • 合规:GDPR 数据可删除/可导出,SOC 2 安全审计