diff --git a/config/config.ts b/config/config.ts index ac9cda5..fd1026e 100644 --- a/config/config.ts +++ b/config/config.ts @@ -8,6 +8,26 @@ import { configureSplitChunks } from './splitChunks'; // (原 public/js/clarity.js 硬编码作者私有项目 ID,下游部署无感知上报访客数据,已删除) const CLARITY_ID = process.env.CLARITY_ID; +// 演示鉴权防线(审计 2026-09-22 H-4):生产构建未配置真实后端时,登录是纯前端演示桩 +// (任意用户名/密码放行)。这里把「静默 fail-open」变为「构建期可见」——banner 无法被忽略。 +// 不做硬阻断(build fail):那会破坏模板 clone 即 build 的开箱体验。 +if (process.env.NODE_ENV === 'production' && !process.env.UMI_APP_API_BASE) { + console.warn( + [ + '', + '!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!', + '!! !!', + '!! 本构建使用纯前端演示鉴权:任意用户名/密码均可登录 !!', + '!! (详见 SECURITY.md「已知限制」) !!', + '!! !!', + '!! 接入真实后端:设置 UMI_APP_API_BASE 后重新构建 !!', + '!! 下游二开请在面向公网部署前完成接入 !!', + '!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!', + '', + ].join('\n'), + ); +} + export default defineConfig({ chainWebpack(config) { if (process.env.NODE_ENV === 'production') { diff --git a/mock/userAPI.ts b/mock/userAPI.ts index bcc0d9e..5a72c03 100644 --- a/mock/userAPI.ts +++ b/mock/userAPI.ts @@ -1,6 +1,11 @@ import type { Request, Response } from 'express'; +// ⚠️ 这是开发环境的演示 mock(仅 `max dev` 生效,不进生产产物)。 +// 禁止把本文件当作真实后端的参考实现:登录不校验密码、token 是客户端可随意构造的 +// 演示约定(见 src/services/demo/demoToken.ts 的警告)——搬进真实后端等于接受万能凭证。 +// // 注意:umi 会把 mock/ 下所有 .ts/.js 当作 mock 文件加载(mock/**/*.[jt]s), // 测试与共享逻辑不要放在这里——查询逻辑在 src/services/demo/userQuery.ts,与静态演示层共用。 +import { demoRoleFor, issueDemoToken, verifyDemoToken } from '../src/services/demo/demoToken'; import { filterAndPaginate, type DemoUserRecord } from '../src/services/demo/userQuery'; let users: DemoUserRecord[] = [ @@ -23,23 +28,23 @@ export default { res.json({ success: true, data: { - token: `${name}-demo-token`, - user: { name, nickName: name, email: '' }, + token: issueDemoToken(name), + user: { name, nickName: name, email: '', role: demoRoleFor(name) }, }, errorCode: 0, }); }, 'GET /api/v1/currentUser': (req: Request, res: Response) => { const authorization = (req.headers && req.headers.authorization) || ''; - const matched = /^Bearer (.+)-demo-token$/.exec(authorization); - if (!matched) { + const matched = /^Bearer (.+)$/.exec(authorization); + const payload = matched ? verifyDemoToken(matched[1]) : null; + if (!payload) { res.status(401).json({ success: false, errorCode: 401, message: '未登录或登录已失效' }); return; } - const name = matched[1]; res.json({ success: true, - data: { name, nickName: name, email: '' }, + data: { name: payload.name, nickName: payload.name, email: '', role: payload.role }, errorCode: 0, }); }, diff --git a/src/access.ts b/src/access.ts index 1d281d4..107b3e0 100644 --- a/src/access.ts +++ b/src/access.ts @@ -3,10 +3,11 @@ import type { AppInitialState } from './utils/Auth/initialState'; // 参数是全局初始状态(未登录时为 undefined),不是表格用的 API.UserInfo export default (initialState: AppInitialState | undefined) => { - // 'dontHaveAccess' 是演示后端(mock)约定的禁止访问用户名: - // 该用户可登录(currentUser 正常返回),但 canSeeAdmin 为 false, - // 用于演示 /access、/table 的路由权限拦截(会跳转 /403) - const canSeeAdmin = !!initialState?.name && initialState.name !== 'dontHaveAccess'; + // 白名单式(审计 2026-09-22 M-1):只有携带 role === 'admin' 声明的用户才有管理权限, + // undefined/其他角色一律拒绝。真实后端必须由服务端返回 role 字段—— + // 不要改回用户名黑名单('dontHaveAccess' 只是演示签发方的降权约定,见 demoToken.ts), + // 前端 access 只是 UX 层,真正的权限校验必须在后端接口完成。 + const canSeeAdmin = initialState?.role === 'admin'; return { canSeeAdmin, }; diff --git a/src/layouts/RightContent.tsx b/src/layouts/RightContent.tsx index 424a6e2..f4a5346 100644 --- a/src/layouts/RightContent.tsx +++ b/src/layouts/RightContent.tsx @@ -54,6 +54,7 @@ const RightContent = () => { name: '', email: '', nickName: '', + role: undefined, ...prev, theme: mode, })); diff --git a/src/locales/en-US.ts b/src/locales/en-US.ts index 8f1b1c4..50d60f8 100644 --- a/src/locales/en-US.ts +++ b/src/locales/en-US.ts @@ -46,6 +46,8 @@ export default { 'login.passwordRequired': 'Please enter your password', 'login.submit': 'Sign in', 'login.success': 'Signed in', + 'login.storageError': + 'Cannot persist the session locally (storage disabled or full). Check browser settings and retry.', // NOTE framework layer (phase 5) 'header.theme': 'Theme', 'header.language': 'Language', diff --git a/src/locales/zh-CN.ts b/src/locales/zh-CN.ts index b44218d..a26b7c9 100644 --- a/src/locales/zh-CN.ts +++ b/src/locales/zh-CN.ts @@ -46,6 +46,7 @@ export default { 'login.passwordRequired': '请输入密码', 'login.submit': '登录', 'login.success': '登录成功', + 'login.storageError': '无法在本地保存登录状态(存储被禁用或已满),请检查浏览器设置后重试', // NOTE 框架层(阶段5) 'header.theme': '主题', 'header.language': '语言', diff --git a/src/pages/Login/index.tsx b/src/pages/Login/index.tsx index 245e149..596c732 100644 --- a/src/pages/Login/index.tsx +++ b/src/pages/Login/index.tsx @@ -13,7 +13,13 @@ const Login: React.FC = () => { const handleFinish = async (values: { name: string; password: string }) => { try { const { token, user } = await login(values); - localStorage.setItem(AUTH_TOKEN_KEY, token); + try { + localStorage.setItem(AUTH_TOKEN_KEY, token); + } catch { + // 隐私模式 / 配额满等本地存储不可用:点击无任何反馈比失败提示更糟(审计 2026-09-22 L-8) + messageApi.error(intl.formatMessage({ id: 'login.storageError' })); + return; + } // 立即刷新全局初始状态(保留当前主题),让路由守卫与 access 权限即时生效 await setInitialState({ ...new UserInfo(user), theme: readTheme() }); messageApi.success(intl.formatMessage({ id: 'login.success' })); diff --git a/src/services/auth.test.ts b/src/services/auth.test.ts index 854361a..784fa43 100644 --- a/src/services/auth.test.ts +++ b/src/services/auth.test.ts @@ -5,6 +5,7 @@ */ import { AUTH_TOKEN_KEY } from '@/constants'; import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { DEMO_TOKEN_PREFIX, DEMO_TOKEN_TTL_MS, issueDemoToken, verifyDemoToken } from './demo/demoToken'; const { requestMock } = vi.hoisted(() => ({ requestMock: vi.fn() })); @@ -19,31 +20,49 @@ const loadAuth = async (apiBase?: string) => { beforeEach(() => { requestMock.mockReset(); localStorage.clear(); + vi.useRealTimers(); }); describe('纯静态演示模式(无后端)', () => { - it('login 本地签发演示 token,不发请求', async () => { + it('login 本地签发可校验的演示 token(含 role 声明),不发请求', async () => { const { login } = await loadAuth(); - await expect(login({ name: 'admin', password: 'any' })).resolves.toEqual({ - token: 'admin-demo-token', - user: { name: 'admin', nickName: 'admin', email: '' }, - }); + const result = await login({ name: 'admin', password: 'any' }); + expect(verifyDemoToken(result.token)).toMatchObject({ name: 'admin', role: 'admin' }); + expect(result.user).toEqual({ name: 'admin', nickName: 'admin', email: '', role: 'admin' }); expect(requestMock).not.toHaveBeenCalled(); }); - it('fetchCurrentUser 从本地 token 还原用户(含 dontHaveAccess 权限演示账号)', async () => { + it('演示降权账号 dontHaveAccess 签发 role=user', async () => { + const { login } = await loadAuth(); + const result = await login({ name: 'dontHaveAccess', password: 'any' }); + expect(verifyDemoToken(result.token)).toMatchObject({ name: 'dontHaveAccess', role: 'user' }); + expect(result.user.role).toBe('user'); + }); + + it('fetchCurrentUser 从本地 token 还原用户与角色', async () => { const { fetchCurrentUser } = await loadAuth(); - localStorage.setItem(AUTH_TOKEN_KEY, 'dontHaveAccess-demo-token'); - await expect(fetchCurrentUser()).resolves.toMatchObject({ name: 'dontHaveAccess' }); + localStorage.setItem(AUTH_TOKEN_KEY, issueDemoToken('dontHaveAccess', 'user')); + await expect(fetchCurrentUser()).resolves.toMatchObject({ name: 'dontHaveAccess', role: 'user' }); expect(requestMock).not.toHaveBeenCalled(); }); - it('缺失、伪造或空用户名的 token 被拒绝', async () => { + it('缺失、伪造、损坏、过期与未来时间戳的 token 一律被拒绝', async () => { const { fetchCurrentUser } = await loadAuth(); await expect(fetchCurrentUser()).rejects.toThrow(); localStorage.setItem(AUTH_TOKEN_KEY, 'forged-token'); await expect(fetchCurrentUser()).rejects.toThrow(); - localStorage.setItem(AUTH_TOKEN_KEY, '-demo-token'); + localStorage.setItem(AUTH_TOKEN_KEY, `${DEMO_TOKEN_PREFIX}not-json`); + await expect(fetchCurrentUser()).rejects.toThrow(); + // 过期(ts 早于 TTL 窗口)与未来时间戳(时钟回拨防御)都无效 + const stale = `${DEMO_TOKEN_PREFIX}${encodeURIComponent( + JSON.stringify({ name: 'x', role: 'admin', ts: Date.now() - DEMO_TOKEN_TTL_MS - 1000 }), + )}`; + localStorage.setItem(AUTH_TOKEN_KEY, stale); + await expect(fetchCurrentUser()).rejects.toThrow(); + const future = `${DEMO_TOKEN_PREFIX}${encodeURIComponent( + JSON.stringify({ name: 'x', role: 'admin', ts: Date.now() + 60_000 }), + )}`; + localStorage.setItem(AUTH_TOKEN_KEY, future); await expect(fetchCurrentUser()).rejects.toThrow(); }); diff --git a/src/services/auth.ts b/src/services/auth.ts index d1ab36f..7874a91 100644 --- a/src/services/auth.ts +++ b/src/services/auth.ts @@ -1,5 +1,6 @@ import { AUTH_TOKEN_KEY } from '@/constants'; import { request } from '@umijs/max'; +import { demoRoleFor, issueDemoToken, verifyDemoToken, type DemoRole } from './demo/demoToken'; import { USE_BACKEND } from './demo/mode'; export interface LoginParams { @@ -11,6 +12,8 @@ export interface CurrentUser { name: string; email: string; nickName?: string; + /** 角色声明:access 权限消费它(canSeeAdmin = role === 'admin')。真实后端必须由服务端返回 */ + role?: DemoRole; } export interface LoginResult { @@ -25,21 +28,20 @@ interface ApiResponse { } /** - * 演示 token 约定:`${name}-demo-token`,与 mock/userAPI.ts 保持一致。 + * 演示 token 的签发与校验见 ./demo/demoToken.ts(明文 JSON + 7 天过期,仅限演示)。 * 纯静态托管(GitHub Pages / Vercel / Docker)没有后端,鉴权在前端本地完成(见 ./demo/mode.ts); * 这只是演示桩,接入真实后端时配置 UMI_APP_API_BASE 即走下方的 HTTP 接口。 */ -const DEMO_TOKEN_SUFFIX = '-demo-token'; -const toDemoUser = (name: string): CurrentUser => ({ name, nickName: name, email: '' }); +const toDemoUser = (name: string, role: DemoRole): CurrentUser => ({ name, nickName: name, email: '', role }); /** * 登录。演示环境对任意用户名/密码放行; - * 用户名 dontHaveAccess 为约定的「禁止访问」演示账号(可登录,但 canSeeAdmin 为 false)。 + * 用户名 dontHaveAccess 为约定的「禁止访问」演示账号(可登录,role 为 user,canSeeAdmin 为 false)。 */ export async function login(params: LoginParams): Promise { if (!USE_BACKEND) { - return { token: `${params.name}${DEMO_TOKEN_SUFFIX}`, user: toDemoUser(params.name) }; + return { token: issueDemoToken(params.name), user: toDemoUser(params.name, demoRoleFor(params.name)) }; } const resp = await request>('/api/v1/login', { method: 'POST', data: params }); return resp.data; @@ -60,11 +62,11 @@ export async function logout(): Promise { export async function fetchCurrentUser(): Promise { if (!USE_BACKEND) { const token = typeof window === 'undefined' ? '' : localStorage.getItem(AUTH_TOKEN_KEY) || ''; - const name = token.endsWith(DEMO_TOKEN_SUFFIX) ? token.slice(0, -DEMO_TOKEN_SUFFIX.length) : ''; - if (!name) { - throw new Error('演示 token 无效或已失效'); + const payload = verifyDemoToken(token); + if (!payload) { + throw new Error('演示 token 无效或已过期'); } - return toDemoUser(name); + return toDemoUser(payload.name, payload.role); } const resp = await request>('/api/v1/currentUser', { skipErrorHandler: true }); return resp.data; diff --git a/src/services/demo/demoToken.ts b/src/services/demo/demoToken.ts new file mode 100644 index 0000000..c010090 --- /dev/null +++ b/src/services/demo/demoToken.ts @@ -0,0 +1,55 @@ +/** + * 演示 token 的签发与校验(services/auth.ts 本地实现与 mock/userAPI.ts 共用)。 + * + * ⚠️ 仅限演示:token 在前端本地签发、本地校验,无任何密码学保护—— + * 任何能在浏览器里写 localStorage 的人都可以给自己签发任意身份。 + * 真实后端必须由服务端签发与校验会话(JWT / opaque session + 服务端状态), + * 把本文件的约定搬进真实后端等于接受「任何人都可构造的万能凭证」。 + * + * 形状:`demo.` + encodeURIComponent(JSON({ name, role, ts })) + * - 明文 JSON:演示 token 无需遮掩,可读性优先;encodeURIComponent 保证 ASCII 与分隔安全 + * - ts:签发时间戳,7 天过期(见 DEMO_TOKEN_TTL_MS),避免演示 token 永久有效 + * - role 编码进 token:权限演示账号(dontHaveAccess)登录后即固定为 user, + * 不再从用户名现推(access.ts 消费服务端/签发方声明的角色,见 M-1) + */ + +export type DemoRole = 'admin' | 'user'; + +export const DEMO_TOKEN_PREFIX = 'demo.'; +export const DEMO_TOKEN_TTL_MS = 7 * 24 * 60 * 60 * 1000; + +/** 演示约定:该用户名可登录但无管理权限(canSeeAdmin 为 false),用于演示路由权限拦截 */ +export const isRestrictedDemoName = (name: string) => name === 'dontHaveAccess'; + +export const demoRoleFor = (name: string): DemoRole => (isRestrictedDemoName(name) ? 'user' : 'admin'); + +export interface DemoTokenPayload { + name: string; + role: DemoRole; + ts: number; +} + +export const issueDemoToken = (name: string, role: DemoRole = demoRoleFor(name)): string => + `${DEMO_TOKEN_PREFIX}${encodeURIComponent(JSON.stringify({ name, role, ts: Date.now() }))}`; + +/** 校验并还原 token 载荷;形状不符、字段缺失或已过期返回 null */ +export const verifyDemoToken = (token: string): DemoTokenPayload | null => { + if (!token.startsWith(DEMO_TOKEN_PREFIX)) { + return null; + } + try { + const payload = JSON.parse(decodeURIComponent(token.slice(DEMO_TOKEN_PREFIX.length))) as Partial; + if (typeof payload.name !== 'string' || !payload.name) { + return null; + } + if (payload.role !== 'admin' && payload.role !== 'user') { + return null; + } + if (typeof payload.ts !== 'number' || Date.now() - payload.ts > DEMO_TOKEN_TTL_MS || payload.ts > Date.now()) { + return null; + } + return { name: payload.name, role: payload.role, ts: payload.ts }; + } catch { + return null; + } +}; diff --git a/src/services/demo/userService.ts b/src/services/demo/userService.ts index ea2375a..2034cb6 100644 --- a/src/services/demo/userService.ts +++ b/src/services/demo/userService.ts @@ -31,6 +31,26 @@ const persist = () => { } }; +/** localStorage 中的演示数据被污染(手改/损坏)时返回 null,由调用方回落静态种子(审计 L-5) */ +const parseStoredUsers = (stored: string): DemoUser[] | null => { + try { + const list = JSON.parse(stored) as unknown; + if (!Array.isArray(list)) { + return null; + } + const shapeOk = list.every( + (item) => + typeof item === 'object' && + item !== null && + typeof (item as DemoUser).id === 'string' && + typeof (item as DemoUser).name === 'string', + ); + return shapeOk ? (list as DemoUser[]) : null; + } catch { + return null; + } +}; + const loadUsers = async (): Promise => { if (cache) { return cache; @@ -38,8 +58,12 @@ const loadUsers = async (): Promise => { if (typeof window !== 'undefined') { const stored = localStorage.getItem(DEMO_USERS_KEY); if (stored) { - cache = JSON.parse(stored) as DemoUser[]; - return cache; + const parsed = parseStoredUsers(stored); + if (parsed) { + cache = parsed; + return cache; + } + localStorage.removeItem(DEMO_USERS_KEY); } } const resp = await fetch(`${PUBLIC_PATH}data/users.json`); diff --git a/src/typings.d.ts b/src/typings.d.ts index 8d5ac0f..6010341 100644 --- a/src/typings.d.ts +++ b/src/typings.d.ts @@ -1,14 +1,14 @@ // 全局类型声明(与 config.ts / config.github.ts 的 define 块一一对应) -// Cesium Ion Token -declare const CESIUM_ION_TOKEN: string; +// Cesium Ion Token(可选:未配置时 Cesium 回退自带默认 token) +declare const CESIUM_ION_TOKEN: string | undefined; declare const CESIUM_BASE_URL: string; // 站点资源根路径(主产线 '/',GitHub Pages '/umi-react-admin/') declare const PUBLIC_PATH: string; // 真实后端 API 地址(可选,未配置时 dev 走 umi mock、生产演示走静态数据) -declare const UMI_APP_API_BASE: string; +declare const UMI_APP_API_BASE: string | undefined; interface Window { Cesium?: typeof import('cesium'); diff --git a/src/utils/Auth/userInfo.ts b/src/utils/Auth/userInfo.ts index a2bb47f..7aea13f 100644 --- a/src/utils/Auth/userInfo.ts +++ b/src/utils/Auth/userInfo.ts @@ -3,16 +3,21 @@ export type UserClaim = { name: string; email: string; nickName?: string; + /** 角色声明(演示签发方或真实后端返回);access 权限消费它 */ + role?: string; }; export class UserInfo { public name: string; public email: string; public nickName: string; + /** undefined 视为无管理权限(见 src/access.ts:白名单式判定,不回退到「登录即 admin」) */ + public role: string | undefined; constructor(userClaim: UserClaim) { this.name = userClaim.name || ''; this.email = userClaim.email || ''; this.nickName = userClaim.nickName || userClaim.name || ''; + this.role = userClaim.role; } } diff --git a/src/utils/requestConfig.test.ts b/src/utils/requestConfig.test.ts index afd3508..ed2534d 100644 --- a/src/utils/requestConfig.test.ts +++ b/src/utils/requestConfig.test.ts @@ -7,9 +7,10 @@ import { AUTH_TOKEN_KEY } from '@/constants'; import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; import { BizError } from './BizError'; -const { errorMock, pushMock } = vi.hoisted(() => ({ +const { errorMock, pushMock, assignMock } = vi.hoisted(() => ({ errorMock: vi.fn(), pushMock: vi.fn(), + assignMock: vi.fn(), })); vi.mock('@umijs/max', () => ({ @@ -34,6 +35,13 @@ let interceptor: Interceptor; beforeAll(async () => { (globalThis as Record).UMI_APP_API_BASE = undefined; + // PUBLIC_PATH 与 UMI_APP_API_BASE 同为构建期 define 注入的全局常量(401 整页跳转用) + (globalThis as Record).PUBLIC_PATH = '/'; + // 401 走 window.location.assign(整页跳转重置内存态),jsdom 下需替换 location 才能断言 + Object.defineProperty(window, 'location', { + value: { ...window.location, assign: assignMock }, + writable: true, + }); ({ requestConfig } = await import('./requestConfig')); errorHandler = requestConfig.errorConfig!.errorHandler! as unknown as ErrorHandler; interceptor = requestConfig.requestInterceptors![0] as unknown as Interceptor; @@ -97,11 +105,11 @@ describe('errorHandler:HTTP 状态码分流(axios 形状)', () => { expect(pushMock).not.toHaveBeenCalled(); }); - it('401 清除 token 并跳转登录页', () => { + it('401 清除 token 并整页跳转登录页(重置内存中的 initialState)', () => { localStorage.setItem(AUTH_TOKEN_KEY, 'stale-token'); errorHandler(httpError(401)); expect(localStorage.getItem(AUTH_TOKEN_KEY)).toBeNull(); - expect(pushMock).toHaveBeenCalledWith('/login'); + expect(assignMock).toHaveBeenCalledWith('/login'); }); it('403 提示并跳转 /403', () => { diff --git a/src/utils/requestConfig.ts b/src/utils/requestConfig.ts index da6e6a0..54eff80 100644 --- a/src/utils/requestConfig.ts +++ b/src/utils/requestConfig.ts @@ -109,8 +109,10 @@ export const requestConfig: RequestConfig = { message.error(t('request.unauthorized', '登录失效,即将跳转至登录页面')); if (typeof window !== 'undefined') { localStorage.removeItem(AUTH_TOKEN_KEY); + // 整页跳转而非 SPA 路由(审计 2026-09-22 L-2):重置内存中的 initialState + //(残留的已登录用户与昵称),也顺带避免并发 401 重复 push;PUBLIC_PATH 兼容子路径部署 + window.location.assign(`${PUBLIC_PATH}login`); } - history.push('/login'); break; case 403: message.error(getErrorMessage(data, t('request.forbidden', '没有权限访问该资源。')));