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:
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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
@@ -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}`);
|
||||
});
|
||||
|
||||
@@ -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: '订单编号',
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user