收紧素材权限与会话边界

This commit is contained in:
WuYongJie 2026-06-01 11:23:44 +08:00
parent 2e35feb1d8
commit 3d7fb9e081
8 changed files with 357 additions and 37 deletions

View File

@ -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: {

View File

@ -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' },

View File

@ -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)

View File

@ -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',

View File

@ -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 }
)
}
}
// 只允许视频和音频类型

View File

@ -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<typeof consumeDingtalkLoginToken>
@ -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,
},
})
})
})

View File

@ -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分钟
// 如果超过刷新间隔未刷新,或者手动触发更新,则从数据库获取最新信息
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,
},
});
(token as any).role = dbUser.role;
(token as any).isSuperAdmin = dbUser.isSuperAdmin || false;
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);
}
if (trigger === 'update' || (now - avatarUpdatedAt) > refreshInterval) {
(token as any).avatar = dbUser.avatar;
(token as any).avatarUpdatedAt = now;
}
}

View File

@ -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<Mate
id: true,
role: true,
isSuperAdmin: true,
isActive: true,
canDownload: true,
department: true,
managedDepartment: true,
},
})
if (!user) {
if (!user || (!user.isSuperAdmin && user.isActive === false)) {
return null
}
@ -48,11 +52,21 @@ export async function getMaterialPermissionActor(session: unknown): Promise<Mate
id: user.id,
role: user.role,
isSuperAdmin: Boolean(user.isSuperAdmin),
isActive: user.isActive,
canDownload: user.canDownload,
department: user.department,
managedDepartment: user.managedDepartment,
}
}
export function canActorDownloadMaterial(actor: MaterialPermissionActor | null) {
if (!actor) {
return false
}
return actor.isSuperAdmin || actor.role === 'ADMIN' || actor.canDownload
}
export function isMaterialOwnedByActor(material: MaterialPermissionTarget, actor: MaterialPermissionActor | null) {
if (!actor) {
return false