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

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_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
View File

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

View File

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

View File

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

View File

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

View File

@ -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) => {
// 清空点赞记录

View File

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

View File

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

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', {
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 不允许执行恢复')
})
})

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 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');

View File

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

View File

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

View 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
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 { 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 {

View File

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