Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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') {
Expand Down
17 changes: 11 additions & 6 deletions mock/userAPI.ts
Original file line number Diff line number Diff line change
@@ -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[] = [
Expand All @@ -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,
});
},
Expand Down
9 changes: 5 additions & 4 deletions src/access.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
Expand Down
1 change: 1 addition & 0 deletions src/layouts/RightContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ const RightContent = () => {
name: '',
email: '',
nickName: '',
role: undefined,
...prev,
theme: mode,
}));
Expand Down
2 changes: 2 additions & 0 deletions src/locales/en-US.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
1 change: 1 addition & 0 deletions src/locales/zh-CN.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ export default {
'login.passwordRequired': '请输入密码',
'login.submit': '登录',
'login.success': '登录成功',
'login.storageError': '无法在本地保存登录状态(存储被禁用或已满),请检查浏览器设置后重试',
// NOTE 框架层(阶段5)
'header.theme': '主题',
'header.language': '语言',
Expand Down
8 changes: 7 additions & 1 deletion src/pages/Login/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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' }));
Expand Down
39 changes: 29 additions & 10 deletions src/services/auth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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() }));

Expand All @@ -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();
});

Expand Down
20 changes: 11 additions & 9 deletions src/services/auth.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -11,6 +12,8 @@ export interface CurrentUser {
name: string;
email: string;
nickName?: string;
/** 角色声明:access 权限消费它(canSeeAdmin = role === 'admin')。真实后端必须由服务端返回 */
role?: DemoRole;
}

export interface LoginResult {
Expand All @@ -25,21 +28,20 @@ interface ApiResponse<T> {
}

/**
* 演示 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<LoginResult> {
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<ApiResponse<LoginResult>>('/api/v1/login', { method: 'POST', data: params });
return resp.data;
Expand All @@ -60,11 +62,11 @@ export async function logout(): Promise<void> {
export async function fetchCurrentUser(): Promise<CurrentUser> {
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<ApiResponse<CurrentUser>>('/api/v1/currentUser', { skipErrorHandler: true });
return resp.data;
Expand Down
55 changes: 55 additions & 0 deletions src/services/demo/demoToken.ts
Original file line number Diff line number Diff line change
@@ -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<DemoTokenPayload>;
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;
}
};
28 changes: 26 additions & 2 deletions src/services/demo/userService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,15 +31,39 @@ 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<DemoUser[]> => {
if (cache) {
return cache;
}
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`);
Expand Down
6 changes: 3 additions & 3 deletions src/typings.d.ts
Original file line number Diff line number Diff line change
@@ -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');
Expand Down
5 changes: 5 additions & 0 deletions src/utils/Auth/userInfo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
Loading
Loading