security: 后台登录态可吊销(令牌版本号) + 清理未使用的 prisma 依赖(依赖漏洞 46→0)
- admin token 载荷新增 ver 版本号,与当前版本不一致即失效;版本持久化到 platform_configs,重启/多实例(30s同步)均生效 - 新增 POST /api/admin/session/revoke-all(requireAdmin):一键吊销全部后台登录态;后台右上角新增入口 - 令牌有效期改为可配置 ADMIN_TOKEN_TTL_HOURS(默认由 7 天收紧到 24 小时) - 移除 server 未使用的 @prisma/client / prisma(server/src 零引用),pnpm 移除 331 个包;pnpm audit --prod 由 46 个漏洞降为 0 - 新增回归护栏 admin-token-revocation.spec.ts(91 套件/653 用例全绿);冒烟 6/6
这个提交包含在:
@@ -18,6 +18,11 @@ export function logout() {
|
||||
return axios.post<LoginRes>('/api/user/logout');
|
||||
}
|
||||
|
||||
/** 吊销所有后台登录态(含当前本机),所有已签发 token 立即失效 */
|
||||
export function revokeAllAdminSessions() {
|
||||
return axios.post<null>('/api/admin/session/revoke-all');
|
||||
}
|
||||
|
||||
export function getUserInfo() {
|
||||
return axios.post<UserState>('/api/user/info');
|
||||
}
|
||||
|
||||
@@ -177,6 +177,12 @@
|
||||
</span>
|
||||
</a-space>
|
||||
</a-doption>
|
||||
<a-doption>
|
||||
<a-space @click="handleRevokeAll">
|
||||
<icon-lock />
|
||||
<span>吊销所有后台登录态</span>
|
||||
</a-space>
|
||||
</a-doption>
|
||||
<a-doption>
|
||||
<a-space @click="handleLogout">
|
||||
<icon-export />
|
||||
@@ -200,6 +206,7 @@
|
||||
import { LOCALE_OPTIONS } from '@/locale';
|
||||
import useLocale from '@/hooks/locale';
|
||||
import useUser from '@/hooks/user';
|
||||
import { revokeAllAdminSessions } from '@/api/user';
|
||||
import Menu from '@/components/menu/index.vue';
|
||||
import MessageBox from '../message-box/index.vue';
|
||||
|
||||
@@ -247,6 +254,17 @@
|
||||
const handleLogout = () => {
|
||||
logout();
|
||||
};
|
||||
const handleRevokeAll = async () => {
|
||||
try {
|
||||
await revokeAllAdminSessions();
|
||||
Message.success('已吊销所有后台登录态(含本机),请重新登录');
|
||||
setTimeout(() => {
|
||||
logout();
|
||||
}, 800);
|
||||
} catch (e: any) {
|
||||
Message.error(e?.message || '吊销失败,请重试');
|
||||
}
|
||||
};
|
||||
const setDropDownVisible = () => {
|
||||
const event = new MouseEvent('click', {
|
||||
view: window,
|
||||
|
||||
@@ -18,6 +18,9 @@ USER_TOKEN_SECRET=<另一个强随机长串>
|
||||
ADMIN_USERNAME=<后台账号,默认 admin>
|
||||
ADMIN_PASSWORD=<强口令;多个可用 | 分隔>
|
||||
|
||||
# 可选:后台令牌有效期小时数(默认 24;改口令/被盗时可在后台右上角「吊销所有后台登录态」立即失效全部令牌)
|
||||
# ADMIN_TOKEN_TTL_HOURS=24
|
||||
|
||||
# 数据库
|
||||
MYSQL_HOST=127.0.0.1
|
||||
MYSQL_PORT=3306
|
||||
|
||||
@@ -26,13 +26,11 @@
|
||||
"@types/multer": "^2.3.0",
|
||||
"@types/node": "^22.13.4",
|
||||
"jest": "^29.7.0",
|
||||
"prisma": "8.0.0-rc.17",
|
||||
"ts-jest": "^29.2.5",
|
||||
"tsx": "^4.23.15",
|
||||
"typescript": "^5.7.3"
|
||||
},
|
||||
"dependencies": {
|
||||
"@prisma/client": "^7.10.0",
|
||||
"cors": "^2.8.6",
|
||||
"dotenv": "^18.0.4",
|
||||
"express": "^5.2.1",
|
||||
|
||||
3924
server/pnpm-lock.yaml
自动生成的
3924
server/pnpm-lock.yaml
自动生成的
文件差异内容过多而无法显示
加载差异
@@ -245,8 +245,25 @@ const ADMIN_PASSWORDS = (ADMIN_PASSWORD_SET || 'admin|admin123').split('|').filt
|
||||
// 生产环境安全闸门:未配置 ADMIN_PASSWORD(或仍使用内置默认口令)时,禁止后台登录,避免默认口令被爆破
|
||||
const ADMIN_PASSWORD_CONFIGURED = Boolean(ADMIN_PASSWORD_SET) && !/^(admin|admin123)$/i.test(ADMIN_PASSWORD_SET);
|
||||
|
||||
// 后台令牌有效期(默认 24 小时,可用 ADMIN_TOKEN_TTL_HOURS 调整)
|
||||
const ADMIN_TOKEN_TTL_MS = Math.max(1, Number(process.env.ADMIN_TOKEN_TTL_HOURS || 24)) * 3600 * 1000;
|
||||
|
||||
// 后台登录态版本号:令牌携带签发时的版本,与当前版本不一致即失效(支持"吊销所有会话")。
|
||||
// 版本持久化在 platform_configs,重启后依然生效;多实例部署下每 30 秒同步一次。
|
||||
let adminTokenEpoch = 1;
|
||||
|
||||
async function loadAdminTokenEpoch(): Promise<void> {
|
||||
try {
|
||||
const rec = await repo.getPlatformConfigByKey('admin_token_epoch');
|
||||
const v = Number(rec && rec.valueJson && (rec.valueJson as any).epoch);
|
||||
if (Number.isFinite(v) && v >= 1) adminTokenEpoch = v;
|
||||
} catch (_) {
|
||||
// 读取失败时保持当前内存值
|
||||
}
|
||||
}
|
||||
|
||||
function signAdminToken(): string {
|
||||
const payload = Buffer.from(JSON.stringify({ role: 'admin', ts: Date.now() })).toString('base64url');
|
||||
const payload = Buffer.from(JSON.stringify({ role: 'admin', ts: Date.now(), ver: adminTokenEpoch })).toString('base64url');
|
||||
const sig = crypto.createHmac('sha256', ADMIN_TOKEN_SECRET).update(payload).digest('base64url');
|
||||
return `${payload}.${sig}`;
|
||||
}
|
||||
@@ -262,7 +279,9 @@ function verifyAdminToken(token: string): boolean {
|
||||
try {
|
||||
const data = JSON.parse(Buffer.from(payload, 'base64url').toString('utf8'));
|
||||
if (data.role !== 'admin' || !data.ts) return false;
|
||||
if (Date.now() - Number(data.ts) > 7 * 24 * 3600 * 1000) return false;
|
||||
// 版本不一致 = 该令牌已被吊销
|
||||
if (Number(data.ver) !== adminTokenEpoch) return false;
|
||||
if (Date.now() - Number(data.ts) > ADMIN_TOKEN_TTL_MS) return false;
|
||||
return true;
|
||||
} catch (_) {
|
||||
return false;
|
||||
@@ -429,6 +448,24 @@ function assertCompanionActor(
|
||||
throw e;
|
||||
}
|
||||
|
||||
// 管理端:吊销所有后台登录态(版本号 +1,所有已签发 admin token 立即失效)
|
||||
app.post('/api/admin/session/revoke-all', requireAdmin, async (req: Request, res: Response) => {
|
||||
try {
|
||||
const next = adminTokenEpoch + 1;
|
||||
await repo.updatePlatformConfig('admin_token_epoch', {
|
||||
name: '后台登录态版本号',
|
||||
valueJson: { epoch: next },
|
||||
description: '内部标记:用于吊销全部后台登录态,勿手动修改',
|
||||
status: 1,
|
||||
});
|
||||
adminTokenEpoch = next;
|
||||
console.warn(`[Peiwan Admin][AUDIT] 后台登录态已全部吊销 version->${next} ip=${clientIpOf(req)}`);
|
||||
res.json({ code: 20000, message: '已吊销所有后台登录态,请重新登录' });
|
||||
} catch (err: any) {
|
||||
res.status(500).json({ code: 50000, message: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// 1.1 管理员鉴权与登录接口 (供 PC 运营管理后台生产环境调用)
|
||||
app.post('/api/user/login', (req: Request, res: Response) => {
|
||||
const { username, password } = req.body || {};
|
||||
@@ -4664,6 +4701,13 @@ async function bootstrap() {
|
||||
await logStartupConfigSelfCheck();
|
||||
startOrderTimeoutScheduler();
|
||||
|
||||
// 载入后台登录态版本号,并周期同步(支持多实例下的"吊销所有会话")
|
||||
await loadAdminTokenEpoch();
|
||||
const epochSyncTimer = setInterval(() => {
|
||||
void loadAdminTokenEpoch();
|
||||
}, 30 * 1000);
|
||||
if (typeof (epochSyncTimer as any).unref === 'function') (epochSyncTimer as any).unref();
|
||||
|
||||
app.listen(port, () => {
|
||||
console.log(`[Peiwan Server] HTTP server is running on http://localhost:${port}`);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
// 已读取AGENTS.md
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
/**
|
||||
* 第七十五批回归闸门:后台登录态吊销 + 依赖清理。
|
||||
*/
|
||||
describe('第七十五批后台令牌吊销闸门', () => {
|
||||
const root = path.resolve(__dirname, '../../');
|
||||
const indexCode = fs.readFileSync(path.join(root, 'server/src/index.ts'), 'utf-8');
|
||||
const pkg = JSON.parse(fs.readFileSync(path.join(root, 'server/package.json'), 'utf-8'));
|
||||
const navbar = fs.readFileSync(path.join(root, 'admin/arco-design-pro-vite/src/components/navbar/index.vue'), 'utf-8');
|
||||
|
||||
it('令牌携带版本号,版本不符即失效', () => {
|
||||
expect(indexCode).toContain('ver: adminTokenEpoch');
|
||||
expect(indexCode).toContain('if (Number(data.ver) !== adminTokenEpoch) return false;');
|
||||
expect(indexCode).toContain('let adminTokenEpoch = 1;');
|
||||
});
|
||||
|
||||
it('提供吊销接口并持久化版本号', () => {
|
||||
expect(indexCode).toContain("app.post('/api/admin/session/revoke-all', requireAdmin");
|
||||
expect(indexCode).toContain("repo.updatePlatformConfig('admin_token_epoch'");
|
||||
expect(indexCode).toContain('loadAdminTokenEpoch');
|
||||
});
|
||||
|
||||
it('令牌有效期可配置(默认 24 小时)', () => {
|
||||
expect(indexCode).toContain('ADMIN_TOKEN_TTL_HOURS');
|
||||
expect(indexCode).toContain('ADMIN_TOKEN_TTL_MS');
|
||||
});
|
||||
|
||||
it('后台提供「吊销所有后台登录态」入口', () => {
|
||||
expect(navbar).toContain('handleRevokeAll');
|
||||
expect(navbar).toContain('revokeAllAdminSessions');
|
||||
});
|
||||
|
||||
it('已移除未使用的 prisma 依赖(消除全部依赖漏洞)', () => {
|
||||
expect(pkg.dependencies['@prisma/client']).toBeUndefined();
|
||||
expect(pkg.devDependencies['prisma']).toBeUndefined();
|
||||
});
|
||||
});
|
||||
在新工单中引用
屏蔽一个用户