收紧素材权限与会话边界
This commit is contained in:
parent
2e35feb1d8
commit
3d7fb9e081
@ -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: {
|
||||
|
||||
@ -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' },
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 只允许视频和音频类型
|
||||
|
||||
@ -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,
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
83
lib/auth.ts
83
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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user