强化首次改密商业化门禁
This commit is contained in:
parent
3256524271
commit
3cdfaa89a9
@ -4,6 +4,16 @@ jest.mock('next/server', () => {
|
||||
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) {
|
||||
return new MockNextResponse(null, {
|
||||
status: 307,
|
||||
@ -126,4 +136,30 @@ describe('middleware admin pages', () => {
|
||||
|
||||
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
|
||||
role?: 'ADMIN' | 'USER'
|
||||
isSuperAdmin?: boolean
|
||||
mustChangePassword?: boolean
|
||||
}
|
||||
|
||||
// API 响应类型
|
||||
|
||||
@ -75,11 +75,16 @@ const departmentCollator = useMemo(() => getDepartmentCollator(), [])
|
||||
}
|
||||
|
||||
const isSuperAdmin = sessionUser?.isSuperAdmin
|
||||
if (status === 'authenticated' && sessionUser?.mustChangePassword) {
|
||||
router.push('/')
|
||||
return
|
||||
}
|
||||
|
||||
if (status === 'authenticated' && sessionUser?.role !== 'ADMIN' && !isSuperAdmin) {
|
||||
router.push('/')
|
||||
return
|
||||
}
|
||||
}, [status, router, sessionUser?.isSuperAdmin, sessionUser?.role])
|
||||
}, [status, router, sessionUser?.isSuperAdmin, sessionUser?.mustChangePassword, sessionUser?.role])
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
const blockUI = !hasLoadedOnceRef.current
|
||||
|
||||
@ -11,6 +11,7 @@ jest.mock('next-auth', () => ({
|
||||
|
||||
jest.mock('@/lib/prisma', () => ({
|
||||
prisma: {
|
||||
$transaction: jest.fn(),
|
||||
user: {
|
||||
findUnique: jest.fn(),
|
||||
update: jest.fn(),
|
||||
@ -58,6 +59,9 @@ describe('POST /api/user/change-password', () => {
|
||||
})
|
||||
isInitialAdminPasswordChangeRequired.mockResolvedValue(false)
|
||||
bcrypt.compare.mockResolvedValue(false)
|
||||
prisma.$transaction.mockImplementation(async (callback: any) => callback({
|
||||
user: prisma.user,
|
||||
}))
|
||||
})
|
||||
|
||||
test('拒绝弱新密码且不查询用户密码', async () => {
|
||||
@ -85,6 +89,10 @@ describe('POST /api/user/change-password', () => {
|
||||
expect(data.success).toBe(true)
|
||||
expect(bcrypt.hash).toHaveBeenCalledWith('Flnxi-2026-Safe', 10)
|
||||
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)
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: { password: hashedNewPassword }
|
||||
})
|
||||
await prisma.$transaction(async (tx) => {
|
||||
await tx.user.update({
|
||||
where: { id: user.id },
|
||||
data: { password: hashedNewPassword }
|
||||
})
|
||||
|
||||
if (mustChangePassword) {
|
||||
await clearInitialAdminPasswordRequirement(user.id)
|
||||
}
|
||||
if (mustChangePassword) {
|
||||
await clearInitialAdminPasswordRequirement(user.id, tx, { throwOnFailure: true })
|
||||
}
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
|
||||
38
app/page.tsx
38
app/page.tsx
@ -238,6 +238,7 @@ export default function Home() {
|
||||
const [showFilterModal, setShowFilterModal] = useState(false) // 筛选弹窗
|
||||
const [showChangePasswordModal, setShowChangePasswordModal] = useState(false) // 修改密码弹窗
|
||||
const [isDefaultPassword, setIsDefaultPassword] = useState(false) // 是否使用默认密码
|
||||
const [initialPasswordCheckDone, setInitialPasswordCheckDone] = useState(false) // 初始密码检查是否完成
|
||||
const [isScrolled, setIsScrolled] = useState(false) // 是否已滚动
|
||||
const searchSectionRef = useRef<HTMLDivElement>(null) // 搜索区域引用(包含TAG和搜索框)
|
||||
const searchStartScrollStateRef = useRef<boolean>(false) // 记录搜索开始时的滚动状态
|
||||
@ -431,7 +432,10 @@ export default function Home() {
|
||||
// 初始管理员强制改密检查
|
||||
useEffect(() => {
|
||||
const checkInitialAdminPassword = async () => {
|
||||
if (status !== 'authenticated') return
|
||||
if (status !== 'authenticated') {
|
||||
setInitialPasswordCheckDone(false)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const passwordCheck = await fetch('/api/user/check-default-password')
|
||||
@ -440,14 +444,19 @@ export default function Home() {
|
||||
console.log('检测到系统初始化账号仍需首次改密,强制修改密码')
|
||||
setIsDefaultPassword(true)
|
||||
setShowChangePasswordModal(true)
|
||||
} else {
|
||||
setIsDefaultPassword(false)
|
||||
setShowChangePasswordModal(false)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('初始管理员改密检查失败:', error)
|
||||
} finally {
|
||||
setInitialPasswordCheckDone(true)
|
||||
}
|
||||
}
|
||||
|
||||
checkInitialAdminPassword()
|
||||
}, [status, session])
|
||||
}, [status, session?.user?.email])
|
||||
|
||||
// 检查是否有未完成的上传(断点续传)
|
||||
useEffect(() => {
|
||||
@ -1727,7 +1736,10 @@ export default function Home() {
|
||||
|
||||
// 只在首次加载时显示加载中(NextAuth检查状态或首次加载素材)
|
||||
// 如果已经有素材了,即使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) {
|
||||
return (
|
||||
@ -1741,6 +1753,26 @@ export default function Home() {
|
||||
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 (
|
||||
<div className="min-h-screen bg-slate-50 dark:bg-slate-900">
|
||||
<Header
|
||||
|
||||
@ -12,6 +12,7 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useSession } from 'next-auth/react'
|
||||
import { useDialog } from '@/components/ui/DialogProvider'
|
||||
import Modal from '@/components/ui/Modal'
|
||||
import Input from '@/components/ui/Input'
|
||||
@ -33,6 +34,7 @@ export default function ChangePasswordModal({
|
||||
onSuccess
|
||||
}: ChangePasswordModalProps) {
|
||||
const { alert } = useDialog()
|
||||
const { update } = useSession()
|
||||
const styles = useThemeStyles()
|
||||
const [oldPassword, setOldPassword] = useState('')
|
||||
const [newPassword, setNewPassword] = useState('')
|
||||
@ -40,6 +42,13 @@ export default function ChangePasswordModal({
|
||||
const [error, setError] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
const resetForm = () => {
|
||||
setOldPassword('')
|
||||
setNewPassword('')
|
||||
setConfirmPassword('')
|
||||
setError('')
|
||||
}
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setError('')
|
||||
@ -78,11 +87,12 @@ export default function ChangePasswordModal({
|
||||
const data = await response.json()
|
||||
|
||||
if (response.ok) {
|
||||
// 清空表单
|
||||
setOldPassword('')
|
||||
setNewPassword('')
|
||||
setConfirmPassword('')
|
||||
setError('')
|
||||
resetForm()
|
||||
try {
|
||||
await update()
|
||||
} catch (error) {
|
||||
console.warn('刷新会话状态失败:', error)
|
||||
}
|
||||
|
||||
if (onSuccess) {
|
||||
onSuccess()
|
||||
@ -107,104 +117,129 @@ export default function ChangePasswordModal({
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={isRequired ? () => {} : onClose}
|
||||
title={isRequired ? '首次登录,请修改密码' : '修改密码'}
|
||||
showCloseButton={!isRequired}
|
||||
closeOnOverlayClick={!isRequired}
|
||||
closeOnEscape={!isRequired}
|
||||
>
|
||||
{isRequired && (
|
||||
<div className={cn(
|
||||
'mb-4 p-3 rounded-lg border',
|
||||
'bg-yellow-50 dark:bg-yellow-900/20',
|
||||
'border-yellow-200 dark:border-yellow-800'
|
||||
)}>
|
||||
<p className="text-sm text-yellow-800 dark:text-yellow-200">
|
||||
⚠️ 您正在使用系统初始化密码,为了账户安全,请立即修改密码。
|
||||
</p>
|
||||
const formContent = (
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{!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>
|
||||
)}
|
||||
|
||||
<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 && (
|
||||
<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
|
||||
type="submit"
|
||||
variant="primary"
|
||||
disabled={loading}
|
||||
loading={loading}
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={onClose}
|
||||
>
|
||||
{loading ? '修改中...' : '确认修改'}
|
||||
取消
|
||||
</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>
|
||||
</form>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
title="修改密码"
|
||||
showCloseButton
|
||||
closeOnOverlayClick
|
||||
closeOnEscape
|
||||
>
|
||||
{formContent}
|
||||
</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"
|
||||
```
|
||||
返回值中会带出本次创建的初始管理员信息;如果没有预先配置 `INIT_ADMIN_PASSWORD`,系统会生成一次性初始密码并只在初始化响应/激活成功页展示一次。
|
||||
- 首次登录后会被强制修改密码,可在“个人中心 → 修改密码”完成。
|
||||
- 首次登录后会先进入“账号安全 / 设置新密码”,完成改密前不能进入首页、后台或业务接口。
|
||||
|
||||
### 7. 首次登录与检查
|
||||
1. 访问 `https://your-domain.com/login`,使用初始化返回的初始管理员账号登录。
|
||||
2. 进入主应用后台 `/admin`;项目选择页属于独立 `license-server` 后台,不是主应用后台流程。
|
||||
3. 打开“系统设置”,填写对象存储、LOGO、前端域名等信息。
|
||||
4. 上传一份素材,验证转码、归属(`ownerType/ownerId`)、预览、下载链路是否正常。
|
||||
2. 按页面提示进入“账号安全 / 设置新密码”,先把初始密码改成正式密码。
|
||||
3. 改密成功后进入主应用后台 `/admin`;项目选择页属于独立 `license-server` 后台,不是主应用后台流程。
|
||||
4. 打开“系统设置”,填写对象存储、LOGO、前端域名等信息。
|
||||
5. 上传一份素材,验证转码、归属(`ownerType/ownerId`)、预览、下载链路是否正常。
|
||||
|
||||
如果涉及许可证服务联调,建议继续按 `docs/许可证服务接入说明.md` 检查项目编码和公开接口路径。
|
||||
|
||||
|
||||
@ -13,16 +13,22 @@ jest.mock('@/lib/dingtalk-login-token', () => ({
|
||||
consumeDingtalkLoginToken: jest.fn(),
|
||||
}))
|
||||
|
||||
jest.mock('@/lib/system-init', () => ({
|
||||
isInitialAdminPasswordChangeRequired: jest.fn(),
|
||||
}))
|
||||
|
||||
jest.mock('bcryptjs', () => ({
|
||||
compare: jest.fn(),
|
||||
}))
|
||||
|
||||
import { authOptions } from '../auth'
|
||||
import { consumeDingtalkLoginToken } from '../dingtalk-login-token'
|
||||
import { isInitialAdminPasswordChangeRequired } from '../system-init'
|
||||
import { prisma } from '../prisma'
|
||||
import bcrypt from 'bcryptjs'
|
||||
|
||||
const mockConsumeDingtalkLoginToken = consumeDingtalkLoginToken as jest.MockedFunction<typeof consumeDingtalkLoginToken>
|
||||
const mockIsInitialAdminPasswordChangeRequired = isInitialAdminPasswordChangeRequired as jest.MockedFunction<typeof isInitialAdminPasswordChangeRequired>
|
||||
|
||||
function getDingtalkProvider() {
|
||||
const provider = authOptions.providers.find((item: any) => item.id === 'credentials') as any
|
||||
@ -36,6 +42,7 @@ describe('auth dingtalk-token provider', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
;(prisma.systemSettings.findFirst as jest.Mock).mockResolvedValue(null)
|
||||
mockIsInitialAdminPasswordChangeRequired.mockResolvedValue(false)
|
||||
})
|
||||
|
||||
it('使用一次性钉钉票据登录并返回会话用户', async () => {
|
||||
@ -58,6 +65,9 @@ describe('auth dingtalk-token provider', () => {
|
||||
name: '张三',
|
||||
role: 'USER',
|
||||
}))
|
||||
expect(user).toEqual(expect.objectContaining({
|
||||
mustChangePassword: false,
|
||||
}))
|
||||
})
|
||||
|
||||
it('拒绝无效或已消费的钉钉票据', async () => {
|
||||
|
||||
@ -129,6 +129,60 @@ describe('system-init', () => {
|
||||
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 () => {
|
||||
;(prisma.systemConfig.findUnique as jest.Mock).mockResolvedValue({
|
||||
key: 'initial_admin_state',
|
||||
|
||||
@ -2,6 +2,7 @@ import { NextAuthOptions } from 'next-auth'
|
||||
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 bcrypt from 'bcryptjs'
|
||||
|
||||
type AccountState = {
|
||||
@ -38,6 +39,7 @@ export const authOptions: NextAuthOptions = {
|
||||
}
|
||||
|
||||
assertAccountCanUseSession(user)
|
||||
const mustChangePassword = await isInitialAdminPasswordChangeRequired(user.id)
|
||||
|
||||
return {
|
||||
id: user.id,
|
||||
@ -45,6 +47,7 @@ export const authOptions: NextAuthOptions = {
|
||||
name: user.username,
|
||||
role: user.role as "ADMIN" | "USER",
|
||||
isSuperAdmin: (user as any).isSuperAdmin,
|
||||
mustChangePassword,
|
||||
avatar: user.avatar,
|
||||
}
|
||||
}
|
||||
@ -71,6 +74,7 @@ export const authOptions: NextAuthOptions = {
|
||||
}
|
||||
|
||||
assertAccountCanUseSession(user)
|
||||
const mustChangePassword = await isInitialAdminPasswordChangeRequired(user.id)
|
||||
|
||||
return {
|
||||
id: user.id,
|
||||
@ -78,6 +82,7 @@ export const authOptions: NextAuthOptions = {
|
||||
name: user.username,
|
||||
role: user.role as "ADMIN" | "USER",
|
||||
isSuperAdmin: (user as any).isSuperAdmin,
|
||||
mustChangePassword,
|
||||
avatar: user.avatar,
|
||||
}
|
||||
},
|
||||
@ -90,6 +95,7 @@ export const authOptions: NextAuthOptions = {
|
||||
token.role = (user as any).role;
|
||||
token.id = user.id;
|
||||
(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).avatarUpdatedAt = Date.now();
|
||||
}
|
||||
@ -130,6 +136,7 @@ export const authOptions: NextAuthOptions = {
|
||||
|
||||
(token as any).role = dbUser.role;
|
||||
(token as any).isSuperAdmin = dbUser.isSuperAdmin || false;
|
||||
(token as any).mustChangePassword = await isInitialAdminPasswordChangeRequired(token.id as string);
|
||||
|
||||
if (trigger === 'update' || (now - avatarUpdatedAt) > refreshInterval) {
|
||||
(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).id = token.id;
|
||||
(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;
|
||||
}
|
||||
return session;
|
||||
|
||||
@ -6,6 +6,14 @@ import { BUILTIN_TEMPLATE_KEYS, ensureBuiltinTemplates } from './system-integrit
|
||||
const INITIAL_ADMIN_STATE_KEY = 'initial_admin_state'
|
||||
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 InitialAdminState = {
|
||||
@ -121,17 +129,24 @@ export async function isInitialAdminPasswordChangeRequired(userId: string): Prom
|
||||
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()
|
||||
if (!state || state.userId !== userId) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await prisma.systemConfig.delete({
|
||||
await client.systemConfig.delete({
|
||||
where: { key: INITIAL_ADMIN_STATE_KEY },
|
||||
})
|
||||
} catch (error) {
|
||||
if (options.throwOnFailure) {
|
||||
throw 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)
|
||||
}
|
||||
|
||||
@ -262,7 +277,7 @@ export async function initializeSystem(): Promise<InitializeSystemResult> {
|
||||
username: result.admin.username,
|
||||
created: result.createdFlags.admin,
|
||||
upgraded: result.createdFlags.adminUpgraded,
|
||||
mustChangePassword: result.createdFlags.admin,
|
||||
mustChangePassword: result.createdFlags.admin || result.createdFlags.adminUpgraded,
|
||||
initialPassword: result.passwordSource === 'generated' ? result.initialPassword || undefined : undefined,
|
||||
passwordSource: result.passwordSource || undefined,
|
||||
}
|
||||
|
||||
@ -15,6 +15,17 @@ const ALLOWED_ORIGINS = (() => {
|
||||
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) {
|
||||
const trimmed = (value || '').trim()
|
||||
if (!trimmed) return null
|
||||
@ -40,6 +51,23 @@ function getAllowedCorsOrigin(requestOrigin: string | null, sameOrigin: string)
|
||||
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) {
|
||||
const { pathname, origin } = request.nextUrl
|
||||
const requestOrigin = request.headers.get('origin')
|
||||
@ -70,17 +98,24 @@ export async function middleware(request: NextRequest) {
|
||||
})
|
||||
}
|
||||
|
||||
// 为所有API响应添加CORS头
|
||||
const response = NextResponse.next()
|
||||
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')
|
||||
if (!isPasswordChangeAllowedApi(pathname)) {
|
||||
const token = await getToken({ req: request, secret: process.env.NEXTAUTH_SECRET })
|
||||
if ((token as any)?.mustChangePassword) {
|
||||
return applyApiCorsHeaders(
|
||||
NextResponse.json(
|
||||
{
|
||||
error: '请先修改初始密码',
|
||||
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)
|
||||
}
|
||||
|
||||
if ((token as any).mustChangePassword && pathname.startsWith('/admin')) {
|
||||
const url = new URL('/', request.url)
|
||||
return NextResponse.redirect(url)
|
||||
}
|
||||
|
||||
// 管理员页面需要管理员权限
|
||||
const requiresAdmin = pathname.startsWith('/admin')
|
||||
if (requiresAdmin) {
|
||||
|
||||
@ -249,6 +249,22 @@ async function login(baseUrl, email, password, 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)
|
||||
assertStatus('/api/upload-templates', templates, 200)
|
||||
logOk('普通上传模板接口正常')
|
||||
@ -257,10 +273,6 @@ async function checkAuthenticated(baseUrl, jar, expectAdmin, timeoutMs) {
|
||||
assertStatus('/api/templates', builtinTemplates, 200)
|
||||
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)
|
||||
if (expectAdmin) {
|
||||
assertStatus('/api/admin/users', adminUsers, 200)
|
||||
@ -269,6 +281,8 @@ async function checkAuthenticated(baseUrl, jar, expectAdmin, timeoutMs) {
|
||||
assertStatus('/api/admin/users', adminUsers, [200, 403])
|
||||
logOk('后台用户接口权限表现符合当前账号类型')
|
||||
}
|
||||
|
||||
return { passwordChangeRequired: false }
|
||||
}
|
||||
|
||||
async function writeUploadSmoke(baseUrl, jar, timeoutMs) {
|
||||
@ -314,8 +328,11 @@ async function main() {
|
||||
|
||||
if (email || password || args.write) {
|
||||
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 (authCheck.passwordChangeRequired) {
|
||||
throw new Error('当前账号仍需首次改密,不能执行写入验收;请先完成改密后再使用 --write')
|
||||
}
|
||||
await writeUploadSmoke(baseUrl, jar, args.timeoutMs)
|
||||
}
|
||||
} else {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user