460 lines
15 KiB
JavaScript
460 lines
15 KiB
JavaScript
/**
|
||
* @description 本地登录代理脚本:启动后会调用后端登录接口并缓存 accessToken,供前端调试读取。
|
||
*
|
||
* 使用方式:
|
||
* - 通过 package.json 脚本启动(推荐):
|
||
* - `pnpm dev`
|
||
* - `pnpm dev:pet`
|
||
* - `pnpm dev:qa`
|
||
* - 或直接启动:
|
||
* - `node ./scripts/local-auth-proxy.mjs --mode pet`
|
||
*
|
||
* 可配置项(优先级:process.env > .env/.env.<mode>):
|
||
* - `BUSINESS_RULES_MODE`:环境模式(默认 `pet`)
|
||
* - `LOCAL_AUTH_PROXY_PORT`:代理服务端口(默认 `9530`)
|
||
* - `LOCAL_AUTH_PROFILE`:启动时指定登录 profile,未配置时读取本地配置中的 defaultProfile
|
||
* - `LOCAL_AUTH_CONFIG_PATH`:本地账号配置文件路径,默认 `./dev-tools/local-auth/config.local.json`
|
||
* - `VITE_API_BASE`:后端网关地址(必填)
|
||
*
|
||
* 代理接口:
|
||
* - `GET /__local_auth/access-token`
|
||
* - `GET /__local_auth/refresh-token`
|
||
* - `GET /__local_auth/state`
|
||
* - `GET /__local_auth/switch-profile?profile=<profileName>`
|
||
*/
|
||
import { readFile } from 'node:fs/promises';
|
||
import http from 'node:http';
|
||
import { dirname, relative, resolve } from 'node:path';
|
||
import process from 'node:process';
|
||
import { fileURLToPath } from 'node:url';
|
||
import {
|
||
LOCAL_AUTH_ACCESS_TOKEN_PATH,
|
||
LOCAL_AUTH_PROXY_DEFAULT_PORT,
|
||
LOCAL_AUTH_PROXY_PORT_SCAN_LIMIT,
|
||
LOCAL_AUTH_PROXY_SERVICE_ID,
|
||
LOCAL_AUTH_REFRESH_TOKEN_PATH,
|
||
LOCAL_AUTH_STATE_PATH,
|
||
LOCAL_AUTH_SWITCH_PROFILE_PATH,
|
||
} from './local-auth-shared.mjs';
|
||
|
||
const __filename = fileURLToPath(import.meta.url);
|
||
const __dirname = dirname(__filename);
|
||
const pkgRoot = resolve(__dirname, '..');
|
||
const localAuthDir = resolve(pkgRoot, 'dev-tools/local-auth');
|
||
const defaultLocalAuthConfigPath = resolve(localAuthDir, 'config.local.json');
|
||
const localAuthConfigPath = process.env.LOCAL_AUTH_CONFIG_PATH
|
||
? resolve(pkgRoot, process.env.LOCAL_AUTH_CONFIG_PATH)
|
||
: defaultLocalAuthConfigPath;
|
||
const localAuthExampleConfigPath = resolve(localAuthDir, 'config.example.json');
|
||
|
||
const proxyPort = Number(process.env.LOCAL_AUTH_PROXY_PORT || LOCAL_AUTH_PROXY_DEFAULT_PORT);
|
||
const cliArgs = process.argv.slice(2);
|
||
const modeFlagIndex = cliArgs.findIndex(arg => arg === '--mode');
|
||
const modeArg = modeFlagIndex >= 0 ? String(cliArgs[modeFlagIndex + 1] || '').trim() : '';
|
||
const mode = modeArg || String(process.env.BUSINESS_RULES_MODE || 'pet').trim() || 'pet';
|
||
const instanceId = String(process.env.LOCAL_AUTH_INSTANCE_ID || '').trim();
|
||
const loginPath = '/biz-gateway/base/foundation/api/auth/login';
|
||
|
||
/**
|
||
* @description 解析 .env 文件文本为键值对象。
|
||
* @param {string} source .env 文件原始文本。
|
||
* @returns {Record<string, string>} 解析后的环境变量映射。
|
||
*/
|
||
function parseEnvText(source) {
|
||
return source
|
||
.split('\n')
|
||
.map(line => line.trim())
|
||
.filter(line => line && !line.startsWith('#'))
|
||
.reduce((acc, line) => {
|
||
const equalIndex = line.indexOf('=');
|
||
if (equalIndex <= 0) {
|
||
return acc;
|
||
}
|
||
const key = line.slice(0, equalIndex).trim();
|
||
const value = line.slice(equalIndex + 1).trim();
|
||
acc[key] = value;
|
||
return acc;
|
||
}, /** @type {Record<string, string>} */ ({}));
|
||
}
|
||
|
||
/**
|
||
* @description 读取基础环境和 mode 环境,合并生成最终环境变量。
|
||
* @returns {Promise<Record<string, string>>} 当前模式生效的环境变量。
|
||
*/
|
||
async function loadBusinessRulesEnv() {
|
||
const baseEnvPath = resolve(pkgRoot, '.env');
|
||
const modeEnvPath = resolve(pkgRoot, `.env.${mode}`);
|
||
const [baseText, modeText] = await Promise.all([
|
||
readFile(baseEnvPath, 'utf8').catch(() => ''),
|
||
readFile(modeEnvPath, 'utf8').catch(() => ''),
|
||
]);
|
||
return {
|
||
...parseEnvText(baseText),
|
||
...parseEnvText(modeText),
|
||
};
|
||
}
|
||
|
||
/**
|
||
* @description 读取本地账号配置。
|
||
* @returns {Promise<{ config: { defaultProfile: string, profiles: Record<string, { userCode: string, password: string }> }, configPath: string } | null>} 账号配置;文件不存在时返回 null。
|
||
*/
|
||
async function loadLocalAuthConfig() {
|
||
let source = '';
|
||
try {
|
||
source = await readFile(localAuthConfigPath, 'utf8');
|
||
} catch (error) {
|
||
if (error && typeof error === 'object' && 'code' in error && error.code === 'ENOENT') {
|
||
return null;
|
||
}
|
||
throw error;
|
||
}
|
||
|
||
let parsed;
|
||
try {
|
||
parsed = JSON.parse(source);
|
||
} catch (error) {
|
||
throw new Error(`local auth config parse failed: ${error instanceof Error ? error.message : String(error)}`);
|
||
}
|
||
|
||
const rawProfiles = parsed?.profiles;
|
||
if (!rawProfiles || typeof rawProfiles !== 'object' || Array.isArray(rawProfiles)) {
|
||
throw new Error(`local auth config invalid: "profiles" must be an object in ${localAuthConfigPath}`);
|
||
}
|
||
|
||
const profiles = Object.entries(rawProfiles).reduce((acc, [profileName, profile]) => {
|
||
const userCode = String(profile?.userCode || '').trim();
|
||
const password = String(profile?.password || '').trim();
|
||
if (!userCode || !password) {
|
||
throw new Error(`local auth config invalid: profile "${profileName}" must contain "userCode" and "password"`);
|
||
}
|
||
acc[profileName] = { userCode, password };
|
||
return acc;
|
||
}, /** @type {Record<string, { userCode: string, password: string }>} */ ({}));
|
||
|
||
if (!Object.keys(profiles).length) {
|
||
throw new Error(`local auth config invalid: no profiles found in ${localAuthConfigPath}`);
|
||
}
|
||
|
||
return {
|
||
config: {
|
||
defaultProfile: String(parsed?.defaultProfile || '').trim(),
|
||
profiles,
|
||
},
|
||
configPath: localAuthConfigPath,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* @description 解析启动时使用的 profile 名称。
|
||
* @param {{ defaultProfile: string, profiles: Record<string, { userCode: string, password: string }> }} config 本地账号配置。
|
||
* @returns {string} 当前应使用的 profile。
|
||
*/
|
||
function resolveInitialProfileName(config) {
|
||
const preferredProfile = String(process.env.LOCAL_AUTH_PROFILE || '').trim();
|
||
if (preferredProfile) {
|
||
if (!config.profiles[preferredProfile]) {
|
||
throw new Error(`LOCAL_AUTH_PROFILE "${preferredProfile}" not found in current local auth config`);
|
||
}
|
||
return preferredProfile;
|
||
}
|
||
|
||
if (config.defaultProfile) {
|
||
if (!config.profiles[config.defaultProfile]) {
|
||
throw new Error(`defaultProfile "${config.defaultProfile}" not found in current local auth config`);
|
||
}
|
||
return config.defaultProfile;
|
||
}
|
||
|
||
return Object.keys(config.profiles)[0];
|
||
}
|
||
|
||
/**
|
||
* @description 启动登录流程并返回登录结果。
|
||
* @param {string} apiBase 远端网关基础域名。
|
||
* @param {{ userCode: string, password: string }} credential 登录凭据。
|
||
* @returns {Promise<any>} 后端登录接口返回 JSON。
|
||
*/
|
||
async function login(apiBase, credential) {
|
||
const url = new URL(loginPath, apiBase).toString();
|
||
const response = await fetch(url, {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
'App-Id': 'app-cisdigital-base-foundation',
|
||
},
|
||
body: JSON.stringify({
|
||
userCode: credential.userCode,
|
||
password: credential.password,
|
||
}),
|
||
});
|
||
if (!response.ok) {
|
||
throw new Error(`login failed: ${response.status} ${response.statusText}`);
|
||
}
|
||
return response.json();
|
||
}
|
||
|
||
/**
|
||
* @description 从不同响应结构中提取 accessToken。
|
||
* @param {any} payload 登录接口响应体。
|
||
* @returns {string} 提取到的 accessToken;无值时返回空字符串。
|
||
*/
|
||
function extractAccessToken(payload) {
|
||
return (
|
||
payload?.accessToken
|
||
?? payload?.data?.token
|
||
?? payload?.data?.accessToken
|
||
?? payload?.result?.accessToken
|
||
?? ''
|
||
);
|
||
}
|
||
|
||
const env = await loadBusinessRulesEnv();
|
||
const apiBase = env.VITE_API_BASE || '';
|
||
const authConfigResult = await loadLocalAuthConfig();
|
||
const authConfig = authConfigResult?.config || null;
|
||
const activeLocalAuthConfigPath = authConfigResult?.configPath || localAuthConfigPath;
|
||
const enabled = Boolean(authConfig);
|
||
const availableProfiles = authConfig ? Object.keys(authConfig.profiles) : [];
|
||
|
||
function toProjectRelativePath(filePath) {
|
||
return relative(pkgRoot, filePath) || '.';
|
||
}
|
||
|
||
if (enabled && !apiBase) {
|
||
throw new Error(`VITE_API_BASE is empty for mode "${mode}"`);
|
||
}
|
||
|
||
let accessToken = '';
|
||
let loginError = '';
|
||
let currentProfile = authConfig ? resolveInitialProfileName(authConfig) : '';
|
||
let lastAttemptProfile = currentProfile;
|
||
let listeningPort = proxyPort;
|
||
|
||
/**
|
||
* @description 使用指定 profile 重新登录并更新缓存 token。
|
||
* @param {string} profileName profile 名称。
|
||
* @returns {Promise<void>}
|
||
*/
|
||
async function refreshAccessToken(profileName) {
|
||
if (!authConfig) {
|
||
return;
|
||
}
|
||
const credential = authConfig.profiles[profileName];
|
||
if (!credential) {
|
||
throw new Error(`profile "${profileName}" not found`);
|
||
}
|
||
|
||
lastAttemptProfile = profileName;
|
||
const nextLoginResponse = await login(apiBase, credential);
|
||
const nextAccessToken = extractAccessToken(nextLoginResponse);
|
||
if (!nextAccessToken) {
|
||
throw new Error(`profile "${profileName}" login succeeded but accessToken is empty`);
|
||
}
|
||
|
||
accessToken = nextAccessToken;
|
||
currentProfile = profileName;
|
||
loginError = '';
|
||
console.log(`[local-auth-proxy] login success, profile: ${profileName}, accessToken exists: true`);
|
||
}
|
||
|
||
/**
|
||
* @description 获取当前代理状态。
|
||
* @returns {{
|
||
* enabled: boolean
|
||
* serviceId: string
|
||
* instanceId: string
|
||
* mode: string
|
||
* port: number
|
||
* apiBase: string
|
||
* accessToken: string
|
||
* accessTokenReady: boolean
|
||
* activeProfile: string
|
||
* lastAttemptProfile: string
|
||
* availableProfiles: string[]
|
||
* configPath: string
|
||
* exampleConfigPath: string
|
||
* error?: string
|
||
* }} 当前状态。
|
||
*/
|
||
function getProxyState() {
|
||
return {
|
||
enabled,
|
||
serviceId: LOCAL_AUTH_PROXY_SERVICE_ID,
|
||
instanceId,
|
||
mode,
|
||
port: listeningPort,
|
||
apiBase,
|
||
accessToken,
|
||
accessTokenReady: Boolean(accessToken),
|
||
activeProfile: currentProfile,
|
||
lastAttemptProfile,
|
||
availableProfiles,
|
||
configPath: toProjectRelativePath(activeLocalAuthConfigPath),
|
||
exampleConfigPath: toProjectRelativePath(localAuthExampleConfigPath),
|
||
error: loginError || undefined,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* @description 写入 JSON 响应。
|
||
* @param {http.ServerResponse} res 响应对象。
|
||
* @param {number} statusCode 状态码。
|
||
* @param {unknown} payload 响应体。
|
||
*/
|
||
function writeJson(res, statusCode, payload) {
|
||
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
||
res.setHeader('Access-Control-Allow-Origin', '*');
|
||
res.setHeader('Access-Control-Allow-Methods', 'GET,OPTIONS');
|
||
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
|
||
res.writeHead(statusCode);
|
||
res.end(JSON.stringify(payload));
|
||
}
|
||
|
||
/**
|
||
* @description 在给定端口上启动服务;若端口被占用则自动向后探测可用端口。
|
||
* @param {http.Server} targetServer http 服务实例。
|
||
* @param {number} startPort 起始端口。
|
||
* @returns {Promise<number>} 实际监听端口。
|
||
*/
|
||
async function listenOnAvailablePort(targetServer, startPort) {
|
||
const maxAttempts = Math.max(1, LOCAL_AUTH_PROXY_PORT_SCAN_LIMIT);
|
||
|
||
/**
|
||
* @param {number} port 目标端口。
|
||
* @returns {Promise<number>} 监听成功后的实际端口。
|
||
*/
|
||
function listenOnce(port) {
|
||
return new Promise((resolvePromise, rejectPromise) => {
|
||
const cleanup = () => {
|
||
targetServer.off('error', handleError);
|
||
targetServer.off('listening', handleListening);
|
||
};
|
||
function handleError(error) {
|
||
cleanup();
|
||
rejectPromise(error);
|
||
}
|
||
function handleListening() {
|
||
cleanup();
|
||
resolvePromise(port);
|
||
}
|
||
|
||
targetServer.once('error', handleError);
|
||
targetServer.once('listening', handleListening);
|
||
targetServer.listen(port, '0.0.0.0');
|
||
});
|
||
}
|
||
|
||
for (let offset = 0; offset < maxAttempts; offset += 1) {
|
||
const nextPort = startPort + offset;
|
||
try {
|
||
return await listenOnce(nextPort);
|
||
} catch (error) {
|
||
if (error && typeof error === 'object' && 'code' in error && error.code === 'EADDRINUSE') {
|
||
continue;
|
||
}
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
throw new Error(`no available local auth port found from ${startPort} to ${startPort + maxAttempts - 1}`);
|
||
}
|
||
|
||
try {
|
||
if (enabled) {
|
||
await refreshAccessToken(currentProfile);
|
||
}
|
||
} catch (error) {
|
||
loginError = error instanceof Error ? error.message : String(error);
|
||
console.error('[local-auth-proxy] login failed:', loginError);
|
||
}
|
||
|
||
const server = http.createServer((req, res) => {
|
||
void (async () => {
|
||
if (req.method === 'OPTIONS') {
|
||
writeJson(res, 204, {});
|
||
return;
|
||
}
|
||
|
||
const requestUrl = new URL(req.url || '/', `http://${req.headers.host || 'localhost'}`);
|
||
|
||
if (req.method === 'GET' && requestUrl.pathname === LOCAL_AUTH_ACCESS_TOKEN_PATH) {
|
||
writeJson(res, loginError ? 500 : 200, getProxyState());
|
||
return;
|
||
}
|
||
|
||
if (req.method === 'GET' && requestUrl.pathname === LOCAL_AUTH_REFRESH_TOKEN_PATH) {
|
||
if (!enabled) {
|
||
writeJson(res, 200, getProxyState());
|
||
return;
|
||
}
|
||
|
||
const profileName = requestUrl.searchParams.get('profile')?.trim() || currentProfile;
|
||
try {
|
||
await refreshAccessToken(profileName);
|
||
writeJson(res, 200, getProxyState());
|
||
} catch (error) {
|
||
loginError = error instanceof Error ? error.message : String(error);
|
||
console.error('[local-auth-proxy] refresh token failed:', loginError);
|
||
writeJson(res, 500, getProxyState());
|
||
}
|
||
return;
|
||
}
|
||
|
||
if (req.method === 'GET' && requestUrl.pathname === LOCAL_AUTH_STATE_PATH) {
|
||
writeJson(res, 200, getProxyState());
|
||
return;
|
||
}
|
||
|
||
if (req.method === 'GET' && requestUrl.pathname === LOCAL_AUTH_SWITCH_PROFILE_PATH) {
|
||
if (!enabled) {
|
||
writeJson(res, 200, getProxyState());
|
||
return;
|
||
}
|
||
|
||
const profileName = requestUrl.searchParams.get('profile')?.trim();
|
||
if (!profileName) {
|
||
writeJson(res, 400, {
|
||
...getProxyState(),
|
||
error: 'query param "profile" is required',
|
||
});
|
||
return;
|
||
}
|
||
|
||
try {
|
||
await refreshAccessToken(profileName);
|
||
writeJson(res, 200, getProxyState());
|
||
} catch (error) {
|
||
loginError = error instanceof Error ? error.message : String(error);
|
||
console.error('[local-auth-proxy] switch profile failed:', loginError);
|
||
writeJson(res, 500, getProxyState());
|
||
}
|
||
return;
|
||
}
|
||
|
||
writeJson(res, 404, { message: 'Not Found' });
|
||
})().catch((error) => {
|
||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||
console.error('[local-auth-proxy] request failed:', errorMessage);
|
||
writeJson(res, 500, { ...getProxyState(), error: errorMessage });
|
||
});
|
||
});
|
||
|
||
listeningPort = await listenOnAvailablePort(server, proxyPort);
|
||
|
||
console.log(`[local-auth-proxy] listening on http://localhost:${listeningPort}`);
|
||
if (listeningPort !== proxyPort) {
|
||
console.log(`[local-auth-proxy] default port ${proxyPort} is busy, switched to ${listeningPort}`);
|
||
}
|
||
if (instanceId) {
|
||
console.log(`[local-auth-proxy] instance id: ${instanceId}`);
|
||
}
|
||
if (!enabled) {
|
||
console.log(`[local-auth-proxy] local auth disabled: config not found at ${toProjectRelativePath(localAuthConfigPath)}`);
|
||
console.log(`[local-auth-proxy] copy template from ${toProjectRelativePath(localAuthExampleConfigPath)} when needed`);
|
||
} else {
|
||
console.log(`[local-auth-proxy] config path: ${toProjectRelativePath(activeLocalAuthConfigPath)}`);
|
||
console.log(`[local-auth-proxy] active profile: ${currentProfile}`);
|
||
console.log(`[local-auth-proxy] available profiles: ${availableProfiles.join(', ')}`);
|
||
console.log(`[local-auth-proxy] accessToken exists: ${Boolean(accessToken)}`);
|
||
}
|