Next.js 博客系统权限设计:从零到完整的 RBAC 实现
前言
在开发博客系统的过程中,权限系统是安全的核心。最近我对项目的权限系统进行了全面的升级和规范化,从简单的前端控制完善为多层防护的完整 RBAC(基于角色的访问控制)系统。
本文记录了这次权限改造的完整过程,包括设计思路、实现细节和最佳实践。
问题背景
在最初的实现中,项目存在以下权限问题:
1. 前端控制为主
大部分权限检查只在前端做了控制,通过隐藏菜单和按钮来限制访问。但这只是用户体验的优化,真正的安全防护应该在服务端。
2. API 缺少权限验证
很多 API 只检查了是否登录,没有验证用户是否有权限执行该操作。例如:
- 合集管理 API:普通用户可以通过直接调用 API 创建/修改合集
- 文章列表 API:可以通过篡改参数查看其他用户的隐藏文章
- 配置管理 API:缺少管理员权限验证
3. 权限逻辑分散
权限检查逻辑散落在各个文件中,没有统一的规范和工具函数,导致:
- 代码重复
- 容易遗漏检查
- 难以维护
4. 特殊场景缺少规则
对于一些特殊场景没有明确的权限规则:
- 普通用户隐藏了自己的文章后,无法在后台恢复
- 已删除文章的查看权限不明确
- 普通用户和管理员在查看隐藏文章时的权限边界不清晰
权限模型设计
角色定义
基于 RBAC 模型,定义了三种角色:
| 角色 | 代码值 | 说明 | 权限范围 |
|---|---|---|---|
| 管理员 | admin |
系统管理员 | 拥有所有权限 |
| 普通用户 | user |
已登录用户 | 只能管理自己创建的资源 |
| 访客 | guest |
未登录用户 | 只能查看公开内容 |
权限矩阵
为了明确每个角色的权限范围,我绘制了详细的权限矩阵:
| 资源/操作 | 访客 | 普通用户 | 管理员 |
|---|---|---|---|
| 文章管理 | |||
| 查看显示的文章 | ✅ | ✅ | ✅ |
| 查看隐藏的文章 | ❌ | ✅(自己的) | ✅(所有) |
| 创建文章 | ❌ | ✅ | ✅ |
| 编辑文章 | ❌ | ✅(自己的) | ✅(所有) |
| 删除文章 | ❌ | ❌ | ✅ |
| 查看已删除文章 | ❌ | ❌ | ✅ |
| 合集管理 | |||
| 查看合集 | ✅ | ✅ | ✅ |
| 创建/编辑/删除合集 | ❌ | ❌ | ✅ |
| 管理合集文章 | ❌ | ❌ | ✅ |
| 配置管理 | |||
| 查看系统配置 | ❌ | ❌ | ✅ |
| 修改系统配置 | ❌ | ❌ | ✅ |
| 用户管理 | |||
| 查看个人信息 | ❌ | ✅(自己的) | ✅(所有) |
| 编辑个人信息 | ❌ | ✅(自己的) | ✅(所有) |
| 管理用户 | ❌ | ❌ | ✅ |
多层防护架构
为了确保系统安全,设计了多层防护机制:
┌─────────────────────────────────────────────────────────────┐
│ Layer 1: 前端 UI 控制 │
│ ├─ 动态菜单生成(根据角色显示/隐藏菜单项) │
│ ├─ 按钮显隐控制(权限不足时隐藏操作按钮) │
│ └─ 路由访问拦截(未授权访问时跳转) │
│ ⚠️ 注意:前端控制只提供用户体验,不提供安全保障 │
├─────────────────────────────────────────────────────────────┤
│ Layer 2: 路由守卫 │
│ └─ 拦截未授权的 URL 访问(如普通用户访问 /c/config) │
├─────────────────────────────────────────────────────────────┤
│ Layer 3: API 权限验证 ✅ 核心防护 │
│ ├─ 身份验证(Token 验证) │
│ ├─ 角色权限检查(isAdmin/canManageXxx) │
│ ├─ 资源所有权检查(canAccessPost/canAccessUser) │
│ └─ 操作类型检查(read/edit/delete) │
├─────────────────────────────────────────────────────────────┤
│ Layer 4: 服务层过滤(最后一道防线) │
│ ├─ 查询条件过滤(WHERE created_by = ?) │
│ └─ 数据返回控制(过滤 is_delete=1 的数据) │
└─────────────────────────────────────────────────────────────┘
关键原则:
- 前端控制只负责用户体验,真正的权限验证必须在 API 层完成
- 每一层都是独立的防护,即使某一层被绕过,其他层仍能保护系统
权限工具库实现
为了统一权限检查逻辑,创建了专门的权限工具库 src/lib/permission.ts。
身份验证函数
validateUserFromRequest
从请求中验证用户身份:
typescript
export async function validateUserFromRequest(
headers: Headers
): Promise<{ user: User | null; error: string | null }> {
const token = getTokenFromRequest(headers);
if (!token) {
return { user: null, error: '未授权' };
}
const user = await validateToken(token);
if (!user) {
return { user: null, error: '登录已过期' };
}
return { user, error: null };
}
requireAdmin
验证用户是否为管理员(快捷方法):
typescript
export async function requireAdmin(
headers: Headers
): Promise<{ user: User | null; error: string | null }> {
const { user, error } = await validateUserFromRequest(headers);
if (error) {
return { user: null, error };
}
if (!user || !isAdmin(user.role)) {
return { user: null, error: '无权限访问' };
}
return { user, error: null };
}
资源权限检查函数
canAccessPost - 文章权限检查
检查用户是否有权限操作指定文章:
typescript
export function canAccessPost(
user: User | null,
post: SerializedPost,
operation: 'read' | 'edit' | 'delete'
): boolean {
// 管理员拥有所有权限
if (isAdmin(user?.role)) {
return true;
}
// 未登录用户无权限(编辑和删除)
if (!user) {
return operation === 'read';
}
// 普通用户只能操作自己创建的文章
if (post.created_by !== user.id) {
return false;
}
// 普通用户可以查看和编辑自己的文章
if (operation === 'read' || operation === 'edit') {
return true;
}
// 普通用户不能删除文章
return false;
}
canAccessUser - 用户权限检查
检查用户是否有权限操作指定用户的信息:
typescript
export function canAccessUser(
currentUser: User | null,
targetUserId: number,
operation: 'read' | 'edit' | 'delete'
): boolean {
// 管理员拥有所有权限
if (isAdmin(currentUser?.role)) {
return true;
}
// 未登录用户无权限
if (!currentUser) {
return false;
}
// 用户只能操作自己的信息(查看和编辑)
if (currentUser.id !== targetUserId) {
return false;
}
// 用户可以查看和编辑自己的信息
if (operation === 'read' || operation === 'edit') {
return true;
}
// 用户不能删除自己
return false;
}
canManageConfig / canManageCollections / canManageUsers
检查用户是否有管理权限(仅管理员):
typescript
export function canManageConfig(user: User | null): boolean {
return isAdmin(user?.role);
}
export function canManageCollections(user: User | null): boolean {
return isAdmin(user?.role);
}
export function canManageUsers(user: User | null): boolean {
return isAdmin(user?.role);
}
API 权限验证实现
标准权限检查模式
所有 API 都遵循标准的权限检查流程:
typescript
export async function POST(request: NextRequest) {
try {
// ===== 步骤 1: 验证身份 =====
const { user, error } = await validateUserFromRequest(request.headers);
if (error) {
return NextResponse.json(errorResponse(error), { status: 401 });
}
// ===== 步骤 2: 验证权限 =====
// 2a. 管理员专属操作
if (!canManageCollections(user)) {
return NextResponse.json(errorResponse('无权限操作合集'), { status: 403 });
}
// 2b. 资源所有权操作
// if (!canAccessPost(user, post, 'edit')) {
// return NextResponse.json(errorResponse('无权限编辑此文章'), { status: 403 });
// }
// ===== 步骤 3: 业务逻辑 =====
const result = await createCollection(data);
return NextResponse.json(successResponse(result));
} catch (error) {
// 错误处理
}
}
场景 1: 合集管理 API(管理员专属)
文件:src/app/api/collection/create/route.ts
typescript
export async function POST(request: NextRequest) {
try {
// 验证 Token
const token = getTokenFromRequest(request.headers);
const user = token ? await validateToken(token) : null;
if (!user) {
return NextResponse.json(errorResponse('未授权'), { status: 401 });
}
// 检查权限:只有管理员可以创建合集
if (!canManageCollections(user)) {
return NextResponse.json(errorResponse('无权限创建合集'), { status: 403 });
}
const body = await request.json();
const result = await createCollection({
...body,
created_by: user.id,
});
return NextResponse.json(successResponse(result, '创建成功'));
} catch (error) {
console.error('创建合集失败:', error);
return NextResponse.json(errorResponse('创建合集失败'), { status: 500 });
}
}
同样地,更新和删除合集的 API 也添加了相同的权限检查:
typescript
// src/app/api/collection/[id]/route.ts
export async function PUT(/* ... */) {
// 验证身份
const { user, error } = await validateUserFromRequest(request.headers);
if (error) {
return NextResponse.json(errorResponse(error), { status: 401 });
}
// 检查权限:只有管理员可以更新合集
if (!canManageCollections(user)) {
return NextResponse.json(errorResponse('无权限更新合集'), { status: 403 });
}
// 业务逻辑...
}
export async function DELETE(/* ... */) {
// 验证身份
const { user, error } = await validateUserFromRequest(request.headers);
if (error) {
return NextResponse.json(errorResponse(error), { status: 401 });
}
// 检查权限:只有管理员可以删除合集
if (!canManageCollections(user)) {
return NextResponse.json(errorResponse('无权限删除合集'), { status: 403 });
}
// 业务逻辑...
}
场景 2: 文章列表 API(隐藏/删除文章权限)
文件:src/app/api/post/list/route.ts
对于隐藏文章和已删除文章,实现了特殊的权限规则:
隐藏文章权限
- 管理员:可以查看所有人的隐藏文章
- 普通用户:可以查看自己的隐藏文章(用于恢复)
typescript
// hide=all 需要管理员权限(查看所有人的隐藏文章)
if (hide === 'all' && !isAdmin(user?.role)) {
return NextResponse.json(
errorResponse('无权限查看所有隐藏文章'),
{ status: 403 }
);
}
// hide=1 时,非管理员只能查看自己的隐藏文章
if (hide === '1' && !isAdmin(user?.role)) {
// 如果没有指定 created_by,或者不是查自己的文章,则拒绝
if (!createdBy || parseInt(createdBy, 10) !== user?.id) {
return NextResponse.json(
errorResponse('无权限查看其他用户的隐藏文章'),
{ status: 403 }
);
}
}
已删除文章权限
- 管理员:可以查看已删除文章(通过
is_delete=1参数) - 普通用户:绝对不能查看任何已删除文章(包括自己的)
typescript
// is_delete=1 只有管理员可以查询
if (isDelete === '1' && !isAdmin(user?.role)) {
return NextResponse.json(
errorResponse('无权限查看已删除文章'),
{ status: 403 }
);
}
场景 3: 文章详情 API(权限检查)
文件:src/app/api/post/[id]/route.ts
在获取文章详情时,也需要检查权限:
typescript
export async function GET(
request: NextRequest,
context: { params: Promise<{ id: string }> }
) {
try {
const { id } = await context.params;
let post;
// 判断是ID还是标题
if (isNaN(Number(id))) {
post = await getPostByTitle(decodeURIComponent(id));
} else {
post = await getPostById(Number(id));
}
if (!post) {
return NextResponse.json(errorResponse('文章不存在'), { status: 404 });
}
// 检查是否已删除:已删除的文章只有管理员可以查看
if (post.is_delete === 1) {
const token = getTokenFromRequest(request.headers);
const user = token ? await validateToken(token) : null;
if (!isAdmin(user?.role)) {
return NextResponse.json(errorResponse('无权限查看已删除文章'), { status: 403 });
}
}
// 检查是否隐藏:隐藏文章只有管理员或作者本人可以查看
if (post.hide === '1') {
const token = getTokenFromRequest(request.headers);
const user = token ? await validateToken(token) : null;
// 不是管理员且不是作者本人
if (!isAdmin(user?.role) && user?.id !== post.created_by) {
return NextResponse.json(errorResponse('无权限查看此隐藏文章'), { status: 403 });
}
}
return NextResponse.json(successResponse(post));
} catch (error) {
console.error('获取文章详情失败:', error);
return NextResponse.json(errorResponse('获取文章详情失败'), {
status: 500,
});
}
}
服务层数据过滤
除了 API 层的权限检查,在服务层也做了数据过滤,作为最后一道防线。
文件:src/services/post.ts
typescript
export async function getPostList(params: QueryCondition): Promise<PageQueryRes<SerializedPost>> {
const { pageNum = 1, pageSize = 10, hide = '0', query = '', created_by, is_delete } = params;
const prisma = await getPrisma();
// 构建查询条件
const whereConditions: Record<string, unknown> = {};
// is_delete 参数:明确指定时使用指定值,否则默认为 0(未删除)
if (is_delete !== undefined) {
whereConditions.is_delete = is_delete;
} else {
whereConditions.is_delete = 0; // 默认不返回已删除文章
}
if (hide !== 'all') {
whereConditions.hide = hide;
}
// 按创建者过滤
if (created_by !== undefined) {
whereConditions.created_by = created_by;
}
if (query) {
whereConditions.OR = [
{ title: { contains: query } },
{ content: { contains: query } },
];
}
const [record, total] = await Promise.all([
prisma.tbPost.findMany({
where: whereConditions,
// ...
}),
prisma.tbPost.count({ where: whereConditions }),
]);
return { record: serializePosts(record), total, pageNum, pageSize };
}
前端权限控制
虽然前端控制不能替代 API 权限验证,但良好的前端控制可以提升用户体验。
动态菜单生成
文件:src/app/c/layout.tsx
根据用户角色动态生成菜单:
typescript
const menuItems: MenuProps["items"] = useMemo(() => {
const items: MenuProps["items"] = [
{
key: "/c/post",
icon: <FileTextOutlined />,
label: "文章管理",
},
];
// 管理员专属菜单
if (isAdmin(user?.role)) {
items.push(
{
key: "/c/collections",
icon: <BookOutlined />,
label: "合集管理",
},
{
key: "/c/config",
icon: <SettingOutlined />,
label: "配置管理",
},
{
key: "/c/user",
icon: <UserOutlined />,
label: "用户管理",
}
);
}
return items;
}, [user?.role]);
路由访问拦截
防止用户直接访问无权限的 URL:
typescript
useEffect(() => {
if (!loading && user) {
// 定义管理员专属路径
const adminOnlyPaths = ['/c/collections', '/c/config', '/c/user'];
const isAdminPath = adminOnlyPaths.some(path =>
pathname === path || pathname.startsWith(path + '/')
);
// 个人中心页面例外处理
const isPersonalCenter = pathname.startsWith('/c/user/info');
if (isAdminPath && !isPersonalCenter && !isAdmin(user.role)) {
message.warning('您没有权限访问此页面');
router.push('/c/post');
}
}
}, [user, loading, pathname, router]);
创建者筛选功能
在文章管理页面,添加了创建者筛选功能:
- 管理员:可以选择"我创建的"或"全部",默认显示"我创建的"
- 普通用户:只能查看自己创建的文章
typescript
// 创建者筛选逻辑
if (user && isAdmin(user.role)) {
// 管理员可以选择查看全部或自己的文章
if (urlState.ownerFilter === 'mine') {
params.created_by = user.id;
}
// ownerFilter === 'all' 时不传 created_by,查看所有文章
} else if (user) {
// 普通用户只能查看自己的文章
params.created_by = user.id;
}
已删除文章筛选
仅管理员可见的"是否包含已删除"筛选选项:
typescript
{/* 是否包含已删除 - 仅管理员可见 */}
{isAdmin(user?.role) && (
<Select
placeholder="已删除文章"
size="large"
style={{ width: 140 }}
value={urlState.includeDeleted ? true : undefined}
onChange={(value) => updateQueryParams({ is_delete: value ? '1' : undefined, page: 1 })}
options={[
{ label: '仅未删除', value: false },
{ label: '包含已删除', value: true },
]}
/>
)}
HTTP 状态码规范
在实现权限检查时,使用了标准的 HTTP 状态码:
| 状态码 | 场景 | 示例 |
|---|---|---|
| 200 | 操作成功 | 更新成功、删除成功 |
| 400 | 请求参数错误 | pageSize 超出范围、无效的 ID |
| 401 | 未登录或 Token 无效 | 未授权、登录已过期 |
| 403 | 已登录但权限不足 | 无权限访问、无权限编辑此文章 |
| 404 | 资源不存在 | 文章不存在、用户不存在 |
| 500 | 服务器内部错误 | 数据库连接失败 |
权限设计文档化
为了确保团队开发的一致性,编写了详细的权限设计文档:
1. docs/PERMISSION.md
完整的权限设计文档,包含:
- 设计原则
- 权限模型
- 权限检查层级
- API 权限实现规范
- 通用权限工具
- 最佳实践
- 特殊场景处理
- 测试建议
2. CLAUDE.md
在项目主文档中添加了权限架构章节,供 Claude Code AI 参考。
3. .cursor/rules/permission.mdc
创建了 Cursor IDE 专用的权限规范文件,包含:
- 核心原则
- 权限模型
- API 权限实现标准
- 权限工具库说明
- 特殊场景处理
- 最佳实践
- 开发检查清单
开发检查清单
为了确保所有 API 都正确实现了权限检查,制定了开发检查清单:
- 是否验证了用户身份(Token 验证)?
- 是否检查了用户权限(角色/资源所有权)?
- 是否使用了封装的权限检查函数?
- 是否返回了正确的 HTTP 状态码?
- 错误消息是否明确指出权限不足?
- 是否考虑了绕过前端直接调用 API 的情况?
- 是否在服务层也做了数据过滤?
最佳实践总结
✅ 推荐做法
- 所有 API 都要验证身份
typescript
const { user, error } = await validateUserFromRequest(request.headers);
if (error) {
return NextResponse.json(errorResponse(error), { status: 401 });
}
- 使用封装的权限检查函数
typescript
// ✅ 推荐
if (!canManageCollections(user)) {
return NextResponse.json(errorResponse('无权限'), { status: 403 });
}
- 先检查权限,再查询数据
typescript
// ✅ 推荐
if (!canManageConfig(user)) {
return NextResponse.json(errorResponse('无权限'), { status: 403 });
}
const config = await getConfigById(id);
- 明确的错误消息
typescript
// ✅ 推荐
return NextResponse.json(errorResponse('无权限创建合集'), { status: 403 });
❌ 避免的做法
- 只依赖前端权限控制
typescript
// ❌ 错误:前端隐藏了,但 API 没有验证
export async function DELETE(request: NextRequest) {
await deleteCollection(id);
return NextResponse.json(successResponse(null, '删除成功'));
}
- 在 API 中信任用户角色
typescript
// ❌ 错误:从请求体中读取角色
const body = await request.json();
if (body.role !== 'admin') { // 用户可以伪造!
return NextResponse.json(errorResponse('无权限'), { status: 403 });
}
// ✅ 正确:从 Token 中验证
const user = await validateUser(token);
if (!canManageCollections(user)) {
return NextResponse.json(errorResponse('无权限'), { status: 403 });
}
- 忽略资源所有权检查
typescript
// ❌ 错误
const post = await getPostById(id);
await updatePost(id, data); // 用户可以修改别人的文章!
// ✅ 正确
const post = await getPostById(id);
if (!canAccessPost(user, post, 'edit')) {
return NextResponse.json(errorResponse('无权限编辑此文章'), { status: 403 });
}
await updatePost(id, data);
总结
通过这次权限系统的改造,实现了:
- 多层防护机制:前端 UI → 路由守卫 → API 验证 → 服务层过滤
- 统一的权限工具库:封装了常用的权限检查函数,避免代码重复
- 明确的权限规则:制定了详细的权限矩阵和特殊场景处理规则
- 完善的文档规范:编写了完整的权限设计文档和开发规范
- 开发检查清单:确保所有 API 都正确实现权限检查
关键收获:
- ✅ 前端控制只负责用户体验,真正的权限验证必须在 API 层完成
- ✅ 使用封装的权限检查函数,保持代码一致性
- ✅ 先检查权限,再查询数据,提升性能
- ❌ 永远不信任客户端请求的角色信息
- ❌ 不能因为前端隐藏了功能就跳过 API 权限检查
权限系统是安全的基石,希望通过本文的分享,帮助大家在 Next.js 项目中构建完善的 RBAC 权限系统。
参考资源
相关代码
- 权限工具库:
src/lib/permission.ts - 角色定义:
src/types/role.ts - API 示例:
src/app/api/collection/*/route.ts - 前端权限控制:
src/app/c/layout.tsx - 完整文档:
docs/PERMISSION.md