强化首次改密商业化门禁
This commit is contained in:
parent
3256524271
commit
3cdfaa89a9
@ -4,6 +4,16 @@ jest.mock('next/server', () => {
|
|||||||
return new MockNextResponse(null, { status: 200 })
|
return new MockNextResponse(null, { status: 200 })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static json(body: unknown, init?: ResponseInit) {
|
||||||
|
return new MockNextResponse(JSON.stringify(body), {
|
||||||
|
...init,
|
||||||
|
headers: {
|
||||||
|
'content-type': 'application/json',
|
||||||
|
...(init?.headers || {}),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
static redirect(url: URL | string) {
|
static redirect(url: URL | string) {
|
||||||
return new MockNextResponse(null, {
|
return new MockNextResponse(null, {
|
||||||
status: 307,
|
status: 307,
|
||||||
@ -126,4 +136,30 @@ describe('middleware admin pages', () => {
|
|||||||
|
|
||||||
expect(response.status).toBe(200)
|
expect(response.status).toBe(200)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('首次改密前访问后台页面会被带回首页', async () => {
|
||||||
|
const { getToken } = require('next-auth/jwt')
|
||||||
|
getToken.mockResolvedValue({ role: 'ADMIN', mustChangePassword: true })
|
||||||
|
|
||||||
|
const { middleware } = await import('../middleware')
|
||||||
|
const response = await middleware(createRequest('https://app.example.com/admin/settings'))
|
||||||
|
|
||||||
|
expect(response.status).toBe(307)
|
||||||
|
expect(response.headers.get('Location')).toBe('https://app.example.com/')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('首次改密前业务 API 会被拦截,但改密接口仍可访问', async () => {
|
||||||
|
const { getToken } = require('next-auth/jwt')
|
||||||
|
getToken.mockResolvedValue({ role: 'ADMIN', mustChangePassword: true })
|
||||||
|
|
||||||
|
const { middleware } = await import('../middleware')
|
||||||
|
const blocked = await middleware(createRequest('https://app.example.com/api/admin/users'))
|
||||||
|
expect(blocked.status).toBe(403)
|
||||||
|
await expect(blocked.json()).resolves.toMatchObject({
|
||||||
|
code: 'PASSWORD_CHANGE_REQUIRED',
|
||||||
|
})
|
||||||
|
|
||||||
|
const allowed = await middleware(createRequest('https://app.example.com/api/user/change-password'))
|
||||||
|
expect(allowed.status).toBe(200)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@ -113,6 +113,7 @@ export interface SessionUser {
|
|||||||
image?: string
|
image?: string
|
||||||
role?: 'ADMIN' | 'USER'
|
role?: 'ADMIN' | 'USER'
|
||||||
isSuperAdmin?: boolean
|
isSuperAdmin?: boolean
|
||||||
|
mustChangePassword?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
// API 响应类型
|
// API 响应类型
|
||||||
|
|||||||
@ -75,11 +75,16 @@ const departmentCollator = useMemo(() => getDepartmentCollator(), [])
|
|||||||
}
|
}
|
||||||
|
|
||||||
const isSuperAdmin = sessionUser?.isSuperAdmin
|
const isSuperAdmin = sessionUser?.isSuperAdmin
|
||||||
|
if (status === 'authenticated' && sessionUser?.mustChangePassword) {
|
||||||
|
router.push('/')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if (status === 'authenticated' && sessionUser?.role !== 'ADMIN' && !isSuperAdmin) {
|
if (status === 'authenticated' && sessionUser?.role !== 'ADMIN' && !isSuperAdmin) {
|
||||||
router.push('/')
|
router.push('/')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}, [status, router, sessionUser?.isSuperAdmin, sessionUser?.role])
|
}, [status, router, sessionUser?.isSuperAdmin, sessionUser?.mustChangePassword, sessionUser?.role])
|
||||||
|
|
||||||
const fetchData = useCallback(async () => {
|
const fetchData = useCallback(async () => {
|
||||||
const blockUI = !hasLoadedOnceRef.current
|
const blockUI = !hasLoadedOnceRef.current
|
||||||
|
|||||||
@ -11,6 +11,7 @@ jest.mock('next-auth', () => ({
|
|||||||
|
|
||||||
jest.mock('@/lib/prisma', () => ({
|
jest.mock('@/lib/prisma', () => ({
|
||||||
prisma: {
|
prisma: {
|
||||||
|
$transaction: jest.fn(),
|
||||||
user: {
|
user: {
|
||||||
findUnique: jest.fn(),
|
findUnique: jest.fn(),
|
||||||
update: jest.fn(),
|
update: jest.fn(),
|
||||||
@ -58,6 +59,9 @@ describe('POST /api/user/change-password', () => {
|
|||||||
})
|
})
|
||||||
isInitialAdminPasswordChangeRequired.mockResolvedValue(false)
|
isInitialAdminPasswordChangeRequired.mockResolvedValue(false)
|
||||||
bcrypt.compare.mockResolvedValue(false)
|
bcrypt.compare.mockResolvedValue(false)
|
||||||
|
prisma.$transaction.mockImplementation(async (callback: any) => callback({
|
||||||
|
user: prisma.user,
|
||||||
|
}))
|
||||||
})
|
})
|
||||||
|
|
||||||
test('拒绝弱新密码且不查询用户密码', async () => {
|
test('拒绝弱新密码且不查询用户密码', async () => {
|
||||||
@ -85,6 +89,10 @@ describe('POST /api/user/change-password', () => {
|
|||||||
expect(data.success).toBe(true)
|
expect(data.success).toBe(true)
|
||||||
expect(bcrypt.hash).toHaveBeenCalledWith('Flnxi-2026-Safe', 10)
|
expect(bcrypt.hash).toHaveBeenCalledWith('Flnxi-2026-Safe', 10)
|
||||||
expect(prisma.user.update).toHaveBeenCalled()
|
expect(prisma.user.update).toHaveBeenCalled()
|
||||||
expect(clearInitialAdminPasswordRequirement).toHaveBeenCalledWith('admin-1')
|
expect(clearInitialAdminPasswordRequirement).toHaveBeenCalledWith(
|
||||||
|
'admin-1',
|
||||||
|
expect.objectContaining({ user: prisma.user }),
|
||||||
|
{ throwOnFailure: true }
|
||||||
|
)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@ -80,14 +80,16 @@ export async function POST(req: NextRequest) {
|
|||||||
|
|
||||||
// 更新密码
|
// 更新密码
|
||||||
const hashedNewPassword = await bcrypt.hash(newPassword, 10)
|
const hashedNewPassword = await bcrypt.hash(newPassword, 10)
|
||||||
await prisma.user.update({
|
await prisma.$transaction(async (tx) => {
|
||||||
where: { id: user.id },
|
await tx.user.update({
|
||||||
data: { password: hashedNewPassword }
|
where: { id: user.id },
|
||||||
})
|
data: { password: hashedNewPassword }
|
||||||
|
})
|
||||||
|
|
||||||
if (mustChangePassword) {
|
if (mustChangePassword) {
|
||||||
await clearInitialAdminPasswordRequirement(user.id)
|
await clearInitialAdminPasswordRequirement(user.id, tx, { throwOnFailure: true })
|
||||||
}
|
}
|
||||||
|
})
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
success: true,
|
success: true,
|
||||||
|
|||||||
38
app/page.tsx
38
app/page.tsx
@ -238,6 +238,7 @@ export default function Home() {
|
|||||||
const [showFilterModal, setShowFilterModal] = useState(false) // 筛选弹窗
|
const [showFilterModal, setShowFilterModal] = useState(false) // 筛选弹窗
|
||||||
const [showChangePasswordModal, setShowChangePasswordModal] = useState(false) // 修改密码弹窗
|
const [showChangePasswordModal, setShowChangePasswordModal] = useState(false) // 修改密码弹窗
|
||||||
const [isDefaultPassword, setIsDefaultPassword] = useState(false) // 是否使用默认密码
|
const [isDefaultPassword, setIsDefaultPassword] = useState(false) // 是否使用默认密码
|
||||||
|
const [initialPasswordCheckDone, setInitialPasswordCheckDone] = useState(false) // 初始密码检查是否完成
|
||||||
const [isScrolled, setIsScrolled] = useState(false) // 是否已滚动
|
const [isScrolled, setIsScrolled] = useState(false) // 是否已滚动
|
||||||
const searchSectionRef = useRef<HTMLDivElement>(null) // 搜索区域引用(包含TAG和搜索框)
|
const searchSectionRef = useRef<HTMLDivElement>(null) // 搜索区域引用(包含TAG和搜索框)
|
||||||
const searchStartScrollStateRef = useRef<boolean>(false) // 记录搜索开始时的滚动状态
|
const searchStartScrollStateRef = useRef<boolean>(false) // 记录搜索开始时的滚动状态
|
||||||
@ -431,7 +432,10 @@ export default function Home() {
|
|||||||
// 初始管理员强制改密检查
|
// 初始管理员强制改密检查
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const checkInitialAdminPassword = async () => {
|
const checkInitialAdminPassword = async () => {
|
||||||
if (status !== 'authenticated') return
|
if (status !== 'authenticated') {
|
||||||
|
setInitialPasswordCheckDone(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const passwordCheck = await fetch('/api/user/check-default-password')
|
const passwordCheck = await fetch('/api/user/check-default-password')
|
||||||
@ -440,14 +444,19 @@ export default function Home() {
|
|||||||
console.log('检测到系统初始化账号仍需首次改密,强制修改密码')
|
console.log('检测到系统初始化账号仍需首次改密,强制修改密码')
|
||||||
setIsDefaultPassword(true)
|
setIsDefaultPassword(true)
|
||||||
setShowChangePasswordModal(true)
|
setShowChangePasswordModal(true)
|
||||||
|
} else {
|
||||||
|
setIsDefaultPassword(false)
|
||||||
|
setShowChangePasswordModal(false)
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('初始管理员改密检查失败:', error)
|
console.error('初始管理员改密检查失败:', error)
|
||||||
|
} finally {
|
||||||
|
setInitialPasswordCheckDone(true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
checkInitialAdminPassword()
|
checkInitialAdminPassword()
|
||||||
}, [status, session])
|
}, [status, session?.user?.email])
|
||||||
|
|
||||||
// 检查是否有未完成的上传(断点续传)
|
// 检查是否有未完成的上传(断点续传)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -1727,7 +1736,10 @@ export default function Home() {
|
|||||||
|
|
||||||
// 只在首次加载时显示加载中(NextAuth检查状态或首次加载素材)
|
// 只在首次加载时显示加载中(NextAuth检查状态或首次加载素材)
|
||||||
// 如果已经有素材了,即使loading为true也不显示加载中(避免tag切换时闪烁)
|
// 如果已经有素材了,即使loading为true也不显示加载中(避免tag切换时闪烁)
|
||||||
const isInitialLoading = status === 'loading' || (loading && materials.length === 0 && !homeFilterReady)
|
const isInitialLoading =
|
||||||
|
status === 'loading' ||
|
||||||
|
(status === 'authenticated' && !initialPasswordCheckDone) ||
|
||||||
|
(!isDefaultPassword && loading && materials.length === 0 && !homeFilterReady)
|
||||||
|
|
||||||
if (isInitialLoading) {
|
if (isInitialLoading) {
|
||||||
return (
|
return (
|
||||||
@ -1741,6 +1753,26 @@ export default function Home() {
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isDefaultPassword) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-slate-50 dark:bg-slate-900">
|
||||||
|
<Header session={session} />
|
||||||
|
<main className="container mx-auto max-w-7xl px-4 py-8">
|
||||||
|
<ChangePasswordModal
|
||||||
|
isOpen
|
||||||
|
onClose={() => {}}
|
||||||
|
isRequired
|
||||||
|
onSuccess={() => {
|
||||||
|
setIsDefaultPassword(false)
|
||||||
|
setShowChangePasswordModal(false)
|
||||||
|
window.location.reload()
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-slate-50 dark:bg-slate-900">
|
<div className="min-h-screen bg-slate-50 dark:bg-slate-900">
|
||||||
<Header
|
<Header
|
||||||
|
|||||||
@ -12,6 +12,7 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
|
import { useSession } from 'next-auth/react'
|
||||||
import { useDialog } from '@/components/ui/DialogProvider'
|
import { useDialog } from '@/components/ui/DialogProvider'
|
||||||
import Modal from '@/components/ui/Modal'
|
import Modal from '@/components/ui/Modal'
|
||||||
import Input from '@/components/ui/Input'
|
import Input from '@/components/ui/Input'
|
||||||
@ -33,6 +34,7 @@ export default function ChangePasswordModal({
|
|||||||
onSuccess
|
onSuccess
|
||||||
}: ChangePasswordModalProps) {
|
}: ChangePasswordModalProps) {
|
||||||
const { alert } = useDialog()
|
const { alert } = useDialog()
|
||||||
|
const { update } = useSession()
|
||||||
const styles = useThemeStyles()
|
const styles = useThemeStyles()
|
||||||
const [oldPassword, setOldPassword] = useState('')
|
const [oldPassword, setOldPassword] = useState('')
|
||||||
const [newPassword, setNewPassword] = useState('')
|
const [newPassword, setNewPassword] = useState('')
|
||||||
@ -40,6 +42,13 @@ export default function ChangePasswordModal({
|
|||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
|
|
||||||
|
const resetForm = () => {
|
||||||
|
setOldPassword('')
|
||||||
|
setNewPassword('')
|
||||||
|
setConfirmPassword('')
|
||||||
|
setError('')
|
||||||
|
}
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
setError('')
|
setError('')
|
||||||
@ -78,11 +87,12 @@ export default function ChangePasswordModal({
|
|||||||
const data = await response.json()
|
const data = await response.json()
|
||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
// 清空表单
|
resetForm()
|
||||||
setOldPassword('')
|
try {
|
||||||
setNewPassword('')
|
await update()
|
||||||
setConfirmPassword('')
|
} catch (error) {
|
||||||
setError('')
|
console.warn('刷新会话状态失败:', error)
|
||||||
|
}
|
||||||
|
|
||||||
if (onSuccess) {
|
if (onSuccess) {
|
||||||
onSuccess()
|
onSuccess()
|
||||||
@ -107,104 +117,129 @@ export default function ChangePasswordModal({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
const formContent = (
|
||||||
<Modal
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
isOpen={isOpen}
|
{!isRequired && (
|
||||||
onClose={isRequired ? () => {} : onClose}
|
<div>
|
||||||
title={isRequired ? '首次登录,请修改密码' : '修改密码'}
|
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">
|
||||||
showCloseButton={!isRequired}
|
旧密码
|
||||||
closeOnOverlayClick={!isRequired}
|
</label>
|
||||||
closeOnEscape={!isRequired}
|
<Input
|
||||||
>
|
type="password"
|
||||||
{isRequired && (
|
value={oldPassword}
|
||||||
<div className={cn(
|
onChange={(e) => setOldPassword(e.target.value)}
|
||||||
'mb-4 p-3 rounded-lg border',
|
placeholder="请输入旧密码"
|
||||||
'bg-yellow-50 dark:bg-yellow-900/20',
|
required={!isRequired}
|
||||||
'border-yellow-200 dark:border-yellow-800'
|
fullWidth
|
||||||
)}>
|
/>
|
||||||
<p className="text-sm text-yellow-800 dark:text-yellow-200">
|
|
||||||
⚠️ 您正在使用系统初始化密码,为了账户安全,请立即修改密码。
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">
|
||||||
|
新密码
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
type="password"
|
||||||
|
value={newPassword}
|
||||||
|
onChange={(e) => setNewPassword(e.target.value)}
|
||||||
|
placeholder="请输入新密码"
|
||||||
|
required
|
||||||
|
minLength={10}
|
||||||
|
fullWidth
|
||||||
|
/>
|
||||||
|
<p className="mt-2 text-xs leading-5 text-slate-500 dark:text-slate-400">
|
||||||
|
{PASSWORD_POLICY_DESCRIPTION}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">
|
||||||
|
确认新密码
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
type="password"
|
||||||
|
value={confirmPassword}
|
||||||
|
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||||
|
placeholder="请再次输入新密码"
|
||||||
|
required
|
||||||
|
minLength={10}
|
||||||
|
fullWidth
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className={cn(
|
||||||
|
'p-3 rounded-md border',
|
||||||
|
'bg-red-50 dark:bg-red-900/20',
|
||||||
|
'border-red-200 dark:border-red-800'
|
||||||
|
)}>
|
||||||
|
<p className="text-sm text-red-800 dark:text-red-200">{error}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex gap-3 justify-end">
|
||||||
{!isRequired && (
|
{!isRequired && (
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">
|
|
||||||
旧密码
|
|
||||||
</label>
|
|
||||||
<Input
|
|
||||||
type="password"
|
|
||||||
value={oldPassword}
|
|
||||||
onChange={(e) => setOldPassword(e.target.value)}
|
|
||||||
placeholder="请输入旧密码"
|
|
||||||
required={!isRequired}
|
|
||||||
fullWidth
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">
|
|
||||||
新密码
|
|
||||||
</label>
|
|
||||||
<Input
|
|
||||||
type="password"
|
|
||||||
value={newPassword}
|
|
||||||
onChange={(e) => setNewPassword(e.target.value)}
|
|
||||||
placeholder={PASSWORD_POLICY_DESCRIPTION}
|
|
||||||
required
|
|
||||||
minLength={10}
|
|
||||||
fullWidth
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">
|
|
||||||
确认新密码
|
|
||||||
</label>
|
|
||||||
<Input
|
|
||||||
type="password"
|
|
||||||
value={confirmPassword}
|
|
||||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
|
||||||
placeholder="请再次输入新密码"
|
|
||||||
required
|
|
||||||
minLength={10}
|
|
||||||
fullWidth
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{error && (
|
|
||||||
<div className={cn(
|
|
||||||
'p-3 rounded-lg border',
|
|
||||||
'bg-red-50 dark:bg-red-900/20',
|
|
||||||
'border-red-200 dark:border-red-800'
|
|
||||||
)}>
|
|
||||||
<p className="text-sm text-red-800 dark:text-red-200">{error}</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="flex gap-3 justify-end">
|
|
||||||
{!isRequired && (
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="secondary"
|
|
||||||
onClick={onClose}
|
|
||||||
>
|
|
||||||
取消
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="button"
|
||||||
variant="primary"
|
variant="secondary"
|
||||||
disabled={loading}
|
onClick={onClose}
|
||||||
loading={loading}
|
|
||||||
>
|
>
|
||||||
{loading ? '修改中...' : '确认修改'}
|
取消
|
||||||
</Button>
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
variant="primary"
|
||||||
|
disabled={loading}
|
||||||
|
loading={loading}
|
||||||
|
className="min-w-24 justify-center"
|
||||||
|
>
|
||||||
|
{loading ? '修改中...' : '确认修改'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!isOpen) return null
|
||||||
|
|
||||||
|
if (isRequired) {
|
||||||
|
return (
|
||||||
|
<section className="mx-auto flex min-h-[calc(100vh-6rem)] w-full max-w-md items-center justify-center px-4 py-10">
|
||||||
|
<div className="w-full rounded-lg border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900">
|
||||||
|
<div className="border-b border-slate-200 px-6 py-5 dark:border-slate-800">
|
||||||
|
<p className="text-xs font-medium text-slate-500 dark:text-slate-400">账号安全</p>
|
||||||
|
<h1 className="mt-2 text-xl font-semibold text-slate-950 dark:text-white">
|
||||||
|
设置新密码
|
||||||
|
</h1>
|
||||||
|
<p className="mt-2 text-sm leading-6 text-slate-500 dark:text-slate-400">
|
||||||
|
首次登录需要先更换初始密码,完成后才能进入素材库。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-5 px-6 py-5">
|
||||||
|
<div className="rounded-md border border-amber-200 bg-amber-50 px-3 py-2.5 dark:border-amber-900/60 dark:bg-amber-950/30">
|
||||||
|
<p className="text-sm leading-6 text-amber-800 dark:text-amber-200">
|
||||||
|
当前账号仍在使用系统初始化密码,请设置一个只由您掌握的新密码。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{formContent}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
isOpen={isOpen}
|
||||||
|
onClose={onClose}
|
||||||
|
title="修改密码"
|
||||||
|
showCloseButton
|
||||||
|
closeOnOverlayClick
|
||||||
|
closeOnEscape
|
||||||
|
>
|
||||||
|
{formContent}
|
||||||
</Modal>
|
</Modal>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
133
components/__tests__/ChangePasswordModal.test.tsx
Normal file
133
components/__tests__/ChangePasswordModal.test.tsx
Normal file
@ -0,0 +1,133 @@
|
|||||||
|
/**
|
||||||
|
* @jest-environment jsdom
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React from 'react'
|
||||||
|
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
|
||||||
|
import '@testing-library/jest-dom'
|
||||||
|
import ChangePasswordModal from '../ChangePasswordModal'
|
||||||
|
|
||||||
|
const mockAlert = jest.fn()
|
||||||
|
const mockFetch = jest.fn()
|
||||||
|
|
||||||
|
jest.mock('@/components/ui/DialogProvider', () => ({
|
||||||
|
useDialog: () => ({
|
||||||
|
alert: mockAlert,
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
|
||||||
|
jest.mock('next-auth/react', () => ({
|
||||||
|
useSession: () => ({
|
||||||
|
update: jest.fn(),
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
|
||||||
|
jest.mock('@/lib/ui-styles', () => ({
|
||||||
|
cn: (...classes: Array<string | false | null | undefined>) => classes.filter(Boolean).join(' '),
|
||||||
|
useThemeStyles: () => ({
|
||||||
|
buttonModifiers: {
|
||||||
|
block: 'w-full',
|
||||||
|
compactXs: 'px-2 py-1',
|
||||||
|
},
|
||||||
|
buttonStyles: {
|
||||||
|
primary: 'primary',
|
||||||
|
secondary: 'secondary',
|
||||||
|
ghost: 'ghost',
|
||||||
|
danger: 'danger',
|
||||||
|
success: 'success',
|
||||||
|
},
|
||||||
|
inputModifiers: {
|
||||||
|
compactXs: 'px-2 py-1',
|
||||||
|
},
|
||||||
|
inputStyles: {
|
||||||
|
base: 'input',
|
||||||
|
error: 'input-error',
|
||||||
|
},
|
||||||
|
modalStyles: {
|
||||||
|
overlay: 'overlay',
|
||||||
|
container: 'container',
|
||||||
|
content: 'content',
|
||||||
|
header: 'header',
|
||||||
|
title: 'title',
|
||||||
|
closeButton: 'close',
|
||||||
|
body: 'body',
|
||||||
|
footer: 'footer',
|
||||||
|
sizes: {
|
||||||
|
xs: 'xs',
|
||||||
|
small: 'small',
|
||||||
|
medium: 'medium',
|
||||||
|
large: 'large',
|
||||||
|
xlarge: 'xlarge',
|
||||||
|
xxlarge: 'xxlarge',
|
||||||
|
fullscreen: 'fullscreen',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
|
||||||
|
global.fetch = mockFetch
|
||||||
|
|
||||||
|
describe('ChangePasswordModal', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks()
|
||||||
|
mockFetch.mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: () => Promise.resolve({ success: true }),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('强制改密时显示独立账号安全页,不显示旧密码和取消入口', () => {
|
||||||
|
render(
|
||||||
|
<ChangePasswordModal
|
||||||
|
isOpen
|
||||||
|
isRequired
|
||||||
|
onClose={jest.fn()}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(screen.getByText('账号安全')).toBeInTheDocument()
|
||||||
|
expect(screen.getByRole('heading', { name: '设置新密码' })).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('首次登录需要先更换初始密码,完成后才能进入素材库。')).toBeInTheDocument()
|
||||||
|
expect(screen.queryByText('旧密码')).not.toBeInTheDocument()
|
||||||
|
expect(screen.queryByRole('button', { name: '取消' })).not.toBeInTheDocument()
|
||||||
|
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('强制改密提交时只发送新密码并回调成功', async () => {
|
||||||
|
const onSuccess = jest.fn()
|
||||||
|
|
||||||
|
render(
|
||||||
|
<ChangePasswordModal
|
||||||
|
isOpen
|
||||||
|
isRequired
|
||||||
|
onClose={jest.fn()}
|
||||||
|
onSuccess={onSuccess}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
|
||||||
|
fireEvent.change(screen.getByPlaceholderText('请输入新密码'), {
|
||||||
|
target: { value: 'StrongPass123!' },
|
||||||
|
})
|
||||||
|
fireEvent.change(screen.getByPlaceholderText('请再次输入新密码'), {
|
||||||
|
target: { value: 'StrongPass123!' },
|
||||||
|
})
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: '确认修改' }))
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockFetch).toHaveBeenCalledWith('/api/user/change-password', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ newPassword: 'StrongPass123!' }),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(onSuccess).toHaveBeenCalled()
|
||||||
|
expect(mockAlert).toHaveBeenCalledWith({
|
||||||
|
title: '成功',
|
||||||
|
message: '密码修改成功!',
|
||||||
|
variant: 'success',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@ -56,13 +56,14 @@ curl -X POST https://your-domain.com/api/system/init \
|
|||||||
-H "x-system-init-token: your-init-token"
|
-H "x-system-init-token: your-init-token"
|
||||||
```
|
```
|
||||||
返回值中会带出本次创建的初始管理员信息;如果没有预先配置 `INIT_ADMIN_PASSWORD`,系统会生成一次性初始密码并只在初始化响应/激活成功页展示一次。
|
返回值中会带出本次创建的初始管理员信息;如果没有预先配置 `INIT_ADMIN_PASSWORD`,系统会生成一次性初始密码并只在初始化响应/激活成功页展示一次。
|
||||||
- 首次登录后会被强制修改密码,可在“个人中心 → 修改密码”完成。
|
- 首次登录后会先进入“账号安全 / 设置新密码”,完成改密前不能进入首页、后台或业务接口。
|
||||||
|
|
||||||
### 7. 首次登录与检查
|
### 7. 首次登录与检查
|
||||||
1. 访问 `https://your-domain.com/login`,使用初始化返回的初始管理员账号登录。
|
1. 访问 `https://your-domain.com/login`,使用初始化返回的初始管理员账号登录。
|
||||||
2. 进入主应用后台 `/admin`;项目选择页属于独立 `license-server` 后台,不是主应用后台流程。
|
2. 按页面提示进入“账号安全 / 设置新密码”,先把初始密码改成正式密码。
|
||||||
3. 打开“系统设置”,填写对象存储、LOGO、前端域名等信息。
|
3. 改密成功后进入主应用后台 `/admin`;项目选择页属于独立 `license-server` 后台,不是主应用后台流程。
|
||||||
4. 上传一份素材,验证转码、归属(`ownerType/ownerId`)、预览、下载链路是否正常。
|
4. 打开“系统设置”,填写对象存储、LOGO、前端域名等信息。
|
||||||
|
5. 上传一份素材,验证转码、归属(`ownerType/ownerId`)、预览、下载链路是否正常。
|
||||||
|
|
||||||
如果涉及许可证服务联调,建议继续按 `docs/许可证服务接入说明.md` 检查项目编码和公开接口路径。
|
如果涉及许可证服务联调,建议继续按 `docs/许可证服务接入说明.md` 检查项目编码和公开接口路径。
|
||||||
|
|
||||||
|
|||||||
@ -13,16 +13,22 @@ jest.mock('@/lib/dingtalk-login-token', () => ({
|
|||||||
consumeDingtalkLoginToken: jest.fn(),
|
consumeDingtalkLoginToken: jest.fn(),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
jest.mock('@/lib/system-init', () => ({
|
||||||
|
isInitialAdminPasswordChangeRequired: jest.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
jest.mock('bcryptjs', () => ({
|
jest.mock('bcryptjs', () => ({
|
||||||
compare: jest.fn(),
|
compare: jest.fn(),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
import { authOptions } from '../auth'
|
import { authOptions } from '../auth'
|
||||||
import { consumeDingtalkLoginToken } from '../dingtalk-login-token'
|
import { consumeDingtalkLoginToken } from '../dingtalk-login-token'
|
||||||
|
import { isInitialAdminPasswordChangeRequired } from '../system-init'
|
||||||
import { prisma } from '../prisma'
|
import { prisma } from '../prisma'
|
||||||
import bcrypt from 'bcryptjs'
|
import bcrypt from 'bcryptjs'
|
||||||
|
|
||||||
const mockConsumeDingtalkLoginToken = consumeDingtalkLoginToken as jest.MockedFunction<typeof consumeDingtalkLoginToken>
|
const mockConsumeDingtalkLoginToken = consumeDingtalkLoginToken as jest.MockedFunction<typeof consumeDingtalkLoginToken>
|
||||||
|
const mockIsInitialAdminPasswordChangeRequired = isInitialAdminPasswordChangeRequired as jest.MockedFunction<typeof isInitialAdminPasswordChangeRequired>
|
||||||
|
|
||||||
function getDingtalkProvider() {
|
function getDingtalkProvider() {
|
||||||
const provider = authOptions.providers.find((item: any) => item.id === 'credentials') as any
|
const provider = authOptions.providers.find((item: any) => item.id === 'credentials') as any
|
||||||
@ -36,6 +42,7 @@ describe('auth dingtalk-token provider', () => {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
jest.clearAllMocks()
|
jest.clearAllMocks()
|
||||||
;(prisma.systemSettings.findFirst as jest.Mock).mockResolvedValue(null)
|
;(prisma.systemSettings.findFirst as jest.Mock).mockResolvedValue(null)
|
||||||
|
mockIsInitialAdminPasswordChangeRequired.mockResolvedValue(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('使用一次性钉钉票据登录并返回会话用户', async () => {
|
it('使用一次性钉钉票据登录并返回会话用户', async () => {
|
||||||
@ -58,6 +65,9 @@ describe('auth dingtalk-token provider', () => {
|
|||||||
name: '张三',
|
name: '张三',
|
||||||
role: 'USER',
|
role: 'USER',
|
||||||
}))
|
}))
|
||||||
|
expect(user).toEqual(expect.objectContaining({
|
||||||
|
mustChangePassword: false,
|
||||||
|
}))
|
||||||
})
|
})
|
||||||
|
|
||||||
it('拒绝无效或已消费的钉钉票据', async () => {
|
it('拒绝无效或已消费的钉钉票据', async () => {
|
||||||
|
|||||||
@ -129,6 +129,60 @@ describe('system-init', () => {
|
|||||||
expect(result.admin?.initialPassword).toBeUndefined()
|
expect(result.admin?.initialPassword).toBeUndefined()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('把已有账号提升为最高权限时也应要求首次改密', async () => {
|
||||||
|
const existingUser = {
|
||||||
|
id: 'existing-admin',
|
||||||
|
email: 'admin@local.flnxi',
|
||||||
|
username: 'admin',
|
||||||
|
role: 'USER',
|
||||||
|
isSuperAdmin: false,
|
||||||
|
}
|
||||||
|
const upgradedUser = {
|
||||||
|
...existingUser,
|
||||||
|
role: 'ADMIN',
|
||||||
|
isSuperAdmin: true,
|
||||||
|
}
|
||||||
|
const tx = {
|
||||||
|
user: {
|
||||||
|
findFirst: jest.fn()
|
||||||
|
.mockResolvedValueOnce(null)
|
||||||
|
.mockResolvedValueOnce(existingUser),
|
||||||
|
update: jest.fn().mockResolvedValue(upgradedUser),
|
||||||
|
create: jest.fn(),
|
||||||
|
},
|
||||||
|
department: {
|
||||||
|
findFirst: jest.fn().mockResolvedValue({ name: '默认部门' }),
|
||||||
|
create: jest.fn(),
|
||||||
|
},
|
||||||
|
category: {
|
||||||
|
findFirst: jest.fn().mockResolvedValue({ name: '默认店铺' }),
|
||||||
|
create: jest.fn(),
|
||||||
|
},
|
||||||
|
scene: {
|
||||||
|
findFirst: jest.fn().mockResolvedValue({ name: '默认场景' }),
|
||||||
|
create: jest.fn(),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
;(prisma.$transaction as jest.Mock).mockImplementation(async (callback) => callback(tx))
|
||||||
|
;(prisma.systemConfig.upsert as jest.Mock).mockResolvedValue({})
|
||||||
|
|
||||||
|
const result = await initializeSystem()
|
||||||
|
|
||||||
|
expect(tx.user.update).toHaveBeenCalledWith({
|
||||||
|
where: { id: 'existing-admin' },
|
||||||
|
data: { isSuperAdmin: true, role: 'ADMIN' },
|
||||||
|
})
|
||||||
|
expect(result.admin?.created).toBe(false)
|
||||||
|
expect(result.admin?.upgraded).toBe(true)
|
||||||
|
expect(result.admin?.mustChangePassword).toBe(true)
|
||||||
|
expect(prisma.systemConfig.upsert).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
where: { key: 'initial_admin_state' },
|
||||||
|
})
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
it('应根据 initial_admin_state 判断是否需要首次改密', async () => {
|
it('应根据 initial_admin_state 判断是否需要首次改密', async () => {
|
||||||
;(prisma.systemConfig.findUnique as jest.Mock).mockResolvedValue({
|
;(prisma.systemConfig.findUnique as jest.Mock).mockResolvedValue({
|
||||||
key: 'initial_admin_state',
|
key: 'initial_admin_state',
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import { NextAuthOptions } from 'next-auth'
|
|||||||
import CredentialsProvider from 'next-auth/providers/credentials'
|
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 bcrypt from 'bcryptjs'
|
import bcrypt from 'bcryptjs'
|
||||||
|
|
||||||
type AccountState = {
|
type AccountState = {
|
||||||
@ -38,6 +39,7 @@ export const authOptions: NextAuthOptions = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
assertAccountCanUseSession(user)
|
assertAccountCanUseSession(user)
|
||||||
|
const mustChangePassword = await isInitialAdminPasswordChangeRequired(user.id)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: user.id,
|
id: user.id,
|
||||||
@ -45,6 +47,7 @@ export const authOptions: NextAuthOptions = {
|
|||||||
name: user.username,
|
name: user.username,
|
||||||
role: user.role as "ADMIN" | "USER",
|
role: user.role as "ADMIN" | "USER",
|
||||||
isSuperAdmin: (user as any).isSuperAdmin,
|
isSuperAdmin: (user as any).isSuperAdmin,
|
||||||
|
mustChangePassword,
|
||||||
avatar: user.avatar,
|
avatar: user.avatar,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -71,6 +74,7 @@ export const authOptions: NextAuthOptions = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
assertAccountCanUseSession(user)
|
assertAccountCanUseSession(user)
|
||||||
|
const mustChangePassword = await isInitialAdminPasswordChangeRequired(user.id)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: user.id,
|
id: user.id,
|
||||||
@ -78,6 +82,7 @@ export const authOptions: NextAuthOptions = {
|
|||||||
name: user.username,
|
name: user.username,
|
||||||
role: user.role as "ADMIN" | "USER",
|
role: user.role as "ADMIN" | "USER",
|
||||||
isSuperAdmin: (user as any).isSuperAdmin,
|
isSuperAdmin: (user as any).isSuperAdmin,
|
||||||
|
mustChangePassword,
|
||||||
avatar: user.avatar,
|
avatar: user.avatar,
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@ -90,6 +95,7 @@ export const authOptions: NextAuthOptions = {
|
|||||||
token.role = (user as any).role;
|
token.role = (user as any).role;
|
||||||
token.id = user.id;
|
token.id = user.id;
|
||||||
(token as any).isSuperAdmin = (user as any).isSuperAdmin || false;
|
(token as any).isSuperAdmin = (user as any).isSuperAdmin || false;
|
||||||
|
(token as any).mustChangePassword = Boolean((user as any).mustChangePassword);
|
||||||
(token as any).avatar = (user as any).avatar;
|
(token as any).avatar = (user as any).avatar;
|
||||||
(token as any).avatarUpdatedAt = Date.now();
|
(token as any).avatarUpdatedAt = Date.now();
|
||||||
}
|
}
|
||||||
@ -130,6 +136,7 @@ export const authOptions: NextAuthOptions = {
|
|||||||
|
|
||||||
(token as any).role = dbUser.role;
|
(token as any).role = dbUser.role;
|
||||||
(token as any).isSuperAdmin = dbUser.isSuperAdmin || false;
|
(token as any).isSuperAdmin = dbUser.isSuperAdmin || false;
|
||||||
|
(token as any).mustChangePassword = await isInitialAdminPasswordChangeRequired(token.id as string);
|
||||||
|
|
||||||
if (trigger === 'update' || (now - avatarUpdatedAt) > refreshInterval) {
|
if (trigger === 'update' || (now - avatarUpdatedAt) > refreshInterval) {
|
||||||
(token as any).avatar = dbUser.avatar;
|
(token as any).avatar = dbUser.avatar;
|
||||||
@ -144,6 +151,7 @@ export const authOptions: NextAuthOptions = {
|
|||||||
(session.user as any).role = token.role || 'USER';
|
(session.user as any).role = token.role || 'USER';
|
||||||
(session.user as any).id = token.id;
|
(session.user as any).id = token.id;
|
||||||
(session.user as any).isSuperAdmin = (token as any).isSuperAdmin || false;
|
(session.user as any).isSuperAdmin = (token as any).isSuperAdmin || false;
|
||||||
|
(session.user as any).mustChangePassword = Boolean((token as any).mustChangePassword);
|
||||||
(session.user as any).avatar = (token as any).avatar;
|
(session.user as any).avatar = (token as any).avatar;
|
||||||
}
|
}
|
||||||
return session;
|
return session;
|
||||||
|
|||||||
@ -6,6 +6,14 @@ import { BUILTIN_TEMPLATE_KEYS, ensureBuiltinTemplates } from './system-integrit
|
|||||||
const INITIAL_ADMIN_STATE_KEY = 'initial_admin_state'
|
const INITIAL_ADMIN_STATE_KEY = 'initial_admin_state'
|
||||||
const DEFAULT_INIT_ADMIN_EMAIL = 'admin@local.flnxi'
|
const DEFAULT_INIT_ADMIN_EMAIL = 'admin@local.flnxi'
|
||||||
|
|
||||||
|
type SystemConfigStore = {
|
||||||
|
systemConfig: {
|
||||||
|
findUnique: (args: any) => Promise<{ value: string } | null>
|
||||||
|
upsert: (args: any) => Promise<unknown>
|
||||||
|
delete: (args: any) => Promise<unknown>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
type PasswordSource = 'environment' | 'generated'
|
type PasswordSource = 'environment' | 'generated'
|
||||||
|
|
||||||
type InitialAdminState = {
|
type InitialAdminState = {
|
||||||
@ -121,17 +129,24 @@ export async function isInitialAdminPasswordChangeRequired(userId: string): Prom
|
|||||||
return Boolean(state && state.userId === userId && state.mustChangePassword)
|
return Boolean(state && state.userId === userId && state.mustChangePassword)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function clearInitialAdminPasswordRequirement(userId: string): Promise<void> {
|
export async function clearInitialAdminPasswordRequirement(
|
||||||
|
userId: string,
|
||||||
|
client: SystemConfigStore = prisma,
|
||||||
|
options: { throwOnFailure?: boolean } = {}
|
||||||
|
): Promise<void> {
|
||||||
const state = await getInitialAdminState()
|
const state = await getInitialAdminState()
|
||||||
if (!state || state.userId !== userId) {
|
if (!state || state.userId !== userId) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await prisma.systemConfig.delete({
|
await client.systemConfig.delete({
|
||||||
where: { key: INITIAL_ADMIN_STATE_KEY },
|
where: { key: INITIAL_ADMIN_STATE_KEY },
|
||||||
})
|
})
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (options.throwOnFailure) {
|
||||||
|
throw error
|
||||||
|
}
|
||||||
console.warn('[SystemInit] 清除初始管理员改密标记失败:', error)
|
console.warn('[SystemInit] 清除初始管理员改密标记失败:', error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -239,7 +254,7 @@ export async function initializeSystem(): Promise<InitializeSystemResult> {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
if (result.createdFlags.admin && result.admin) {
|
if ((result.createdFlags.admin || result.createdFlags.adminUpgraded) && result.admin) {
|
||||||
await saveInitialAdminState(result.admin.id, result.admin.email)
|
await saveInitialAdminState(result.admin.id, result.admin.email)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -262,7 +277,7 @@ export async function initializeSystem(): Promise<InitializeSystemResult> {
|
|||||||
username: result.admin.username,
|
username: result.admin.username,
|
||||||
created: result.createdFlags.admin,
|
created: result.createdFlags.admin,
|
||||||
upgraded: result.createdFlags.adminUpgraded,
|
upgraded: result.createdFlags.adminUpgraded,
|
||||||
mustChangePassword: result.createdFlags.admin,
|
mustChangePassword: result.createdFlags.admin || result.createdFlags.adminUpgraded,
|
||||||
initialPassword: result.passwordSource === 'generated' ? result.initialPassword || undefined : undefined,
|
initialPassword: result.passwordSource === 'generated' ? result.initialPassword || undefined : undefined,
|
||||||
passwordSource: result.passwordSource || undefined,
|
passwordSource: result.passwordSource || undefined,
|
||||||
}
|
}
|
||||||
|
|||||||
@ -15,6 +15,17 @@ const ALLOWED_ORIGINS = (() => {
|
|||||||
return Array.from(new Set([...defaults, ...list]))
|
return Array.from(new Set([...defaults, ...list]))
|
||||||
})()
|
})()
|
||||||
|
|
||||||
|
const PASSWORD_CHANGE_ALLOWED_API_PATHS = new Set([
|
||||||
|
'/api/health',
|
||||||
|
'/api/user/change-password',
|
||||||
|
'/api/user/check-default-password',
|
||||||
|
'/api/license/check',
|
||||||
|
])
|
||||||
|
|
||||||
|
const PASSWORD_CHANGE_ALLOWED_API_PREFIXES = [
|
||||||
|
'/api/auth/',
|
||||||
|
]
|
||||||
|
|
||||||
function normalizeOrigin(value?: string | null) {
|
function normalizeOrigin(value?: string | null) {
|
||||||
const trimmed = (value || '').trim()
|
const trimmed = (value || '').trim()
|
||||||
if (!trimmed) return null
|
if (!trimmed) return null
|
||||||
@ -40,6 +51,23 @@ function getAllowedCorsOrigin(requestOrigin: string | null, sameOrigin: string)
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isPasswordChangeAllowedApi(pathname: string) {
|
||||||
|
return PASSWORD_CHANGE_ALLOWED_API_PATHS.has(pathname) ||
|
||||||
|
PASSWORD_CHANGE_ALLOWED_API_PREFIXES.some((prefix) => pathname.startsWith(prefix))
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyApiCorsHeaders(response: NextResponse, corsOrigin: string | null) {
|
||||||
|
if (corsOrigin) {
|
||||||
|
response.headers.set('Access-Control-Allow-Credentials', 'true')
|
||||||
|
response.headers.set('Access-Control-Allow-Origin', corsOrigin)
|
||||||
|
response.headers.set('Access-Control-Allow-Methods', 'GET,DELETE,PATCH,POST,PUT,OPTIONS')
|
||||||
|
response.headers.set('Access-Control-Allow-Headers', 'X-CSRF-Token, X-Requested-With, Accept, Accept-Version, Content-Length, Content-MD5, Content-Type, Date, X-Api-Version, Authorization, Cookie')
|
||||||
|
response.headers.set('Vary', 'Origin')
|
||||||
|
}
|
||||||
|
|
||||||
|
return response
|
||||||
|
}
|
||||||
|
|
||||||
export async function middleware(request: NextRequest) {
|
export async function middleware(request: NextRequest) {
|
||||||
const { pathname, origin } = request.nextUrl
|
const { pathname, origin } = request.nextUrl
|
||||||
const requestOrigin = request.headers.get('origin')
|
const requestOrigin = request.headers.get('origin')
|
||||||
@ -70,17 +98,24 @@ export async function middleware(request: NextRequest) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// 为所有API响应添加CORS头
|
if (!isPasswordChangeAllowedApi(pathname)) {
|
||||||
const response = NextResponse.next()
|
const token = await getToken({ req: request, secret: process.env.NEXTAUTH_SECRET })
|
||||||
if (corsOrigin) {
|
if ((token as any)?.mustChangePassword) {
|
||||||
response.headers.set('Access-Control-Allow-Credentials', 'true')
|
return applyApiCorsHeaders(
|
||||||
response.headers.set('Access-Control-Allow-Origin', corsOrigin)
|
NextResponse.json(
|
||||||
response.headers.set('Access-Control-Allow-Methods', 'GET,DELETE,PATCH,POST,PUT,OPTIONS')
|
{
|
||||||
response.headers.set('Access-Control-Allow-Headers', 'X-CSRF-Token, X-Requested-With, Accept, Accept-Version, Content-Length, Content-MD5, Content-Type, Date, X-Api-Version, Authorization, Cookie')
|
error: '请先修改初始密码',
|
||||||
response.headers.set('Vary', 'Origin')
|
code: 'PASSWORD_CHANGE_REQUIRED',
|
||||||
|
},
|
||||||
|
{ status: 403 }
|
||||||
|
),
|
||||||
|
corsOrigin
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return response
|
// 为所有API响应添加CORS头
|
||||||
|
return applyApiCorsHeaders(NextResponse.next(), corsOrigin)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 处理需要认证的页面
|
// 处理需要认证的页面
|
||||||
@ -92,6 +127,11 @@ export async function middleware(request: NextRequest) {
|
|||||||
return NextResponse.redirect(url)
|
return NextResponse.redirect(url)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ((token as any).mustChangePassword && pathname.startsWith('/admin')) {
|
||||||
|
const url = new URL('/', request.url)
|
||||||
|
return NextResponse.redirect(url)
|
||||||
|
}
|
||||||
|
|
||||||
// 管理员页面需要管理员权限
|
// 管理员页面需要管理员权限
|
||||||
const requiresAdmin = pathname.startsWith('/admin')
|
const requiresAdmin = pathname.startsWith('/admin')
|
||||||
if (requiresAdmin) {
|
if (requiresAdmin) {
|
||||||
|
|||||||
@ -249,6 +249,22 @@ async function login(baseUrl, email, password, timeoutMs) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function checkAuthenticated(baseUrl, jar, expectAdmin, timeoutMs) {
|
async function checkAuthenticated(baseUrl, jar, expectAdmin, timeoutMs) {
|
||||||
|
const defaultPassword = await request(baseUrl, '/api/user/check-default-password', { method: 'GET' }, jar, timeoutMs)
|
||||||
|
assertStatus('/api/user/check-default-password', defaultPassword, 200)
|
||||||
|
const defaultPasswordJson = await readJsonSafe(defaultPassword)
|
||||||
|
logOk('默认密码检查接口正常')
|
||||||
|
|
||||||
|
if (defaultPasswordJson?.mustChangePassword || defaultPasswordJson?.isDefaultPassword) {
|
||||||
|
const templatesWhileLocked = await request(baseUrl, '/api/upload-templates', { method: 'GET' }, jar, timeoutMs)
|
||||||
|
assertStatus('首次改密前访问 /api/upload-templates', templatesWhileLocked, 403)
|
||||||
|
|
||||||
|
const adminUsersWhileLocked = await request(baseUrl, '/api/admin/users', { method: 'GET' }, jar, timeoutMs)
|
||||||
|
assertStatus('首次改密前访问 /api/admin/users', adminUsersWhileLocked, 403)
|
||||||
|
|
||||||
|
logOk('首次改密前业务接口已被拦截')
|
||||||
|
return { passwordChangeRequired: true }
|
||||||
|
}
|
||||||
|
|
||||||
const templates = await request(baseUrl, '/api/upload-templates', { method: 'GET' }, jar, timeoutMs)
|
const templates = await request(baseUrl, '/api/upload-templates', { method: 'GET' }, jar, timeoutMs)
|
||||||
assertStatus('/api/upload-templates', templates, 200)
|
assertStatus('/api/upload-templates', templates, 200)
|
||||||
logOk('普通上传模板接口正常')
|
logOk('普通上传模板接口正常')
|
||||||
@ -257,10 +273,6 @@ async function checkAuthenticated(baseUrl, jar, expectAdmin, timeoutMs) {
|
|||||||
assertStatus('/api/templates', builtinTemplates, 200)
|
assertStatus('/api/templates', builtinTemplates, 200)
|
||||||
logOk('内置模板接口正常')
|
logOk('内置模板接口正常')
|
||||||
|
|
||||||
const defaultPassword = await request(baseUrl, '/api/user/check-default-password', { method: 'GET' }, jar, timeoutMs)
|
|
||||||
assertStatus('/api/user/check-default-password', defaultPassword, 200)
|
|
||||||
logOk('默认密码检查接口正常')
|
|
||||||
|
|
||||||
const adminUsers = await request(baseUrl, '/api/admin/users', { method: 'GET' }, jar, timeoutMs)
|
const adminUsers = await request(baseUrl, '/api/admin/users', { method: 'GET' }, jar, timeoutMs)
|
||||||
if (expectAdmin) {
|
if (expectAdmin) {
|
||||||
assertStatus('/api/admin/users', adminUsers, 200)
|
assertStatus('/api/admin/users', adminUsers, 200)
|
||||||
@ -269,6 +281,8 @@ async function checkAuthenticated(baseUrl, jar, expectAdmin, timeoutMs) {
|
|||||||
assertStatus('/api/admin/users', adminUsers, [200, 403])
|
assertStatus('/api/admin/users', adminUsers, [200, 403])
|
||||||
logOk('后台用户接口权限表现符合当前账号类型')
|
logOk('后台用户接口权限表现符合当前账号类型')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return { passwordChangeRequired: false }
|
||||||
}
|
}
|
||||||
|
|
||||||
async function writeUploadSmoke(baseUrl, jar, timeoutMs) {
|
async function writeUploadSmoke(baseUrl, jar, timeoutMs) {
|
||||||
@ -314,8 +328,11 @@ async function main() {
|
|||||||
|
|
||||||
if (email || password || args.write) {
|
if (email || password || args.write) {
|
||||||
const jar = await login(baseUrl, email, password, args.timeoutMs)
|
const jar = await login(baseUrl, email, password, args.timeoutMs)
|
||||||
await checkAuthenticated(baseUrl, jar, args.expectAdmin, args.timeoutMs)
|
const authCheck = await checkAuthenticated(baseUrl, jar, args.expectAdmin, args.timeoutMs)
|
||||||
if (args.write) {
|
if (args.write) {
|
||||||
|
if (authCheck.passwordChangeRequired) {
|
||||||
|
throw new Error('当前账号仍需首次改密,不能执行写入验收;请先完成改密后再使用 --write')
|
||||||
|
}
|
||||||
await writeUploadSmoke(baseUrl, jar, args.timeoutMs)
|
await writeUploadSmoke(baseUrl, jar, args.timeoutMs)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user