Next.js 博客系统权限设计:从零到完整的 RBAC 实现

2026年01月16日13 次阅读2 人喜欢
Next.js权限设计RBACTypeScriptAPI安全架构设计
所属合集

前言

在开发博客系统的过程中,权限系统是安全的核心。最近我对项目的权限系统进行了全面的升级和规范化,从简单的前端控制完善为多层防护的完整 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 的情况?
  • 是否在服务层也做了数据过滤?

最佳实践总结

✅ 推荐做法

  1. 所有 API 都要验证身份
typescript 复制代码
const { user, error } = await validateUserFromRequest(request.headers);
if (error) {
  return NextResponse.json(errorResponse(error), { status: 401 });
}
  1. 使用封装的权限检查函数
typescript 复制代码
// ✅ 推荐
if (!canManageCollections(user)) {
  return NextResponse.json(errorResponse('无权限'), { status: 403 });
}
  1. 先检查权限,再查询数据
typescript 复制代码
// ✅ 推荐
if (!canManageConfig(user)) {
  return NextResponse.json(errorResponse('无权限'), { status: 403 });
}
const config = await getConfigById(id);
  1. 明确的错误消息
typescript 复制代码
// ✅ 推荐
return NextResponse.json(errorResponse('无权限创建合集'), { status: 403 });

❌ 避免的做法

  1. 只依赖前端权限控制
typescript 复制代码
// ❌ 错误:前端隐藏了,但 API 没有验证
export async function DELETE(request: NextRequest) {
  await deleteCollection(id);
  return NextResponse.json(successResponse(null, '删除成功'));
}
  1. 在 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 });
}
  1. 忽略资源所有权检查
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);

总结

通过这次权限系统的改造,实现了:

  1. 多层防护机制:前端 UI → 路由守卫 → API 验证 → 服务层过滤
  2. 统一的权限工具库:封装了常用的权限检查函数,避免代码重复
  3. 明确的权限规则:制定了详细的权限矩阵和特殊场景处理规则
  4. 完善的文档规范:编写了完整的权限设计文档和开发规范
  5. 开发检查清单:确保所有 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
加载评论中...