加固登录与高危接口安全边界
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_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
10
.gitignore
vendored
@ -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
|
||||||
|
|
||||||
|
|||||||
@ -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 },
|
||||||
|
|||||||
@ -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(
|
||||||
|
|||||||
@ -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`,
|
||||||
|
|||||||
@ -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) => {
|
||||||
// 清空点赞记录
|
// 清空点赞记录
|
||||||
|
|||||||
@ -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: [
|
||||||
{
|
{
|
||||||
|
|||||||
@ -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)
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
|
|||||||
@ -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 不允许执行恢复')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@ -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');
|
||||||
|
|
||||||
|
|||||||
@ -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 },
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
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 { 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 {
|
||||||
|
|||||||
4
license-server/.gitignore
vendored
4
license-server/.gitignore
vendored
@ -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
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user