加固登录与高危接口安全边界

This commit is contained in:
WuYongJie 2026-06-02 10:16:28 +08:00
parent a052494eed
commit ccc25629c2
16 changed files with 466 additions and 40 deletions

View File

@ -37,6 +37,13 @@ MAX_UPLOAD_FILE_SIZE_BYTES=10737418240
MAX_UPLOAD_TOTAL_FILE_SIZE_BYTES=10737418240 MAX_UPLOAD_TOTAL_FILE_SIZE_BYTES=10737418240
MAX_UPLOAD_FIELD_SIZE_BYTES=2097152 MAX_UPLOAD_FIELD_SIZE_BYTES=2097152
# Login protection
# Defaults: same IP 5 password-login attempts per minute; same account locks for 15 minutes after 5 failures.
AUTH_LOGIN_IP_WINDOW_MS=60000
AUTH_LOGIN_IP_MAX_ATTEMPTS=5
AUTH_LOGIN_ACCOUNT_MAX_FAILURES=5
AUTH_LOGIN_ACCOUNT_LOCK_MS=900000
# Database settings # Database settings
# Leave DATABASE_URL empty when using the PostgreSQL service from docker-compose.prod.yml. # Leave DATABASE_URL empty when using the PostgreSQL service from docker-compose.prod.yml.
# Fill it only when using an external PostgreSQL database, for example: # Fill it only when using an external PostgreSQL database, for example:
@ -94,6 +101,16 @@ SYSTEM_INIT_TOKEN=
INIT_ADMIN_EMAIL=admin@local.flnxi INIT_ADMIN_EMAIL=admin@local.flnxi
INIT_ADMIN_PASSWORD= INIT_ADMIN_PASSWORD=
# Legacy one-off data restore endpoint. Keep disabled in customer environments unless doing a controlled restore window.
RESTORE_DATA_ENABLED=0
RESTORE_DATA_TOKEN=
RESTORE_DATA_ALLOWED_IPS=127.0.0.1
RESTORE_ADMIN_EMAIL=admin@local.flnxi
RESTORE_ADMIN_USERNAME=admin
RESTORE_ADMIN_PASSWORD=
RESTORE_S3_BUCKET_URL=
RESTORE_S3_PUBLIC_URL=
# AI model (optional) # AI model (optional)
GEMINI_API_KEY= GEMINI_API_KEY=
GEMINI_API_BASE_URL=https://api.huandutech.com/v1 GEMINI_API_BASE_URL=https://api.huandutech.com/v1

10
.gitignore vendored
View File

@ -6,10 +6,9 @@ node_modules/
out/ out/
# Env # Env
.env .env*
.env.production !.env.example
.env.local !**/.env.example
.env.*.local
server.env server.env
env-backups/ env-backups/
@ -75,9 +74,6 @@ npm-debug.log*
yarn-debug.log* yarn-debug.log*
yarn-error.log* yarn-error.log*
# Local env files
.env*.local
# Vercel # Vercel
.vercel .vercel

View File

@ -33,6 +33,10 @@ jest.mock('@/lib/system-integrity', () => ({
ensureBuiltinTemplates: jest.fn(), ensureBuiltinTemplates: jest.fn(),
})) }))
jest.mock('@/lib/license', () => ({
canUseSystemSettings: jest.fn(),
}))
jest.mock('fs/promises', () => ({ jest.mock('fs/promises', () => ({
access: jest.fn(), access: jest.fn(),
unlink: jest.fn(), unlink: jest.fn(),
@ -41,6 +45,7 @@ jest.mock('fs/promises', () => ({
const { getServerSession } = require('next-auth') const { getServerSession } = require('next-auth')
const { prisma } = require('@/lib/prisma') const { prisma } = require('@/lib/prisma')
const { repairSystemIntegrity } = require('@/lib/system-integrity') const { repairSystemIntegrity } = require('@/lib/system-integrity')
const { canUseSystemSettings } = require('@/lib/license')
import { POST as cleanupDatabase } from '../cleanup-database/route' import { POST as cleanupDatabase } from '../cleanup-database/route'
import { POST as migrateMaterialFields } from '../migrate-material-fields/route' import { POST as migrateMaterialFields } from '../migrate-material-fields/route'
@ -51,6 +56,7 @@ import { POST as repairSystemIntegrityRoute } from '../../system/integrity/route
describe('admin destructive actions require super admin', () => { describe('admin destructive actions require super admin', () => {
beforeEach(() => { beforeEach(() => {
jest.resetAllMocks() jest.resetAllMocks()
canUseSystemSettings.mockResolvedValue(true)
}) })
it('普通管理员不能清理数据库', async () => { it('普通管理员不能清理数据库', async () => {
@ -105,6 +111,27 @@ describe('admin destructive actions require super admin', () => {
expect(prisma.$executeRawUnsafe).toHaveBeenCalled() expect(prisma.$executeRawUnsafe).toHaveBeenCalled()
}) })
it('免费版即使是最高权限账号也不能清理数据库', async () => {
getServerSession.mockResolvedValue({
user: { id: 'super-1', role: 'ADMIN', isSuperAdmin: true },
})
prisma.user.findUnique.mockResolvedValue({ isSuperAdmin: true })
canUseSystemSettings.mockResolvedValue(false)
const response = await cleanupDatabase(
new NextRequest('http://localhost/api/admin/cleanup-database', {
method: 'POST',
body: JSON.stringify({ confirmPhrase: '清理全部素材' }),
})
)
const data = await response.json()
expect(response.status).toBe(403)
expect(data.error).toContain('当前许可证未包含系统设置功能')
expect(prisma.$executeRawUnsafe).not.toHaveBeenCalled()
expect(prisma.$queryRawUnsafe).not.toHaveBeenCalled()
})
it('最高权限账号清理数据库时也必须输入确认短语', async () => { it('最高权限账号清理数据库时也必须输入确认短语', async () => {
getServerSession.mockResolvedValue({ getServerSession.mockResolvedValue({
user: { id: 'super-1', role: 'ADMIN', isSuperAdmin: true }, user: { id: 'super-1', role: 'ADMIN', isSuperAdmin: true },
@ -182,6 +209,23 @@ describe('admin destructive actions require super admin', () => {
expect(tx.$executeRawUnsafe).toHaveBeenCalledTimes(3) expect(tx.$executeRawUnsafe).toHaveBeenCalledTimes(3)
}) })
it('免费版即使是最高权限账号也不能重置排行榜数据', async () => {
getServerSession.mockResolvedValue({
user: { id: 'super-1', role: 'ADMIN', isSuperAdmin: true },
})
prisma.user.findUnique.mockResolvedValue({ isSuperAdmin: true })
canUseSystemSettings.mockResolvedValue(false)
const response = await resetMaterialRankings(
new NextRequest('http://localhost/api/admin/reset-material-rankings', { method: 'POST' })
)
const data = await response.json()
expect(response.status).toBe(403)
expect(data.error).toContain('当前许可证未包含系统设置功能')
expect(prisma.$transaction).not.toHaveBeenCalled()
})
it('普通管理员不能批量移交素材归属', async () => { it('普通管理员不能批量移交素材归属', async () => {
getServerSession.mockResolvedValue({ getServerSession.mockResolvedValue({
user: { id: 'admin-1', role: 'ADMIN', isSuperAdmin: false }, user: { id: 'admin-1', role: 'ADMIN', isSuperAdmin: false },
@ -272,6 +316,24 @@ describe('admin destructive actions require super admin', () => {
expect(prisma.$queryRawUnsafe).toHaveBeenCalled() expect(prisma.$queryRawUnsafe).toHaveBeenCalled()
}) })
it('免费版即使是最高权限账号也不能执行素材字段迁移', async () => {
getServerSession.mockResolvedValue({
user: { id: 'super-1', role: 'ADMIN', isSuperAdmin: true },
})
prisma.user.findUnique.mockResolvedValue({ isSuperAdmin: true })
canUseSystemSettings.mockResolvedValue(false)
const response = await migrateMaterialFields(
new NextRequest('http://localhost/api/admin/migrate-material-fields', { method: 'POST' })
)
const data = await response.json()
expect(response.status).toBe(403)
expect(data.error).toContain('当前许可证未包含系统设置功能')
expect(prisma.$executeRawUnsafe).not.toHaveBeenCalled()
expect(prisma.$queryRawUnsafe).not.toHaveBeenCalled()
})
it('普通管理员不能执行系统完整性修复', async () => { it('普通管理员不能执行系统完整性修复', async () => {
getServerSession.mockResolvedValue({ getServerSession.mockResolvedValue({
user: { id: 'admin-1', role: 'ADMIN', isSuperAdmin: false }, user: { id: 'admin-1', role: 'ADMIN', isSuperAdmin: false },

View File

@ -3,6 +3,7 @@ import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth' import { authOptions } from '@/lib/auth'
import { prisma } from '@/lib/prisma' import { prisma } from '@/lib/prisma'
import { isCurrentSuperAdmin } from '@/lib/admin-permissions' import { isCurrentSuperAdmin } from '@/lib/admin-permissions'
import { canUseSystemSettings } from '@/lib/license'
import { import {
collectMaterialStorageUrls, collectMaterialStorageUrls,
deleteMaterialStorageFiles, deleteMaterialStorageFiles,
@ -21,6 +22,13 @@ export async function POST(req: NextRequest) {
) )
} }
if (!(await canUseSystemSettings())) {
return NextResponse.json(
{ error: '当前许可证未包含系统设置功能,请联系管理员处理授权' },
{ status: 403 }
)
}
const body = await req.json().catch(() => ({})) const body = await req.json().catch(() => ({}))
if (body?.confirmPhrase !== '清理全部素材') { if (body?.confirmPhrase !== '清理全部素材') {
return NextResponse.json( return NextResponse.json(

View File

@ -3,6 +3,7 @@ import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth' import { authOptions } from '@/lib/auth'
import { prisma } from '@/lib/prisma' import { prisma } from '@/lib/prisma'
import { isCurrentSuperAdmin } from '@/lib/admin-permissions' import { isCurrentSuperAdmin } from '@/lib/admin-permissions'
import { canUseSystemSettings } from '@/lib/license'
export const dynamic = 'force-dynamic' export const dynamic = 'force-dynamic'
@ -17,6 +18,13 @@ export async function POST(req: NextRequest) {
) )
} }
if (!(await canUseSystemSettings())) {
return NextResponse.json(
{ error: '当前许可证未包含系统设置功能,请联系管理员处理授权' },
{ status: 403 }
)
}
// 执行 SQL 迁移 - 需要分开执行每个命令 // 执行 SQL 迁移 - 需要分开执行每个命令
const commands = [ const commands = [
`ALTER TABLE "materials" ADD COLUMN IF NOT EXISTS "brandName" TEXT`, `ALTER TABLE "materials" ADD COLUMN IF NOT EXISTS "brandName" TEXT`,

View File

@ -3,6 +3,7 @@ import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth' import { authOptions } from '@/lib/auth'
import { prisma } from '@/lib/prisma' import { prisma } from '@/lib/prisma'
import { isCurrentSuperAdmin } from '@/lib/admin-permissions' import { isCurrentSuperAdmin } from '@/lib/admin-permissions'
import { canUseSystemSettings } from '@/lib/license'
export const dynamic = 'force-dynamic' export const dynamic = 'force-dynamic'
@ -25,6 +26,13 @@ export async function POST(req: NextRequest) {
) )
} }
if (!(await canUseSystemSettings())) {
return NextResponse.json(
{ error: '当前许可证未包含系统设置功能,请联系管理员处理授权' },
{ status: 403 }
)
}
// 使用事务,确保一致性 // 使用事务,确保一致性
const result = await prisma.$transaction(async (tx) => { const result = await prisma.$transaction(async (tx) => {
// 清空点赞记录 // 清空点赞记录

View File

@ -62,7 +62,7 @@ describe('GET /api/home-filter-fields 权限边界', () => {
options: [{ label: '店铺A', value: '店铺A' }], options: [{ label: '店铺A', value: '店铺A' }],
}, },
]) ])
prisma.$queryRawUnsafe prisma.$queryRaw
.mockResolvedValueOnce([{ value: '店铺A', count: BigInt(2) }]) .mockResolvedValueOnce([{ value: '店铺A', count: BigInt(2) }])
.mockResolvedValueOnce([{ count: BigInt(3) }]) .mockResolvedValueOnce([{ count: BigInt(3) }])
@ -71,6 +71,8 @@ describe('GET /api/home-filter-fields 权限边界', () => {
expect(response.status).toBe(200) expect(response.status).toBe(200)
expect(syncFieldPoolFromTemplates).not.toHaveBeenCalled() expect(syncFieldPoolFromTemplates).not.toHaveBeenCalled()
expect(prisma.$queryRaw).toHaveBeenCalledTimes(2)
expect(prisma.$queryRawUnsafe).not.toHaveBeenCalled()
expect(data).toEqual({ expect(data).toEqual({
fields: [ fields: [
{ {

View File

@ -7,20 +7,20 @@ import { getFieldPoolRecords, formatFieldOptionEntries } from '@/lib/field-pool'
// 字段池key到数据库列名的映射 // 字段池key到数据库列名的映射
// 注意:只有在 materials 表中存在对应列的字段才能统计 // 注意:只有在 materials 表中存在对应列的字段才能统计
const MATERIAL_FIELD_COLUMN_MAP: Record<string, string> = { const MATERIAL_FIELD_COLUMN_MAP: Record<string, Prisma.Sql> = {
categoryName: 'categoryName', categoryName: Prisma.sql`"categoryName"`,
sceneName: 'sceneName', sceneName: Prisma.sql`"sceneName"`,
platformName: 'platformName', platformName: Prisma.sql`"platformName"`,
materialType: 'materialType', materialType: Prisma.sql`"materialType"`,
renderer: 'renderer', renderer: Prisma.sql`"renderer"`,
brandName: 'brandName', brandName: Prisma.sql`"brandName"`,
colorMode: 'colorMode', colorMode: Prisma.sql`"colorMode"`,
renderTime: 'renderTime', renderTime: Prisma.sql`"renderTime"`,
imageResolution: 'imageResolution', imageResolution: Prisma.sql`"imageResolution"`,
seriesName: 'seriesName', seriesName: Prisma.sql`"seriesName"`,
skuCode: 'skuCode', skuCode: Prisma.sql`"skuCode"`,
materialTexture: 'materialTexture', materialTexture: Prisma.sql`"materialTexture"`,
authorName: 'authorName', authorName: Prisma.sql`"authorName"`,
} }
function metadataTemplateFieldsJson() { function metadataTemplateFieldsJson() {
@ -46,8 +46,8 @@ async function buildMaterialCounts(fieldKeys: string[]) {
await Promise.all( await Promise.all(
fieldKeys.map(async (fieldKey) => { fieldKeys.map(async (fieldKey) => {
const column = MATERIAL_FIELD_COLUMN_MAP[fieldKey] const columnSql = MATERIAL_FIELD_COLUMN_MAP[fieldKey]
if (!column) { if (!columnSql) {
const fieldValueSql = Prisma.sql`${metadataTemplateFieldsJson()} ->> ${fieldKey}` const fieldValueSql = Prisma.sql`${metadataTemplateFieldsJson()} ->> ${fieldKey}`
const rows = await prisma.$queryRaw<Array<{ value: string | null; count: bigint }>>(Prisma.sql` const rows = await prisma.$queryRaw<Array<{ value: string | null; count: bigint }>>(Prisma.sql`
SELECT value, COUNT(*)::bigint AS count SELECT value, COUNT(*)::bigint AS count
@ -83,9 +83,14 @@ async function buildMaterialCounts(fieldKeys: string[]) {
} }
// 获取每个选项的素材数量(排除已删除的素材) // 获取每个选项的素材数量(排除已删除的素材)
const rows = await prisma.$queryRawUnsafe<Array<{ value: string | null; count: bigint }>>( const rows = await prisma.$queryRaw<Array<{ value: string | null; count: bigint }>>(Prisma.sql`
`SELECT "${column}" as value, COUNT(*)::bigint as count FROM "materials" WHERE "${column}" IS NOT NULL AND "${column}" != '' AND "deletedAt" IS NULL GROUP BY "${column}"` SELECT ${columnSql} AS value, COUNT(*)::bigint AS count
) FROM "materials"
WHERE ${columnSql} IS NOT NULL
AND ${columnSql} != ''
AND "deletedAt" IS NULL
GROUP BY ${columnSql}
`)
const map = new Map<string, number>() const map = new Map<string, number>()
rows.forEach((row) => { rows.forEach((row) => {
if (!row.value) return if (!row.value) return
@ -95,9 +100,13 @@ async function buildMaterialCounts(fieldKeys: string[]) {
// 获取该字段有非空值的素材总数(不重复计算,排除已删除的素材) // 获取该字段有非空值的素材总数(不重复计算,排除已删除的素材)
// 这是"全部"按钮应该显示的数量 // 这是"全部"按钮应该显示的数量
const totalResult = await prisma.$queryRawUnsafe<Array<{ count: bigint }>>( const totalResult = await prisma.$queryRaw<Array<{ count: bigint }>>(Prisma.sql`
`SELECT COUNT(*)::bigint as count FROM "materials" WHERE "${column}" IS NOT NULL AND "${column}" != '' AND "deletedAt" IS NULL` SELECT COUNT(*)::bigint AS count
) FROM "materials"
WHERE ${columnSql} IS NOT NULL
AND ${columnSql} != ''
AND "deletedAt" IS NULL
`)
totals[fieldKey] = Number(totalResult[0]?.count ?? 0) totals[fieldKey] = Number(totalResult[0]?.count ?? 0)
}) })
) )

View File

@ -18,10 +18,13 @@ jest.mock('@/lib/prisma', () => ({
}, },
})) }))
function createRequest(token?: string) { function createRequest(token?: string, ip = '127.0.0.1') {
return new NextRequest('http://localhost/api/restore-data', { return new NextRequest('http://localhost/api/restore-data', {
method: 'POST', method: 'POST',
headers: token ? { 'x-restore-data-token': token } : undefined, headers: {
...(token ? { 'x-restore-data-token': token } : {}),
'x-forwarded-for': ip,
},
}) })
} }
@ -38,6 +41,7 @@ describe('POST /api/restore-data', () => {
process.env = { ...originalEnv } process.env = { ...originalEnv }
delete process.env.RESTORE_DATA_ENABLED delete process.env.RESTORE_DATA_ENABLED
delete process.env.RESTORE_DATA_TOKEN delete process.env.RESTORE_DATA_TOKEN
delete process.env.RESTORE_DATA_ALLOWED_IPS
delete process.env.RESTORE_ADMIN_PASSWORD delete process.env.RESTORE_ADMIN_PASSWORD
}) })
@ -74,6 +78,7 @@ describe('POST /api/restore-data', () => {
it('启用且配置令牌后会拒绝错误令牌', async () => { it('启用且配置令牌后会拒绝错误令牌', async () => {
process.env.RESTORE_DATA_ENABLED = '1' process.env.RESTORE_DATA_ENABLED = '1'
process.env.RESTORE_DATA_TOKEN = 'restore-token' process.env.RESTORE_DATA_TOKEN = 'restore-token'
process.env.RESTORE_DATA_ALLOWED_IPS = '127.0.0.1'
process.env.RESTORE_ADMIN_PASSWORD = 'configured-password' process.env.RESTORE_ADMIN_PASSWORD = 'configured-password'
const POST = await loadPost() const POST = await loadPost()
@ -84,4 +89,33 @@ describe('POST /api/restore-data', () => {
expect(data.success).toBe(false) expect(data.success).toBe(false)
expect(data.message).toBe('恢复令牌无效') expect(data.message).toBe('恢复令牌无效')
}) })
it('启用且配置令牌后仍要求配置 IP 白名单', async () => {
process.env.RESTORE_DATA_ENABLED = '1'
process.env.RESTORE_DATA_TOKEN = 'restore-token'
process.env.RESTORE_ADMIN_PASSWORD = 'configured-password'
const POST = await loadPost()
const response = await POST(createRequest('restore-token'))
const data = await response.json()
expect(response.status).toBe(503)
expect(data.success).toBe(false)
expect(data.message).toContain('RESTORE_DATA_ALLOWED_IPS')
})
it('启用且配置令牌后会拒绝非白名单 IP', async () => {
process.env.RESTORE_DATA_ENABLED = '1'
process.env.RESTORE_DATA_TOKEN = 'restore-token'
process.env.RESTORE_DATA_ALLOWED_IPS = '127.0.0.1'
process.env.RESTORE_ADMIN_PASSWORD = 'configured-password'
const POST = await loadPost()
const response = await POST(createRequest('restore-token', '10.0.0.8'))
const data = await response.json()
expect(response.status).toBe(403)
expect(data.success).toBe(false)
expect(data.message).toBe('当前来源 IP 不允许执行恢复')
})
}) })

View File

@ -11,6 +11,10 @@ const S3_BUCKET_URL = process.env.RESTORE_S3_BUCKET_URL as string;
const S3_PUBLIC_URL = process.env.RESTORE_S3_PUBLIC_URL as string; const S3_PUBLIC_URL = process.env.RESTORE_S3_PUBLIC_URL as string;
const RESTORE_DATA_ENABLED = process.env.RESTORE_DATA_ENABLED?.trim() === '1'; const RESTORE_DATA_ENABLED = process.env.RESTORE_DATA_ENABLED?.trim() === '1';
const RESTORE_DATA_TOKEN = process.env.RESTORE_DATA_TOKEN?.trim() || ''; const RESTORE_DATA_TOKEN = process.env.RESTORE_DATA_TOKEN?.trim() || '';
const RESTORE_DATA_ALLOWED_IPS = (process.env.RESTORE_DATA_ALLOWED_IPS || '')
.split(',')
.map((item) => item.trim())
.filter(Boolean);
// 默认配置 // 默认配置
const ADMIN_EMAIL = process.env.RESTORE_ADMIN_EMAIL?.trim() || 'admin@local.flnxi'; const ADMIN_EMAIL = process.env.RESTORE_ADMIN_EMAIL?.trim() || 'admin@local.flnxi';
@ -21,6 +25,21 @@ const DEFAULT_SCENE = '默认场景';
const DEFAULT_RENDERER = 'CR'; const DEFAULT_RENDERER = 'CR';
const DEFAULT_MATERIAL_TYPE = '3DMAX'; const DEFAULT_MATERIAL_TYPE = '3DMAX';
function getClientIp(req: NextRequest): string {
const forwardedFor = req.headers.get('x-forwarded-for') || '';
const realIp = req.headers.get('x-real-ip') || '';
return forwardedFor.split(',')[0]?.trim() || realIp.trim() || 'unknown';
}
function auditRestoreData(message: string, req: NextRequest, extra?: Record<string, unknown>) {
console.warn('[RestoreData]', {
message,
ip: getClientIp(req),
userAgent: req.headers.get('user-agent') || '',
...extra,
});
}
// 解析S3 XML // 解析S3 XML
function parseS3XML(xml: string): string[] { function parseS3XML(xml: string): string[] {
const keys: string[] = []; const keys: string[] = [];
@ -108,7 +127,10 @@ function matchFiles(previewFiles: string[], materialFiles: string[]): Match[] {
export async function POST(req: NextRequest) { export async function POST(req: NextRequest) {
try { try {
const clientIp = getClientIp(req);
if (!RESTORE_DATA_ENABLED) { if (!RESTORE_DATA_ENABLED) {
auditRestoreData('restore endpoint disabled', req);
return NextResponse.json({ return NextResponse.json({
success: false, success: false,
message: '未显式启用 RESTORE_DATA_ENABLED=1,恢复接口已禁用', message: '未显式启用 RESTORE_DATA_ENABLED=1,恢复接口已禁用',
@ -116,14 +138,32 @@ export async function POST(req: NextRequest) {
} }
if (!RESTORE_DATA_TOKEN) { if (!RESTORE_DATA_TOKEN) {
auditRestoreData('restore token missing in environment', req);
return NextResponse.json({ return NextResponse.json({
success: false, success: false,
message: '未配置 RESTORE_DATA_TOKEN,恢复接口已禁用', message: '未配置 RESTORE_DATA_TOKEN,恢复接口已禁用',
}, { status: 503 }); }, { status: 503 });
} }
if (RESTORE_DATA_ALLOWED_IPS.length === 0) {
auditRestoreData('restore allowed ip list missing', req);
return NextResponse.json({
success: false,
message: '未配置 RESTORE_DATA_ALLOWED_IPS,恢复接口已禁用',
}, { status: 503 });
}
if (!RESTORE_DATA_ALLOWED_IPS.includes(clientIp)) {
auditRestoreData('restore ip rejected', req, { allowedIpCount: RESTORE_DATA_ALLOWED_IPS.length });
return NextResponse.json({
success: false,
message: '当前来源 IP 不允许执行恢复',
}, { status: 403 });
}
const requestToken = req.headers.get('x-restore-data-token')?.trim() || ''; const requestToken = req.headers.get('x-restore-data-token')?.trim() || '';
if (requestToken !== RESTORE_DATA_TOKEN) { if (requestToken !== RESTORE_DATA_TOKEN) {
auditRestoreData('restore token rejected', req);
return NextResponse.json({ return NextResponse.json({
success: false, success: false,
message: '恢复令牌无效', message: '恢复令牌无效',
@ -131,6 +171,7 @@ export async function POST(req: NextRequest) {
} }
if (!ADMIN_PASSWORD) { if (!ADMIN_PASSWORD) {
auditRestoreData('restore admin password missing in environment', req);
return NextResponse.json({ return NextResponse.json({
success: false, success: false,
message: '未配置 RESTORE_ADMIN_PASSWORD,无法创建恢复管理员', message: '未配置 RESTORE_ADMIN_PASSWORD,无法创建恢复管理员',
@ -138,6 +179,7 @@ export async function POST(req: NextRequest) {
} }
const output: string[] = []; const output: string[] = [];
auditRestoreData('restore started', req);
output.push('🚀 开始数据恢复...\n'); output.push('🚀 开始数据恢复...\n');

View File

@ -410,6 +410,7 @@ describe('PUT /api/system-settings 权限边界', () => {
describe('POST /api/system-settings/upload-logo 权限边界', () => { describe('POST /api/system-settings/upload-logo 权限边界', () => {
beforeEach(() => { beforeEach(() => {
jest.resetAllMocks() jest.resetAllMocks()
canUseSystemSettings.mockResolvedValue(true)
processImage.mockResolvedValue(Buffer.from('processed-logo')) processImage.mockResolvedValue(Buffer.from('processed-logo'))
uploadToS3.mockResolvedValue('https://cdn.example.com/header-logo.png') uploadToS3.mockResolvedValue('https://cdn.example.com/header-logo.png')
prisma.systemSettings.findFirst.mockResolvedValue({ ...mockSettings }) prisma.systemSettings.findFirst.mockResolvedValue({ ...mockSettings })
@ -472,6 +473,23 @@ describe('POST /api/system-settings/upload-logo 权限边界', () => {
}) })
}) })
it('免费版即使是最高权限账号也不能上传系统 LOGO', async () => {
getServerSession.mockResolvedValue({
user: { id: 'super-id', role: 'ADMIN', isSuperAdmin: true },
})
prisma.user.findUnique.mockResolvedValue({ isSuperAdmin: true })
canUseSystemSettings.mockResolvedValue(false)
const response = await uploadLogo(createLogoRequest())
const data = await response.json()
expect(response.status).toBe(403)
expect(data.error).toContain('当前许可证未包含系统设置功能')
expect(processImage).not.toHaveBeenCalled()
expect(uploadToS3).not.toHaveBeenCalled()
expect(prisma.systemSettings.update).not.toHaveBeenCalled()
})
it('拒绝 SVG 或非白名单图片格式', async () => { it('拒绝 SVG 或非白名单图片格式', async () => {
getServerSession.mockResolvedValue({ getServerSession.mockResolvedValue({
user: { id: 'super-id', role: 'ADMIN', isSuperAdmin: true }, user: { id: 'super-id', role: 'ADMIN', isSuperAdmin: true },

View File

@ -5,6 +5,7 @@ import { prisma } from '@/lib/prisma'
import { uploadToS3 } from '@/lib/s3' import { uploadToS3 } from '@/lib/s3'
import { isCurrentSuperAdmin } from '@/lib/admin-permissions' import { isCurrentSuperAdmin } from '@/lib/admin-permissions'
import { processImage } from '@/lib/image-processor' import { processImage } from '@/lib/image-processor'
import { canUseSystemSettings } from '@/lib/license'
// POST - 上传LOGO(最高权限) // POST - 上传LOGO(最高权限)
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
@ -18,6 +19,13 @@ export async function POST(request: NextRequest) {
) )
} }
if (!(await canUseSystemSettings())) {
return NextResponse.json(
{ error: '当前许可证未包含系统设置功能,请联系管理员处理授权' },
{ status: 403 }
)
}
// 将NextRequest转换为Node.js IncomingMessage // 将NextRequest转换为Node.js IncomingMessage
const formData = await request.formData() const formData = await request.formData()
const logoFile = formData.get('logo') as File const logoFile = formData.get('logo') as File

View File

@ -22,6 +22,7 @@ jest.mock('bcryptjs', () => ({
})) }))
import { authOptions } from '../auth' import { authOptions } from '../auth'
import { resetAuthRateLimitForTests } from '../auth-rate-limit'
import { consumeDingtalkLoginToken } from '../dingtalk-login-token' import { consumeDingtalkLoginToken } from '../dingtalk-login-token'
import { isInitialAdminPasswordChangeRequired } from '../system-init' import { isInitialAdminPasswordChangeRequired } from '../system-init'
import { prisma } from '../prisma' import { prisma } from '../prisma'
@ -41,6 +42,11 @@ function getDingtalkProvider() {
describe('auth dingtalk-token provider', () => { describe('auth dingtalk-token provider', () => {
beforeEach(() => { beforeEach(() => {
jest.clearAllMocks() jest.clearAllMocks()
resetAuthRateLimitForTests()
delete process.env.AUTH_LOGIN_IP_MAX_ATTEMPTS
delete process.env.AUTH_LOGIN_IP_WINDOW_MS
delete process.env.AUTH_LOGIN_ACCOUNT_MAX_FAILURES
delete process.env.AUTH_LOGIN_ACCOUNT_LOCK_MS
;(prisma.systemSettings.findFirst as jest.Mock).mockResolvedValue(null) ;(prisma.systemSettings.findFirst as jest.Mock).mockResolvedValue(null)
mockIsInitialAdminPasswordChangeRequired.mockResolvedValue(false) mockIsInitialAdminPasswordChangeRequired.mockResolvedValue(false)
}) })
@ -94,6 +100,84 @@ describe('auth dingtalk-token provider', () => {
.toThrow('账号未激活或已被禁用') .toThrow('账号未激活或已被禁用')
}) })
it('账号密码登录失败时不暴露账号是否存在', async () => {
;(prisma.user.findUnique as jest.Mock).mockResolvedValue(null)
await expect(
getDingtalkProvider().options.authorize({
email: 'missing@example.com',
password: 'plain-password',
}, {
headers: new Headers({ 'x-forwarded-for': '127.0.0.1' }),
})
)
.rejects
.toThrow('邮箱或密码错误')
})
it('同一账号连续失败后会临时锁定', async () => {
process.env.AUTH_LOGIN_ACCOUNT_MAX_FAILURES = '2'
process.env.AUTH_LOGIN_ACCOUNT_LOCK_MS = '60000'
;(prisma.user.findUnique as jest.Mock).mockResolvedValue({
id: 'user-locked',
email: 'locked@example.com',
username: '锁定用户',
password: 'hashed-password',
role: 'USER',
isSuperAdmin: false,
avatar: null,
isActive: true,
})
;(bcrypt.compare as jest.Mock).mockResolvedValue(false)
await expect(getDingtalkProvider().options.authorize({
email: 'locked@example.com',
password: 'wrong-password',
}, { headers: new Headers({ 'x-forwarded-for': '127.0.0.2' }) }))
.rejects
.toThrow('邮箱或密码错误')
await expect(getDingtalkProvider().options.authorize({
email: 'locked@example.com',
password: 'wrong-password',
}, { headers: new Headers({ 'x-forwarded-for': '127.0.0.2' }) }))
.rejects
.toThrow('邮箱或密码错误')
await expect(getDingtalkProvider().options.authorize({
email: 'locked@example.com',
password: 'right-password',
}, { headers: new Headers({ 'x-forwarded-for': '127.0.0.2' }) }))
.rejects
.toThrow('该账号登录失败次数过多')
})
it('同一 IP 短时间登录尝试过多会被限制', async () => {
process.env.AUTH_LOGIN_IP_MAX_ATTEMPTS = '2'
;(prisma.user.findUnique as jest.Mock).mockResolvedValue(null)
await expect(getDingtalkProvider().options.authorize({
email: 'a@example.com',
password: 'wrong-password',
}, { headers: new Headers({ 'x-forwarded-for': '127.0.0.3' }) }))
.rejects
.toThrow('邮箱或密码错误')
await expect(getDingtalkProvider().options.authorize({
email: 'b@example.com',
password: 'wrong-password',
}, { headers: new Headers({ 'x-forwarded-for': '127.0.0.3' }) }))
.rejects
.toThrow('邮箱或密码错误')
await expect(getDingtalkProvider().options.authorize({
email: 'c@example.com',
password: 'wrong-password',
}, { headers: new Headers({ 'x-forwarded-for': '127.0.0.3' }) }))
.rejects
.toThrow('登录尝试过于频繁')
})
it('拒绝已禁用用户使用账号密码登录,即使该用户是普通管理员', async () => { it('拒绝已禁用用户使用账号密码登录,即使该用户是普通管理员', async () => {
;(prisma.user.findUnique as jest.Mock).mockResolvedValue({ ;(prisma.user.findUnique as jest.Mock).mockResolvedValue({
id: 'admin-disabled', id: 'admin-disabled',

117
lib/auth-rate-limit.ts Normal file
View File

@ -0,0 +1,117 @@
type HeaderSource =
| Headers
| Record<string, string | string[] | undefined>
| undefined
type AuthRequestLike = {
headers?: HeaderSource
}
type RateState = {
count: number
firstAttemptAt: number
lockedUntil?: number
}
const ipAttempts = new Map<string, RateState>()
const accountFailures = new Map<string, RateState>()
const DEFAULT_IP_WINDOW_MS = 60 * 1000
const DEFAULT_IP_MAX_ATTEMPTS = 5
const DEFAULT_ACCOUNT_MAX_FAILURES = 5
const DEFAULT_ACCOUNT_LOCK_MS = 15 * 60 * 1000
function readPositiveIntEnv(name: string, fallback: number) {
const value = Number(process.env[name])
return Number.isFinite(value) && value > 0 ? Math.floor(value) : fallback
}
function getConfig() {
return {
ipWindowMs: readPositiveIntEnv('AUTH_LOGIN_IP_WINDOW_MS', DEFAULT_IP_WINDOW_MS),
ipMaxAttempts: readPositiveIntEnv('AUTH_LOGIN_IP_MAX_ATTEMPTS', DEFAULT_IP_MAX_ATTEMPTS),
accountMaxFailures: readPositiveIntEnv(
'AUTH_LOGIN_ACCOUNT_MAX_FAILURES',
DEFAULT_ACCOUNT_MAX_FAILURES
),
accountLockMs: readPositiveIntEnv('AUTH_LOGIN_ACCOUNT_LOCK_MS', DEFAULT_ACCOUNT_LOCK_MS),
}
}
function normalizeEmail(email: string) {
return email.trim().toLowerCase()
}
function readHeader(headers: HeaderSource, name: string) {
if (!headers) return ''
if (typeof (headers as Headers).get === 'function') {
return (headers as Headers).get(name) || ''
}
const record = headers as Record<string, string | string[] | undefined>
const lowerName = name.toLowerCase()
const value = record[name] ?? record[lowerName]
if (Array.isArray(value)) return value[0] || ''
return value || ''
}
function getClientIp(request?: AuthRequestLike) {
const forwardedFor = readHeader(request?.headers, 'x-forwarded-for')
const realIp = readHeader(request?.headers, 'x-real-ip')
const candidate = forwardedFor.split(',')[0]?.trim() || realIp.trim()
return candidate || 'unknown'
}
function getOrCreateWindowState(store: Map<string, RateState>, key: string, now: number, windowMs: number) {
const existing = store.get(key)
if (!existing || now - existing.firstAttemptAt >= windowMs) {
const fresh: RateState = { count: 0, firstAttemptAt: now }
store.set(key, fresh)
return fresh
}
return existing
}
export function assertCanAttemptPasswordLogin(email: string, request?: AuthRequestLike) {
const now = Date.now()
const config = getConfig()
const ipKey = getClientIp(request)
const accountKey = normalizeEmail(email)
const ipState = getOrCreateWindowState(ipAttempts, ipKey, now, config.ipWindowMs)
if (ipState.count >= config.ipMaxAttempts) {
throw new Error('登录尝试过于频繁,请稍后再试')
}
ipState.count += 1
const accountState = accountFailures.get(accountKey)
if (accountState?.lockedUntil && accountState.lockedUntil > now) {
throw new Error('该账号登录失败次数过多,请稍后再试')
}
if (accountState?.lockedUntil && accountState.lockedUntil <= now) {
accountFailures.delete(accountKey)
}
}
export function recordPasswordLoginFailure(email: string) {
const now = Date.now()
const config = getConfig()
const accountKey = normalizeEmail(email)
const state = getOrCreateWindowState(accountFailures, accountKey, now, config.accountLockMs)
state.count += 1
if (state.count >= config.accountMaxFailures) {
state.lockedUntil = now + config.accountLockMs
}
}
export function recordPasswordLoginSuccess(email: string) {
accountFailures.delete(normalizeEmail(email))
}
export function resetAuthRateLimitForTests() {
ipAttempts.clear()
accountFailures.clear()
}

View File

@ -3,6 +3,11 @@ import CredentialsProvider from 'next-auth/providers/credentials'
import { prisma } from '@/lib/prisma' import { prisma } from '@/lib/prisma'
import { consumeDingtalkLoginToken } from '@/lib/dingtalk-login-token' import { consumeDingtalkLoginToken } from '@/lib/dingtalk-login-token'
import { isInitialAdminPasswordChangeRequired } from '@/lib/system-init' import { isInitialAdminPasswordChangeRequired } from '@/lib/system-init'
import {
assertCanAttemptPasswordLogin,
recordPasswordLoginFailure,
recordPasswordLoginSuccess,
} from '@/lib/auth-rate-limit'
import bcrypt from 'bcryptjs' import bcrypt from 'bcryptjs'
type AccountState = { type AccountState = {
@ -30,7 +35,7 @@ export const authOptions: NextAuthOptions = {
password: { label: 'Password', type: 'password' }, password: { label: 'Password', type: 'password' },
dingtalkToken: { label: 'Dingtalk Token', type: 'text' }, dingtalkToken: { label: 'Dingtalk Token', type: 'text' },
}, },
async authorize(credentials) { async authorize(credentials, request) {
const dingtalkToken = credentials?.dingtalkToken?.trim() const dingtalkToken = credentials?.dingtalkToken?.trim()
if (dingtalkToken) { if (dingtalkToken) {
const user = await consumeDingtalkLoginToken(dingtalkToken, 'login') const user = await consumeDingtalkLoginToken(dingtalkToken, 'login')
@ -52,28 +57,36 @@ export const authOptions: NextAuthOptions = {
} }
} }
if (!credentials?.email || !credentials?.password) { const email = credentials?.email?.trim()
const password = credentials?.password
if (!email || !password) {
throw new Error('请输入邮箱和密码') throw new Error('请输入邮箱和密码')
} }
assertCanAttemptPasswordLogin(email, request)
const user = await prisma.user.findUnique({ const user = await prisma.user.findUnique({
where: { email: credentials.email }, where: { email },
}) })
if (!user) { if (!user) {
throw new Error('用户不存在') recordPasswordLoginFailure(email)
throw new Error('邮箱或密码错误')
} }
const isPasswordValid = await bcrypt.compare( const isPasswordValid = await bcrypt.compare(
credentials.password, password,
user.password user.password
) )
if (!isPasswordValid) { if (!isPasswordValid) {
throw new Error('密码错误') recordPasswordLoginFailure(email)
throw new Error('邮箱或密码错误')
} }
assertAccountCanUseSession(user) assertAccountCanUseSession(user)
recordPasswordLoginSuccess(email)
const mustChangePassword = await isInitialAdminPasswordChangeRequired(user.id) const mustChangePassword = await isInitialAdminPasswordChangeRequired(user.id)
return { return {

View File

@ -23,8 +23,8 @@ yarn-debug.log*
yarn-error.log* yarn-error.log*
# local env files # local env files
.env .env*
.env*.local !.env.example
# vercel # vercel
.vercel .vercel