diff --git a/__tests__/middleware-cors.test.ts b/__tests__/middleware-cors.test.ts index c2f1d13..05cc6aa 100644 --- a/__tests__/middleware-cors.test.ts +++ b/__tests__/middleware-cors.test.ts @@ -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) + }) }) diff --git a/app/admin/_components/types.ts b/app/admin/_components/types.ts index c98d7bb..12edd0a 100644 --- a/app/admin/_components/types.ts +++ b/app/admin/_components/types.ts @@ -113,6 +113,7 @@ export interface SessionUser { image?: string role?: 'ADMIN' | 'USER' isSuperAdmin?: boolean + mustChangePassword?: boolean } // API 响应类型 diff --git a/app/admin/page.tsx b/app/admin/page.tsx index 7dc1041..c4dd95e 100644 --- a/app/admin/page.tsx +++ b/app/admin/page.tsx @@ -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 diff --git a/app/api/user/change-password/__tests__/route.test.ts b/app/api/user/change-password/__tests__/route.test.ts index 12f721b..f5c3ccd 100644 --- a/app/api/user/change-password/__tests__/route.test.ts +++ b/app/api/user/change-password/__tests__/route.test.ts @@ -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 } + ) }) }) diff --git a/app/api/user/change-password/route.ts b/app/api/user/change-password/route.ts index 7bcc576..6687481 100644 --- a/app/api/user/change-password/route.ts +++ b/app/api/user/change-password/route.ts @@ -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, diff --git a/app/page.tsx b/app/page.tsx index cdcfb43..6b3bef4 100644 --- a/app/page.tsx +++ b/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(null) // 搜索区域引用(包含TAG和搜索框) const searchStartScrollStateRef = useRef(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 ( +
+
+
+ {}} + isRequired + onSuccess={() => { + setIsDefaultPassword(false) + setShowChangePasswordModal(false) + window.location.reload() + }} + /> +
+
+ ) + } + return (
{ + 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 ( - {} : onClose} - title={isRequired ? '首次登录,请修改密码' : '修改密码'} - showCloseButton={!isRequired} - closeOnOverlayClick={!isRequired} - closeOnEscape={!isRequired} - > - {isRequired && ( -
-

- ⚠️ 您正在使用系统初始化密码,为了账户安全,请立即修改密码。 -

+ const formContent = ( +
+ {!isRequired && ( +
+ + setOldPassword(e.target.value)} + placeholder="请输入旧密码" + required={!isRequired} + fullWidth + />
)} - +
+ + setNewPassword(e.target.value)} + placeholder="请输入新密码" + required + minLength={10} + fullWidth + /> +

+ {PASSWORD_POLICY_DESCRIPTION} +

+
+ +
+ + setConfirmPassword(e.target.value)} + placeholder="请再次输入新密码" + required + minLength={10} + fullWidth + /> +
+ + {error && ( +
+

{error}

+
+ )} + +
{!isRequired && ( -
- - setOldPassword(e.target.value)} - placeholder="请输入旧密码" - required={!isRequired} - fullWidth - /> -
- )} - -
- - setNewPassword(e.target.value)} - placeholder={PASSWORD_POLICY_DESCRIPTION} - required - minLength={10} - fullWidth - /> -
- -
- - setConfirmPassword(e.target.value)} - placeholder="请再次输入新密码" - required - minLength={10} - fullWidth - /> -
- - {error && ( -
-

{error}

-
- )} - -
- {!isRequired && ( - - )} + )} + +
+ + ) + + if (!isOpen) return null + + if (isRequired) { + return ( +
+
+
+

账号安全

+

+ 设置新密码 +

+

+ 首次登录需要先更换初始密码,完成后才能进入素材库。 +

+
+ +
+
+

+ 当前账号仍在使用系统初始化密码,请设置一个只由您掌握的新密码。 +

+
+ {formContent} +
- +
+ ) + } + + return ( + + {formContent} ) } diff --git a/components/__tests__/ChangePasswordModal.test.tsx b/components/__tests__/ChangePasswordModal.test.tsx new file mode 100644 index 0000000..7b448e4 --- /dev/null +++ b/components/__tests__/ChangePasswordModal.test.tsx @@ -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) => 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( + + ) + + 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( + + ) + + 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', + }) + }) +}) diff --git a/docs/初次部署与出厂设置指南.md b/docs/初次部署与出厂设置指南.md index 20ad974..0eeb206 100644 --- a/docs/初次部署与出厂设置指南.md +++ b/docs/初次部署与出厂设置指南.md @@ -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` 检查项目编码和公开接口路径。 diff --git a/lib/__tests__/auth-dingtalk-token.test.ts b/lib/__tests__/auth-dingtalk-token.test.ts index 60dc855..df15595 100644 --- a/lib/__tests__/auth-dingtalk-token.test.ts +++ b/lib/__tests__/auth-dingtalk-token.test.ts @@ -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 +const mockIsInitialAdminPasswordChangeRequired = isInitialAdminPasswordChangeRequired as jest.MockedFunction 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 () => { diff --git a/lib/__tests__/system-init.test.ts b/lib/__tests__/system-init.test.ts index b16fa59..45436a9 100644 --- a/lib/__tests__/system-init.test.ts +++ b/lib/__tests__/system-init.test.ts @@ -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', diff --git a/lib/auth.ts b/lib/auth.ts index d566fb0..1978d32 100644 --- a/lib/auth.ts +++ b/lib/auth.ts @@ -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; diff --git a/lib/system-init.ts b/lib/system-init.ts index f217e5c..4d51e99 100644 --- a/lib/system-init.ts +++ b/lib/system-init.ts @@ -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 + delete: (args: any) => Promise + } +} + 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 { +export async function clearInitialAdminPasswordRequirement( + userId: string, + client: SystemConfigStore = prisma, + options: { throwOnFailure?: boolean } = {} +): Promise { 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 { } }) - 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 { 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, } diff --git a/middleware.ts b/middleware.ts index 73293cd..c38525d 100644 --- a/middleware.ts +++ b/middleware.ts @@ -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) { diff --git a/scripts/customer-acceptance-smoke.mjs b/scripts/customer-acceptance-smoke.mjs index cf4857c..27ea3af 100644 --- a/scripts/customer-acceptance-smoke.mjs +++ b/scripts/customer-acceptance-smoke.mjs @@ -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 {