登录注册与权限体系
认证(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 后返回数据
每一层都可以做认证检查——但策略不同:
| 层级 | 认证方式 | 场景 |
|---|---|---|
| Middleware | JWT 验证(快,无数据库查询) | 路由保护 |
| Server Component | Session 查询(完整用户信息) | 页面渲染 |
| Server Action | Session + 权限检查 | 数据变更 |
| API Route | Bearer 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
2.3 Magic Link
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 权限矩阵
| 操作 | Owner | Admin | Member | Viewer |
|---|---|---|---|---|
| 查看数据 | ✅ | ✅ | ✅ | ✅ |
| 创建/编辑 | ✅ | ✅ | ✅ | ❌ |
| 删除 | ✅ | ✅ | ⚠️ 仅自己的 | ❌ |
| 邀请成员 | ✅ | ✅ | ❌ | ❌ |
| 管理角色 | ✅ | ✅ | ❌ | ❌ |
| 管理计费 | ✅ | ✅ | ❌ | ❌ |
| 删除组织 | ✅ | ❌ | ❌ | ❌ |
| 转让所有权 | ✅ | ❌ | ❌ | ❌ |
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.0 | 2005 | XML,复杂,但企业 IdP 普遍支持 | 高 |
| OIDC | 2014 | JSON,简单,现代 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 Cookie | Session Token 不暴露给 JS |
| ✅ Secure + SameSite | Cookie 安全属性 |
| ✅ 密码哈希 | 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 |
| CSRF | SameSite Cookie + CSRF Token |
| XSS | CSP + 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 安全审计