强化首次改密商业化门禁

This commit is contained in:
WuYongJie 2026-06-01 17:00:05 +08:00
parent 3256524271
commit 3cdfaa89a9
15 changed files with 527 additions and 130 deletions

View File

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

View File

@ -113,6 +113,7 @@ export interface SessionUser {
image?: string
role?: 'ADMIN' | 'USER'
isSuperAdmin?: boolean
mustChangePassword?: boolean
}
// API 响应类型

View File

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

View File

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

View File

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

View File

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

View File

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

View 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',
})
})
})

View File

@ -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` 检查项目编码和公开接口路径。

View File

@ -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 () => {

View File

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

View File

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

View File

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

View File

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

View File

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