diff --git a/.env.example b/.env.example index cc31d5a..9287b23 100644 --- a/.env.example +++ b/.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 diff --git a/.gitignore b/.gitignore index 06dfdaf..a0fe8b5 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/app/api/admin/__tests__/destructive-actions-superadmin.test.ts b/app/api/admin/__tests__/destructive-actions-superadmin.test.ts index 3a64a97..0572639 100644 --- a/app/api/admin/__tests__/destructive-actions-superadmin.test.ts +++ b/app/api/admin/__tests__/destructive-actions-superadmin.test.ts @@ -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 }, diff --git a/app/api/admin/cleanup-database/route.ts b/app/api/admin/cleanup-database/route.ts index 6495a2b..938ef78 100644 --- a/app/api/admin/cleanup-database/route.ts +++ b/app/api/admin/cleanup-database/route.ts @@ -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( diff --git a/app/api/admin/migrate-material-fields/route.ts b/app/api/admin/migrate-material-fields/route.ts index 76e522c..ba50dfa 100644 --- a/app/api/admin/migrate-material-fields/route.ts +++ b/app/api/admin/migrate-material-fields/route.ts @@ -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`, diff --git a/app/api/admin/reset-material-rankings/route.ts b/app/api/admin/reset-material-rankings/route.ts index e23b2bf..c0c7296 100644 --- a/app/api/admin/reset-material-rankings/route.ts +++ b/app/api/admin/reset-material-rankings/route.ts @@ -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) => { // 清空点赞记录 diff --git a/app/api/home-filter-fields/__tests__/route-auth.test.ts b/app/api/home-filter-fields/__tests__/route-auth.test.ts index 0c3e2f0..6ac249c 100644 --- a/app/api/home-filter-fields/__tests__/route-auth.test.ts +++ b/app/api/home-filter-fields/__tests__/route-auth.test.ts @@ -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: [ { diff --git a/app/api/home-filter-fields/route.ts b/app/api/home-filter-fields/route.ts index a1f2850..798e048 100644 --- a/app/api/home-filter-fields/route.ts +++ b/app/api/home-filter-fields/route.ts @@ -7,20 +7,20 @@ import { getFieldPoolRecords, formatFieldOptionEntries } from '@/lib/field-pool' // 字段池key到数据库列名的映射 // 注意:只有在 materials 表中存在对应列的字段才能统计 -const MATERIAL_FIELD_COLUMN_MAP: Record = { - 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 = { + 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>(Prisma.sql` SELECT value, COUNT(*)::bigint AS count @@ -83,9 +83,14 @@ async function buildMaterialCounts(fieldKeys: string[]) { } // 获取每个选项的素材数量(排除已删除的素材) - const rows = await prisma.$queryRawUnsafe>( - `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>(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() rows.forEach((row) => { if (!row.value) return @@ -95,9 +100,13 @@ async function buildMaterialCounts(fieldKeys: string[]) { // 获取该字段有非空值的素材总数(不重复计算,排除已删除的素材) // 这是"全部"按钮应该显示的数量 - const totalResult = await prisma.$queryRawUnsafe>( - `SELECT COUNT(*)::bigint as count FROM "materials" WHERE "${column}" IS NOT NULL AND "${column}" != '' AND "deletedAt" IS NULL` - ) + const totalResult = await prisma.$queryRaw>(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) }) ) diff --git a/app/api/restore-data/__tests__/route.test.ts b/app/api/restore-data/__tests__/route.test.ts index 50cc320..eababfc 100644 --- a/app/api/restore-data/__tests__/route.test.ts +++ b/app/api/restore-data/__tests__/route.test.ts @@ -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 不允许执行恢复') + }) }) diff --git a/app/api/restore-data/route.ts b/app/api/restore-data/route.ts index c32204e..834e8bf 100644 --- a/app/api/restore-data/route.ts +++ b/app/api/restore-data/route.ts @@ -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) { + 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'); diff --git a/app/api/system-settings/__tests__/route.test.ts b/app/api/system-settings/__tests__/route.test.ts index 577b14a..e9cb7d9 100644 --- a/app/api/system-settings/__tests__/route.test.ts +++ b/app/api/system-settings/__tests__/route.test.ts @@ -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 }, diff --git a/app/api/system-settings/upload-logo/route.ts b/app/api/system-settings/upload-logo/route.ts index dc7bcf5..aeac808 100644 --- a/app/api/system-settings/upload-logo/route.ts +++ b/app/api/system-settings/upload-logo/route.ts @@ -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 diff --git a/lib/__tests__/auth-dingtalk-token.test.ts b/lib/__tests__/auth-dingtalk-token.test.ts index df15595..6b6dfd2 100644 --- a/lib/__tests__/auth-dingtalk-token.test.ts +++ b/lib/__tests__/auth-dingtalk-token.test.ts @@ -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', diff --git a/lib/auth-rate-limit.ts b/lib/auth-rate-limit.ts new file mode 100644 index 0000000..d714dc4 --- /dev/null +++ b/lib/auth-rate-limit.ts @@ -0,0 +1,117 @@ +type HeaderSource = + | Headers + | Record + | undefined + +type AuthRequestLike = { + headers?: HeaderSource +} + +type RateState = { + count: number + firstAttemptAt: number + lockedUntil?: number +} + +const ipAttempts = new Map() +const accountFailures = new Map() + +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 + 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, 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() +} diff --git a/lib/auth.ts b/lib/auth.ts index 1978d32..97c902d 100644 --- a/lib/auth.ts +++ b/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 { diff --git a/license-server/.gitignore b/license-server/.gitignore index 7ce8a51..4711595 100644 --- a/license-server/.gitignore +++ b/license-server/.gitignore @@ -23,8 +23,8 @@ yarn-debug.log* yarn-error.log* # local env files -.env -.env*.local +.env* +!.env.example # vercel .vercel