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:
@@ -3,3 +3,5 @@ dist
|
|||||||
.env
|
.env
|
||||||
.env.local
|
.env.local
|
||||||
.worktrees
|
.worktrees
|
||||||
|
.DS_Store
|
||||||
|
scripts-tmp
|
||||||
|
|||||||
+3
-1
@@ -17,7 +17,9 @@ COPY src/shared ./src/shared
|
|||||||
COPY tsconfig.json ./
|
COPY tsconfig.json ./
|
||||||
|
|
||||||
EXPOSE 3001
|
EXPOSE 3001
|
||||||
|
ENV NODE_ENV=production
|
||||||
ENV SERVER_PORT=3001
|
ENV SERVER_PORT=3001
|
||||||
ENV EXTERNAL_API_BASE=https://lnh2e.com
|
ENV EXTERNAL_API_BASE=https://lnh2e.com
|
||||||
ENV JWT_SECRET=ln-bi-jwt-prod-secret
|
# JWT_SECRET 不再写入镜像:镜像层里的默认密钥等同于公开密钥,任何人可离线伪造令牌。
|
||||||
|
# 必须在运行环境注入(至少 32 字符),缺失时服务会拒绝启动(见 src/server/config.ts)。
|
||||||
CMD ["npm", "run", "start"]
|
CMD ["npm", "run", "start"]
|
||||||
|
|||||||
+30
-12
@@ -1,20 +1,29 @@
|
|||||||
version: '3.8'
|
version: '3.8'
|
||||||
|
|
||||||
|
# 凭据一律通过 Portainer / 环境变量注入,不再写入本文件。
|
||||||
|
# 注意:此前提交到 Git 的数据库口令与 JWT 密钥已视为泄漏,必须轮换后再使用。
|
||||||
|
# JWT_SECRET 至少要 32 个字符,缺失时服务会拒绝启动(src/server/config.ts)。
|
||||||
|
|
||||||
services:
|
services:
|
||||||
ln-bi:
|
ln-bi:
|
||||||
image: harbor.lnh2e.com/lingniu-v1/ln-bi:main-1.0.0
|
image: harbor.lnh2e.com/lingniu-v1/ln-bi:main-1.1.15
|
||||||
network_mode: host
|
network_mode: host
|
||||||
environment:
|
environment:
|
||||||
DB_HOST: "rm-bp179zbv481rnw3e2no.mysql.rds.aliyuncs.com"
|
NODE_ENV: "production"
|
||||||
DB_PORT: "3306"
|
# 生产必须保持为 0:该开关会绕过全部认证。
|
||||||
DB_USER: "oneos_db_prod"
|
DEV_BYPASS_AUTH: "0"
|
||||||
DB_PASSWORD: "adASHJcviqwjkbn23ngt1"
|
DB_HOST: "${DB_HOST:?请注入主业务库地址}"
|
||||||
DB_NAME: "ln_asset_management"
|
DB_PORT: "${DB_PORT:-3306}"
|
||||||
MILEAGE_DB_HOST: "101.133.130.65"
|
DB_USER: "${DB_USER:?请注入主业务库只读账号}"
|
||||||
MILEAGE_DB_PORT: "3306"
|
DB_PASSWORD: "${DB_PASSWORD:?请注入主业务库口令}"
|
||||||
MILEAGE_DB_USER: "bi_reader_02"
|
DB_NAME: "${DB_NAME:-ln_asset_management}"
|
||||||
MILEAGE_DB_PASSWORD: "bi_reader_02_Pass"
|
# 氢能库未配置时复用主库,因此默认不再单独注入;如需独立只读库再配置。
|
||||||
MILEAGE_DB_NAME: "hydrogen_energy"
|
HYDROGEN_DB_HOST: "${HYDROGEN_DB_HOST:-}"
|
||||||
|
HYDROGEN_DB_PORT: "${HYDROGEN_DB_PORT:-3306}"
|
||||||
|
HYDROGEN_DB_USER: "${HYDROGEN_DB_USER:-}"
|
||||||
|
HYDROGEN_DB_PASSWORD: "${HYDROGEN_DB_PASSWORD:-}"
|
||||||
|
HYDROGEN_DB_NAME: "${HYDROGEN_DB_NAME:-}"
|
||||||
|
HYDROGEN_TENANT_ID: "${HYDROGEN_TENANT_ID:-000000}"
|
||||||
# ECS production accesses OneOS over the private network. Configure the key
|
# ECS production accesses OneOS over the private network. Configure the key
|
||||||
# as a Portainer stack environment variable; never commit it to this file.
|
# as a Portainer stack environment variable; never commit it to this file.
|
||||||
ONEOS_MILEAGE_API_BASE_URL: "${ONEOS_MILEAGE_API_BASE_URL:-http://172.17.111.55:20310}"
|
ONEOS_MILEAGE_API_BASE_URL: "${ONEOS_MILEAGE_API_BASE_URL:-http://172.17.111.55:20310}"
|
||||||
@@ -22,7 +31,9 @@ services:
|
|||||||
ONEOS_MILEAGE_API_TIMEOUT_MS: "${ONEOS_MILEAGE_API_TIMEOUT_MS:-20000}"
|
ONEOS_MILEAGE_API_TIMEOUT_MS: "${ONEOS_MILEAGE_API_TIMEOUT_MS:-20000}"
|
||||||
SERVER_PORT: "8111"
|
SERVER_PORT: "8111"
|
||||||
EXTERNAL_API_BASE: "https://lnh2e.com"
|
EXTERNAL_API_BASE: "https://lnh2e.com"
|
||||||
JWT_SECRET: "ln-bi-jwt-prod-k8s9m2x7"
|
BI_AUTH_MODE: "${BI_AUTH_MODE:-sso}"
|
||||||
|
BI_AUTH_PASSWORD: "${BI_AUTH_PASSWORD:-}"
|
||||||
|
JWT_SECRET: "${JWT_SECRET:?请注入至少 32 字符的随机签名密钥}"
|
||||||
AMAP_WEB_KEY: "${AMAP_WEB_KEY}"
|
AMAP_WEB_KEY: "${AMAP_WEB_KEY}"
|
||||||
AMAP_SECURITY_JS_CODE: "${AMAP_SECURITY_JS_CODE}"
|
AMAP_SECURITY_JS_CODE: "${AMAP_SECURITY_JS_CODE}"
|
||||||
HEATMAP_DB_HOST: "${HEATMAP_DB_HOST}"
|
HEATMAP_DB_HOST: "${HEATMAP_DB_HOST}"
|
||||||
@@ -31,6 +42,13 @@ services:
|
|||||||
HEATMAP_DB_PASSWORD: "${HEATMAP_DB_PASSWORD}"
|
HEATMAP_DB_PASSWORD: "${HEATMAP_DB_PASSWORD}"
|
||||||
HEATMAP_DB_NAME: "${HEATMAP_DB_NAME:-lingniu_vehicle_data}"
|
HEATMAP_DB_NAME: "${HEATMAP_DB_NAME:-lingniu_vehicle_data}"
|
||||||
HEATMAP_DB_SSL: "${HEATMAP_DB_SSL:-false}"
|
HEATMAP_DB_SSL: "${HEATMAP_DB_SSL:-false}"
|
||||||
|
# 用户反馈截图上传所需;此前缺失会导致反馈上传必然失败。
|
||||||
|
OSS_REGION: "${OSS_REGION}"
|
||||||
|
OSS_ENDPOINT: "${OSS_ENDPOINT}"
|
||||||
|
OSS_BUCKET: "${OSS_BUCKET}"
|
||||||
|
OSS_ACCESS_KEY_ID: "${OSS_ACCESS_KEY_ID}"
|
||||||
|
OSS_ACCESS_KEY_SECRET: "${OSS_ACCESS_KEY_SECRET}"
|
||||||
|
OSS_BASE_DIR: "${OSS_BASE_DIR}"
|
||||||
deploy:
|
deploy:
|
||||||
replicas: 1
|
replicas: 1
|
||||||
restart_policy:
|
restart_policy:
|
||||||
|
|||||||
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
@@ -43,7 +43,7 @@ const mileagePool = mysql.createPool({
|
|||||||
host: '101.133.130.65',
|
host: '101.133.130.65',
|
||||||
port: 3306,
|
port: 3306,
|
||||||
user: 'bi_reader_02',
|
user: 'bi_reader_02',
|
||||||
password: 'bi_reader_02_Pass',
|
password: process.env.MILEAGE_DB_PASSWORD,
|
||||||
database: 'hydrogen_energy',
|
database: 'hydrogen_energy',
|
||||||
waitForConnections: true,
|
waitForConnections: true,
|
||||||
connectionLimit: 5,
|
connectionLimit: 5,
|
||||||
|
|||||||
@@ -20,7 +20,7 @@
|
|||||||
|
|
||||||
### 数据库 2:hydrogen_energy(新增连接)
|
### 数据库 2:hydrogen_energy(新增连接)
|
||||||
|
|
||||||
- 连接信息:`101.133.130.65:3306`,用户 `bi_reader_02`,密码 `bi_reader_02_Pass`,库名 `hydrogen_energy`
|
- 连接信息:`101.133.130.65:3306`,用户 `bi_reader_02`,密码 `<见环境变量 MILEAGE_DB_PASSWORD>`,库名 `hydrogen_energy`
|
||||||
- `v_vehicle_daily_stats` — 1004 辆车的每日里程明细(plate, vin, stat_date, daily_km, total_km, day_hydrogen, daily_run_secs, source)
|
- `v_vehicle_daily_stats` — 1004 辆车的每日里程明细(plate, vin, stat_date, daily_km, total_km, day_hydrogen, daily_run_secs, source)
|
||||||
|
|
||||||
## 架构
|
## 架构
|
||||||
|
|||||||
@@ -1,178 +0,0 @@
|
|||||||
沪A00113F
|
|
||||||
沪A00220F
|
|
||||||
沪A00333F
|
|
||||||
沪A00607F
|
|
||||||
沪A01056F
|
|
||||||
沪A01311F
|
|
||||||
沪A01775F
|
|
||||||
沪A01813F
|
|
||||||
沪A01855F
|
|
||||||
沪A02303F
|
|
||||||
沪A02311F
|
|
||||||
沪A02326F
|
|
||||||
沪A02361F
|
|
||||||
沪A02720F
|
|
||||||
沪A03086F
|
|
||||||
沪A03397F
|
|
||||||
沪A03565F
|
|
||||||
沪A03620F
|
|
||||||
沪A03659F
|
|
||||||
沪A03801F
|
|
||||||
沪A03870F
|
|
||||||
沪A05035F
|
|
||||||
沪A05113F
|
|
||||||
沪A05223F
|
|
||||||
沪A05501F
|
|
||||||
沪A05675F
|
|
||||||
沪A05697F
|
|
||||||
沪A05830F
|
|
||||||
沪A06335F
|
|
||||||
沪A06599F
|
|
||||||
沪A06695F
|
|
||||||
沪A07006F
|
|
||||||
沪A07153F
|
|
||||||
沪A07806F
|
|
||||||
沪A08037F
|
|
||||||
沪A08150F
|
|
||||||
沪A08315F
|
|
||||||
沪A08598F
|
|
||||||
沪A08786F
|
|
||||||
沪A09100F
|
|
||||||
沪A09251F
|
|
||||||
沪A09276F
|
|
||||||
沪A09303F
|
|
||||||
沪A09313F
|
|
||||||
沪A09322F
|
|
||||||
沪A09689F
|
|
||||||
沪A30010F
|
|
||||||
沪A30399F
|
|
||||||
沪A31031F
|
|
||||||
沪A31211F
|
|
||||||
沪A31281F
|
|
||||||
沪A31308F
|
|
||||||
沪A31381F
|
|
||||||
沪A31613F
|
|
||||||
沪A32269F
|
|
||||||
沪A33216F
|
|
||||||
沪A35236F
|
|
||||||
沪A35798F
|
|
||||||
沪A35879F
|
|
||||||
沪A35898F
|
|
||||||
沪A36133F
|
|
||||||
沪A36169F
|
|
||||||
沪A36569F
|
|
||||||
沪A36980F
|
|
||||||
沪A37785F
|
|
||||||
沪A38795F
|
|
||||||
沪A39287F
|
|
||||||
沪A39289F
|
|
||||||
沪A39585F
|
|
||||||
沪A39608F
|
|
||||||
沪A39626F
|
|
||||||
沪A39815F
|
|
||||||
沪A39835F
|
|
||||||
沪A39912F
|
|
||||||
沪A50026F
|
|
||||||
沪A50069F
|
|
||||||
沪A50309F
|
|
||||||
沪A51580F
|
|
||||||
沪A51612F
|
|
||||||
沪A51677F
|
|
||||||
沪A51893F
|
|
||||||
沪A52331F
|
|
||||||
沪A52511F
|
|
||||||
沪A53309F
|
|
||||||
沪A53322F
|
|
||||||
沪A53506F
|
|
||||||
沪A53960F
|
|
||||||
沪A55179F
|
|
||||||
沪A55297F
|
|
||||||
沪A55339F
|
|
||||||
沪A55666F
|
|
||||||
沪A55695F
|
|
||||||
沪A56122F
|
|
||||||
沪A56701F
|
|
||||||
沪A56959F
|
|
||||||
沪A56988F
|
|
||||||
沪A57139F
|
|
||||||
沪A57167F
|
|
||||||
沪A57198F
|
|
||||||
沪A57838F
|
|
||||||
沪A57850F
|
|
||||||
沪A57895F
|
|
||||||
沪A58087F
|
|
||||||
沪A58159F
|
|
||||||
沪A58185F
|
|
||||||
沪A58307F
|
|
||||||
沪A58533F
|
|
||||||
沪A58538F
|
|
||||||
沪A58593F
|
|
||||||
沪A58922F
|
|
||||||
沪A59095F
|
|
||||||
沪A59510F
|
|
||||||
沪A59613F
|
|
||||||
沪A59682F
|
|
||||||
沪A59799F
|
|
||||||
沪A59932F
|
|
||||||
沪A60339F
|
|
||||||
沪A60691F
|
|
||||||
沪A60820F
|
|
||||||
沪A61187F
|
|
||||||
沪A61193F
|
|
||||||
沪A61312F
|
|
||||||
沪A61559F
|
|
||||||
沪A61600F
|
|
||||||
沪A61711F
|
|
||||||
沪A61738F
|
|
||||||
沪A62322F
|
|
||||||
沪A62772F
|
|
||||||
沪A62928F
|
|
||||||
沪A63013F
|
|
||||||
沪A63305F
|
|
||||||
沪A63522F
|
|
||||||
沪A63660F
|
|
||||||
沪A63697F
|
|
||||||
沪A65036F
|
|
||||||
沪A65181F
|
|
||||||
沪A65522F
|
|
||||||
沪A65995F
|
|
||||||
沪A66216F
|
|
||||||
沪A66256F
|
|
||||||
沪A66329F
|
|
||||||
沪A66593F
|
|
||||||
沪A66710F
|
|
||||||
沪A66921F
|
|
||||||
沪A67018F
|
|
||||||
沪A67033F
|
|
||||||
沪A67872F
|
|
||||||
沪A68115F
|
|
||||||
沪A68139F
|
|
||||||
沪A68332F
|
|
||||||
沪A68613F
|
|
||||||
沪A68658F
|
|
||||||
沪A68752F
|
|
||||||
沪A69311F
|
|
||||||
沪A69826F
|
|
||||||
沪A69997F
|
|
||||||
沪A85021F
|
|
||||||
沪A89315F
|
|
||||||
沪A89385F
|
|
||||||
沪A89662F
|
|
||||||
浙F00885F
|
|
||||||
浙F08889F
|
|
||||||
浙F09898F
|
|
||||||
粤A00255F
|
|
||||||
粤A02683F
|
|
||||||
粤A02956F
|
|
||||||
粤A03502F
|
|
||||||
粤A03532F
|
|
||||||
粤A03569F
|
|
||||||
粤A05106F
|
|
||||||
粤A05391F
|
|
||||||
粤A05428F
|
|
||||||
粤A05839F
|
|
||||||
粤A05985F
|
|
||||||
粤A05995F
|
|
||||||
粤A06569F
|
|
||||||
粤A06931F
|
|
||||||
粤A06932F
|
|
||||||
@@ -1,60 +0,0 @@
|
|||||||
import mysql from 'mysql2/promise';
|
|
||||||
import fs from 'node:fs';
|
|
||||||
|
|
||||||
const pool = mysql.createPool({
|
|
||||||
host: 'rm-uf65w5v2r77n674x2.mysql.rds.aliyuncs.com',
|
|
||||||
port: 3306,
|
|
||||||
user: 'root',
|
|
||||||
password: 'LN#Passw0rd@2026',
|
|
||||||
database: 'lingniu_prod',
|
|
||||||
connectTimeout: 15000, ssl: { rejectUnauthorized: false },
|
|
||||||
});
|
|
||||||
|
|
||||||
async function main() {
|
|
||||||
const excelPlates = new Set(
|
|
||||||
fs.readFileSync('/Users/kkfluous/Projects/ai-coding/ln-bi/scripts-tmp/excel_plates.txt', 'utf8').trim().split('\n').map((s) => s.trim())
|
|
||||||
);
|
|
||||||
console.log('excel plates:', excelPlates.size);
|
|
||||||
|
|
||||||
// 按 dept-stats 逻辑查金可鹏 18T Operating
|
|
||||||
const [rows] = await pool.query<any[]>(`
|
|
||||||
SELECT truck.plate_number AS plate,
|
|
||||||
dic_type.dic_name AS type_label,
|
|
||||||
dic_status.dic_name AS status_label,
|
|
||||||
cus.customer_name AS customer,
|
|
||||||
org_truck.org_name AS subject_org
|
|
||||||
FROM tab_truck truck
|
|
||||||
LEFT JOIN tab_dic dic_type ON dic_type.parent_code='dic_truck_type' AND dic_type.dic_code=truck.model AND dic_type.is_deleted=0
|
|
||||||
LEFT JOIN tab_dic dic_status ON dic_status.parent_code='dic_truck_rent_status' AND dic_status.dic_code=truck.truck_rent_status AND dic_status.is_deleted=0
|
|
||||||
LEFT JOIN tab_truck_status_info si ON si.truck_id=truck.id AND si.is_deleted=0
|
|
||||||
LEFT JOIN tab_contract c ON c.id=si.contract_id AND c.is_deleted=0
|
|
||||||
LEFT JOIN tab_customer cus ON cus.id=c.customer_id AND cus.is_deleted=0
|
|
||||||
LEFT JOIN tab_org org_truck ON org_truck.id=truck.org_id AND org_truck.is_deleted=0
|
|
||||||
LEFT JOIN tab_user u ON u.id=c.bd AND u.is_deleted=0
|
|
||||||
WHERE truck.is_deleted=0 AND truck.is_operation=1
|
|
||||||
AND u.user_name='金可鹏'
|
|
||||||
AND dic_type.dic_name LIKE '%18吨%'
|
|
||||||
AND dic_status.dic_name IN ('租赁','自营','挂靠')
|
|
||||||
ORDER BY truck.plate_number
|
|
||||||
`);
|
|
||||||
|
|
||||||
console.log('DB 金可鹏 18T operating:', rows.length);
|
|
||||||
const dbPlates = new Set((rows as any[]).map((r) => (r.plate || '').trim()));
|
|
||||||
|
|
||||||
const extra = [...dbPlates].filter((p) => !excelPlates.has(p)).sort();
|
|
||||||
const missing = [...excelPlates].filter((p) => !dbPlates.has(p)).sort();
|
|
||||||
|
|
||||||
console.log('\n=== DB 有但 Excel 没有(多出来的) ===');
|
|
||||||
console.log('数量:', extra.length);
|
|
||||||
for (const p of extra) {
|
|
||||||
const r = (rows as any[]).find((x) => x.plate === p);
|
|
||||||
console.log(' ', p, '|', r?.type_label, '|', r?.customer, '|', r?.subject_org);
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('\n=== Excel 有但 DB 没有 ===');
|
|
||||||
console.log('数量:', missing.length);
|
|
||||||
for (const p of missing) console.log(' ', p);
|
|
||||||
|
|
||||||
await pool.end();
|
|
||||||
}
|
|
||||||
main().catch((e) => { console.error(e); process.exit(1); });
|
|
||||||
@@ -41,6 +41,17 @@ export function createApp(): Hono {
|
|||||||
time: new Date().toISOString(),
|
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 构建产物,并回退到单页应用入口。
|
// 生产环境由同一进程托管 Vite 构建产物,并回退到单页应用入口。
|
||||||
app.use('/*', serveStatic({ root: './dist' }));
|
app.use('/*', serveStatic({ root: './dist' }));
|
||||||
app.use('/*', serveStatic({ root: './dist', path: 'index.html' }));
|
app.use('/*', serveStatic({ root: './dist', path: 'index.html' }));
|
||||||
|
|||||||
+18
-10
@@ -1,24 +1,32 @@
|
|||||||
import { createHmac } from 'node:crypto';
|
import { createHmac } from 'node:crypto';
|
||||||
import jwt from 'jsonwebtoken';
|
import jwt from 'jsonwebtoken';
|
||||||
import type { JwtPayload } from './types.js';
|
import type { JwtPayload } from './types.js';
|
||||||
|
import { authMode, jwtSecret } from '../config.js';
|
||||||
|
|
||||||
export function authMode() {
|
// 认证方式统一由 server/config.ts 读取与校验,这里只做转发,
|
||||||
const mode = process.env.BI_AUTH_MODE || 'sso';
|
// 让既有的 `from './config.js'` 引用保持有效。
|
||||||
if (mode !== 'sso' && mode !== 'password') throw new Error('Invalid BI_AUTH_MODE');
|
export { authMode };
|
||||||
return mode;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function passwordConfig() {
|
export function passwordConfig() {
|
||||||
const password = process.env.BI_AUTH_PASSWORD || '';
|
const password = process.env.BI_AUTH_PASSWORD || '';
|
||||||
const secret = process.env.JWT_SECRET || '';
|
const secret = jwtSecret();
|
||||||
if (!password.trim() || secret.length < 32) throw new Error('Password authentication is not configured securely');
|
if (!password.trim() || secret.length < 32) {
|
||||||
return { password, key: createHmac('sha256', secret).update(`bi-password:${password}`).digest('hex') };
|
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 {
|
export function verifyAuthToken(token: string): JwtPayload {
|
||||||
const passwordMode = authMode() === 'password';
|
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;
|
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;
|
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 { FULL_ACCESS_ROLES, DEPT_ACCESS_ROLES } from './types.js';
|
||||||
import { authMode, verifyAuthToken } from './config.js';
|
import { authMode, verifyAuthToken } from './config.js';
|
||||||
import { passwordRouter } from './password.js';
|
import { passwordRouter } from './password.js';
|
||||||
|
import { jwtSecret, serverConfig } from '../config.js';
|
||||||
|
|
||||||
const app = new Hono();
|
const app = new Hono();
|
||||||
app.route('/', passwordRouter());
|
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 */
|
/** GET /api/auth/exchange?jumpToken=xxx — 一步完成:换取用户信息 + 签发 JWT */
|
||||||
app.get('/exchange', async (c) => {
|
app.get('/exchange', async (c) => {
|
||||||
if (authMode() !== 'sso') return c.json({ message: '当前使用固定密码登录' }, 403);
|
if (authMode() !== 'sso') return c.json({ message: '当前使用固定密码登录' }, 403);
|
||||||
@@ -20,7 +18,7 @@ app.get('/exchange', async (c) => {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch(
|
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 {
|
const data = await res.json() as {
|
||||||
code: number;
|
code: number;
|
||||||
@@ -77,7 +75,7 @@ app.get('/exchange', async (c) => {
|
|||||||
roles: roleNames,
|
roles: roleNames,
|
||||||
};
|
};
|
||||||
|
|
||||||
const token = jwt.sign(payload, JWT_SECRET, { expiresIn: '8h' });
|
const token = jwt.sign(payload, jwtSecret(), { expiresIn: '8h' });
|
||||||
const authUser: AuthUser = { ...payload };
|
const authUser: AuthUser = { ...payload };
|
||||||
|
|
||||||
return c.json({ token, user: authUser });
|
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 { serve } from '@hono/node-server';
|
||||||
import dotenv from 'dotenv';
|
|
||||||
import { createApp } from './app.js';
|
import { createApp } from './app.js';
|
||||||
import { startBackgroundServices } from './bootstrap.js';
|
import { startBackgroundServices } from './bootstrap.js';
|
||||||
|
import { assertRuntimeConfig, serverConfig } from './config.js';
|
||||||
|
|
||||||
dotenv.config();
|
// 配置错误在启动期暴露,而不是等到第一次请求才 500。
|
||||||
|
assertRuntimeConfig();
|
||||||
|
|
||||||
const app = createApp();
|
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();
|
startBackgroundServices();
|
||||||
serve({ fetch: app.fetch, port }, () => {
|
serve({ fetch: app.fetch, port: serverConfig.port }, () => {
|
||||||
console.log(`Server running at http://localhost:${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 type { RowDataPacket, ResultSetHeader } from 'mysql2';
|
||||||
import * as XLSX from 'xlsx';
|
import * as XLSX from 'xlsx';
|
||||||
import pool from '../../db.js';
|
import pool from '../../db.js';
|
||||||
|
import type { AuthUser } from '../../auth/types.js';
|
||||||
|
import { canAccessEnergy } from '../../auth/types.js';
|
||||||
import { ensureChargeRecordTable } from './migration.js';
|
import { ensureChargeRecordTable } from './migration.js';
|
||||||
|
|
||||||
const app = new Hono();
|
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 列名对齐
|
// 与 xlsx 列名对齐
|
||||||
const COL = {
|
const COL = {
|
||||||
orderNo: '订单编号',
|
orderNo: '订单编号',
|
||||||
|
|||||||
@@ -14,11 +14,11 @@ import { registerHydrogenBiV2Routes } from './hydrogen-bi-v2.js';
|
|||||||
|
|
||||||
const app = new Hono();
|
const app = new Hono();
|
||||||
|
|
||||||
// 模块级访问守卫:dev 旁路 auth 时 user 为 undefined,直接放行;
|
// 模块级访问守卫(fail-closed):必须持有 BI-LEADER-ENERGY 或「所有权限」。
|
||||||
// 生产环境必须具备 BI-LEADER-ENERGY 或全量权限角色
|
// 认证中间件挂在本路由之前,因此 user 缺失本身就是异常,按无权限处理。
|
||||||
app.use('*', async (c, next) => {
|
app.use('*', async (c, next) => {
|
||||||
const user = (c as { get: (k: string) => unknown }).get('user') as AuthUser | undefined;
|
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 c.json({ error: 'Forbidden: 能源管理访问需要 BI-LEADER-ENERGY 角色' }, 403);
|
||||||
}
|
}
|
||||||
return next();
|
return next();
|
||||||
|
|||||||
@@ -82,7 +82,7 @@ const router = new Hono();
|
|||||||
|
|
||||||
router.use('*', async (c, next) => {
|
router.use('*', async (c, next) => {
|
||||||
const user = (c as { get: (key: string) => unknown }).get('user') as AuthUser | undefined;
|
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 c.json({ error: 'Forbidden: 能源管理访问需要 BI-LEADER-ENERGY 角色' }, 403);
|
||||||
}
|
}
|
||||||
return next();
|
return next();
|
||||||
|
|||||||
@@ -6,12 +6,12 @@ import { canAccessScheduling } from '../../auth/types.js';
|
|||||||
|
|
||||||
const app = new Hono();
|
const app = new Hono();
|
||||||
|
|
||||||
// Module-level access guard. When auth middleware is active, `user` is set and
|
// Module-level access guard (fail-closed): requires BI-SCHEDULE-OPT.
|
||||||
// we require a role from SCHEDULING_ACCESS_ROLES (or a full-access role).
|
// Auth middleware runs before this router, so a missing `user` is an anomaly and
|
||||||
// When auth is bypassed (dev), `user` is undefined and requests pass through.
|
// must be treated as "no permission" rather than silently allowed.
|
||||||
app.use('*', async (c, next) => {
|
app.use('*', async (c, next) => {
|
||||||
const user = (c as any).get('user') as AuthUser | undefined;
|
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 c.json({ error: 'Forbidden: 智能调度访问需要 BI-SCHEDULE-OPT 角色' }, 403);
|
||||||
}
|
}
|
||||||
return next();
|
return next();
|
||||||
|
|||||||
+16
-15
@@ -1,3 +1,7 @@
|
|||||||
|
# 凭据通过 Woodpecker Secret 注入(harbor_user / harbor_password),
|
||||||
|
# 不再把 Harbor 账号密码 base64 后写进流水线脚本。
|
||||||
|
# 镜像推送只在 push / manual 触发:pull_request 不应向生产 registry 推送。
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: npm-build
|
- name: npm-build
|
||||||
image: node:22-alpine
|
image: node:22-alpine
|
||||||
@@ -18,7 +22,7 @@ steps:
|
|||||||
npm run build
|
npm run build
|
||||||
|
|
||||||
# 获取分支名
|
# 获取分支名
|
||||||
BRANCH_NAME=$(echo $CI_COMMIT_BRANCH | tr / -)
|
BRANCH_NAME=$(echo ${CI_COMMIT_BRANCH:-manual} | tr / -)
|
||||||
echo "Branch name: $BRANCH_NAME"
|
echo "Branch name: $BRANCH_NAME"
|
||||||
|
|
||||||
# 版本号: 分支名-package.json版本
|
# 版本号: 分支名-package.json版本
|
||||||
@@ -29,10 +33,10 @@ steps:
|
|||||||
|
|
||||||
- name: docker-build
|
- name: docker-build
|
||||||
image: docker:24.0.5-cli
|
image: docker:24.0.5-cli
|
||||||
|
depends_on: [npm-build]
|
||||||
when:
|
when:
|
||||||
event:
|
event:
|
||||||
- push
|
- push
|
||||||
- pull_request
|
|
||||||
- manual
|
- manual
|
||||||
branch:
|
branch:
|
||||||
- master
|
- master
|
||||||
@@ -40,25 +44,22 @@ steps:
|
|||||||
- main
|
- main
|
||||||
volumes:
|
volumes:
|
||||||
- /var/run/docker.sock:/var/run/docker.sock
|
- /var/run/docker.sock:/var/run/docker.sock
|
||||||
|
environment:
|
||||||
|
HARBOR_USER:
|
||||||
|
from_secret: harbor_user
|
||||||
|
HARBOR_PASSWORD:
|
||||||
|
from_secret: harbor_password
|
||||||
commands: |
|
commands: |
|
||||||
PROJECT_VERSION=$(cat $CI_WORKSPACE/project_version.txt)
|
PROJECT_VERSION=$(cat $CI_WORKSPACE/project_version.txt)
|
||||||
MODULE_NAME=ln-bi
|
MODULE_NAME=ln-bi
|
||||||
|
REGISTRY=harbor.lnh2e.com
|
||||||
|
PROJECT=lingniu-v1
|
||||||
|
|
||||||
echo "Building Docker image: $MODULE_NAME:$PROJECT_VERSION"
|
echo "Building Docker image: $MODULE_NAME:$PROJECT_VERSION"
|
||||||
|
|
||||||
cd $CI_WORKSPACE
|
cd $CI_WORKSPACE
|
||||||
|
docker build -t $REGISTRY/$PROJECT/$MODULE_NAME:$PROJECT_VERSION .
|
||||||
|
|
||||||
docker build -t harbor.lnh2e.com/lingniu-v1/$MODULE_NAME:$PROJECT_VERSION .
|
echo "$HARBOR_PASSWORD" | docker login $REGISTRY -u "$HARBOR_USER" --password-stdin
|
||||||
|
|
||||||
mkdir -p /root/.docker
|
docker push $REGISTRY/$PROJECT/$MODULE_NAME:$PROJECT_VERSION
|
||||||
cat > /root/.docker/config.json <<EOF
|
|
||||||
{
|
|
||||||
"auths": {
|
|
||||||
"harbor.lnh2e.com": {
|
|
||||||
"auth": "$(echo Y2ljZDpMbkBjaWNkMDE=)"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
EOF
|
|
||||||
|
|
||||||
docker push harbor.lnh2e.com/lingniu-v1/$MODULE_NAME:$PROJECT_VERSION
|
|
||||||
|
|||||||
Reference in New Issue
Block a user