加固登录与高危接口安全边界
This commit is contained in:
parent
a052494eed
commit
ccc25629c2
17
.env.example
17
.env.example
@ -37,6 +37,13 @@ MAX_UPLOAD_FILE_SIZE_BYTES=10737418240
|
||||
MAX_UPLOAD_TOTAL_FILE_SIZE_BYTES=10737418240
|
||||
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
|
||||
# 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:
|
||||
@ -94,6 +101,16 @@ SYSTEM_INIT_TOKEN=
|
||||
INIT_ADMIN_EMAIL=admin@local.flnxi
|
||||
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)
|
||||
GEMINI_API_KEY=
|
||||
GEMINI_API_BASE_URL=https://api.huandutech.com/v1
|
||||
|
||||
10
.gitignore
vendored
10
.gitignore
vendored
@ -6,10 +6,9 @@ node_modules/
|
||||
out/
|
||||
|
||||
# Env
|
||||
.env
|
||||
.env.production
|
||||
.env.local
|
||||
.env.*.local
|
||||
.env*
|
||||
!.env.example
|
||||
!**/.env.example
|
||||
server.env
|
||||
env-backups/
|
||||
|
||||
@ -75,9 +74,6 @@ npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# Local env files
|
||||
.env*.local
|
||||
|
||||
# Vercel
|
||||
.vercel
|
||||
|
||||
|
||||
@ -33,6 +33,10 @@ jest.mock('@/lib/system-integrity', () => ({
|
||||
ensureBuiltinTemplates: jest.fn(),
|
||||
}))
|
||||
|
||||
jest.mock('@/lib/license', () => ({
|
||||
canUseSystemSettings: jest.fn(),
|
||||
}))
|
||||
|
||||
jest.mock('fs/promises', () => ({
|
||||
access: jest.fn(),
|
||||
unlink: jest.fn(),
|
||||
@ -41,6 +45,7 @@ jest.mock('fs/promises', () => ({
|
||||
const { getServerSession } = require('next-auth')
|
||||
const { prisma } = require('@/lib/prisma')
|
||||
const { repairSystemIntegrity } = require('@/lib/system-integrity')
|
||||
const { canUseSystemSettings } = require('@/lib/license')
|
||||
|
||||
import { POST as cleanupDatabase } from '../cleanup-database/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', () => {
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks()
|
||||
canUseSystemSettings.mockResolvedValue(true)
|
||||
})
|
||||
|
||||
it('普通管理员不能清理数据库', async () => {
|
||||
@ -105,6 +111,27 @@ describe('admin destructive actions require super admin', () => {
|
||||
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 () => {
|
||||
getServerSession.mockResolvedValue({
|
||||
user: { id: 'super-1', role: 'ADMIN', isSuperAdmin: true },
|
||||
@ -182,6 +209,23 @@ describe('admin destructive actions require super admin', () => {
|
||||
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 () => {
|
||||
getServerSession.mockResolvedValue({
|
||||
user: { id: 'admin-1', role: 'ADMIN', isSuperAdmin: false },
|
||||
@ -272,6 +316,24 @@ describe('admin destructive actions require super admin', () => {
|
||||
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 () => {
|
||||
getServerSession.mockResolvedValue({
|
||||
user: { id: 'admin-1', role: 'ADMIN', isSuperAdmin: false },
|
||||
|
||||
@ -3,6 +3,7 @@ import { getServerSession } from 'next-auth'
|
||||
import { authOptions } from '@/lib/auth'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { isCurrentSuperAdmin } from '@/lib/admin-permissions'
|
||||
import { canUseSystemSettings } from '@/lib/license'
|
||||
import {
|
||||
collectMaterialStorageUrls,
|
||||
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(() => ({}))
|
||||
if (body?.confirmPhrase !== '清理全部素材') {
|
||||
return NextResponse.json(
|
||||
|
||||
@ -3,6 +3,7 @@ import { getServerSession } from 'next-auth'
|
||||
import { authOptions } from '@/lib/auth'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { isCurrentSuperAdmin } from '@/lib/admin-permissions'
|
||||
import { canUseSystemSettings } from '@/lib/license'
|
||||
|
||||
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 迁移 - 需要分开执行每个命令
|
||||
const commands = [
|
||||
`ALTER TABLE "materials" ADD COLUMN IF NOT EXISTS "brandName" TEXT`,
|
||||
|
||||
@ -3,6 +3,7 @@ import { getServerSession } from 'next-auth'
|
||||
import { authOptions } from '@/lib/auth'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { isCurrentSuperAdmin } from '@/lib/admin-permissions'
|
||||
import { canUseSystemSettings } from '@/lib/license'
|
||||
|
||||
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) => {
|
||||
// 清空点赞记录
|
||||
|
||||
@ -62,7 +62,7 @@ describe('GET /api/home-filter-fields 权限边界', () => {
|
||||
options: [{ label: '店铺A', value: '店铺A' }],
|
||||
},
|
||||
])
|
||||
prisma.$queryRawUnsafe
|
||||
prisma.$queryRaw
|
||||
.mockResolvedValueOnce([{ value: '店铺A', count: BigInt(2) }])
|
||||
.mockResolvedValueOnce([{ count: BigInt(3) }])
|
||||
|
||||
@ -71,6 +71,8 @@ describe('GET /api/home-filter-fields 权限边界', () => {
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(syncFieldPoolFromTemplates).not.toHaveBeenCalled()
|
||||
expect(prisma.$queryRaw).toHaveBeenCalledTimes(2)
|
||||
expect(prisma.$queryRawUnsafe).not.toHaveBeenCalled()
|
||||
expect(data).toEqual({
|
||||
fields: [
|
||||
{
|
||||
|
||||
@ -7,20 +7,20 @@ import { getFieldPoolRecords, formatFieldOptionEntries } from '@/lib/field-pool'
|
||||
|
||||
// 字段池key到数据库列名的映射
|
||||
// 注意:只有在 materials 表中存在对应列的字段才能统计
|
||||
const MATERIAL_FIELD_COLUMN_MAP: Record<string, string> = {
|
||||
categoryName: 'categoryName',
|
||||
sceneName: 'sceneName',
|
||||
platformName: 'platformName',
|
||||
materialType: 'materialType',
|
||||
renderer: 'renderer',
|
||||
brandName: 'brandName',
|
||||
colorMode: 'colorMode',
|
||||
renderTime: 'renderTime',
|
||||
imageResolution: 'imageResolution',
|
||||
seriesName: 'seriesName',
|
||||
skuCode: 'skuCode',
|
||||
materialTexture: 'materialTexture',
|
||||
authorName: 'authorName',
|
||||
const MATERIAL_FIELD_COLUMN_MAP: Record<string, Prisma.Sql> = {
|
||||
categoryName: Prisma.sql`"categoryName"`,
|
||||
sceneName: Prisma.sql`"sceneName"`,
|
||||
platformName: Prisma.sql`"platformName"`,
|
||||
materialType: Prisma.sql`"materialType"`,
|
||||
renderer: Prisma.sql`"renderer"`,
|
||||
brandName: Prisma.sql`"brandName"`,
|
||||
colorMode: Prisma.sql`"colorMode"`,
|
||||
renderTime: Prisma.sql`"renderTime"`,
|
||||
imageResolution: Prisma.sql`"imageResolution"`,
|
||||
seriesName: Prisma.sql`"seriesName"`,
|
||||
skuCode: Prisma.sql`"skuCode"`,
|
||||
materialTexture: Prisma.sql`"materialTexture"`,
|
||||
authorName: Prisma.sql`"authorName"`,
|
||||
}
|
||||
|
||||
function metadataTemplateFieldsJson() {
|
||||
@ -46,8 +46,8 @@ async function buildMaterialCounts(fieldKeys: string[]) {
|
||||
|
||||
await Promise.all(
|
||||
fieldKeys.map(async (fieldKey) => {
|
||||
const column = MATERIAL_FIELD_COLUMN_MAP[fieldKey]
|
||||
if (!column) {
|
||||
const columnSql = MATERIAL_FIELD_COLUMN_MAP[fieldKey]
|
||||
if (!columnSql) {
|
||||
const fieldValueSql = Prisma.sql`${metadataTemplateFieldsJson()} ->> ${fieldKey}`
|
||||
const rows = await prisma.$queryRaw<Array<{ value: string | null; count: bigint }>>(Prisma.sql`
|
||||
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 }>>(
|
||||
`SELECT "${column}" as value, COUNT(*)::bigint as count FROM "materials" WHERE "${column}" IS NOT NULL AND "${column}" != '' AND "deletedAt" IS NULL GROUP BY "${column}"`
|
||||
)
|
||||
const rows = await prisma.$queryRaw<Array<{ value: string | null; count: bigint }>>(Prisma.sql`
|
||||
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>()
|
||||
rows.forEach((row) => {
|
||||
if (!row.value) return
|
||||
@ -95,9 +100,13 @@ async function buildMaterialCounts(fieldKeys: string[]) {
|
||||
|
||||
// 获取该字段有非空值的素材总数(不重复计算,排除已删除的素材)
|
||||
// 这是"全部"按钮应该显示的数量
|
||||
const totalResult = await prisma.$queryRawUnsafe<Array<{ count: bigint }>>(
|
||||
`SELECT COUNT(*)::bigint as count FROM "materials" WHERE "${column}" IS NOT NULL AND "${column}" != '' AND "deletedAt" IS NULL`
|
||||
)
|
||||
const totalResult = await prisma.$queryRaw<Array<{ count: bigint }>>(Prisma.sql`
|
||||
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)
|
||||
})
|
||||
)
|
||||
|
||||
@ -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', {
|
||||
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 }
|
||||
delete process.env.RESTORE_DATA_ENABLED
|
||||
delete process.env.RESTORE_DATA_TOKEN
|
||||
delete process.env.RESTORE_DATA_ALLOWED_IPS
|
||||
delete process.env.RESTORE_ADMIN_PASSWORD
|
||||
})
|
||||
|
||||
@ -74,6 +78,7 @@ describe('POST /api/restore-data', () => {
|
||||
it('启用且配置令牌后会拒绝错误令牌', 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()
|
||||
|
||||
@ -84,4 +89,33 @@ describe('POST /api/restore-data', () => {
|
||||
expect(data.success).toBe(false)
|
||||
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 不允许执行恢复')
|
||||
})
|
||||
})
|
||||
|
||||
@ -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 RESTORE_DATA_ENABLED = process.env.RESTORE_DATA_ENABLED?.trim() === '1';
|
||||
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';
|
||||
@ -21,6 +25,21 @@ const DEFAULT_SCENE = '默认场景';
|
||||
const DEFAULT_RENDERER = 'CR';
|
||||
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
|
||||
function parseS3XML(xml: string): string[] {
|
||||
const keys: string[] = [];
|
||||
@ -108,7 +127,10 @@ function matchFiles(previewFiles: string[], materialFiles: string[]): Match[] {
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const clientIp = getClientIp(req);
|
||||
|
||||
if (!RESTORE_DATA_ENABLED) {
|
||||
auditRestoreData('restore endpoint disabled', req);
|
||||
return NextResponse.json({
|
||||
success: false,
|
||||
message: '未显式启用 RESTORE_DATA_ENABLED=1,恢复接口已禁用',
|
||||
@ -116,14 +138,32 @@ export async function POST(req: NextRequest) {
|
||||
}
|
||||
|
||||
if (!RESTORE_DATA_TOKEN) {
|
||||
auditRestoreData('restore token missing in environment', req);
|
||||
return NextResponse.json({
|
||||
success: false,
|
||||
message: '未配置 RESTORE_DATA_TOKEN,恢复接口已禁用',
|
||||
}, { 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() || '';
|
||||
if (requestToken !== RESTORE_DATA_TOKEN) {
|
||||
auditRestoreData('restore token rejected', req);
|
||||
return NextResponse.json({
|
||||
success: false,
|
||||
message: '恢复令牌无效',
|
||||
@ -131,6 +171,7 @@ export async function POST(req: NextRequest) {
|
||||
}
|
||||
|
||||
if (!ADMIN_PASSWORD) {
|
||||
auditRestoreData('restore admin password missing in environment', req);
|
||||
return NextResponse.json({
|
||||
success: false,
|
||||
message: '未配置 RESTORE_ADMIN_PASSWORD,无法创建恢复管理员',
|
||||
@ -138,6 +179,7 @@ export async function POST(req: NextRequest) {
|
||||
}
|
||||
|
||||
const output: string[] = [];
|
||||
auditRestoreData('restore started', req);
|
||||
|
||||
output.push('🚀 开始数据恢复...\n');
|
||||
|
||||
|
||||
@ -410,6 +410,7 @@ describe('PUT /api/system-settings 权限边界', () => {
|
||||
describe('POST /api/system-settings/upload-logo 权限边界', () => {
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks()
|
||||
canUseSystemSettings.mockResolvedValue(true)
|
||||
processImage.mockResolvedValue(Buffer.from('processed-logo'))
|
||||
uploadToS3.mockResolvedValue('https://cdn.example.com/header-logo.png')
|
||||
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 () => {
|
||||
getServerSession.mockResolvedValue({
|
||||
user: { id: 'super-id', role: 'ADMIN', isSuperAdmin: true },
|
||||
|
||||
@ -5,6 +5,7 @@ import { prisma } from '@/lib/prisma'
|
||||
import { uploadToS3 } from '@/lib/s3'
|
||||
import { isCurrentSuperAdmin } from '@/lib/admin-permissions'
|
||||
import { processImage } from '@/lib/image-processor'
|
||||
import { canUseSystemSettings } from '@/lib/license'
|
||||
|
||||
// POST - 上传LOGO(最高权限)
|
||||
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
|
||||
const formData = await request.formData()
|
||||
const logoFile = formData.get('logo') as File
|
||||
|
||||
@ -22,6 +22,7 @@ jest.mock('bcryptjs', () => ({
|
||||
}))
|
||||
|
||||
import { authOptions } from '../auth'
|
||||
import { resetAuthRateLimitForTests } from '../auth-rate-limit'
|
||||
import { consumeDingtalkLoginToken } from '../dingtalk-login-token'
|
||||
import { isInitialAdminPasswordChangeRequired } from '../system-init'
|
||||
import { prisma } from '../prisma'
|
||||
@ -41,6 +42,11 @@ function getDingtalkProvider() {
|
||||
describe('auth dingtalk-token provider', () => {
|
||||
beforeEach(() => {
|
||||
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)
|
||||
mockIsInitialAdminPasswordChangeRequired.mockResolvedValue(false)
|
||||
})
|
||||
@ -94,6 +100,84 @@ describe('auth dingtalk-token provider', () => {
|
||||
.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 () => {
|
||||
;(prisma.user.findUnique as jest.Mock).mockResolvedValue({
|
||||
id: 'admin-disabled',
|
||||
|
||||
117
lib/auth-rate-limit.ts
Normal file
117
lib/auth-rate-limit.ts
Normal 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()
|
||||
}
|
||||
25
lib/auth.ts
25
lib/auth.ts
@ -3,6 +3,11 @@ import CredentialsProvider from 'next-auth/providers/credentials'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { consumeDingtalkLoginToken } from '@/lib/dingtalk-login-token'
|
||||
import { isInitialAdminPasswordChangeRequired } from '@/lib/system-init'
|
||||
import {
|
||||
assertCanAttemptPasswordLogin,
|
||||
recordPasswordLoginFailure,
|
||||
recordPasswordLoginSuccess,
|
||||
} from '@/lib/auth-rate-limit'
|
||||
import bcrypt from 'bcryptjs'
|
||||
|
||||
type AccountState = {
|
||||
@ -30,7 +35,7 @@ export const authOptions: NextAuthOptions = {
|
||||
password: { label: 'Password', type: 'password' },
|
||||
dingtalkToken: { label: 'Dingtalk Token', type: 'text' },
|
||||
},
|
||||
async authorize(credentials) {
|
||||
async authorize(credentials, request) {
|
||||
const dingtalkToken = credentials?.dingtalkToken?.trim()
|
||||
if (dingtalkToken) {
|
||||
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('请输入邮箱和密码')
|
||||
}
|
||||
|
||||
assertCanAttemptPasswordLogin(email, request)
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { email: credentials.email },
|
||||
where: { email },
|
||||
})
|
||||
|
||||
if (!user) {
|
||||
throw new Error('用户不存在')
|
||||
recordPasswordLoginFailure(email)
|
||||
throw new Error('邮箱或密码错误')
|
||||
}
|
||||
|
||||
const isPasswordValid = await bcrypt.compare(
|
||||
credentials.password,
|
||||
password,
|
||||
user.password
|
||||
)
|
||||
|
||||
if (!isPasswordValid) {
|
||||
throw new Error('密码错误')
|
||||
recordPasswordLoginFailure(email)
|
||||
throw new Error('邮箱或密码错误')
|
||||
}
|
||||
|
||||
assertAccountCanUseSession(user)
|
||||
recordPasswordLoginSuccess(email)
|
||||
const mustChangePassword = await isInitialAdminPasswordChangeRequired(user.id)
|
||||
|
||||
return {
|
||||
|
||||
4
license-server/.gitignore
vendored
4
license-server/.gitignore
vendored
@ -23,8 +23,8 @@ yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# local env files
|
||||
.env
|
||||
.env*.local
|
||||
.env*
|
||||
!.env.example
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user