refactor(stage4): 集中配置与启动校验,权限守卫 fail-closed,凭据移出仓库

配置分层
- 新增 src/server/config.ts:环境变量集中读取 + 启动期校验。
  删除公开兜底密钥 ln-bi-default-secret:SSO 模式下 JWT_SECRET 缺失或
  短于 32 字符时 assertRuntimeConfig() 直接拒绝启动(已实测)。
- index.ts 不再自己读端口/环境;auth/config.ts 与 auth/login.ts 改为引用集中配置。

权限守卫
- energy / scheduling / hydrogen-heatmap 的守卫由 “user 存在才校验” 改为
  “无角色即拒绝”:user 缺失时按无权限处理,不再静默放行(fail-closed)。
- /api/ele/* 此前完全无鉴权,任何已登录用户都能写入电费表;现按能源域
  (BI-LEADER-ENERGY) 守卫。

接口契约
- 未匹配的 /api/* 由 200 text/html(SPA) 改为 404 application/json;
  未认证时仍是 401,避免向未授权调用方暴露路由是否存在。
- 新增全局 onError 返回 JSON 500。

凭据治理(此前均为 git 跟踪文件中的明文)
- Dockerfile 删除烧进镜像的 JWT_SECRET,改为必须运行时注入。
- docker-compose.yml 删除生产库口令/JWT 密钥/失效的 MILEAGE_DB_*,
  改为强制注入写法;补齐 OSS_* 与 NODE_ENV/DEV_BYPASS_AUTH/BI_AUTH_*。
- woodpecker.yml 删除 Harbor base64 凭据改用 secret,pull_request 不再推镜像。
- 删除 scripts-tmp/(含生产库 root 口令)与已跟踪的 .DS_Store;.gitignore 补全。
- 文档中残留的里程库口令改为占位符。

lint / test(128) / build 全绿。
This commit is contained in:
dsh-agent
2026-09-11 10:19:07 +08:00
parent 680c7e0662
commit 9a9c9d08e1
20 changed files with 176 additions and 297 deletions
+11
View File
@@ -41,6 +41,17 @@ export function createApp(): Hono {
time: new Date().toISOString(),
}));
// 未匹配的 /api/* 必须返回 JSON 404。若落到下面的静态回退,会返回
// 200 + index.html,让前端 res.ok 为真、再在 res.json() 处抛解析错误,
// 把"路由不存在"伪装成数据问题。
app.all('/api/*', (context) => context.json({ error: 'Not Found' }, 404));
// 兜底错误处理:统一返回 JSON,避免把栈信息或 HTML 暴露给调用方。
app.onError((error, context) => {
console.error(`[server] unhandled error on ${context.req.method} ${context.req.path}:`, error);
return context.json({ error: 'Internal Server Error' }, 500);
});
// 生产环境由同一进程托管 Vite 构建产物,并回退到单页应用入口。
app.use('/*', serveStatic({ root: './dist' }));
app.use('/*', serveStatic({ root: './dist', path: 'index.html' }));
+18 -10
View File
@@ -1,24 +1,32 @@
import { createHmac } from 'node:crypto';
import jwt from 'jsonwebtoken';
import type { JwtPayload } from './types.js';
import { authMode, jwtSecret } from '../config.js';
export function authMode() {
const mode = process.env.BI_AUTH_MODE || 'sso';
if (mode !== 'sso' && mode !== 'password') throw new Error('Invalid BI_AUTH_MODE');
return mode;
}
// 认证方式统一由 server/config.ts 读取与校验,这里只做转发,
// 让既有的 `from './config.js'` 引用保持有效。
export { authMode };
export function passwordConfig() {
const password = process.env.BI_AUTH_PASSWORD || '';
const secret = process.env.JWT_SECRET || '';
if (!password.trim() || secret.length < 32) throw new Error('Password authentication is not configured securely');
return { password, key: createHmac('sha256', secret).update(`bi-password:${password}`).digest('hex') };
const secret = jwtSecret();
if (!password.trim() || secret.length < 32) {
throw new Error('Password authentication is not configured securely');
}
return {
password,
key: createHmac('sha256', secret).update(`bi-password:${password}`).digest('hex'),
};
}
export function verifyAuthToken(token: string): JwtPayload {
const passwordMode = authMode() === 'password';
const key = passwordMode ? passwordConfig().key : process.env.JWT_SECRET || 'ln-bi-default-secret';
// 不再有公开兜底密钥:SSO 模式下 JWT_SECRET 缺失会在启动期被 assertRuntimeConfig 拦下。
const key = passwordMode ? passwordConfig().key : jwtSecret();
if (!key) throw new Error('JWT_SECRET is not configured');
const payload = jwt.verify(token, key, { algorithms: ['HS256'] }) as JwtPayload;
if (passwordMode ? payload.authMethod !== 'password' : payload.authMethod === 'password') throw new Error('Authentication mode changed');
if (passwordMode ? payload.authMethod !== 'password' : payload.authMethod === 'password') {
throw new Error('Authentication mode changed');
}
return payload;
}
+3 -5
View File
@@ -5,13 +5,11 @@ import type { AuthUser, JwtPayload, PermissionLevel } from './types.js';
import { FULL_ACCESS_ROLES, DEPT_ACCESS_ROLES } from './types.js';
import { authMode, verifyAuthToken } from './config.js';
import { passwordRouter } from './password.js';
import { jwtSecret, serverConfig } from '../config.js';
const app = new Hono();
app.route('/', passwordRouter());
const EXTERNAL_API_BASE = process.env.EXTERNAL_API_BASE || 'https://beta.lnh2e.com';
const JWT_SECRET = process.env.JWT_SECRET || 'ln-bi-default-secret';
/** GET /api/auth/exchange?jumpToken=xxx — 一步完成:换取用户信息 + 签发 JWT */
app.get('/exchange', async (c) => {
if (authMode() !== 'sso') return c.json({ message: '当前使用固定密码登录' }, 403);
@@ -20,7 +18,7 @@ app.get('/exchange', async (c) => {
try {
const res = await fetch(
`${EXTERNAL_API_BASE}/api/lingniu-manager-v1/v1/auth/issueTokenByJump?jumpToken=${encodeURIComponent(jumpToken)}`
`${serverConfig.externalApiBase}/api/lingniu-manager-v1/v1/auth/issueTokenByJump?jumpToken=${encodeURIComponent(jumpToken)}`
);
const data = await res.json() as {
code: number;
@@ -77,7 +75,7 @@ app.get('/exchange', async (c) => {
roles: roleNames,
};
const token = jwt.sign(payload, JWT_SECRET, { expiresIn: '8h' });
const token = jwt.sign(payload, jwtSecret(), { expiresIn: '8h' });
const authUser: AuthUser = { ...payload };
return c.json({ token, user: authUser });
+65
View File
@@ -0,0 +1,65 @@
import dotenv from 'dotenv';
// ESM 的 import 会被提升到调用方语句之前执行,所以这里显式加载一次环境文件,
// 保证本模块下面的常量真的读到了 .env。dotenv 是幂等的(不覆盖已有变量),
// 各数据库模块仍保留自己的 dotenv 调用以规避模块初始化顺序问题。
dotenv.config();
function readNumber(value: string | undefined, fallback: number): number {
if (value === undefined || value.trim() === '') return fallback;
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : fallback;
}
/** 认证方式:`sso` 走业务系统跳转换票,`password` 只用固定密码。 */
export type AuthMode = 'sso' | 'password';
export function authMode(): AuthMode {
const mode = process.env.BI_AUTH_MODE || 'sso';
if (mode !== 'sso' && mode !== 'password') throw new Error('Invalid BI_AUTH_MODE');
return mode;
}
export const serverConfig = {
port: readNumber(process.env.SERVER_PORT, 3001),
externalApiBase: process.env.EXTERNAL_API_BASE || 'https://beta.lnh2e.com',
} as const;
/**
* JWT 签名密钥。
* 刻意不提供兜底值:历史上 `'ln-bi-default-secret'` 这类公开常量会让任何人
* 离线伪造全权限令牌。缺失或过短一律由 {@link assertRuntimeConfig} 拒绝启动。
*/
export function jwtSecret(): string {
return process.env.JWT_SECRET || '';
}
/** 生产只读预览:DB_READ_ONLY=1 时禁止业务写入且不启动后台任务。 */
export const isDbReadOnly = () => process.env.DB_READ_ONLY === '1';
/** 氢能查询只读:对氢能连接池启用 SELECT/SHOW/WITH/EXPLAIN 白名单。 */
export const isHydrogenReadOnly = () => process.env.HYDROGEN_DB_READ_ONLY === '1';
const MIN_SECRET_LENGTH = 32;
/**
* 启动期配置校验。在监听端口之前调用,让配置错误在启动时暴露而不是在第一次请求时。
* 只检查"缺失会导致安全问题"的项,不检查业务数据库等可选能力。
*/
export function assertRuntimeConfig(): void {
if (authMode() === 'sso') {
const secret = jwtSecret();
if (secret.length < MIN_SECRET_LENGTH) {
throw new Error(
`JWT_SECRET must be set to a random value of at least ${MIN_SECRET_LENGTH} characters `
+ '(SSO 模式下缺失会让任何人都能伪造令牌)',
);
}
}
if (authMode() === 'password') {
const password = (process.env.BI_AUTH_PASSWORD || '').trim();
if (!password) throw new Error('BI_AUTH_PASSWORD must be set when BI_AUTH_MODE=password');
if (jwtSecret().length < MIN_SECRET_LENGTH) {
throw new Error(`JWT_SECRET must be at least ${MIN_SECRET_LENGTH} characters`);
}
}
}
+6 -6
View File
@@ -1,15 +1,15 @@
import { serve } from '@hono/node-server';
import dotenv from 'dotenv';
import { createApp } from './app.js';
import { startBackgroundServices } from './bootstrap.js';
import { assertRuntimeConfig, serverConfig } from './config.js';
dotenv.config();
// 配置错误在启动期暴露,而不是等到第一次请求才 500。
assertRuntimeConfig();
const app = createApp();
const port = Number(process.env.SERVER_PORT) || 3001;
console.log(`Server starting on port ${port}...`);
console.log(`Server starting on port ${serverConfig.port}...`);
startBackgroundServices();
serve({ fetch: app.fetch, port }, () => {
console.log(`Server running at http://localhost:${port}`);
serve({ fetch: app.fetch, port: serverConfig.port }, () => {
console.log(`Server running at http://localhost:${serverConfig.port}`);
});
+12
View File
@@ -2,10 +2,22 @@ import { Hono } from 'hono';
import type { RowDataPacket, ResultSetHeader } from 'mysql2';
import * as XLSX from 'xlsx';
import pool from '../../db.js';
import type { AuthUser } from '../../auth/types.js';
import { canAccessEnergy } from '../../auth/types.js';
import { ensureChargeRecordTable } from './migration.js';
const app = new Hono();
// 电能数据导入属于能源域的管理能力。该路由只展示在隐藏入口,但必须在服务端
// 独立鉴权(fail-closed),否则任何已登录用户都能写入 bi_ele_charge_record。
app.use('*', async (c, next) => {
const user = (c as { get: (key: string) => unknown }).get('user') as AuthUser | undefined;
if (!canAccessEnergy(user?.roles)) {
return c.json({ error: 'Forbidden: 电能导入需要 BI-LEADER-ENERGY 角色' }, 403);
}
return next();
});
// 与 xlsx 列名对齐
const COL = {
orderNo: '订单编号',
+3 -3
View File
@@ -14,11 +14,11 @@ import { registerHydrogenBiV2Routes } from './hydrogen-bi-v2.js';
const app = new Hono();
// 模块级访问守卫dev 旁路 auth 时 user 为 undefined,直接放行;
// 生产环境必须具备 BI-LEADER-ENERGY 或全量权限角色
// 模块级访问守卫fail-closed):必须持有 BI-LEADER-ENERGY 或「所有权限」。
// 认证中间件挂在本路由之前,因此 user 缺失本身就是异常,按无权限处理。
app.use('*', async (c, next) => {
const user = (c as { get: (k: string) => unknown }).get('user') as AuthUser | undefined;
if (user && !canAccessEnergy(user.roles)) {
if (!canAccessEnergy(user?.roles)) {
return c.json({ error: 'Forbidden: 能源管理访问需要 BI-LEADER-ENERGY 角色' }, 403);
}
return next();
+1 -1
View File
@@ -82,7 +82,7 @@ const router = new Hono();
router.use('*', async (c, next) => {
const user = (c as { get: (key: string) => unknown }).get('user') as AuthUser | undefined;
if (user && !canAccessEnergy(user.roles)) {
if (!canAccessEnergy(user?.roles)) {
return c.json({ error: 'Forbidden: 能源管理访问需要 BI-LEADER-ENERGY 角色' }, 403);
}
return next();
+4 -4
View File
@@ -6,12 +6,12 @@ import { canAccessScheduling } from '../../auth/types.js';
const app = new Hono();
// Module-level access guard. When auth middleware is active, `user` is set and
// we require a role from SCHEDULING_ACCESS_ROLES (or a full-access role).
// When auth is bypassed (dev), `user` is undefined and requests pass through.
// Module-level access guard (fail-closed): requires BI-SCHEDULE-OPT.
// Auth middleware runs before this router, so a missing `user` is an anomaly and
// must be treated as "no permission" rather than silently allowed.
app.use('*', async (c, next) => {
const user = (c as any).get('user') as AuthUser | undefined;
if (user && !canAccessScheduling(user.roles)) {
if (!canAccessScheduling(user?.roles)) {
return c.json({ error: 'Forbidden: 智能调度访问需要 BI-SCHEDULE-OPT 角色' }, 403);
}
return next();