Эх сурвалжийг харах

fix(越权批2): 新增 access-control helper + album/album-management 归属校验

- 新增 access-control.ts: assertBookAccess/assertChapterAccess
  统一可见性规则(本人/游客null/已公开仅读),供所有 book 接口复用
- album-controller: /albums /books 列表强制当前用户; /albums/:id /books/:id
  详情、getChapters、merge-audio、/books/chapters/:id 加归属校验; 改书(POST)用 write
- album-management: rename/章节rename/move 三个写接口加归属校验
MyFramework User 2 сар өмнө
parent
commit
e36d56356c

+ 72 - 0
server/src/modules/book-generator/access-control.ts

@@ -0,0 +1,72 @@
+/**
+ * 书籍/章节归属校验(防止水平越权 IDOR)
+ *
+ * 统一可见性规则(与 getAllByUser / GET /books/:id 一致):
+ *   - 本人创建(userId 匹配)→ 读写均放行
+ *   - 游客书(book.userId == null)→ 放行(兼容历史/匿名数据)
+ *   - 已公开发布(isPublished=true)→ 仅 read 放行,write 拒绝
+ *   - 其余 → 视为不存在(不暴露他人私有资源)
+ *
+ * 用法(在 handler 顶部):
+ *   const ok = await assertBookAccess(bookId, userId, 'write');
+ *   if (!ok) { ctx.status = 404; ctx.body = { code:1, message:'书籍不存在' }; return; }
+ */
+import { prisma } from '../../models';
+
+export type AccessMode = 'read' | 'write';
+
+/** 解析 userId 为数字;无效返回 NaN */
+function toUid(userId: number | string | undefined | null): number {
+  if (userId === undefined || userId === null) return NaN;
+  return typeof userId === 'number' ? userId : parseInt(userId);
+}
+
+/**
+ * 校验当前用户能否访问指定书籍。
+ * @returns true=放行;false=拒绝(调用方应返回 404)
+ */
+export async function assertBookAccess(
+  bookId: number | string,
+  userId: number | string | undefined | null,
+  mode: AccessMode = 'read',
+): Promise<boolean> {
+  const idNum = typeof bookId === 'number' ? bookId : parseInt(bookId);
+  if (Number.isNaN(idNum)) return false;
+
+  const book = await prisma.book.findUnique({
+    where: { id: idNum },
+    select: { userId: true, isPublished: true },
+  });
+  if (!book) return false;
+
+  const uid = toUid(userId);
+  // 本人 or 游客书(userId 为 null)
+  if (book.userId == null || book.userId === uid) return true;
+  // 已公开:仅读放行
+  if (mode === 'read' && book.isPublished === true) return true;
+  return false;
+}
+
+/**
+ * 校验当前用户能否访问指定章节(并校验章节确实属于该书)。
+ * @returns true=放行;false=拒绝(调用方应返回 404)
+ */
+export async function assertChapterAccess(
+  bookId: number | string,
+  chapterId: number | string,
+  userId: number | string | undefined | null,
+  mode: AccessMode = 'read',
+): Promise<boolean> {
+  const bIdNum = typeof bookId === 'number' ? bookId : parseInt(bookId);
+  const cIdNum = typeof chapterId === 'number' ? chapterId : parseInt(chapterId);
+  if (Number.isNaN(bIdNum) || Number.isNaN(cIdNum)) return false;
+
+  const chapter = await prisma.bookChapter.findUnique({
+    where: { id: cIdNum },
+    select: { bookId: true },
+  });
+  // 章节不存在,或不属于 URL 里的这本书 → 拒绝
+  if (!chapter || chapter.bookId !== bIdNum) return false;
+
+  return assertBookAccess(bIdNum, userId, mode);
+}

+ 50 - 3
server/src/modules/book-generator/album-controller.ts

@@ -6,6 +6,7 @@ import Router from '@koa/router';
 import { Context } from 'koa';
 import path from 'path';
 import { bookStore } from './book-generator.store';
+import { assertBookAccess, assertChapterAccess } from './access-control';
 import { optionalAuth } from '../../middleware/auth';
 import { prisma } from '../../models';
 
@@ -19,7 +20,9 @@ const router = new Router();
  */
 async function getAlbums(ctx: Context) {
   try {
-    const books = await bookStore.getAllByUser();
+    // 用户隔离:只返回当前用户(或游客)的书,避免列出他人专辑
+    const userId = ctx.state.user?.userId || TEST_USER_ID;
+    const books = await bookStore.getAllByUser(parseInt(userId as string), false);
     const albums = books.map(book => ({
       id: book.id,
       title: book.title,
@@ -38,15 +41,22 @@ async function getAlbums(ctx: Context) {
 }
 
 // GET /api/book-generator/books
-router.get('/books', getAlbums);
+router.get('/books', optionalAuth, getAlbums);
 
 // GET /api/book-generator/albums (与 /books 同样的内容,作为 AL01 用例路径)
-router.get('/albums', getAlbums);
+router.get('/albums', optionalAuth, getAlbums);
 
 // GET /api/book-generator/albums/:id (单独书籍详情,便于前端跳转)
 router.get('/albums/:id', optionalAuth, async (ctx: Context) => {
   try {
     const bookId = parseInt(ctx.params.id as string);
+    const userId = ctx.state.user?.userId || TEST_USER_ID;
+    // 归属校验:非本人且未公开 → 404
+    if (!(await assertBookAccess(bookId, userId, 'read'))) {
+      ctx.status = 404;
+      ctx.body = { code: 1, message: '专辑不存在' };
+      return;
+    }
     const book = await prisma.book.findUnique({
       where: { id: bookId },
       include: { chapters: { orderBy: { number: 'asc' } } },
@@ -201,6 +211,12 @@ router.post('/books', createAlbum);
 router.get('/books/:id', optionalAuth, async (ctx: Context) => {
   try {
     const bookId = parseInt(ctx.params.id as string);
+    const userId = ctx.state.user?.userId || TEST_USER_ID;
+    if (!(await assertBookAccess(bookId, userId, 'read'))) {
+      ctx.status = 404;
+      ctx.body = { code: 1, message: '书籍不存在' };
+      return;
+    }
     const book = await prisma.book.findUnique({
       where: { id: bookId },
       include: { chapters: { orderBy: { number: 'asc' } } },
@@ -250,6 +266,7 @@ router.get('/books/:id', optionalAuth, async (ctx: Context) => {
 router.post('/books/:id', optionalAuth, async (ctx: Context) => {
   try {
     const bookId = parseInt(ctx.params.id as string);
+    const userId = ctx.state.user?.userId || TEST_USER_ID;
     const body = ctx.request.body as {
       title?: string;
       description?: string;
@@ -258,6 +275,13 @@ router.post('/books/:id', optionalAuth, async (ctx: Context) => {
       targetAudience?: string;
     };
 
+    // 归属校验:只能改自己的书(写操作,已公开也不放行)
+    if (!(await assertBookAccess(bookId, userId, 'write'))) {
+      ctx.status = 404;
+      ctx.body = { code: 1, message: '书籍不存在' };
+      return;
+    }
+
     const existing = await prisma.book.findUnique({ where: { id: bookId } });
     if (!existing) {
       ctx.status = 404;
@@ -299,6 +323,13 @@ async function getChapters(ctx: Context) {
     const bookId = ctx.params.id as string;
     const userId = ctx.state.user?.userId || TEST_USER_ID;
 
+    // 归属校验:非本人且未公开 → 404(公开书允许读,音频可见性下面再细化)
+    if (!(await assertBookAccess(bookId, userId, 'read'))) {
+      ctx.status = 404;
+      ctx.body = { code: 1, message: '专辑不存在' };
+      return;
+    }
+
     const book = await prisma.book.findUnique({
       where: { id: parseInt(bookId) },
     });
@@ -429,6 +460,14 @@ router.post('/books/:id/chapters/:chapterId/merge-audio', optionalAuth, async (c
   try {
     const bookId = ctx.params.id as string;
     const chapterId = parseInt(ctx.params.chapterId as string);
+    const userId = ctx.state.user?.userId || TEST_USER_ID;
+
+    // 归属校验:章节须属于该书,且该书属于当前用户(写操作)
+    if (!(await assertChapterAccess(bookId, chapterId, userId, 'write'))) {
+      ctx.status = 404;
+      ctx.body = { code: 1, message: '章节不存在' };
+      return;
+    }
 
     // 获取章信息
     const chapter = await prisma.bookChapter.findFirst({
@@ -514,6 +553,7 @@ router.post('/books/:id/chapters/:chapterId/merge-audio', optionalAuth, async (c
 async function getChapterDetail(ctx: Context) {
   try {
     const chapterId = parseInt(ctx.params.id as string);
+    const userId = ctx.state.user?.userId || TEST_USER_ID;
     const chapter = await prisma.bookChapter.findUnique({
       where: { id: chapterId },
     });
@@ -524,6 +564,13 @@ async function getChapterDetail(ctx: Context) {
       return;
     }
 
+    // 归属校验:章节所属书须本人或已公开(读)
+    if (!(await assertBookAccess(chapter.bookId, userId, 'read'))) {
+      ctx.status = 404;
+      ctx.body = { code: 1, message: '章节不存在' };
+      return;
+    }
+
     ctx.body = {
       code: 0,
       message: 'success',

+ 27 - 1
server/src/modules/book-generator/album-management.controller.ts

@@ -1,15 +1,25 @@
 import Router from '@koa/router';
 import { Context } from 'koa';
 import { optionalAuth } from '../../middleware/auth';
+import { assertBookAccess, assertChapterAccess } from './access-control';
 import { prisma } from '../../models';
 
 const router = new Router();
+const TEST_USER_ID = '1';
 
 // 重命名专辑
 router.put('/:id/rename', optionalAuth, async (ctx: Context) => {
   const { id } = ctx.params;
+  const userId = ctx.state.user?.userId || TEST_USER_ID;
   const { title } = ctx.request.body as { title: string };
 
+  // 归属校验:只能改自己的书
+  if (!(await assertBookAccess(id, userId, 'write'))) {
+    ctx.status = 404;
+    ctx.body = { code: 1, message: '书籍不存在' };
+    return;
+  }
+
   const book = await prisma.book.update({
     where: { id: parseInt(id) },
     data: { title },
@@ -20,9 +30,17 @@ router.put('/:id/rename', optionalAuth, async (ctx: Context) => {
 
 // 重命名章节
 router.put('/:bookId/chapters/:chapterId/rename', optionalAuth, async (ctx: Context) => {
-  const { chapterId } = ctx.params;
+  const { bookId, chapterId } = ctx.params;
+  const userId = ctx.state.user?.userId || TEST_USER_ID;
   const { title } = ctx.request.body as { title: string };
 
+  // 归属校验:章节须属于该书,且书属于当前用户
+  if (!(await assertChapterAccess(bookId, chapterId, userId, 'write'))) {
+    ctx.status = 404;
+    ctx.body = { code: 1, message: '章节不存在' };
+    return;
+  }
+
   const chapter = await prisma.bookChapter.update({
     where: { id: parseInt(chapterId) },
     data: { title },
@@ -34,8 +52,16 @@ router.put('/:bookId/chapters/:chapterId/rename', optionalAuth, async (ctx: Cont
 // 移动章节到新位置
 router.put('/:bookId/chapters/:chapterId/move', optionalAuth, async (ctx: Context) => {
   const { bookId, chapterId } = ctx.params;
+  const userId = ctx.state.user?.userId || TEST_USER_ID;
   const { newNumber } = ctx.request.body as { newNumber: number };
 
+  // 归属校验:章节须属于该书,且书属于当前用户
+  if (!(await assertChapterAccess(bookId, chapterId, userId, 'write'))) {
+    ctx.status = 404;
+    ctx.body = { code: 1, message: '章节不存在' };
+    return;
+  }
+
   const chapter = await prisma.bookChapter.findUnique({
     where: { id: parseInt(chapterId) },
   });