import express from 'express';
import cors from 'cors';
import axios from 'axios';
import nodeRSA from 'node-rsa';
import cookieParser from 'cookie-parser';
import { readFileSync } from 'fs';
import { fileURLToPath } from 'url';
import { dirname, join } from 'path';
import { CookieJar } from 'tough-cookie';
import { wrapper } from 'axios-cookiejar-support';
// node-rsa 是 CommonJS 模块,需要使用默认导入
const NodeRSA = nodeRSA;
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const app = express();
const PORT = process.env.PORT || 8889;
// 中间件
app.use(cors({
origin: true,
credentials: true
}));
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
app.use(cookieParser());
// 静态文件服务 - 提供 example_page 文件夹访问
app.use('/example_page', express.static(join(__dirname, 'example_page')));
// 请求日志中间件(用于调试)
app.use((req, res, next) => {
console.log(`[请求] ${req.method} ${req.path} - ${new Date().toISOString()}`);
next();
});
// 加载自动登录配置
let autoLoginConfig = {};
try {
const configPath = join(__dirname, 'auto-login-config.json');
console.log('正在加载自动登录配置文件:', configPath);
const configData = readFileSync(configPath, 'utf-8');
autoLoginConfig = JSON.parse(configData);
console.log('✓ 已加载自动登录配置');
console.log(' 配置的网站数量:', Object.keys(autoLoginConfig).length);
console.log(' 网站列表:', Object.keys(autoLoginConfig).join(', '));
Object.keys(autoLoginConfig).forEach(siteId => {
const site = autoLoginConfig[siteId];
console.log(` - ${siteId}: ${site.name} (${site.loginMethod})`);
});
} catch (error) {
console.error('✗ 加载自动登录配置失败:', error.message);
console.error(' 错误堆栈:', error.stack);
console.log('将使用默认配置');
}
// RSA 加密函数
// 注意:JSEncrypt 使用 PKCS1 填充,需要匹配
function encryptWithRSA(text, publicKey) {
try {
const key = new NodeRSA(publicKey, 'public', {
encryptionScheme: 'pkcs1' // 使用 PKCS1 填充,与 JSEncrypt 兼容
});
const encrypted = key.encrypt(text, 'base64');
console.log(`RSA加密: "${text}" -> 长度 ${encrypted.length}`);
return encrypted;
} catch (error) {
console.error('RSA加密失败:', error.message);
throw error;
}
}
// 解析 Cookie
function parseCookies(setCookieHeaders) {
return setCookieHeaders.map(cookie => {
const match = cookie.match(/^([^=]+)=([^;]+)/);
if (match) {
const name = match[1];
const value = match[2];
// 提取其他属性
const pathMatch = cookie.match(/Path=([^;]+)/);
const expiresMatch = cookie.match(/Expires=([^;]+)/);
const maxAgeMatch = cookie.match(/Max-Age=([^;]+)/);
const httpOnlyMatch = cookie.match(/HttpOnly/);
const secureMatch = cookie.match(/Secure/);
const sameSiteMatch = cookie.match(/SameSite=([^;]+)/);
return {
name,
value,
path: pathMatch ? pathMatch[1] : '/',
expires: expiresMatch ? expiresMatch[1] : null,
maxAge: maxAgeMatch ? maxAgeMatch[1] : null,
httpOnly: !!httpOnlyMatch,
secure: !!secureMatch,
sameSite: sameSiteMatch ? sameSiteMatch[1] : null
};
}
return null;
}).filter(Boolean);
}
// 生成跳转 HTML
function generateRedirectHTML(cookieData, targetHost, targetDomain, requestId = '', customUrl = null, homeAssistantData = null) {
const targetUrl = customUrl || `http://${targetHost}/`;
const isHomeAssistant = homeAssistantData !== null;
return `
自动登录中...
`;
}
// 处理 RSA 加密表单登录
async function handleRSAEncryptedFormLogin(config, credentials) {
const { targetBaseUrl, loginUrl, loginMethodConfig } = config;
const { publicKey, usernameField, passwordField, captchaField, captchaRequired, contentType, successCode, successField } = loginMethodConfig;
console.log('=== RSA 加密表单登录 ===');
console.log(`目标URL: ${targetBaseUrl}${loginUrl}`);
console.log(`用户名: ${credentials.username}`);
console.log(`密码: ${'*'.repeat(credentials.password.length)}`);
console.log(`内容类型: ${contentType}`);
console.log(`成功标识字段: ${successField || 'code'}, 成功值: ${successCode}`);
// 加密用户名和密码
const usernameEncrypted = encryptWithRSA(credentials.username, publicKey);
const passwordEncrypted = encryptWithRSA(credentials.password, publicKey);
console.log('用户名和密码已加密');
console.log(`加密后用户名长度: ${usernameEncrypted.length}`);
console.log(`加密后密码长度: ${passwordEncrypted.length}`);
// 构建请求数据
const requestData = {
[usernameField]: usernameEncrypted,
[passwordField]: passwordEncrypted
};
if (captchaField) {
requestData[captchaField] = captchaRequired ? '' : '';
}
// 发送登录请求
const headers = {};
let requestBody;
if (contentType === 'application/x-www-form-urlencoded') {
headers['Content-Type'] = 'application/x-www-form-urlencoded';
requestBody = new URLSearchParams(requestData).toString();
} else if (contentType === 'application/json') {
headers['Content-Type'] = 'application/json';
requestBody = JSON.stringify(requestData);
} else {
requestBody = requestData;
}
console.log(`发送登录请求到: ${targetBaseUrl}${loginUrl}`);
// 添加可能需要的请求头(模拟浏览器请求)
headers['Referer'] = `${targetBaseUrl}/`;
headers['Origin'] = targetBaseUrl;
headers['User-Agent'] = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36';
headers['Accept'] = 'application/json, text/javascript, */*; q=0.01';
headers['Accept-Language'] = 'zh-CN,zh;q=0.9,en;q=0.8';
headers['X-Requested-With'] = 'XMLHttpRequest';
console.log(`请求头:`, JSON.stringify(headers, null, 2));
console.log(`请求体长度: ${requestBody.length} 字符`);
console.log(`请求体内容预览: ${requestBody.substring(0, 300)}...`);
// 先访问登录页面获取可能的session cookie
console.log('先访问登录页面获取session...');
try {
const loginPageResponse = await axios.get(`${targetBaseUrl}/`, {
headers: {
'User-Agent': headers['User-Agent']
},
withCredentials: true,
maxRedirects: 5
});
console.log('登录页面访问成功,获取到的Cookie:', loginPageResponse.headers['set-cookie'] || []);
} catch (error) {
console.log('访问登录页面失败(可能不需要):', error.message);
}
const loginResponse = await axios.post(
`${targetBaseUrl}${loginUrl}`,
requestBody,
{
headers,
withCredentials: true,
maxRedirects: 0,
validateStatus: function (status) {
return status >= 200 && status < 400;
}
}
);
console.log(`登录响应状态码: ${loginResponse.status}`);
console.log(`响应头:`, JSON.stringify(loginResponse.headers, null, 2));
console.log(`响应数据:`, JSON.stringify(loginResponse.data, null, 2));
// 检查登录是否成功
const responseData = loginResponse.data || {};
const successValue = successField ? responseData[successField] : responseData.code;
console.log(`成功标识值: ${successValue}, 期望值: ${successCode}`);
if (successValue === successCode) {
const cookies = loginResponse.headers['set-cookie'] || [];
console.log(`登录成功!获取到 ${cookies.length} 个 Cookie`);
cookies.forEach((cookie, index) => {
console.log(`Cookie ${index + 1}: ${cookie.substring(0, 100)}...`);
});
return {
success: true,
cookies: cookies,
response: loginResponse.data
};
} else {
console.error(`登录失败!响应:`, responseData);
return {
success: false,
message: responseData.msg || responseData.message || '登录失败',
response: responseData
};
}
}
// 处理 Home Assistant 登录(OAuth2 流程 - 严格匹配 redirect_uri)
async function handleHomeAssistantLogin(config, credentials) {
const { targetBaseUrl } = config;
console.log('=== Home Assistant 登录 (OAuth2 严格模式) ===');
console.log(`目标URL: ${targetBaseUrl}`);
console.log(`用户名: ${credentials.username}`);
console.log(`密码: ${'*'.repeat(credentials.password.length)}`);
// 基础请求头,伪装成浏览器
const baseHeaders = {
'Content-Type': 'application/json',
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
'Accept': 'application/json, text/plain, */*',
'Origin': targetBaseUrl,
'Referer': `${targetBaseUrl}/`
};
// 【关键】:OAuth2 协议要求 client_id 和 redirect_uri 在整个流程中完全一致
const CLIENT_ID = `${targetBaseUrl}/`;
const REDIRECT_URI = `${targetBaseUrl}/?auth_callback=1`;
console.log('Client ID:', CLIENT_ID);
console.log('Redirect URI:', REDIRECT_URI);
try {
// ==========================================
// 步骤1: 创建登录流程 (Init Flow)
// ==========================================
console.log('[1/3] 初始化登录流程...');
const flowResponse = await axios.post(
`${targetBaseUrl}/auth/login_flow`,
{
client_id: CLIENT_ID,
handler: ['homeassistant', null],
redirect_uri: REDIRECT_URI // 【重要】:必须和最后跳转的地址完全一致
},
{
headers: baseHeaders,
validateStatus: function (status) {
return status >= 200 && status < 500;
}
}
);
console.log(`流程创建响应状态码: ${flowResponse.status}`);
console.log(`流程创建响应数据:`, JSON.stringify(flowResponse.data, null, 2));
if (flowResponse.status !== 200) {
return {
success: false,
message: `创建登录流程失败,状态码: ${flowResponse.status}`,
response: flowResponse.data
};
}
const flowId = flowResponse.data?.flow_id;
if (!flowId) {
console.error('无法获取 flow_id');
return {
success: false,
message: '无法获取 flow_id',
response: flowResponse.data
};
}
console.log(`获取到 flow_id: ${flowId}`);
// ==========================================
// 步骤2: 提交用户名和密码 (Submit Credentials)
// ==========================================
console.log('[2/3] 提交用户名和密码...');
const loginResponse = await axios.post(
`${targetBaseUrl}/auth/login_flow/${flowId}`,
{
username: credentials.username,
password: credentials.password,
client_id: CLIENT_ID // 【重要】:必须和步骤1的 client_id 完全一致
},
{
headers: baseHeaders,
validateStatus: function (status) {
return status >= 200 && status < 500;
}
}
);
console.log(`登录响应状态码: ${loginResponse.status}`);
console.log(`登录响应数据:`, JSON.stringify(loginResponse.data, null, 2));
// ==========================================
// 步骤3: 换取 Token(全托管方案)
// ==========================================
const responseData = loginResponse.data || {};
const responseType = responseData.type;
console.log(`响应类型: ${responseType}`);
// 如果登录成功,type 为 'create_entry',result 字段包含 Authorization Code
if (responseData.result && responseType === 'create_entry') {
const authCode = responseData.result;
console.log('[3/4] 登录成功!获取到 Authorization Code:', authCode);
console.log('[3/4] Node.js 将代替浏览器换取 Token...');
try {
// ==========================================
// Node.js 直接换取 Token(避免前端路由抢跑问题)
// ==========================================
const tokenResponse = await axios.post(
`${targetBaseUrl}/auth/token`,
new URLSearchParams({
grant_type: 'authorization_code',
code: authCode,
client_id: CLIENT_ID
}).toString(),
{
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
}
}
);
const tokens = tokenResponse.data;
console.log('[4/4] ✅ Token 换取成功!');
console.log(`Access Token: ${tokens.access_token.substring(0, 20)}...`);
console.log(`Token 类型: ${tokens.token_type}`);
console.log(`过期时间: ${tokens.expires_in}秒`);
// OAuth2 跨端口方案:返回带有 code 的 URL,但使用增强的中间页面
// 虽然获取了 Token,但由于跨端口限制,我们仍然使用 code 方式
// 只是添加更好的处理逻辑
const magicLink = `${REDIRECT_URI}&code=${encodeURIComponent(authCode)}`;
return {
success: true,
useEnhancedRedirect: true, // 使用增强的重定向方案
redirectUrl: magicLink,
tokens: tokens, // 保留 Token 信息用于日志
targetBaseUrl: targetBaseUrl,
cookies: [],
response: loginResponse.data
};
} catch (tokenError) {
console.error('❌ Token 换取失败:', tokenError.message);
if (tokenError.response) {
console.error('Token 响应:', JSON.stringify(tokenError.response.data, null, 2));
}
// 如果 Token 换取失败,降级到传统方式
console.log('⚠️ 降级到传统 redirect 方式...');
const magicLink = `${REDIRECT_URI}&code=${encodeURIComponent(authCode)}`;
return {
success: true,
redirectUrl: magicLink,
cookies: [],
response: loginResponse.data
};
}
} else {
console.error('❌ 登录失败!未返回 Authorization Code');
console.error('响应数据:', responseData);
// 提取错误信息
const errorMessage = responseData.errors?.base?.[0]
|| responseData.errors?.username?.[0]
|| responseData.errors?.password?.[0]
|| responseData.message
|| `登录失败,响应类型: ${responseType}`;
return {
success: false,
message: errorMessage,
response: responseData
};
}
} catch (error) {
console.error('Home Assistant 登录流程异常:', error.message);
if (error.response) {
console.error('响应状态:', error.response.status);
console.error('响应数据:', JSON.stringify(error.response.data, null, 2));
return {
success: false,
message: `登录失败: ${error.response.status} - ${JSON.stringify(error.response.data)}`,
response: error.response.data
};
}
return {
success: false,
message: `登录失败: ${error.message}`,
response: null
};
}
}
// 处理 GET 查询参数登录(OA系统等)
async function handleGetQueryLogin(config, credentials) {
const { targetBaseUrl, loginUrl, loginMethodConfig, successRedirectUrl } = config;
const { usernameParam, passwordParam, entCode, saveCookie, isOnly, successResponse } = loginMethodConfig;
console.log('=== GET 查询参数登录 ===');
console.log(`目标URL: ${targetBaseUrl}${loginUrl}`);
console.log(`用户名参数名: ${usernameParam}`);
console.log(`密码参数名: ${passwordParam}`);
console.log(`用户名: ${credentials.username}`);
console.log(`密码: ${'*'.repeat(credentials.password.length)}`);
console.log(`企业代码: ${entCode}`);
// 构建查询参数 - 确保参数名正确
const params = new URLSearchParams();
params.append(usernameParam, credentials.username);
params.append(passwordParam, credentials.password);
params.append('ent_code', entCode);
params.append('code', 'undefined');
params.append('mySel', 'undefined');
params.append('saveCookie', saveCookie);
params.append('isOnly', isOnly);
params.append('_', Date.now().toString()); // 实时时间戳,防止缓存
const loginUrlWithParams = `${targetBaseUrl}${loginUrl}?${params.toString()}`;
console.log(`发送登录请求到: ${loginUrlWithParams}`);
try {
const loginResponse = await axios.get(loginUrlWithParams, {
headers: {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
'Accept': '*/*',
'Referer': `${targetBaseUrl}/`
},
withCredentials: true,
maxRedirects: 0,
validateStatus: function (status) {
return status >= 200 && status < 400;
}
});
console.log(`登录响应状态码: ${loginResponse.status}`);
console.log(`响应数据: ${loginResponse.data}`);
// 检查登录是否成功(响应内容为 "ok")
const responseText = loginResponse.data?.toString().trim() || '';
const isSuccess = responseText.toLowerCase() === successResponse.toLowerCase();
console.log(`响应内容: "${responseText}"`);
console.log(`成功标识: ${successResponse}, 匹配结果: ${isSuccess}`);
if (isSuccess) {
const cookies = loginResponse.headers['set-cookie'] || [];
console.log(`登录成功!获取到 ${cookies.length} 个 Cookie`);
cookies.forEach((cookie, index) => {
console.log(`Cookie ${index + 1}: ${cookie.substring(0, 100)}...`);
});
return {
success: true,
cookies: cookies,
redirectUrl: successRedirectUrl ? `${targetBaseUrl}${successRedirectUrl}` : null,
response: loginResponse.data
};
} else {
console.error(`登录失败!响应内容: "${responseText}"`);
return {
success: false,
message: `登录失败,响应: ${responseText}`,
response: loginResponse.data
};
}
} catch (error) {
console.error('登录请求异常:', error.message);
if (error.response) {
console.error('响应状态:', error.response.status);
console.error('响应数据:', error.response.data);
return {
success: false,
message: `登录失败: ${error.response.status} - ${error.response.data}`,
response: error.response.data
};
}
return {
success: false,
message: `登录失败: ${error.message}`,
response: null
};
}
}
// 处理普通表单登录(未加密)
async function handlePlainFormLogin(config, credentials) {
const { targetBaseUrl, loginUrl, loginMethodConfig } = config;
const { usernameField, passwordField, captchaField, contentType, successCode, successField } = loginMethodConfig;
console.log('=== 普通表单登录 ===');
console.log(`目标URL: ${targetBaseUrl}${loginUrl}`);
console.log(`用户名: ${credentials.username}`);
console.log(`密码: ${'*'.repeat(credentials.password.length)}`);
console.log(`内容类型: ${contentType}`);
console.log(`成功标识字段: ${successField || 'code'}, 成功值: ${successCode}`);
// 构建请求数据
const requestData = {
[usernameField]: credentials.username,
[passwordField]: credentials.password
};
if (captchaField) {
requestData[captchaField] = '';
}
// 发送登录请求
const headers = {};
let requestBody;
if (contentType === 'application/x-www-form-urlencoded') {
headers['Content-Type'] = 'application/x-www-form-urlencoded';
requestBody = new URLSearchParams(requestData).toString();
} else if (contentType === 'application/json') {
headers['Content-Type'] = 'application/json';
requestBody = JSON.stringify(requestData);
} else {
requestBody = requestData;
}
console.log(`发送登录请求到: ${targetBaseUrl}${loginUrl}`);
console.log(`请求头:`, JSON.stringify(headers, null, 2));
console.log(`请求体:`, contentType === 'application/json' ? requestBody : requestBody.substring(0, 200) + '...');
const loginResponse = await axios.post(
`${targetBaseUrl}${loginUrl}`,
requestBody,
{
headers,
withCredentials: true,
maxRedirects: 0,
validateStatus: function (status) {
return status >= 200 && status < 400;
}
}
);
console.log(`登录响应状态码: ${loginResponse.status}`);
console.log(`响应数据:`, JSON.stringify(loginResponse.data, null, 2));
// 检查登录是否成功
const responseData = loginResponse.data || {};
const successValue = successField ? responseData[successField] : responseData.code;
console.log(`成功标识值: ${successValue}, 期望值: ${successCode}`);
if (successValue === successCode) {
const cookies = loginResponse.headers['set-cookie'] || [];
console.log(`登录成功!获取到 ${cookies.length} 个 Cookie`);
cookies.forEach((cookie, index) => {
console.log(`Cookie ${index + 1}: ${cookie.substring(0, 100)}...`);
});
return {
success: true,
cookies: cookies,
response: loginResponse.data
};
} else {
console.error(`登录失败!响应:`, responseData);
return {
success: false,
message: responseData.msg || responseData.message || '登录失败',
response: responseData
};
}
}
// 通用的自动登录端点
app.get('/api/auto-login/:siteId', async (req, res) => {
const startTime = Date.now();
const requestId = `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
// 立即输出日志,确认请求已到达
console.log('\n' + '='.repeat(80));
console.log(`[${requestId}] ⚡⚡⚡ 收到自动登录请求!⚡⚡⚡`);
console.log(`[${requestId}] 时间: ${new Date().toISOString()}`);
console.log(`[${requestId}] 请求路径: ${req.path}`);
console.log(`[${requestId}] 请求方法: ${req.method}`);
console.log(`[${requestId}] 完整URL: ${req.protocol}://${req.get('host')}${req.originalUrl}`);
console.log(`[${requestId}] 客户端IP: ${req.ip || req.connection.remoteAddress || req.socket.remoteAddress}`);
console.log(`[${requestId}] User-Agent: ${req.get('user-agent') || 'Unknown'}`);
try {
const { siteId } = req.params;
console.log(`[${requestId}] 网站ID: ${siteId}`);
// 获取网站配置
const config = autoLoginConfig[siteId];
if (!config) {
console.error(`[${requestId}] 错误: 未找到网站ID "${siteId}" 的配置`);
console.error(`[${requestId}] 可用的网站ID: ${Object.keys(autoLoginConfig).join(', ') || '无'}`);
return res.status(404).json({
success: false,
message: `未找到网站ID "${siteId}" 的配置`,
availableSites: Object.keys(autoLoginConfig)
});
}
console.log(`[${requestId}] 网站名称: ${config.name}`);
console.log(`[${requestId}] 目标地址: ${config.targetBaseUrl}`);
console.log(`[${requestId}] 登录方法: ${config.loginMethod}`);
// 获取登录凭据(优先使用环境变量)
const envUsername = process.env[config.credentials.envUsername];
const envPassword = process.env[config.credentials.envPassword];
const credentials = {
username: envUsername || config.credentials.username,
password: envPassword || config.credentials.password
};
console.log(`[${requestId}] 凭据来源: ${envUsername ? '环境变量' : '配置文件'}`);
console.log(`[${requestId}] 用户名: ${credentials.username}`);
console.log(`[${requestId}] 密码: ${'*'.repeat(credentials.password.length)}`);
if (!credentials.username || !credentials.password) {
console.error(`[${requestId}] 错误: 登录凭据未配置`);
return res.status(400).json({
success: false,
message: '登录凭据未配置'
});
}
// 根据登录方法处理登录
let loginResult;
console.log(`[${requestId}] 开始执行登录...`);
switch (config.loginMethod) {
case 'rsa-encrypted-form':
loginResult = await handleRSAEncryptedFormLogin(config, credentials);
break;
case 'plain-form':
loginResult = await handlePlainFormLogin(config, credentials);
break;
case 'home-assistant':
loginResult = await handleHomeAssistantLogin(config, credentials);
break;
case 'get-query-login':
loginResult = await handleGetQueryLogin(config, credentials);
break;
default:
console.error(`[${requestId}] 错误: 不支持的登录方法: ${config.loginMethod}`);
return res.status(400).json({
success: false,
message: `不支持的登录方法: ${config.loginMethod}`
});
}
if (!loginResult.success) {
console.error(`[${requestId}] 登录失败:`, loginResult.message);
console.error(`[${requestId}] 失败响应:`, JSON.stringify(loginResult.response, null, 2));
const duration = Date.now() - startTime;
console.log(`[${requestId}] 总耗时: ${duration}ms`);
console.log('='.repeat(80) + '\n');
// 返回错误页面而不是 JSON
const errorHtml = `
自动登录失败
❌
自动登录失败
${loginResult.message}
请求ID: ${requestId}
网站: ${config.name}
详细信息:
${JSON.stringify(loginResult.response, null, 2)}
`;
return res.status(500).send(errorHtml);
}
console.log(`[${requestId}] 登录成功!`);
// OAuth2 跨端口:调试页面方案
if (config.loginMethod === 'home-assistant' && loginResult.useEnhancedRedirect) {
console.log(`[${requestId}] 🚀 Home Assistant OAuth2 - 调试重定向方案`);
console.log(`[${requestId}] Token 已获取: ${loginResult.tokens.access_token.substring(0, 30)}...`);
console.log(`[${requestId}] Authorization Code: ${loginResult.redirectUrl.match(/code=([^&]+)/)?.[1]}`);
console.log(`[${requestId}] 重定向 URL: ${loginResult.redirectUrl}`);
const magicLink = loginResult.redirectUrl;
const authCode = magicLink.match(/code=([^&]+)/)?.[1] || 'unknown';
const targetBaseUrl = loginResult.targetBaseUrl || config.targetBaseUrl;
// 生成调试页面
const debugHtml = `
Home Assistant OAuth2 调试
🔍 Home Assistant OAuth2 登录调试
✅ 后端登录成功
Authorization Code 已获取,Token 已验证。
Authorization Code: ${authCode.substring(0, 20)}...
📋 OAuth2 跨端口问题分析
⚠️ 检测到跨端口场景:
- Node.js 后端:
222.243.138.146:8889
- Home Assistant:
222.243.138.146:8123
- localStorage 隔离:不同端口无法共享 Token
🎯 手动测试步骤
请按以下步骤测试,帮助我们诊断问题:
测试 1:直接访问魔术链接
复制下面的 URL 到新标签页,看是否能登录:
${magicLink}
测试 2:在当前标签页跳转
让当前页面跳转过去(可能有更好的效果):
测试 3:iframe 预加载然后跳转
使用 iframe 预加载,5秒后跳转:
💡 建议
如果以上测试都失败,强烈建议使用 Trusted Networks 方案:
- ✅ 官方支持,100% 可靠
- ✅ 无需复杂的 OAuth2 流程
- ✅ 零延迟,直接登录
`;
console.log(`[${requestId}] 返回调试页面,供手动测试`);
console.log(`[${requestId}] 魔术链接: ${magicLink}`);
console.log(`[${requestId}] 目标地址: ${targetBaseUrl}`);
console.log(`[${requestId}] 总耗时: ${Date.now() - startTime}ms`);
console.log('='.repeat(80) + '\n');
return res.send(debugHtml);
}
// 对于 Home Assistant,如果使用传统 redirect 方式(降级方案)
if (config.loginMethod === 'home-assistant' && loginResult.redirectUrl) {
console.log(`[${requestId}] Home Assistant 登录成功,使用传统 redirect 方式(降级)`);
console.log(`[${requestId}] 重定向到: ${loginResult.redirectUrl}`);
// 使用中间页面而不是直接 redirect
// 这样可以添加延迟,让 HA 前端有时间处理 code
const intermediateHtml = `
正在登录 Home Assistant...
正在登录...
准备进入 Home Assistant
`;
console.log(`[${requestId}] 总耗时: ${Date.now() - startTime}ms`);
console.log('='.repeat(80) + '\n');
return res.send(intermediateHtml);
}
// 对于 GET 查询登录,创建一个代理端点,返回 HTML 页面自动处理登录和跳转
if (config.loginMethod === 'get-query-login' && loginResult.redirectUrl) {
console.log(`[${requestId}] GET 查询登录成功,重定向到: ${loginResult.redirectUrl}`);
// 解析 Cookie
const cookieData = parseCookies(loginResult.cookies);
console.log(`[${requestId}] 解析到 ${cookieData.length} 个 Cookie:`);
cookieData.forEach((cookie, index) => {
console.log(`[${requestId}] Cookie ${index + 1}: ${cookie.name} = ${cookie.value.substring(0, 20)}...`);
});
// 重新构建登录 URL(带参数)
const { targetBaseUrl, loginUrl, loginMethodConfig } = config;
const { usernameParam, passwordParam, entCode, saveCookie, isOnly } = loginMethodConfig;
const params = new URLSearchParams();
params.append(usernameParam, credentials.username);
params.append(passwordParam, credentials.password);
params.append('ent_code', entCode);
params.append('code', 'undefined');
params.append('mySel', 'undefined');
params.append('saveCookie', saveCookie);
params.append('isOnly', isOnly);
params.append('_', Date.now().toString());
const loginUrlWithParams = `${targetBaseUrl}${loginUrl}?${params.toString()}`;
// 生成 HTML:使用 window.open 在新窗口打开登录 URL,然后在新窗口跳转
// 这是最可靠的方法,因为新窗口可以正常设置 Cookie
const html = `
自动登录中...
`;
const duration = Date.now() - startTime;
console.log(`[${requestId}] 总耗时: ${duration}ms`);
console.log(`[${requestId}] 返回直接跳转登录页面`);
console.log(`[${requestId}] 登录 URL: ${loginUrlWithParams}`);
console.log(`[${requestId}] 目标 URL: ${loginResult.redirectUrl}`);
console.log('='.repeat(80) + '\n');
return res.send(html);
}
// 解析 Cookie
const cookieData = parseCookies(loginResult.cookies);
console.log(`[${requestId}] 解析到 ${cookieData.length} 个 Cookie:`);
cookieData.forEach((cookie, index) => {
console.log(`[${requestId}] Cookie ${index + 1}: ${cookie.name} = ${cookie.value.substring(0, 20)}...`);
});
// 生成跳转 HTML
let redirectUrl = `http://${config.targetHost}/`;
console.log(`[${requestId}] 生成跳转页面,目标: ${redirectUrl}`);
const html = generateRedirectHTML(
cookieData,
config.targetHost,
config.targetDomain,
requestId,
redirectUrl,
null
);
// 在响应头中设置 Cookie
console.log(`[${requestId}] 设置响应头 Cookie...`);
loginResult.cookies.forEach((cookie, index) => {
// 修改 Cookie 的 Domain,移除端口号
let modifiedCookie = cookie.replace(/Domain=[^;]+/i, `Domain=${config.targetDomain}`);
res.setHeader('Set-Cookie', modifiedCookie);
console.log(`[${requestId}] 设置 Cookie ${index + 1}: ${modifiedCookie.substring(0, 80)}...`);
});
const duration = Date.now() - startTime;
console.log(`[${requestId}] 总耗时: ${duration}ms`);
console.log(`[${requestId}] 返回跳转页面`);
console.log('='.repeat(80) + '\n');
res.send(html);
} catch (error) {
const duration = Date.now() - startTime;
console.error(`[${requestId}] 自动登录异常:`, error.message);
console.error(`[${requestId}] 错误堆栈:`, error.stack);
if (error.response) {
console.error(`[${requestId}] 响应状态:`, error.response.status);
console.error(`[${requestId}] 响应头:`, JSON.stringify(error.response.headers, null, 2));
console.error(`[${requestId}] 响应数据:`, JSON.stringify(error.response.data, null, 2));
}
if (error.request) {
console.error(`[${requestId}] 请求信息:`, {
url: error.config?.url,
method: error.config?.method,
headers: error.config?.headers
});
}
console.log(`[${requestId}] 总耗时: ${duration}ms`);
console.log('='.repeat(80) + '\n');
res.status(500).json({
success: false,
message: '自动登录失败: ' + error.message,
error: process.env.NODE_ENV === 'development' ? error.stack : undefined
});
}
});
// Home Assistant 登录代理端点(解决浏览器 CORS 问题)
app.post('/api/home-assistant-proxy/login-flow', async (req, res) => {
try {
const targetBaseUrl = req.body.targetBaseUrl;
console.log('[代理] 创建 Home Assistant 登录流程:', targetBaseUrl);
const response = await axios.post(
`${targetBaseUrl}/auth/login_flow`,
{
client_id: `${targetBaseUrl}/`,
handler: ['homeassistant', null],
redirect_uri: `${targetBaseUrl}/`
},
{
headers: {
'Content-Type': 'application/json'
}
}
);
res.json(response.data);
} catch (error) {
console.error('[代理] 创建登录流程失败:', error.message);
res.status(500).json({ error: error.message });
}
});
app.post('/api/home-assistant-proxy/login', async (req, res) => {
try {
const { targetBaseUrl, flowId, username, password } = req.body;
console.log('[代理] 提交 Home Assistant 登录凭据:', targetBaseUrl, flowId);
const response = await axios.post(
`${targetBaseUrl}/auth/login_flow/${flowId}`,
{
username: username,
password: password,
client_id: `${targetBaseUrl}/`
},
{
headers: {
'Content-Type': 'application/json'
}
}
);
res.json(response.data);
} catch (error) {
console.error('[代理] 登录失败:', error.message);
res.status(500).json({ error: error.message });
}
});
// 获取所有配置的网站列表
app.get('/api/auto-login', (req, res) => {
const sites = Object.keys(autoLoginConfig).map(siteId => ({
id: siteId,
name: autoLoginConfig[siteId].name,
endpoint: `/api/auto-login/${siteId}`
}));
res.json({ sites });
});
// 健康检查端点
app.get('/api/health', (req, res) => {
res.json({
status: 'ok',
timestamp: new Date().toISOString(),
port: PORT,
configuredSites: Object.keys(autoLoginConfig)
});
});
// 测试端点 - 用于验证配置
app.get('/api/test/:siteId', (req, res) => {
const { siteId } = req.params;
const config = autoLoginConfig[siteId];
if (!config) {
return res.json({
success: false,
message: `未找到网站ID "${siteId}" 的配置`,
availableSites: Object.keys(autoLoginConfig)
});
}
const envUsername = process.env[config.credentials.envUsername];
const envPassword = process.env[config.credentials.envPassword];
const credentials = {
username: envUsername || config.credentials.username,
password: envPassword || config.credentials.password
};
res.json({
success: true,
siteId,
config: {
name: config.name,
targetBaseUrl: config.targetBaseUrl,
loginMethod: config.loginMethod,
loginUrl: config.loginUrl,
hasCredentials: !!(credentials.username && credentials.password),
credentialsSource: envUsername ? '环境变量' : '配置文件',
username: credentials.username,
passwordLength: credentials.password ? credentials.password.length : 0
}
});
});
app.listen(PORT, '0.0.0.0', () => {
console.log('\n' + '='.repeat(80));
console.log('🚀 后端服务器启动成功!');
console.log('='.repeat(80));
console.log(`📍 本地地址: http://localhost:${PORT}`);
console.log(`📍 服务器地址: http://0.0.0.0:${PORT}`);
console.log(`📍 外部访问: http://222.243.138.146:${PORT} (通过防火墙端口映射)`);
console.log(`\n📋 已配置的自动登录网站: ${Object.keys(autoLoginConfig).join(', ') || '无'}`);
console.log(`\n🔗 可用端点:`);
console.log(` - 健康检查: http://localhost:${PORT}/api/health`);
console.log(` - 测试配置: http://localhost:${PORT}/api/test/:siteId`);
console.log(` - 自动登录: http://localhost:${PORT}/api/auto-login/:siteId`);
console.log(`\n💡 提示: 确保防火墙已配置端口映射 (前端:8888, 后端:8889 -> 外网)`);
console.log('='.repeat(80) + '\n');
});