diff --git a/app/api/materials/[id]/route.ts b/app/api/materials/[id]/route.ts index bcfdc59..58c8820 100644 --- a/app/api/materials/[id]/route.ts +++ b/app/api/materials/[id]/route.ts @@ -27,8 +27,19 @@ export async function GET( ) } - const material = await prisma.material.findUnique({ - where: { id: params.id }, + const actor = await getMaterialPermissionActor(session) + if (!actor) { + return NextResponse.json( + { error: '账号不可用,请重新登录或联系管理员' }, + { status: 403 } + ) + } + + const material = await prisma.material.findFirst({ + where: { + id: params.id, + deletedAt: null, + }, select: { id: true, userId: true, @@ -197,8 +208,11 @@ export async function PUT( } // 获取素材信息 - const material = await prisma.material.findUnique({ - where: { id: params.id }, + const material = await prisma.material.findFirst({ + where: { + id: params.id, + deletedAt: null, + }, include: { user: { select: { diff --git a/app/api/materials/__tests__/materials-list-auth.test.ts b/app/api/materials/__tests__/materials-list-auth.test.ts index 99ba5d1..978f9f3 100644 --- a/app/api/materials/__tests__/materials-list-auth.test.ts +++ b/app/api/materials/__tests__/materials-list-auth.test.ts @@ -15,6 +15,12 @@ jest.mock('@/lib/prisma', () => ({ count: jest.fn(), findMany: jest.fn(), }, + user: { + findUnique: jest.fn(), + }, + department: { + findMany: jest.fn(), + }, uploadField: { findMany: jest.fn(), }, @@ -50,6 +56,20 @@ const { prisma } = require('@/lib/prisma') describe('GET /api/materials 权限边界', () => { beforeEach(() => { jest.resetAllMocks() + prisma.user.findUnique.mockResolvedValue({ + id: 'user-1', + role: 'USER', + isSuperAdmin: false, + isActive: true, + canDownload: true, + department: '设计部', + managedDepartment: null, + }) + prisma.department.findMany.mockResolvedValue([ + { name: '设计部' }, + { name: '设计部-建模组' }, + { name: '运营部' }, + ]) }) it('未登录时拒绝读取素材列表', async () => { @@ -83,6 +103,115 @@ describe('GET /api/materials 权限边界', () => { expect(prisma.material.findMany).toHaveBeenCalled() }) + it('禁用用户即使带着旧会话也不能读取素材列表', async () => { + getServerSession.mockResolvedValue({ + user: { id: 'user-1', role: 'USER' }, + }) + prisma.user.findUnique.mockResolvedValueOnce({ + id: 'user-1', + role: 'USER', + isSuperAdmin: false, + isActive: false, + canDownload: true, + department: '设计部', + managedDepartment: null, + }) + + const response = await GET(new NextRequest('http://localhost/api/materials?pageSize=1')) + const data = await response.json() + + expect(response.status).toBe(403) + expect(data.error).toContain('账号不可用') + expect(prisma.material.count).not.toHaveBeenCalled() + expect(prisma.material.findMany).not.toHaveBeenCalled() + }) + + it('普通用户不能直接读取回收站素材列表', async () => { + getServerSession.mockResolvedValue({ + user: { id: 'user-1', role: 'USER' }, + }) + + const response = await GET(new NextRequest('http://localhost/api/materials?showDeleted=true&pageSize=1')) + const data = await response.json() + + expect(response.status).toBe(403) + expect(data.error).toBe('无权限查看回收站') + expect(prisma.material.count).not.toHaveBeenCalled() + expect(prisma.material.findMany).not.toHaveBeenCalled() + }) + + it('普通管理员读取素材列表时只返回自己和管理部门范围内的素材', async () => { + getServerSession.mockResolvedValue({ + user: { id: 'manager-1', role: 'ADMIN' }, + }) + prisma.user.findUnique.mockResolvedValueOnce({ + id: 'manager-1', + role: 'ADMIN', + isSuperAdmin: false, + isActive: true, + canDownload: true, + department: '设计部', + managedDepartment: '设计部', + }) + prisma.material.count.mockResolvedValue(0) + prisma.material.findMany.mockResolvedValue([]) + prisma.uploadField.findMany.mockResolvedValue([]) + prisma.fieldDefinition.findMany.mockResolvedValue([]) + + const response = await GET(new NextRequest('http://localhost/api/materials?pageSize=1')) + + expect(response.status).toBe(200) + expect(prisma.material.count.mock.calls[0][0].where.AND).toEqual( + expect.arrayContaining([ + { deletedAt: null }, + expect.objectContaining({ + OR: expect.arrayContaining([ + { userId: 'manager-1' }, + { ownerType: 'USER', ownerId: 'manager-1' }, + { user: { department: { in: ['设计部', '设计部-建模组'] } } }, + { user: { department: { startsWith: '设计部-' } } }, + ]), + }), + ]) + ) + }) + + it('普通管理员读取回收站时同样只返回管理范围内的素材', async () => { + getServerSession.mockResolvedValue({ + user: { id: 'manager-1', role: 'ADMIN' }, + }) + prisma.user.findUnique.mockResolvedValueOnce({ + id: 'manager-1', + role: 'ADMIN', + isSuperAdmin: false, + isActive: true, + canDownload: true, + department: '设计部', + managedDepartment: '设计部', + }) + prisma.material.count.mockResolvedValue(0) + prisma.material.findMany.mockResolvedValue([]) + prisma.uploadField.findMany.mockResolvedValue([]) + prisma.fieldDefinition.findMany.mockResolvedValue([]) + + const response = await GET(new NextRequest('http://localhost/api/materials?showDeleted=true&pageSize=1')) + + expect(response.status).toBe(200) + expect(prisma.material.count.mock.calls[0][0].where.AND).toEqual( + expect.arrayContaining([ + { deletedAt: { not: null } }, + expect.objectContaining({ + OR: expect.arrayContaining([ + { userId: 'manager-1' }, + { ownerType: 'USER', ownerId: 'manager-1' }, + { user: { department: { in: ['设计部', '设计部-建模组'] } } }, + { user: { department: { startsWith: '设计部-' } } }, + ]), + }), + ]) + ) + }) + it('搜索时会把可搜索自定义字段纳入查询', async () => { getServerSession.mockResolvedValue({ user: { id: 'user-1', role: 'USER' }, diff --git a/app/api/materials/route.ts b/app/api/materials/route.ts index 658ebee..8fb22ae 100644 --- a/app/api/materials/route.ts +++ b/app/api/materials/route.ts @@ -7,6 +7,7 @@ import { rewriteMaterialStorageUrlsForClient } from '@/lib/url-rewrite' import { createMaterialLog } from '@/lib/material-log' import { buildDepartmentDescendants, isDepartmentWithinScope } from '@/lib/departments' import { isStandardField } from '@/lib/field-data-storage' +import { buildMaterialManagementScopeWhere, getMaterialPermissionActor } from '@/lib/material-permissions' import { collectMaterialStorageUrls, deleteMaterialStorageFiles, @@ -158,6 +159,19 @@ export async function GET(req: NextRequest) { const pageSize = parseInt(searchParams.get('pageSize') || '20') const showDeleted = searchParams.get('showDeleted') || '' const year = searchParams.get('year') || '' + const actor = await getMaterialPermissionActor(session) + if (!actor) { + return NextResponse.json( + { error: '账号不可用,请重新登录或联系管理员' }, + { status: 403 } + ) + } + if (showDeleted === 'true' && actor.role !== 'ADMIN' && !actor.isSuperAdmin) { + return NextResponse.json( + { error: '无权限查看回收站' }, + { status: 403 } + ) + } const dynamicFieldFilters: Array<{ key: string; value: string }> = [] const requestedDynamicFieldKeys: string[] = [] searchParams.forEach((value, key) => { @@ -194,6 +208,13 @@ export async function GET(req: NextRequest) { andConditions.push({ deletedAt: null }) } + if (actor.role === 'ADMIN' || actor.isSuperAdmin) { + const scopeWhere = await buildMaterialManagementScopeWhere(actor) + if (Object.keys(scopeWhere).length > 0) { + andConditions.push(scopeWhere) + } + } + // Add search condition (search in SPU, shop, scene category, author) if (search) { const customSearchMaterialIds = await findMaterialIdsByCustomFieldSearch(search) diff --git a/app/api/materials/stream/__tests__/route.test.ts b/app/api/materials/stream/__tests__/route.test.ts index f7634fb..0ce3193 100644 --- a/app/api/materials/stream/__tests__/route.test.ts +++ b/app/api/materials/stream/__tests__/route.test.ts @@ -12,6 +12,9 @@ jest.mock('next-auth', () => ({ jest.mock('@/lib/prisma', () => ({ prisma: { + user: { + findUnique: jest.fn(), + }, material: { findFirst: jest.fn(), }, @@ -42,6 +45,15 @@ describe('GET /api/materials/stream', () => { jest.resetAllMocks() isS3Url.mockImplementation((value?: string | null) => /^https?:\/\//.test(value || '')) isPublicShareEnabled.mockResolvedValue(true) + prisma.user.findUnique.mockResolvedValue({ + id: 'user-1', + role: 'USER', + isSuperAdmin: false, + isActive: true, + canDownload: true, + department: '设计部', + managedDepartment: null, + }) }) it('未登录且没有有效分享票据时拒绝播放', async () => { @@ -63,6 +75,35 @@ describe('GET /api/materials/stream', () => { expect(getS3ObjectStream).not.toHaveBeenCalled() }) + it('已登录用户下载权限关闭时拒绝通过 stream 播放源文件', async () => { + prisma.material.findFirst.mockResolvedValue({ + id: 'material-1', + zipFilePath: 'http://minio:9000/bucket/video.mp4', + zipFileName: 'video.mp4', + materialType: '视频', + materialKind: 'VIDEO', + }) + getServerSession.mockResolvedValue({ + user: { id: 'user-1', role: 'USER' }, + }) + prisma.user.findUnique.mockResolvedValueOnce({ + id: 'user-1', + role: 'USER', + isSuperAdmin: false, + isActive: true, + canDownload: false, + department: '设计部', + managedDepartment: null, + }) + + const response = await GET(new NextRequest('http://localhost/api/materials/stream?id=material-1')) + const data = await response.json() + + expect(response.status).toBe(403) + expect(data.error).toBe('您的下载权限已被限制,请联系管理员') + expect(getS3ObjectStream).not.toHaveBeenCalled() + }) + it('未登录但分享票据有效时允许播放', async () => { prisma.material.findFirst.mockResolvedValue({ id: 'material-1', diff --git a/app/api/materials/stream/route.ts b/app/api/materials/stream/route.ts index ca291a6..8b644e3 100644 --- a/app/api/materials/stream/route.ts +++ b/app/api/materials/stream/route.ts @@ -6,6 +6,7 @@ import { getS3ObjectStream, isS3Url } from '@/lib/s3' import { findValidMaterialShareToken } from '@/lib/material-share-token' import { createLocalFileStreamResponse } from '@/lib/local-file-stream' import { isPublicShareEnabled } from '@/lib/public-share-settings' +import { canActorDownloadMaterial, getMaterialPermissionActor } from '@/lib/material-permissions' import { access } from 'fs/promises' import path from 'path' import { Readable } from 'stream' @@ -66,6 +67,20 @@ export async function GET(req: NextRequest) { { status: 401 } ) } + } else { + const actor = await getMaterialPermissionActor(session) + if (!actor) { + return NextResponse.json( + { error: '账号不可用,请重新登录或联系管理员' }, + { status: 403 } + ) + } + if (!canActorDownloadMaterial(actor)) { + return NextResponse.json( + { error: '您的下载权限已被限制,请联系管理员' }, + { status: 403 } + ) + } } // 只允许视频和音频类型 diff --git a/lib/__tests__/auth-dingtalk-token.test.ts b/lib/__tests__/auth-dingtalk-token.test.ts index 8d81146..60dc855 100644 --- a/lib/__tests__/auth-dingtalk-token.test.ts +++ b/lib/__tests__/auth-dingtalk-token.test.ts @@ -20,6 +20,7 @@ jest.mock('bcryptjs', () => ({ import { authOptions } from '../auth' import { consumeDingtalkLoginToken } from '../dingtalk-login-token' import { prisma } from '../prisma' +import bcrypt from 'bcryptjs' const mockConsumeDingtalkLoginToken = consumeDingtalkLoginToken as jest.MockedFunction @@ -66,4 +67,70 @@ describe('auth dingtalk-token provider', () => { .rejects .toThrow('钉钉登录凭证已失效') }) + + it('拒绝已禁用用户使用钉钉票据登录', async () => { + mockConsumeDingtalkLoginToken.mockResolvedValue({ + id: 'user-disabled', + email: 'disabled@example.com', + username: '禁用用户', + role: 'USER', + isSuperAdmin: false, + avatar: null, + isActive: false, + }) + + await expect(getDingtalkProvider().options.authorize({ dingtalkToken: 'disabled-token' })) + .rejects + .toThrow('账号未激活或已被禁用') + }) + + it('拒绝已禁用用户使用账号密码登录,即使该用户是普通管理员', async () => { + ;(prisma.user.findUnique as jest.Mock).mockResolvedValue({ + id: 'admin-disabled', + email: 'disabled-admin@example.com', + username: '禁用管理员', + password: 'hashed-password', + role: 'ADMIN', + isSuperAdmin: false, + avatar: null, + isActive: false, + }) + ;(bcrypt.compare as jest.Mock).mockResolvedValue(true) + + await expect( + getDingtalkProvider().options.authorize({ + email: 'disabled-admin@example.com', + password: 'plain-password', + }) + ) + .rejects + .toThrow('账号未激活或已被禁用') + }) + + it('旧 JWT 会话遇到已禁用用户时会在下次校验失效', async () => { + ;(prisma.user.findUnique as jest.Mock).mockResolvedValue({ + id: 'user-disabled', + role: 'USER', + isSuperAdmin: false, + isActive: false, + avatar: null, + }) + + await expect( + (authOptions.callbacks?.jwt as any)({ + token: { id: 'user-disabled', avatarUpdatedAt: Date.now() }, + }) + ) + .rejects + .toThrow('账号不存在或已被禁用') + expect(prisma.user.findUnique).toHaveBeenCalledWith({ + where: { id: 'user-disabled' }, + select: { + avatar: true, + role: true, + isSuperAdmin: true, + isActive: true, + }, + }) + }) }) diff --git a/lib/auth.ts b/lib/auth.ts index 29c7f82..d566fb0 100644 --- a/lib/auth.ts +++ b/lib/auth.ts @@ -4,6 +4,21 @@ import { prisma } from '@/lib/prisma' import { consumeDingtalkLoginToken } from '@/lib/dingtalk-login-token' import bcrypt from 'bcryptjs' +type AccountState = { + isActive?: boolean | null + isSuperAdmin?: boolean | null +} + +function isDisabledAccount(user: AccountState) { + return !Boolean(user.isSuperAdmin) && user.isActive === false +} + +function assertAccountCanUseSession(user: AccountState) { + if (isDisabledAccount(user)) { + throw new Error('账号未激活或已被禁用,请联系管理员') + } +} + export const authOptions: NextAuthOptions = { providers: [ CredentialsProvider({ @@ -22,10 +37,7 @@ export const authOptions: NextAuthOptions = { throw new Error('钉钉登录凭证已失效,请重新扫码') } - const settings = await prisma.systemSettings.findFirst() - if (settings?.enableUserActivation && !user.isActive && user.role !== 'ADMIN' && !(user as any).isSuperAdmin) { - throw new Error('账号未激活,请联系管理员') - } + assertAccountCanUseSession(user) return { id: user.id, @@ -58,11 +70,7 @@ export const authOptions: NextAuthOptions = { throw new Error('密码错误') } - // 检查用户是否已激活(如果启用了激活功能且用户不是管理员或超级管理员) - const settings = await prisma.systemSettings.findFirst() - if (settings?.enableUserActivation && !user.isActive && user.role !== 'ADMIN' && !(user as any).isSuperAdmin) { - throw new Error('账号未激活,请联系管理员') - } + assertAccountCanUseSession(user) return { id: user.id, @@ -86,35 +94,46 @@ export const authOptions: NextAuthOptions = { (token as any).avatarUpdatedAt = Date.now(); } - // 如果 token 中有 id,定期从数据库刷新用户信息(包括头像) + // 如果 token 中有 id,从数据库刷新用户状态;账号禁用后让旧 JWT 会话失效。 // 每5分钟刷新一次,或者如果 trigger 是 'update'(手动刷新) if (token.id && typeof token.id === 'string') { + let dbUser: { + avatar: string | null + role: string + isSuperAdmin: boolean + isActive: boolean + } | null = null + + try { + dbUser = await prisma.user.findUnique({ + where: { id: token.id as string }, + select: { + avatar: true, + role: true, + isSuperAdmin: true, + isActive: true, + }, + }); + } catch (error) { + // 数据库短暂异常时继续使用旧 token,避免把可恢复故障放大成全员登出。 + console.error('[Auth] Failed to refresh user info:', error); + return token; + } + + if (!dbUser || isDisabledAccount(dbUser)) { + throw new Error('账号不存在或已被禁用,请重新登录'); + } + const avatarUpdatedAt = (token as any).avatarUpdatedAt || 0; const now = Date.now(); const refreshInterval = 5 * 60 * 1000; // 5分钟 - - // 如果超过刷新间隔未刷新,或者手动触发更新,则从数据库获取最新信息 + + (token as any).role = dbUser.role; + (token as any).isSuperAdmin = dbUser.isSuperAdmin || false; + if (trigger === 'update' || (now - avatarUpdatedAt) > refreshInterval) { - try { - const dbUser = await prisma.user.findUnique({ - where: { id: token.id as string }, - select: { - avatar: true, - role: true, - isSuperAdmin: true, - }, - }); - - if (dbUser) { - (token as any).avatar = dbUser.avatar; - (token as any).role = dbUser.role; - (token as any).isSuperAdmin = (dbUser as any).isSuperAdmin || false; - (token as any).avatarUpdatedAt = now; - } - } catch (error) { - // 如果查询失败,继续使用 token 中的旧值 - console.error('[Auth] Failed to refresh user info:', error); - } + (token as any).avatar = dbUser.avatar; + (token as any).avatarUpdatedAt = now; } } diff --git a/lib/material-permissions.ts b/lib/material-permissions.ts index fd90dcf..049fee7 100644 --- a/lib/material-permissions.ts +++ b/lib/material-permissions.ts @@ -6,6 +6,8 @@ export type MaterialPermissionActor = { id: string role: string isSuperAdmin: boolean + isActive: boolean + canDownload: boolean department?: string | null managedDepartment?: string | null } @@ -35,12 +37,14 @@ export async function getMaterialPermissionActor(session: unknown): Promise