refactor: RBAC权限系统重构 + UI组件拆分 + 测试修复 + 架构文档
Some checks failed
CI / build-deploy (push) Has been cancelled
Some checks failed
CI / build-deploy (push) Has been cancelled
- RBAC: 新增30个权限点、DataScope行级权限、requirePermission守卫,所有57+ Server Action接入权限校验 - UI拆分: exam-form(1623行→11文件)、textbook-reader(744行→7文件),均降至300行以内 - 测试: 新增5个单元测试文件(19用例),修复4个集成测试文件(38用例全部通过) - 架构文档: 新增架构影响地图(004/005)、标准功能清单(006)、差距审计报告(007) - 项目规则: 架构图优先规则,改码必同步图 - 安全: rehype-sanitize净化、AES加密API Key、权限路由守卫 - 无障碍: skip-link、aria-label、prefers-reduced-motion - 性能: next/font优化、next/image、代码分割
This commit is contained in:
975
docs/architecture/002_rbac_refactoring.md
Normal file
975
docs/architecture/002_rbac_refactoring.md
Normal file
@@ -0,0 +1,975 @@
|
||||
# 企业级权限体系重构方案
|
||||
|
||||
> 基于源码逆向分析,覆盖 proxy.ts / auth.ts / 各模块 actions.ts / 前端组件
|
||||
|
||||
---
|
||||
|
||||
## 一、问题诊断
|
||||
|
||||
### 1.1 安全隐患
|
||||
|
||||
#### 隐患 A:考试模块 Server Action 零鉴权
|
||||
|
||||
[exams/actions.ts:721-723](file:///e:/Desktop/CICD/src/modules/exams/actions.ts#L721-L723) 中的 `getCurrentUser()` 是一个硬编码存根:
|
||||
|
||||
```typescript
|
||||
async function getCurrentUser() {
|
||||
return { id: "user_teacher_math", role: "teacher" }
|
||||
}
|
||||
```
|
||||
|
||||
**后果**:任何已登录用户(包括 student/parent)都可以调用 `deleteExamAction`、`updateExamAction`、`duplicateExamAction`,删除或修改任意考试。攻击路径:
|
||||
|
||||
1. 学生 A 登录系统,获取合法 session
|
||||
2. 直接调用 `deleteExamAction`,传入任意 `examId`
|
||||
3. Server Action 不校验调用者身份,直接执行 `db.delete(exams).where(eq(exams.id, examId))`
|
||||
4. 考试被删除
|
||||
|
||||
#### 隐患 B:越权修改他人考试
|
||||
|
||||
即使修复了 `getCurrentUser()`,`updateExamAction` 和 `deleteExamAction` 仍不检查资源归属:
|
||||
|
||||
```typescript
|
||||
// updateExamAction — 任何 teacher 可修改任何考试
|
||||
await db.update(exams).set(updateData).where(eq(exams.id, examId))
|
||||
// 缺少: AND creatorId = currentUserId
|
||||
```
|
||||
|
||||
教师 B 可以修改教师 A 创建的考试,只需知道 examId。
|
||||
|
||||
#### 隐患 C:班级管理部分接口无鉴权
|
||||
|
||||
[classes/actions.ts](file:///e:/Desktop/CICD/src/modules/classes/actions.ts) 中:
|
||||
- `enrollStudentByEmailAction` — 无任何 auth 检查
|
||||
- `setStudentEnrollmentStatusAction` — 无任何 auth 检查
|
||||
- `createClassScheduleItemAction` — 无任何 auth 检查
|
||||
- `deleteClassScheduleItemAction` — 无任何 auth 检查
|
||||
- `ensureClassInvitationCodeAction` — 无任何 auth 检查
|
||||
- `regenerateClassInvitationCodeAction` — 无任何 auth 检查
|
||||
|
||||
任何已登录用户都可以操作任意班级的学生和课表。
|
||||
|
||||
#### 隐患 D:作业批改无资源归属校验
|
||||
|
||||
`gradeHomeworkSubmissionAction` 只调用 `ensureTeacher()`,确认调用者是教师身份,但不检查该教师是否有权批改此作业。任何教师可批改任何班级的作业。
|
||||
|
||||
### 1.2 扩展性问题
|
||||
|
||||
#### 问题 A:角色硬编码散布全栈
|
||||
|
||||
前端 14 处 `role === "xxx"` 硬编码,后端各模块各自实现鉴权逻辑:
|
||||
|
||||
| 位置 | 模式 |
|
||||
|------|------|
|
||||
| `proxy.ts` | `if (role === "admin")` |
|
||||
| `onboarding-gate.tsx` | `if (role === "admin")` / `role === "teacher"` / `role === "student"` |
|
||||
| `dashboard/page.tsx` | `if (role === "admin") redirect(...)` |
|
||||
| `settings/page.tsx` | `if (role === "admin") return <AdminSettingsView>` |
|
||||
| `homework/actions.ts` | `ensureTeacher()` / `ensureStudent()` |
|
||||
| `classes/actions.ts` | `session.user.role !== "admin"` |
|
||||
|
||||
新增角色(如 `teaching_head`、`grade_head`)需要改动所有这些位置。
|
||||
|
||||
#### 问题 B:多角色用户被强制取一个
|
||||
|
||||
[auth.ts](file:///e:/Desktop/CICD/src/auth.ts) 中多角色解析逻辑取优先级最高的一个角色,存入 JWT 的单一 `role` 字段。一个同时是 `teacher` + `grade_head` 的用户,在 JWT 中只保留 `teacher`,导致年级管理功能不可用。
|
||||
|
||||
#### 问题 C:数据权限无统一抽象
|
||||
|
||||
`homework/actions.ts` 手动实现了"教师只能为自己班级的作业发布任务"的逻辑(30+ 行),`classes/actions.ts` 手动实现了"年级主任只能管理自己年级"的逻辑(20+ 行)。每个模块各自实现,无法复用。
|
||||
|
||||
### 1.3 可维护性问题
|
||||
|
||||
#### 问题 A:鉴权逻辑与业务逻辑耦合
|
||||
|
||||
每个 Server Action 的前 10-30 行都是鉴权代码,与业务逻辑混杂,难以测试和复用。
|
||||
|
||||
#### 问题 B:前端条件渲染依赖角色字符串
|
||||
|
||||
```tsx
|
||||
// 当前:硬编码角色判断
|
||||
{role === "teacher" ? <TeacherView /> : <StudentView />}
|
||||
|
||||
// 无法支持:年级主任看到额外的管理入口
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 二、企业级权限模型设计
|
||||
|
||||
### 2.1 权限模型:RBAC + 数据权限
|
||||
|
||||
```
|
||||
┌─────────────┐ ┌──────────────┐ ┌─────────────┐
|
||||
│ User │────→│ UserRole │←────│ Role │
|
||||
│ │ M:N │ (多角色) │ │ (角色定义) │
|
||||
└─────────────┘ └──────────────┘ └──────┬──────┘
|
||||
│ M:N
|
||||
┌──────┴──────┐
|
||||
│ RolePermission│
|
||||
└──────┬──────┘
|
||||
│ N:1
|
||||
┌──────┴──────┐
|
||||
│ Permission │
|
||||
│ (权限点) │
|
||||
└─────────────┘
|
||||
```
|
||||
|
||||
### 2.2 权限点定义
|
||||
|
||||
权限点采用 `resource:action` 命名规范:
|
||||
|
||||
```typescript
|
||||
// src/shared/types/permissions.ts
|
||||
|
||||
export const Permissions = {
|
||||
// 考试
|
||||
EXAM_CREATE: "exam:create",
|
||||
EXAM_READ: "exam:read",
|
||||
EXAM_UPDATE: "exam:update",
|
||||
EXAM_DELETE: "exam:delete",
|
||||
EXAM_DUPLICATE: "exam:duplicate",
|
||||
EXAM_PUBLISH: "exam:publish",
|
||||
EXAM_AI_GENERATE: "exam:ai_generate",
|
||||
|
||||
// 作业
|
||||
HOMEWORK_CREATE: "homework:create",
|
||||
HOMEWORK_GRADE: "homework:grade",
|
||||
HOMEWORK_SUBMIT: "homework:submit",
|
||||
|
||||
// 题库
|
||||
QUESTION_CREATE: "question:create",
|
||||
QUESTION_READ: "question:read",
|
||||
QUESTION_UPDATE: "question:update",
|
||||
QUESTION_DELETE: "question:delete",
|
||||
|
||||
// 教材
|
||||
TEXTBOOK_CREATE: "textbook:create",
|
||||
TEXTBOOK_READ: "textbook:read",
|
||||
TEXTBOOK_UPDATE: "textbook:update",
|
||||
TEXTBOOK_DELETE: "textbook:delete",
|
||||
|
||||
// 班级
|
||||
CLASS_CREATE: "class:create",
|
||||
CLASS_READ: "class:read",
|
||||
CLASS_UPDATE: "class:update",
|
||||
CLASS_DELETE: "class:delete",
|
||||
CLASS_ENROLL: "class:enroll",
|
||||
CLASS_SCHEDULE: "class:schedule",
|
||||
|
||||
// 学校管理
|
||||
SCHOOL_MANAGE: "school:manage",
|
||||
GRADE_MANAGE: "grade:manage",
|
||||
USER_MANAGE: "user:manage",
|
||||
|
||||
// AI
|
||||
AI_CHAT: "ai:chat",
|
||||
AI_CONFIGURE: "ai:configure",
|
||||
|
||||
// 设置
|
||||
SETTINGS_ADMIN: "settings:admin",
|
||||
} as const
|
||||
|
||||
export type Permission = typeof Permissions[keyof typeof Permissions]
|
||||
```
|
||||
|
||||
### 2.3 角色-权限映射
|
||||
|
||||
```typescript
|
||||
// src/shared/lib/permissions.ts
|
||||
|
||||
import { Permissions, type Permission } from "@/shared/types/permissions"
|
||||
|
||||
export const ROLE_PERMISSIONS: Record<string, Permission[]> = {
|
||||
admin: [
|
||||
Permissions.EXAM_CREATE, Permissions.EXAM_READ, Permissions.EXAM_UPDATE,
|
||||
Permissions.EXAM_DELETE, Permissions.EXAM_DUPLICATE, Permissions.EXAM_PUBLISH,
|
||||
Permissions.EXAM_AI_GENERATE,
|
||||
Permissions.HOMEWORK_CREATE, Permissions.HOMEWORK_GRADE,
|
||||
Permissions.QUESTION_CREATE, Permissions.QUESTION_READ,
|
||||
Permissions.QUESTION_UPDATE, Permissions.QUESTION_DELETE,
|
||||
Permissions.TEXTBOOK_CREATE, Permissions.TEXTBOOK_READ,
|
||||
Permissions.TEXTBOOK_UPDATE, Permissions.TEXTBOOK_DELETE,
|
||||
Permissions.CLASS_CREATE, Permissions.CLASS_READ,
|
||||
Permissions.CLASS_UPDATE, Permissions.CLASS_DELETE,
|
||||
Permissions.CLASS_ENROLL, Permissions.CLASS_SCHEDULE,
|
||||
Permissions.SCHOOL_MANAGE, Permissions.GRADE_MANAGE,
|
||||
Permissions.USER_MANAGE,
|
||||
Permissions.AI_CHAT, Permissions.AI_CONFIGURE,
|
||||
Permissions.SETTINGS_ADMIN,
|
||||
],
|
||||
teacher: [
|
||||
Permissions.EXAM_CREATE, Permissions.EXAM_READ, Permissions.EXAM_UPDATE,
|
||||
Permissions.EXAM_DELETE, Permissions.EXAM_DUPLICATE, Permissions.EXAM_PUBLISH,
|
||||
Permissions.EXAM_AI_GENERATE,
|
||||
Permissions.HOMEWORK_CREATE, Permissions.HOMEWORK_GRADE,
|
||||
Permissions.QUESTION_CREATE, Permissions.QUESTION_READ,
|
||||
Permissions.QUESTION_UPDATE, Permissions.QUESTION_DELETE,
|
||||
Permissions.TEXTBOOK_CREATE, Permissions.TEXTBOOK_READ,
|
||||
Permissions.TEXTBOOK_UPDATE,
|
||||
Permissions.CLASS_READ, Permissions.CLASS_ENROLL,
|
||||
Permissions.CLASS_SCHEDULE,
|
||||
Permissions.AI_CHAT,
|
||||
],
|
||||
student: [
|
||||
Permissions.EXAM_READ,
|
||||
Permissions.HOMEWORK_SUBMIT,
|
||||
Permissions.QUESTION_READ,
|
||||
Permissions.TEXTBOOK_READ,
|
||||
Permissions.CLASS_READ,
|
||||
Permissions.AI_CHAT,
|
||||
],
|
||||
parent: [
|
||||
Permissions.EXAM_READ,
|
||||
Permissions.TEXTBOOK_READ,
|
||||
Permissions.CLASS_READ,
|
||||
],
|
||||
// 可扩展:年级主任、教研组长等
|
||||
grade_head: [
|
||||
Permissions.EXAM_CREATE, Permissions.EXAM_READ, Permissions.EXAM_UPDATE,
|
||||
Permissions.EXAM_DELETE, Permissions.EXAM_DUPLICATE, Permissions.EXAM_PUBLISH,
|
||||
Permissions.EXAM_AI_GENERATE,
|
||||
Permissions.HOMEWORK_CREATE, Permissions.HOMEWORK_GRADE,
|
||||
Permissions.QUESTION_CREATE, Permissions.QUESTION_READ,
|
||||
Permissions.QUESTION_UPDATE, Permissions.QUESTION_DELETE,
|
||||
Permissions.TEXTBOOK_CREATE, Permissions.TEXTBOOK_READ,
|
||||
Permissions.TEXTBOOK_UPDATE,
|
||||
Permissions.CLASS_CREATE, Permissions.CLASS_READ,
|
||||
Permissions.CLASS_UPDATE, Permissions.CLASS_ENROLL,
|
||||
Permissions.CLASS_SCHEDULE,
|
||||
Permissions.GRADE_MANAGE,
|
||||
Permissions.AI_CHAT,
|
||||
],
|
||||
}
|
||||
```
|
||||
|
||||
### 2.4 数据权限策略
|
||||
|
||||
数据权限通过 `DataScope` 定义,在 data-access 层自动注入过滤条件:
|
||||
|
||||
```typescript
|
||||
// src/shared/types/permissions.ts
|
||||
|
||||
export type DataScope =
|
||||
| { type: "all" } // admin: 全量
|
||||
| { type: "owned" } // 仅自己创建的
|
||||
| { type: "class_members" } // 所在班级的成员数据
|
||||
| { type: "grade_managed"; gradeIds: string[] } // 管理的年级
|
||||
| { type: "class_taught"; classIds: string[]; subjectIds?: string[] } // 教学的班级(可限定科目)
|
||||
| { type: "children"; childrenIds: string[] } // 家长:子女数据
|
||||
|
||||
export interface AuthContext {
|
||||
userId: string
|
||||
roles: string[] // 所有角色,不再只取一个
|
||||
permissions: Permission[] // 合并所有角色的权限(去重)
|
||||
dataScope: DataScope // 数据权限范围
|
||||
}
|
||||
```
|
||||
|
||||
### 2.5 数据库变更
|
||||
|
||||
**新增一张表**(最小变更,不修改现有表结构):
|
||||
|
||||
```sql
|
||||
CREATE TABLE role_permissions (
|
||||
role_id VARCHAR(128) NOT NULL,
|
||||
permission VARCHAR(100) NOT NULL,
|
||||
PRIMARY KEY (role_id, permission),
|
||||
FOREIGN KEY (role_id) REFERENCES roles(id) ON DELETE CASCADE,
|
||||
INDEX idx_role_permissions_role (role_id)
|
||||
);
|
||||
```
|
||||
|
||||
Drizzle schema:
|
||||
|
||||
```typescript
|
||||
// 追加到 src/shared/db/schema.ts
|
||||
|
||||
export const rolePermissions = mysqlTable("role_permissions", {
|
||||
roleId: varchar("role_id", { length: 128 }).notNull()
|
||||
.references(() => roles.id, { onDelete: "cascade" }),
|
||||
permission: varchar("permission", { length: 100 }).notNull(),
|
||||
}, (table) => ({
|
||||
pk: primaryKey({ columns: [table.roleId, table.permission] }),
|
||||
roleIdIdx: index("role_permissions_role_idx").on(table.roleId),
|
||||
}))
|
||||
```
|
||||
|
||||
**不修改现有表**。`ROLE_PERMISSIONS` 常量作为初始 seed 数据,运行时优先从数据库读取(支持动态调整),数据库无记录时 fallback 到常量。
|
||||
|
||||
---
|
||||
|
||||
## 三、实现方案
|
||||
|
||||
### 3.1 NextAuth Session 携带权限信息
|
||||
|
||||
#### 修改 JWT 和 Session 类型
|
||||
|
||||
```typescript
|
||||
// src/next-auth.d.ts
|
||||
|
||||
declare module "next-auth" {
|
||||
interface Session {
|
||||
user: DefaultSession["user"] & {
|
||||
id: string
|
||||
roles: string[] // 所有角色
|
||||
permissions: string[] // 合并后的权限列表
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
declare module "next-auth/jwt" {
|
||||
interface JWT {
|
||||
id: string
|
||||
roles: string[]
|
||||
permissions: string[]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 修改 auth.ts callbacks
|
||||
|
||||
```typescript
|
||||
// src/auth.ts — 关键改动
|
||||
|
||||
import { ROLE_PERMISSIONS } from "@/shared/lib/permissions"
|
||||
|
||||
function resolvePermissions(roleNames: string[]): string[] {
|
||||
const set = new Set<string>()
|
||||
for (const name of roleNames) {
|
||||
const perms = ROLE_PERMISSIONS[name] ?? []
|
||||
for (const p of perms) set.add(p)
|
||||
}
|
||||
return Array.from(set)
|
||||
}
|
||||
|
||||
export const authConfig = {
|
||||
callbacks: {
|
||||
async jwt({ token, user }) {
|
||||
if (user) {
|
||||
token.id = user.id
|
||||
// 查询用户所有角色
|
||||
const userRoles = await db
|
||||
.select({ name: roles.name })
|
||||
.from(usersToRoles)
|
||||
.innerJoin(roles, eq(usersToRoles.roleId, roles.id))
|
||||
.where(eq(usersToRoles.userId, user.id))
|
||||
|
||||
const roleNames = userRoles.map(r => r.name)
|
||||
token.roles = roleNames
|
||||
token.permissions = resolvePermissions(roleNames)
|
||||
}
|
||||
return token
|
||||
},
|
||||
async session({ session, token }) {
|
||||
session.user.id = token.id
|
||||
session.user.roles = token.roles
|
||||
session.user.permissions = token.permissions
|
||||
return session
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
### 3.2 `requirePermission()` 服务端权限断言
|
||||
|
||||
```typescript
|
||||
// src/shared/lib/auth-guard.ts
|
||||
|
||||
import { auth } from "@/auth"
|
||||
import { ROLE_PERMISSIONS } from "@/shared/lib/permissions"
|
||||
import type { Permission, DataScope, AuthContext } from "@/shared/types/permissions"
|
||||
import { db } from "@/shared/db"
|
||||
import { users, usersToRoles, roles, classes, classEnrollments, classSubjectTeachers, grades } from "@/shared/db/schema"
|
||||
import { eq, and, inArray } from "drizzle-orm"
|
||||
|
||||
class PermissionDeniedError extends Error {
|
||||
constructor(permission: string) {
|
||||
super(`Permission denied: ${permission}`)
|
||||
this.name = "PermissionDeniedError"
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前用户的完整认证上下文
|
||||
*/
|
||||
export async function getAuthContext(): Promise<AuthContext> {
|
||||
const session = await auth()
|
||||
const userId = session?.user?.id
|
||||
if (!userId) throw new PermissionDeniedError("auth_required")
|
||||
|
||||
const roles = session.user.roles ?? []
|
||||
const permissions = session.user.permissions ?? []
|
||||
const dataScope = await resolveDataScope(userId, roles)
|
||||
|
||||
return { userId, roles, permissions, dataScope }
|
||||
}
|
||||
|
||||
/**
|
||||
* 断言当前用户拥有指定权限
|
||||
*/
|
||||
export async function requirePermission(permission: Permission): Promise<AuthContext> {
|
||||
const ctx = await getAuthContext()
|
||||
if (!ctx.permissions.includes(permission)) {
|
||||
throw new PermissionDeniedError(permission)
|
||||
}
|
||||
return ctx
|
||||
}
|
||||
|
||||
/**
|
||||
* 断言当前用户拥有指定权限,并返回上下文(不抛异常版本)
|
||||
*/
|
||||
export async function checkPermission(permission: Permission): Promise<{ allowed: boolean; ctx: AuthContext }> {
|
||||
const ctx = await getAuthContext()
|
||||
return { allowed: ctx.permissions.includes(permission), ctx }
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据角色解析数据权限范围
|
||||
*/
|
||||
async function resolveDataScope(userId: string, roleNames: string[]): Promise<DataScope> {
|
||||
if (roleNames.includes("admin")) {
|
||||
return { type: "all" }
|
||||
}
|
||||
|
||||
// 年级主任
|
||||
if (roleNames.includes("grade_head")) {
|
||||
const managedGrades = await db
|
||||
.select({ id: grades.id })
|
||||
.from(grades)
|
||||
.where(
|
||||
and(
|
||||
eq(grades.gradeHeadId, userId),
|
||||
// 或 teachingHeadId
|
||||
)
|
||||
)
|
||||
if (managedGrades.length > 0) {
|
||||
return { type: "grade_managed", gradeIds: managedGrades.map(g => g.id) }
|
||||
}
|
||||
}
|
||||
|
||||
// 教师
|
||||
if (roleNames.includes("teacher")) {
|
||||
const taughtClasses = await db
|
||||
.select({ classId: classes.id, subjectId: classSubjectTeachers.subjectId })
|
||||
.from(classes)
|
||||
.leftJoin(classSubjectTeachers, eq(classSubjectTeachers.classId, classes.id))
|
||||
.where(eq(classes.teacherId, userId))
|
||||
|
||||
const classIds = [...new Set(taughtClasses.map(c => c.classId))]
|
||||
const subjectIds = taughtClasses
|
||||
.map(c => c.subjectId)
|
||||
.filter((s): s is string => s !== null)
|
||||
|
||||
return { type: "class_taught", classIds, subjectIds: subjectIds.length > 0 ? subjectIds : undefined }
|
||||
}
|
||||
|
||||
// 学生
|
||||
if (roleNames.includes("student")) {
|
||||
return { type: "class_members" }
|
||||
}
|
||||
|
||||
// 家长
|
||||
if (roleNames.includes("parent")) {
|
||||
// TODO: 查询关联子女
|
||||
return { type: "children", childrenIds: [] }
|
||||
}
|
||||
|
||||
return { type: "owned" }
|
||||
}
|
||||
|
||||
/**
|
||||
* 在 data-access 层使用:根据 DataScope 生成查询过滤条件
|
||||
*/
|
||||
export function applyDataScope(
|
||||
scope: DataScope,
|
||||
options: {
|
||||
creatorIdField?: any // 如 exams.creatorId
|
||||
classIdField?: any // 如 classes.id
|
||||
gradeIdField?: any // 如 grades.id
|
||||
studentIdField?: any // 如 classEnrollments.studentId
|
||||
}
|
||||
): any[] {
|
||||
const conditions: any[] = []
|
||||
|
||||
switch (scope.type) {
|
||||
case "all":
|
||||
break // 无过滤
|
||||
case "owned":
|
||||
if (options.creatorIdField) {
|
||||
conditions.push(eq(options.creatorIdField, /* currentUserId */))
|
||||
}
|
||||
break
|
||||
case "class_taught":
|
||||
if (options.classIdField && scope.classIds.length > 0) {
|
||||
conditions.push(inArray(options.classIdField, scope.classIds))
|
||||
}
|
||||
break
|
||||
case "grade_managed":
|
||||
if (options.gradeIdField && scope.gradeIds.length > 0) {
|
||||
conditions.push(inArray(options.gradeIdField, scope.gradeIds))
|
||||
}
|
||||
break
|
||||
case "class_members":
|
||||
// 需要子查询:学生所在班级
|
||||
break
|
||||
}
|
||||
|
||||
return conditions
|
||||
}
|
||||
|
||||
export { PermissionDeniedError }
|
||||
```
|
||||
|
||||
### 3.3 `usePermission()` 前端 Hook
|
||||
|
||||
```typescript
|
||||
// src/shared/hooks/use-permission.ts
|
||||
|
||||
"use client"
|
||||
|
||||
import { useSession } from "next-auth/react"
|
||||
import type { Permission } from "@/shared/types/permissions"
|
||||
|
||||
export function usePermission() {
|
||||
const { data: session } = useSession()
|
||||
const permissions = session?.user?.permissions ?? []
|
||||
const roles = session?.user?.roles ?? []
|
||||
|
||||
const hasPermission = (permission: Permission): boolean => {
|
||||
return permissions.includes(permission)
|
||||
}
|
||||
|
||||
const hasAnyPermission = (...perms: Permission[]): boolean => {
|
||||
return perms.some(p => permissions.includes(p))
|
||||
}
|
||||
|
||||
const hasAllPermissions = (...perms: Permission[]): boolean => {
|
||||
return perms.every(p => permissions.includes(p))
|
||||
}
|
||||
|
||||
const hasRole = (role: string): boolean => {
|
||||
return roles.includes(role)
|
||||
}
|
||||
|
||||
return { permissions, roles, hasPermission, hasAnyPermission, hasAllPermissions, hasRole }
|
||||
}
|
||||
```
|
||||
|
||||
### 3.4 Server Action 集成模式
|
||||
|
||||
#### 改造前(exams/actions.ts)
|
||||
|
||||
```typescript
|
||||
// 零鉴权,getCurrentUser() 返回硬编码值
|
||||
export async function deleteExamAction(prevState, formData) {
|
||||
const { examId } = parsed.data
|
||||
await db.delete(exams).where(eq(exams.id, examId)) // 任何人可删任何考试
|
||||
}
|
||||
```
|
||||
|
||||
#### 改造后
|
||||
|
||||
```typescript
|
||||
import { requirePermission, getAuthContext, PermissionDeniedError } from "@/shared/lib/auth-guard"
|
||||
import { Permissions } from "@/shared/types/permissions"
|
||||
|
||||
export async function deleteExamAction(prevState, formData) {
|
||||
try {
|
||||
const ctx = await requirePermission(Permissions.EXAM_DELETE)
|
||||
const { examId } = parsed.data
|
||||
|
||||
// 数据权限:非 admin 只能删自己的考试
|
||||
if (ctx.dataScope.type !== "all") {
|
||||
const exam = await db.query.exams.findFirst({
|
||||
where: eq(exams.id, examId),
|
||||
columns: { creatorId: true },
|
||||
})
|
||||
if (!exam || exam.creatorId !== ctx.userId) {
|
||||
return failState("You can only delete your own exams")
|
||||
}
|
||||
}
|
||||
|
||||
await db.delete(exams).where(eq(exams.id, examId))
|
||||
} catch (e) {
|
||||
if (e instanceof PermissionDeniedError) return failState(e.message)
|
||||
throw e
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3.5 data-access 层数据权限过滤
|
||||
|
||||
```typescript
|
||||
// src/modules/exams/data-access.ts — 改造后
|
||||
|
||||
import { applyDataScope } from "@/shared/lib/auth-guard"
|
||||
import type { DataScope } from "@/shared/types/permissions"
|
||||
|
||||
export const getExams = cache(async (params: GetExamsParams & { scope: DataScope }) => {
|
||||
const conditions = []
|
||||
|
||||
// 原有筛选条件
|
||||
if (params.q) { /* ... */ }
|
||||
if (params.status && params.status !== "all") { /* ... */ }
|
||||
|
||||
// 数据权限过滤
|
||||
const scopeConditions = applyDataScope(params.scope, {
|
||||
creatorIdField: exams.creatorId,
|
||||
gradeIdField: exams.gradeId,
|
||||
})
|
||||
conditions.push(...scopeConditions)
|
||||
|
||||
const data = await db.query.exams.findMany({
|
||||
where: conditions.length ? and(...conditions) : undefined,
|
||||
// ...
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
### 3.6 Middleware 改造
|
||||
|
||||
```typescript
|
||||
// src/proxy.ts — 改造后
|
||||
|
||||
import { NextResponse } from "next/server"
|
||||
import type { NextRequest } from "next/server"
|
||||
import { getToken } from "next-auth/jwt"
|
||||
|
||||
// 路由 → 所需权限映射
|
||||
const ROUTE_PERMISSIONS: Record<string, string> = {
|
||||
"/admin": "school:manage",
|
||||
"/teacher": "exam:read", // teacher 区域最低权限
|
||||
"/student": "exam:read", // student 区域最低权限
|
||||
"/parent": "exam:read", // parent 区域最低权限
|
||||
}
|
||||
|
||||
// API 路由 → 所需权限映射
|
||||
const API_PERMISSIONS: Record<string, string> = {
|
||||
"/api/ai/chat": "ai:chat",
|
||||
"/api/onboarding": "auth_required", // 特殊标记:仅需登录
|
||||
}
|
||||
|
||||
export async function middleware(request: NextRequest) {
|
||||
const token = await getToken({ req: request })
|
||||
|
||||
if (!token) {
|
||||
const loginUrl = new URL("/login", request.url)
|
||||
loginUrl.searchParams.set("callbackUrl", request.url)
|
||||
return NextResponse.redirect(loginUrl)
|
||||
}
|
||||
|
||||
const { pathname } = request.nextUrl
|
||||
const permissions: string[] = (token.permissions as string[]) ?? []
|
||||
const roles: string[] = (token.roles as string[]) ?? []
|
||||
|
||||
// 检查 API 路由权限
|
||||
for (const [prefix, requiredPerm] of Object.entries(API_PERMISSIONS)) {
|
||||
if (pathname.startsWith(prefix)) {
|
||||
if (requiredPerm === "auth_required") break // 仅需登录
|
||||
if (!permissions.includes(requiredPerm)) {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 })
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// 检查页面路由权限
|
||||
for (const [prefix, requiredPerm] of Object.entries(ROUTE_PERMISSIONS)) {
|
||||
if (pathname.startsWith(prefix)) {
|
||||
if (!permissions.includes(requiredPerm)) {
|
||||
// 无权限 → 重定向到角色首页
|
||||
const defaultPath = resolveDefaultPath(roles)
|
||||
return NextResponse.redirect(new URL(defaultPath, request.url))
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.next()
|
||||
}
|
||||
|
||||
function resolveDefaultPath(roles: string[]): string {
|
||||
if (roles.includes("admin")) return "/admin/dashboard"
|
||||
if (roles.includes("teacher")) return "/teacher/dashboard"
|
||||
if (roles.includes("student")) return "/student/dashboard"
|
||||
if (roles.includes("parent")) return "/parent/dashboard"
|
||||
return "/dashboard"
|
||||
}
|
||||
|
||||
export const config = {
|
||||
matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"],
|
||||
}
|
||||
```
|
||||
|
||||
### 3.7 前端组件改造
|
||||
|
||||
#### 改造前
|
||||
|
||||
```tsx
|
||||
// 硬编码角色判断
|
||||
{role === "teacher" ? <TeacherView /> : <StudentView />}
|
||||
```
|
||||
|
||||
#### 改造后
|
||||
|
||||
```tsx
|
||||
import { usePermission } from "@/shared/hooks/use-permission"
|
||||
import { Permissions } from "@/shared/types/permissions"
|
||||
|
||||
function DashboardPage() {
|
||||
const { hasPermission, hasRole } = usePermission()
|
||||
|
||||
return (
|
||||
<>
|
||||
{hasPermission(Permissions.HOMEWORK_GRADE) && <GradingWidget />}
|
||||
{hasPermission(Permissions.EXAM_CREATE) && <CreateExamButton />}
|
||||
{hasPermission(Permissions.SCHOOL_MANAGE) && <AdminPanel />}
|
||||
</>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 四、渐进式重构路线图
|
||||
|
||||
### Phase 1:基础设施(1-2 天)
|
||||
|
||||
1. 创建 `src/shared/types/permissions.ts` — 权限点定义
|
||||
2. 创建 `src/shared/lib/permissions.ts` — 角色-权限映射
|
||||
3. 创建 `src/shared/lib/auth-guard.ts` — `requirePermission()` / `getAuthContext()`
|
||||
4. 创建 `src/shared/hooks/use-permission.ts` — 前端 Hook
|
||||
5. 修改 `src/next-auth.d.ts` — Session/JWT 类型扩展
|
||||
6. 修改 `src/auth.ts` — JWT/Session callbacks 注入权限
|
||||
7. 新增 `role_permissions` 数据库表 + seed 脚本
|
||||
|
||||
### Phase 2:修复高危模块(2-3 天)
|
||||
|
||||
优先修复零鉴权和越权漏洞:
|
||||
|
||||
1. **exams/actions.ts** — 替换 `getCurrentUser()` 存根,所有 Action 加 `requirePermission()`
|
||||
2. **classes/actions.ts** — 无鉴权接口补全,统一使用 `requirePermission()`
|
||||
3. **homework/actions.ts** — 用 `requirePermission()` 替换 `ensureTeacher()`/`ensureStudent()`
|
||||
4. **questions/actions.ts** — 补全鉴权
|
||||
5. **textbooks/actions.ts** — 补全鉴权
|
||||
|
||||
### Phase 3:数据权限集成(2-3 天)
|
||||
|
||||
1. 各模块 `data-access.ts` 接受 `DataScope` 参数
|
||||
2. `getExams()` / `getHomeworkAssignments()` 等查询函数自动过滤
|
||||
3. 页面级 Server Component 传入 `AuthContext`
|
||||
|
||||
### Phase 4:Middleware 升级(1 天)
|
||||
|
||||
1. `proxy.ts` 从角色路由匹配 → 权限点路由匹配
|
||||
2. API 路由增加权限检查
|
||||
|
||||
### Phase 5:前端改造(2-3 天)
|
||||
|
||||
1. 逐步替换 `role === "xxx"` 为 `hasPermission()`
|
||||
2. 侧边栏导航从 `NAV_CONFIG[role]` → `NAV_CONFIG.filter(item => hasPermission(item.permission))`
|
||||
3. 条件渲染统一使用 `usePermission()`
|
||||
|
||||
---
|
||||
|
||||
## 五、考试模块改造对比
|
||||
|
||||
### 5.1 Server Actions 改造
|
||||
|
||||
#### 改造前 — createExamAction
|
||||
|
||||
```typescript
|
||||
export async function createExamAction(prevState, formData) {
|
||||
// ... 解析表单 ...
|
||||
try {
|
||||
const user = await getCurrentUser() // 硬编码存根!
|
||||
await persistExamDraft({
|
||||
examId: context.examId,
|
||||
creatorId: user?.id ?? "user_teacher_math", // 硬编码 fallback!
|
||||
// ...
|
||||
})
|
||||
} catch (error) {
|
||||
return failState("Database error")
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 改造后 — createExamAction
|
||||
|
||||
```typescript
|
||||
export async function createExamAction(prevState, formData) {
|
||||
let ctx: AuthContext
|
||||
try {
|
||||
ctx = await requirePermission(Permissions.EXAM_CREATE)
|
||||
} catch (e) {
|
||||
if (e instanceof PermissionDeniedError) return failState(e.message)
|
||||
throw e
|
||||
}
|
||||
|
||||
// ... 解析表单 ...
|
||||
|
||||
try {
|
||||
await persistExamDraft({
|
||||
examId: context.examId,
|
||||
creatorId: ctx.userId, // 真实用户 ID
|
||||
// ...
|
||||
})
|
||||
} catch (error) {
|
||||
return failState("Database error")
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 改造前 — deleteExamAction
|
||||
|
||||
```typescript
|
||||
export async function deleteExamAction(prevState, formData) {
|
||||
const { examId } = parsed.data
|
||||
// 无任何鉴权!任何人可删任何考试!
|
||||
await db.delete(exams).where(eq(exams.id, examId))
|
||||
}
|
||||
```
|
||||
|
||||
#### 改造后 — deleteExamAction
|
||||
|
||||
```typescript
|
||||
export async function deleteExamAction(prevState, formData) {
|
||||
let ctx: AuthContext
|
||||
try {
|
||||
ctx = await requirePermission(Permissions.EXAM_DELETE)
|
||||
} catch (e) {
|
||||
if (e instanceof PermissionDeniedError) return failState(e.message)
|
||||
throw e
|
||||
}
|
||||
|
||||
const { examId } = parsed.data
|
||||
|
||||
// 数据权限:非 admin 只能删自己创建的考试
|
||||
if (ctx.dataScope.type !== "all") {
|
||||
const exam = await db.query.exams.findFirst({
|
||||
where: eq(exams.id, examId),
|
||||
columns: { creatorId: true },
|
||||
})
|
||||
if (!exam) return failState("Exam not found")
|
||||
if (exam.creatorId !== ctx.userId) {
|
||||
return failState("You can only delete your own exams")
|
||||
}
|
||||
}
|
||||
|
||||
await db.delete(exams).where(eq(exams.id, examId))
|
||||
revalidatePath("/teacher/exams/all")
|
||||
return successState(examId, "Exam deleted")
|
||||
}
|
||||
```
|
||||
|
||||
### 5.2 Data-Access 层改造
|
||||
|
||||
#### 改造前 — getExams
|
||||
|
||||
```typescript
|
||||
export const getExams = cache(async (params: GetExamsParams) => {
|
||||
// 任何调用者都能看到所有考试
|
||||
const data = await db.query.exams.findMany({
|
||||
where: conditions.length ? and(...conditions) : undefined,
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
#### 改造后 — getExams
|
||||
|
||||
```typescript
|
||||
export const getExams = cache(async (params: GetExamsParams & { scope: DataScope }) => {
|
||||
const conditions = []
|
||||
|
||||
// 原有筛选
|
||||
if (params.q) conditions.push(or(like(exams.title, search), like(exams.description, search)))
|
||||
if (params.status && params.status !== "all") conditions.push(eq(exams.status, params.status))
|
||||
|
||||
// 数据权限自动过滤
|
||||
switch (params.scope.type) {
|
||||
case "all":
|
||||
break
|
||||
case "owned":
|
||||
conditions.push(eq(exams.creatorId, /* userId from caller */))
|
||||
break
|
||||
case "class_taught": {
|
||||
// 教师能看到自己班级关联的年级的考试
|
||||
if (params.scope.classIds.length > 0) {
|
||||
// 通过 class → grade 关联查询
|
||||
const teacherGradeIds = await db
|
||||
.selectDistinct({ gradeId: classes.gradeId })
|
||||
.from(classes)
|
||||
.where(inArray(classes.id, params.scope.classIds))
|
||||
if (teacherGradeIds.length > 0) {
|
||||
conditions.push(inArray(exams.gradeId, teacherGradeIds.map(g => g.gradeId)))
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
case "grade_managed":
|
||||
if (params.scope.gradeIds.length > 0) {
|
||||
conditions.push(inArray(exams.gradeId, params.scope.gradeIds))
|
||||
}
|
||||
break
|
||||
case "class_members": {
|
||||
// 学生看到自己年级的已发布考试
|
||||
// 需要子查询获取学生所在班级的年级
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
const data = await db.query.exams.findMany({
|
||||
where: conditions.length ? and(...conditions) : undefined,
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
### 5.3 前端改造
|
||||
|
||||
#### 改造前 — exam-actions.tsx
|
||||
|
||||
```tsx
|
||||
// 仅通过路由守卫保护,组件内无权限判断
|
||||
<Button onClick={handleDelete}>删除考试</Button>
|
||||
```
|
||||
|
||||
#### 改造后 — exam-actions.tsx
|
||||
|
||||
```tsx
|
||||
import { usePermission } from "@/shared/hooks/use-permission"
|
||||
import { Permissions } from "@/shared/types/permissions"
|
||||
|
||||
function ExamActions({ examId }) {
|
||||
const { hasPermission } = usePermission()
|
||||
|
||||
return (
|
||||
<>
|
||||
{hasPermission(Permissions.EXAM_UPDATE) && (
|
||||
<Button onClick={handleEdit}>编辑</Button>
|
||||
)}
|
||||
{hasPermission(Permissions.EXAM_DELETE) && (
|
||||
<Button onClick={handleDelete}>删除</Button>
|
||||
)}
|
||||
{hasPermission(Permissions.EXAM_DUPLICATE) && (
|
||||
<Button onClick={handleDuplicate}>复制</Button>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 六、验收标准
|
||||
|
||||
- [ ] 所有 Server Action 都有 `requirePermission()` 调用
|
||||
- [ ] `getCurrentUser()` 硬编码存根已移除
|
||||
- [ ] 前端零 `role === "xxx"` 硬编码
|
||||
- [ ] `exams/actions.ts` 的 CRUD 操作有资源归属校验
|
||||
- [ ] `classes/actions.ts` 所有接口有鉴权
|
||||
- [ ] `homework/actions.ts` 批改操作有资源归属校验
|
||||
- [ ] Middleware 基于权限点而非角色字符串拦截
|
||||
- [ ] 多角色用户可同时使用所有角色的功能
|
||||
- [ ] `role_permissions` 表有 seed 数据
|
||||
- [ ] 新增角色只需修改 `ROLE_PERMISSIONS` 映射 + seed,无需改动业务代码
|
||||
Reference in New Issue
Block a user