server.js 61 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620
  1. import express from 'express';
  2. import cors from 'cors';
  3. import axios from 'axios';
  4. import nodeRSA from 'node-rsa';
  5. import cookieParser from 'cookie-parser';
  6. import { readFileSync } from 'fs';
  7. import { fileURLToPath } from 'url';
  8. import { dirname, join } from 'path';
  9. import { CookieJar } from 'tough-cookie';
  10. import { wrapper } from 'axios-cookiejar-support';
  11. // node-rsa 是 CommonJS 模块,需要使用默认导入
  12. const NodeRSA = nodeRSA;
  13. const __filename = fileURLToPath(import.meta.url);
  14. const __dirname = dirname(__filename);
  15. const app = express();
  16. const PORT = process.env.PORT || 8889;
  17. // 中间件
  18. app.use(cors({
  19. origin: true,
  20. credentials: true
  21. }));
  22. app.use(express.json());
  23. app.use(express.urlencoded({ extended: true }));
  24. app.use(cookieParser());
  25. // 静态文件服务 - 提供 example_page 文件夹访问
  26. app.use('/example_page', express.static(join(__dirname, 'example_page')));
  27. // 请求日志中间件(用于调试)
  28. app.use((req, res, next) => {
  29. console.log(`[请求] ${req.method} ${req.path} - ${new Date().toISOString()}`);
  30. next();
  31. });
  32. // 加载自动登录配置
  33. let autoLoginConfig = {};
  34. try {
  35. const configPath = join(__dirname, 'auto-login-config.json');
  36. console.log('正在加载自动登录配置文件:', configPath);
  37. const configData = readFileSync(configPath, 'utf-8');
  38. autoLoginConfig = JSON.parse(configData);
  39. console.log('✓ 已加载自动登录配置');
  40. console.log(' 配置的网站数量:', Object.keys(autoLoginConfig).length);
  41. console.log(' 网站列表:', Object.keys(autoLoginConfig).join(', '));
  42. Object.keys(autoLoginConfig).forEach(siteId => {
  43. const site = autoLoginConfig[siteId];
  44. console.log(` - ${siteId}: ${site.name} (${site.loginMethod})`);
  45. });
  46. } catch (error) {
  47. console.error('✗ 加载自动登录配置失败:', error.message);
  48. console.error(' 错误堆栈:', error.stack);
  49. console.log('将使用默认配置');
  50. }
  51. // RSA 加密函数
  52. // 注意:JSEncrypt 使用 PKCS1 填充,需要匹配
  53. function encryptWithRSA(text, publicKey) {
  54. try {
  55. const key = new NodeRSA(publicKey, 'public', {
  56. encryptionScheme: 'pkcs1' // 使用 PKCS1 填充,与 JSEncrypt 兼容
  57. });
  58. const encrypted = key.encrypt(text, 'base64');
  59. console.log(`RSA加密: "${text}" -> 长度 ${encrypted.length}`);
  60. return encrypted;
  61. } catch (error) {
  62. console.error('RSA加密失败:', error.message);
  63. throw error;
  64. }
  65. }
  66. // 解析 Cookie
  67. function parseCookies(setCookieHeaders) {
  68. return setCookieHeaders.map(cookie => {
  69. const match = cookie.match(/^([^=]+)=([^;]+)/);
  70. if (match) {
  71. const name = match[1];
  72. const value = match[2];
  73. // 提取其他属性
  74. const pathMatch = cookie.match(/Path=([^;]+)/);
  75. const expiresMatch = cookie.match(/Expires=([^;]+)/);
  76. const maxAgeMatch = cookie.match(/Max-Age=([^;]+)/);
  77. const httpOnlyMatch = cookie.match(/HttpOnly/);
  78. const secureMatch = cookie.match(/Secure/);
  79. const sameSiteMatch = cookie.match(/SameSite=([^;]+)/);
  80. return {
  81. name,
  82. value,
  83. path: pathMatch ? pathMatch[1] : '/',
  84. expires: expiresMatch ? expiresMatch[1] : null,
  85. maxAge: maxAgeMatch ? maxAgeMatch[1] : null,
  86. httpOnly: !!httpOnlyMatch,
  87. secure: !!secureMatch,
  88. sameSite: sameSiteMatch ? sameSiteMatch[1] : null
  89. };
  90. }
  91. return null;
  92. }).filter(Boolean);
  93. }
  94. // 生成跳转 HTML
  95. function generateRedirectHTML(cookieData, targetHost, targetDomain, requestId = '', customUrl = null, homeAssistantData = null) {
  96. const targetUrl = customUrl || `http://${targetHost}/`;
  97. const isHomeAssistant = homeAssistantData !== null;
  98. return `
  99. <!DOCTYPE html>
  100. <html lang="zh-CN">
  101. <head>
  102. <meta charset="UTF-8">
  103. <meta name="viewport" content="width=device-width, initial-scale=1.0">
  104. <title>自动登录中...</title>
  105. <style>
  106. body {
  107. display: flex;
  108. justify-content: center;
  109. align-items: center;
  110. height: 100vh;
  111. margin: 0;
  112. font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
  113. background: linear-gradient(135deg, #f5f7fa 0%, #c3cfe2 100%);
  114. }
  115. .loading {
  116. text-align: center;
  117. }
  118. .spinner {
  119. border: 4px solid #f3f3f3;
  120. border-top: 4px solid #3498db;
  121. border-radius: 50%;
  122. width: 50px;
  123. height: 50px;
  124. animation: spin 1s linear infinite;
  125. margin: 0 auto 20px;
  126. }
  127. @keyframes spin {
  128. 0% { transform: rotate(0deg); }
  129. 100% { transform: rotate(360deg); }
  130. }
  131. .message {
  132. color: #333;
  133. font-size: 18px;
  134. }
  135. </style>
  136. </head>
  137. <body>
  138. <div class="loading">
  139. <div class="spinner"></div>
  140. <div class="message">正在自动登录,请稍候...</div>
  141. </div>
  142. <iframe id="cookieFrame" style="display:none;"></iframe>
  143. <script>
  144. (function() {
  145. const requestId = '${requestId}';
  146. const cookies = ${JSON.stringify(cookieData)};
  147. const targetUrl = '${targetUrl}';
  148. const targetDomain = '${targetDomain}';
  149. const isHomeAssistant = ${isHomeAssistant};
  150. const homeAssistantData = ${homeAssistantData ? JSON.stringify(homeAssistantData) : 'null'};
  151. console.log('========================================');
  152. console.log('[浏览器端] 自动登录脚本开始执行');
  153. console.log('[浏览器端] 请求ID:', requestId);
  154. console.log('[浏览器端] 目标URL:', targetUrl);
  155. console.log('[浏览器端] 目标域名:', targetDomain);
  156. console.log('[浏览器端] Cookie 数量:', cookies.length);
  157. console.log('[浏览器端] Cookie 详情:', cookies);
  158. console.log('[浏览器端] 是否为 Home Assistant:', isHomeAssistant);
  159. console.log('[浏览器端] Home Assistant 数据:', homeAssistantData);
  160. // 方法1: 尝试直接设置 Cookie(可能因为跨域限制而失败)
  161. if (cookies.length > 0) {
  162. console.log('[浏览器端] 开始尝试设置 Cookie...');
  163. let successCount = 0;
  164. let failCount = 0;
  165. cookies.forEach(function(cookie) {
  166. try {
  167. // 构建 Cookie 字符串
  168. let cookieStr = cookie.name + '=' + cookie.value;
  169. cookieStr += '; path=' + (cookie.path || '/');
  170. if (cookie.maxAge) {
  171. cookieStr += '; max-age=' + cookie.maxAge;
  172. }
  173. if (cookie.expires) {
  174. cookieStr += '; expires=' + cookie.expires;
  175. }
  176. if (cookie.secure) {
  177. cookieStr += '; secure';
  178. }
  179. if (cookie.sameSite) {
  180. cookieStr += '; samesite=' + cookie.sameSite;
  181. }
  182. // 注意:Domain 属性无法通过 JavaScript 设置跨域 Cookie
  183. // 但我们可以尝试设置(浏览器会忽略跨域的 Domain)
  184. if (cookie.domain) {
  185. cookieStr += '; domain=' + cookie.domain;
  186. }
  187. document.cookie = cookieStr;
  188. console.log('[浏览器端] ✓ 尝试设置 Cookie:', cookie.name);
  189. successCount++;
  190. // 验证 Cookie 是否设置成功
  191. const allCookies = document.cookie;
  192. if (allCookies.indexOf(cookie.name + '=') !== -1) {
  193. console.log('[浏览器端] ✓ Cookie 设置成功:', cookie.name);
  194. } else {
  195. console.warn('[浏览器端] ⚠ Cookie 可能未设置成功:', cookie.name, '(可能是跨域限制)');
  196. }
  197. } catch(e) {
  198. console.error('[浏览器端] ✗ 设置 Cookie 失败:', cookie.name, e);
  199. failCount++;
  200. }
  201. });
  202. console.log('[浏览器端] Cookie 设置结果: 成功 ' + successCount + ', 失败 ' + failCount);
  203. } else {
  204. console.log('[浏览器端] 没有 Cookie 需要设置,直接跳转');
  205. }
  206. // 对于 Home Assistant,在浏览器端执行登录流程
  207. if (isHomeAssistant && homeAssistantData) {
  208. console.log('[浏览器端] Home Assistant 登录,在浏览器端执行登录流程');
  209. console.log('[浏览器端] 目标 URL:', homeAssistantData.targetBaseUrl);
  210. console.log('[浏览器端] 用户名:', homeAssistantData.username);
  211. // 异步执行登录流程(通过后端代理避免 CORS)
  212. async function loginHomeAssistant() {
  213. try {
  214. console.log('[浏览器端] 步骤1: 创建登录流程(通过代理)...');
  215. // 使用后端代理避免 CORS 问题
  216. const proxyBaseUrl = window.location.origin; // 后端服务器地址
  217. const flowResponse = await fetch(proxyBaseUrl + '/api/home-assistant-proxy/login-flow', {
  218. method: 'POST',
  219. headers: {
  220. 'Content-Type': 'application/json'
  221. },
  222. body: JSON.stringify({
  223. targetBaseUrl: homeAssistantData.targetBaseUrl
  224. })
  225. });
  226. if (!flowResponse.ok) {
  227. throw new Error('创建登录流程失败: ' + flowResponse.status);
  228. }
  229. const flowData = await flowResponse.json();
  230. console.log('[浏览器端] 流程创建响应:', flowData);
  231. if (!flowData.flow_id) {
  232. throw new Error('无法获取 flow_id');
  233. }
  234. console.log('[浏览器端] 步骤2: 提交用户名和密码(通过代理)...');
  235. const loginResponse = await fetch(proxyBaseUrl + '/api/home-assistant-proxy/login', {
  236. method: 'POST',
  237. headers: {
  238. 'Content-Type': 'application/json'
  239. },
  240. body: JSON.stringify({
  241. targetBaseUrl: homeAssistantData.targetBaseUrl,
  242. flowId: flowData.flow_id,
  243. username: homeAssistantData.username,
  244. password: homeAssistantData.password
  245. })
  246. });
  247. if (!loginResponse.ok) {
  248. throw new Error('登录失败: ' + loginResponse.status);
  249. }
  250. const loginData = await loginResponse.json();
  251. console.log('[浏览器端] 登录响应:', loginData);
  252. if (loginData.type === 'create_entry') {
  253. console.log('[浏览器端] 登录成功!准备跳转到授权端点...');
  254. // 构建授权 URL
  255. const stateData = {
  256. hassUrl: homeAssistantData.targetBaseUrl,
  257. clientId: homeAssistantData.targetBaseUrl + '/'
  258. };
  259. const state = btoa(JSON.stringify(stateData));
  260. const redirectUri = homeAssistantData.targetBaseUrl + '/?auth_callback=1';
  261. const clientId = homeAssistantData.targetBaseUrl + '/';
  262. const authorizeUrl = homeAssistantData.targetBaseUrl + '/auth/authorize?response_type=code&redirect_uri=' + encodeURIComponent(redirectUri) + '&client_id=' + encodeURIComponent(clientId) + '&state=' + encodeURIComponent(state);
  263. console.log('[浏览器端] 授权 URL:', authorizeUrl);
  264. console.log('[浏览器端] 跳转到授权端点...');
  265. console.log('========================================');
  266. window.location.href = authorizeUrl;
  267. } else {
  268. throw new Error('登录失败: ' + JSON.stringify(loginData));
  269. }
  270. } catch (error) {
  271. console.error('[浏览器端] 登录失败:', error);
  272. alert('自动登录失败: ' + error.message + '\\n\\n将跳转到登录页面,请手动登录。');
  273. window.location.href = targetUrl;
  274. }
  275. }
  276. // 执行登录
  277. loginHomeAssistant();
  278. return;
  279. }
  280. // 方法2: 使用隐藏的 iframe 加载目标站点,让服务器设置 Cookie
  281. // 然后跳转到目标站点
  282. console.log('[浏览器端] 创建隐藏 iframe 加载目标站点...');
  283. const iframe = document.getElementById('cookieFrame');
  284. iframe.onload = function() {
  285. console.log('[浏览器端] iframe 加载完成');
  286. };
  287. iframe.onerror = function(error) {
  288. console.error('[浏览器端] iframe 加载失败:', error);
  289. };
  290. iframe.src = targetUrl;
  291. // 延迟跳转,确保 iframe 加载完成
  292. setTimeout(function() {
  293. console.log('[浏览器端] 准备跳转到目标站点:', targetUrl);
  294. console.log('[浏览器端] 当前页面 Cookie:', document.cookie);
  295. console.log('========================================');
  296. window.location.href = targetUrl;
  297. }, 1500);
  298. })();
  299. </script>
  300. </body>
  301. </html>
  302. `;
  303. }
  304. // 处理 RSA 加密表单登录
  305. async function handleRSAEncryptedFormLogin(config, credentials) {
  306. const { targetBaseUrl, loginUrl, loginMethodConfig } = config;
  307. const { publicKey, usernameField, passwordField, captchaField, captchaRequired, contentType, successCode, successField } = loginMethodConfig;
  308. console.log('=== RSA 加密表单登录 ===');
  309. console.log(`目标URL: ${targetBaseUrl}${loginUrl}`);
  310. console.log(`用户名: ${credentials.username}`);
  311. console.log(`密码: ${'*'.repeat(credentials.password.length)}`);
  312. console.log(`内容类型: ${contentType}`);
  313. console.log(`成功标识字段: ${successField || 'code'}, 成功值: ${successCode}`);
  314. // 加密用户名和密码
  315. const usernameEncrypted = encryptWithRSA(credentials.username, publicKey);
  316. const passwordEncrypted = encryptWithRSA(credentials.password, publicKey);
  317. console.log('用户名和密码已加密');
  318. console.log(`加密后用户名长度: ${usernameEncrypted.length}`);
  319. console.log(`加密后密码长度: ${passwordEncrypted.length}`);
  320. // 构建请求数据
  321. const requestData = {
  322. [usernameField]: usernameEncrypted,
  323. [passwordField]: passwordEncrypted
  324. };
  325. if (captchaField) {
  326. requestData[captchaField] = captchaRequired ? '' : '';
  327. }
  328. // 发送登录请求
  329. const headers = {};
  330. let requestBody;
  331. if (contentType === 'application/x-www-form-urlencoded') {
  332. headers['Content-Type'] = 'application/x-www-form-urlencoded';
  333. requestBody = new URLSearchParams(requestData).toString();
  334. } else if (contentType === 'application/json') {
  335. headers['Content-Type'] = 'application/json';
  336. requestBody = JSON.stringify(requestData);
  337. } else {
  338. requestBody = requestData;
  339. }
  340. console.log(`发送登录请求到: ${targetBaseUrl}${loginUrl}`);
  341. // 添加可能需要的请求头(模拟浏览器请求)
  342. headers['Referer'] = `${targetBaseUrl}/`;
  343. headers['Origin'] = targetBaseUrl;
  344. 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';
  345. headers['Accept'] = 'application/json, text/javascript, */*; q=0.01';
  346. headers['Accept-Language'] = 'zh-CN,zh;q=0.9,en;q=0.8';
  347. headers['X-Requested-With'] = 'XMLHttpRequest';
  348. console.log(`请求头:`, JSON.stringify(headers, null, 2));
  349. console.log(`请求体长度: ${requestBody.length} 字符`);
  350. console.log(`请求体内容预览: ${requestBody.substring(0, 300)}...`);
  351. // 先访问登录页面获取可能的session cookie
  352. console.log('先访问登录页面获取session...');
  353. try {
  354. const loginPageResponse = await axios.get(`${targetBaseUrl}/`, {
  355. headers: {
  356. 'User-Agent': headers['User-Agent']
  357. },
  358. withCredentials: true,
  359. maxRedirects: 5
  360. });
  361. console.log('登录页面访问成功,获取到的Cookie:', loginPageResponse.headers['set-cookie'] || []);
  362. } catch (error) {
  363. console.log('访问登录页面失败(可能不需要):', error.message);
  364. }
  365. const loginResponse = await axios.post(
  366. `${targetBaseUrl}${loginUrl}`,
  367. requestBody,
  368. {
  369. headers,
  370. withCredentials: true,
  371. maxRedirects: 0,
  372. validateStatus: function (status) {
  373. return status >= 200 && status < 400;
  374. }
  375. }
  376. );
  377. console.log(`登录响应状态码: ${loginResponse.status}`);
  378. console.log(`响应头:`, JSON.stringify(loginResponse.headers, null, 2));
  379. console.log(`响应数据:`, JSON.stringify(loginResponse.data, null, 2));
  380. // 检查登录是否成功
  381. const responseData = loginResponse.data || {};
  382. const successValue = successField ? responseData[successField] : responseData.code;
  383. console.log(`成功标识值: ${successValue}, 期望值: ${successCode}`);
  384. if (successValue === successCode) {
  385. const cookies = loginResponse.headers['set-cookie'] || [];
  386. console.log(`登录成功!获取到 ${cookies.length} 个 Cookie`);
  387. cookies.forEach((cookie, index) => {
  388. console.log(`Cookie ${index + 1}: ${cookie.substring(0, 100)}...`);
  389. });
  390. return {
  391. success: true,
  392. cookies: cookies,
  393. response: loginResponse.data
  394. };
  395. } else {
  396. console.error(`登录失败!响应:`, responseData);
  397. return {
  398. success: false,
  399. message: responseData.msg || responseData.message || '登录失败',
  400. response: responseData
  401. };
  402. }
  403. }
  404. // 处理 Home Assistant 登录(OAuth2 流程 - 严格匹配 redirect_uri)
  405. async function handleHomeAssistantLogin(config, credentials) {
  406. const { targetBaseUrl } = config;
  407. console.log('=== Home Assistant 登录 (OAuth2 严格模式) ===');
  408. console.log(`目标URL: ${targetBaseUrl}`);
  409. console.log(`用户名: ${credentials.username}`);
  410. console.log(`密码: ${'*'.repeat(credentials.password.length)}`);
  411. // 基础请求头,伪装成浏览器
  412. const baseHeaders = {
  413. 'Content-Type': 'application/json',
  414. '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',
  415. 'Accept': 'application/json, text/plain, */*',
  416. 'Origin': targetBaseUrl,
  417. 'Referer': `${targetBaseUrl}/`
  418. };
  419. // 【关键】:OAuth2 协议要求 client_id 和 redirect_uri 在整个流程中完全一致
  420. const CLIENT_ID = `${targetBaseUrl}/`;
  421. const REDIRECT_URI = `${targetBaseUrl}/?auth_callback=1`;
  422. console.log('Client ID:', CLIENT_ID);
  423. console.log('Redirect URI:', REDIRECT_URI);
  424. try {
  425. // ==========================================
  426. // 步骤1: 创建登录流程 (Init Flow)
  427. // ==========================================
  428. console.log('[1/3] 初始化登录流程...');
  429. const flowResponse = await axios.post(
  430. `${targetBaseUrl}/auth/login_flow`,
  431. {
  432. client_id: CLIENT_ID,
  433. handler: ['homeassistant', null],
  434. redirect_uri: REDIRECT_URI // 【重要】:必须和最后跳转的地址完全一致
  435. },
  436. {
  437. headers: baseHeaders,
  438. validateStatus: function (status) {
  439. return status >= 200 && status < 500;
  440. }
  441. }
  442. );
  443. console.log(`流程创建响应状态码: ${flowResponse.status}`);
  444. console.log(`流程创建响应数据:`, JSON.stringify(flowResponse.data, null, 2));
  445. if (flowResponse.status !== 200) {
  446. return {
  447. success: false,
  448. message: `创建登录流程失败,状态码: ${flowResponse.status}`,
  449. response: flowResponse.data
  450. };
  451. }
  452. const flowId = flowResponse.data?.flow_id;
  453. if (!flowId) {
  454. console.error('无法获取 flow_id');
  455. return {
  456. success: false,
  457. message: '无法获取 flow_id',
  458. response: flowResponse.data
  459. };
  460. }
  461. console.log(`获取到 flow_id: ${flowId}`);
  462. // ==========================================
  463. // 步骤2: 提交用户名和密码 (Submit Credentials)
  464. // ==========================================
  465. console.log('[2/3] 提交用户名和密码...');
  466. const loginResponse = await axios.post(
  467. `${targetBaseUrl}/auth/login_flow/${flowId}`,
  468. {
  469. username: credentials.username,
  470. password: credentials.password,
  471. client_id: CLIENT_ID // 【重要】:必须和步骤1的 client_id 完全一致
  472. },
  473. {
  474. headers: baseHeaders,
  475. validateStatus: function (status) {
  476. return status >= 200 && status < 500;
  477. }
  478. }
  479. );
  480. console.log(`登录响应状态码: ${loginResponse.status}`);
  481. console.log(`登录响应数据:`, JSON.stringify(loginResponse.data, null, 2));
  482. // ==========================================
  483. // 步骤3: 换取 Token(全托管方案)
  484. // ==========================================
  485. const responseData = loginResponse.data || {};
  486. const responseType = responseData.type;
  487. console.log(`响应类型: ${responseType}`);
  488. // 如果登录成功,type 为 'create_entry',result 字段包含 Authorization Code
  489. if (responseData.result && responseType === 'create_entry') {
  490. const authCode = responseData.result;
  491. console.log('[3/4] 登录成功!获取到 Authorization Code:', authCode);
  492. console.log('[3/4] Node.js 将代替浏览器换取 Token...');
  493. try {
  494. // ==========================================
  495. // Node.js 直接换取 Token(避免前端路由抢跑问题)
  496. // ==========================================
  497. const tokenResponse = await axios.post(
  498. `${targetBaseUrl}/auth/token`,
  499. new URLSearchParams({
  500. grant_type: 'authorization_code',
  501. code: authCode,
  502. client_id: CLIENT_ID
  503. }).toString(),
  504. {
  505. headers: {
  506. 'Content-Type': 'application/x-www-form-urlencoded'
  507. }
  508. }
  509. );
  510. const tokens = tokenResponse.data;
  511. console.log('[4/4] ✅ Token 换取成功!');
  512. console.log(`Access Token: ${tokens.access_token.substring(0, 20)}...`);
  513. console.log(`Token 类型: ${tokens.token_type}`);
  514. console.log(`过期时间: ${tokens.expires_in}秒`);
  515. // OAuth2 跨端口方案:返回带有 code 的 URL,但使用增强的中间页面
  516. // 虽然获取了 Token,但由于跨端口限制,我们仍然使用 code 方式
  517. // 只是添加更好的处理逻辑
  518. const magicLink = `${REDIRECT_URI}&code=${encodeURIComponent(authCode)}`;
  519. return {
  520. success: true,
  521. useEnhancedRedirect: true, // 使用增强的重定向方案
  522. redirectUrl: magicLink,
  523. tokens: tokens, // 保留 Token 信息用于日志
  524. targetBaseUrl: targetBaseUrl,
  525. cookies: [],
  526. response: loginResponse.data
  527. };
  528. } catch (tokenError) {
  529. console.error('❌ Token 换取失败:', tokenError.message);
  530. if (tokenError.response) {
  531. console.error('Token 响应:', JSON.stringify(tokenError.response.data, null, 2));
  532. }
  533. // 如果 Token 换取失败,降级到传统方式
  534. console.log('⚠️ 降级到传统 redirect 方式...');
  535. const magicLink = `${REDIRECT_URI}&code=${encodeURIComponent(authCode)}`;
  536. return {
  537. success: true,
  538. redirectUrl: magicLink,
  539. cookies: [],
  540. response: loginResponse.data
  541. };
  542. }
  543. } else {
  544. console.error('❌ 登录失败!未返回 Authorization Code');
  545. console.error('响应数据:', responseData);
  546. // 提取错误信息
  547. const errorMessage = responseData.errors?.base?.[0]
  548. || responseData.errors?.username?.[0]
  549. || responseData.errors?.password?.[0]
  550. || responseData.message
  551. || `登录失败,响应类型: ${responseType}`;
  552. return {
  553. success: false,
  554. message: errorMessage,
  555. response: responseData
  556. };
  557. }
  558. } catch (error) {
  559. console.error('Home Assistant 登录流程异常:', error.message);
  560. if (error.response) {
  561. console.error('响应状态:', error.response.status);
  562. console.error('响应数据:', JSON.stringify(error.response.data, null, 2));
  563. return {
  564. success: false,
  565. message: `登录失败: ${error.response.status} - ${JSON.stringify(error.response.data)}`,
  566. response: error.response.data
  567. };
  568. }
  569. return {
  570. success: false,
  571. message: `登录失败: ${error.message}`,
  572. response: null
  573. };
  574. }
  575. }
  576. // 处理 GET 查询参数登录(OA系统等)
  577. async function handleGetQueryLogin(config, credentials) {
  578. const { targetBaseUrl, loginUrl, loginMethodConfig, successRedirectUrl } = config;
  579. const { usernameParam, passwordParam, entCode, saveCookie, isOnly, successResponse } = loginMethodConfig;
  580. console.log('=== GET 查询参数登录 ===');
  581. console.log(`目标URL: ${targetBaseUrl}${loginUrl}`);
  582. console.log(`用户名参数名: ${usernameParam}`);
  583. console.log(`密码参数名: ${passwordParam}`);
  584. console.log(`用户名: ${credentials.username}`);
  585. console.log(`密码: ${'*'.repeat(credentials.password.length)}`);
  586. console.log(`企业代码: ${entCode}`);
  587. // 构建查询参数 - 确保参数名正确
  588. const params = new URLSearchParams();
  589. params.append(usernameParam, credentials.username);
  590. params.append(passwordParam, credentials.password);
  591. params.append('ent_code', entCode);
  592. params.append('code', 'undefined');
  593. params.append('mySel', 'undefined');
  594. params.append('saveCookie', saveCookie);
  595. params.append('isOnly', isOnly);
  596. params.append('_', Date.now().toString()); // 实时时间戳,防止缓存
  597. const loginUrlWithParams = `${targetBaseUrl}${loginUrl}?${params.toString()}`;
  598. console.log(`发送登录请求到: ${loginUrlWithParams}`);
  599. try {
  600. const loginResponse = await axios.get(loginUrlWithParams, {
  601. headers: {
  602. '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',
  603. 'Accept': '*/*',
  604. 'Referer': `${targetBaseUrl}/`
  605. },
  606. withCredentials: true,
  607. maxRedirects: 0,
  608. validateStatus: function (status) {
  609. return status >= 200 && status < 400;
  610. }
  611. });
  612. console.log(`登录响应状态码: ${loginResponse.status}`);
  613. console.log(`响应数据: ${loginResponse.data}`);
  614. // 检查登录是否成功(响应内容为 "ok")
  615. const responseText = loginResponse.data?.toString().trim() || '';
  616. const isSuccess = responseText.toLowerCase() === successResponse.toLowerCase();
  617. console.log(`响应内容: "${responseText}"`);
  618. console.log(`成功标识: ${successResponse}, 匹配结果: ${isSuccess}`);
  619. if (isSuccess) {
  620. const cookies = loginResponse.headers['set-cookie'] || [];
  621. console.log(`登录成功!获取到 ${cookies.length} 个 Cookie`);
  622. cookies.forEach((cookie, index) => {
  623. console.log(`Cookie ${index + 1}: ${cookie.substring(0, 100)}...`);
  624. });
  625. return {
  626. success: true,
  627. cookies: cookies,
  628. redirectUrl: successRedirectUrl ? `${targetBaseUrl}${successRedirectUrl}` : null,
  629. response: loginResponse.data
  630. };
  631. } else {
  632. console.error(`登录失败!响应内容: "${responseText}"`);
  633. return {
  634. success: false,
  635. message: `登录失败,响应: ${responseText}`,
  636. response: loginResponse.data
  637. };
  638. }
  639. } catch (error) {
  640. console.error('登录请求异常:', error.message);
  641. if (error.response) {
  642. console.error('响应状态:', error.response.status);
  643. console.error('响应数据:', error.response.data);
  644. return {
  645. success: false,
  646. message: `登录失败: ${error.response.status} - ${error.response.data}`,
  647. response: error.response.data
  648. };
  649. }
  650. return {
  651. success: false,
  652. message: `登录失败: ${error.message}`,
  653. response: null
  654. };
  655. }
  656. }
  657. // 处理普通表单登录(未加密)
  658. async function handlePlainFormLogin(config, credentials) {
  659. const { targetBaseUrl, loginUrl, loginMethodConfig } = config;
  660. const { usernameField, passwordField, captchaField, contentType, successCode, successField } = loginMethodConfig;
  661. console.log('=== 普通表单登录 ===');
  662. console.log(`目标URL: ${targetBaseUrl}${loginUrl}`);
  663. console.log(`用户名: ${credentials.username}`);
  664. console.log(`密码: ${'*'.repeat(credentials.password.length)}`);
  665. console.log(`内容类型: ${contentType}`);
  666. console.log(`成功标识字段: ${successField || 'code'}, 成功值: ${successCode}`);
  667. // 构建请求数据
  668. const requestData = {
  669. [usernameField]: credentials.username,
  670. [passwordField]: credentials.password
  671. };
  672. if (captchaField) {
  673. requestData[captchaField] = '';
  674. }
  675. // 发送登录请求
  676. const headers = {};
  677. let requestBody;
  678. if (contentType === 'application/x-www-form-urlencoded') {
  679. headers['Content-Type'] = 'application/x-www-form-urlencoded';
  680. requestBody = new URLSearchParams(requestData).toString();
  681. } else if (contentType === 'application/json') {
  682. headers['Content-Type'] = 'application/json';
  683. requestBody = JSON.stringify(requestData);
  684. } else {
  685. requestBody = requestData;
  686. }
  687. console.log(`发送登录请求到: ${targetBaseUrl}${loginUrl}`);
  688. console.log(`请求头:`, JSON.stringify(headers, null, 2));
  689. console.log(`请求体:`, contentType === 'application/json' ? requestBody : requestBody.substring(0, 200) + '...');
  690. const loginResponse = await axios.post(
  691. `${targetBaseUrl}${loginUrl}`,
  692. requestBody,
  693. {
  694. headers,
  695. withCredentials: true,
  696. maxRedirects: 0,
  697. validateStatus: function (status) {
  698. return status >= 200 && status < 400;
  699. }
  700. }
  701. );
  702. console.log(`登录响应状态码: ${loginResponse.status}`);
  703. console.log(`响应数据:`, JSON.stringify(loginResponse.data, null, 2));
  704. // 检查登录是否成功
  705. const responseData = loginResponse.data || {};
  706. const successValue = successField ? responseData[successField] : responseData.code;
  707. console.log(`成功标识值: ${successValue}, 期望值: ${successCode}`);
  708. if (successValue === successCode) {
  709. const cookies = loginResponse.headers['set-cookie'] || [];
  710. console.log(`登录成功!获取到 ${cookies.length} 个 Cookie`);
  711. cookies.forEach((cookie, index) => {
  712. console.log(`Cookie ${index + 1}: ${cookie.substring(0, 100)}...`);
  713. });
  714. return {
  715. success: true,
  716. cookies: cookies,
  717. response: loginResponse.data
  718. };
  719. } else {
  720. console.error(`登录失败!响应:`, responseData);
  721. return {
  722. success: false,
  723. message: responseData.msg || responseData.message || '登录失败',
  724. response: responseData
  725. };
  726. }
  727. }
  728. // 通用的自动登录端点
  729. app.get('/api/auto-login/:siteId', async (req, res) => {
  730. const startTime = Date.now();
  731. const requestId = `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
  732. // 立即输出日志,确认请求已到达
  733. console.log('\n' + '='.repeat(80));
  734. console.log(`[${requestId}] ⚡⚡⚡ 收到自动登录请求!⚡⚡⚡`);
  735. console.log(`[${requestId}] 时间: ${new Date().toISOString()}`);
  736. console.log(`[${requestId}] 请求路径: ${req.path}`);
  737. console.log(`[${requestId}] 请求方法: ${req.method}`);
  738. console.log(`[${requestId}] 完整URL: ${req.protocol}://${req.get('host')}${req.originalUrl}`);
  739. console.log(`[${requestId}] 客户端IP: ${req.ip || req.connection.remoteAddress || req.socket.remoteAddress}`);
  740. console.log(`[${requestId}] User-Agent: ${req.get('user-agent') || 'Unknown'}`);
  741. try {
  742. const { siteId } = req.params;
  743. console.log(`[${requestId}] 网站ID: ${siteId}`);
  744. // 获取网站配置
  745. const config = autoLoginConfig[siteId];
  746. if (!config) {
  747. console.error(`[${requestId}] 错误: 未找到网站ID "${siteId}" 的配置`);
  748. console.error(`[${requestId}] 可用的网站ID: ${Object.keys(autoLoginConfig).join(', ') || '无'}`);
  749. return res.status(404).json({
  750. success: false,
  751. message: `未找到网站ID "${siteId}" 的配置`,
  752. availableSites: Object.keys(autoLoginConfig)
  753. });
  754. }
  755. console.log(`[${requestId}] 网站名称: ${config.name}`);
  756. console.log(`[${requestId}] 目标地址: ${config.targetBaseUrl}`);
  757. console.log(`[${requestId}] 登录方法: ${config.loginMethod}`);
  758. // 获取登录凭据(优先使用环境变量)
  759. const envUsername = process.env[config.credentials.envUsername];
  760. const envPassword = process.env[config.credentials.envPassword];
  761. const credentials = {
  762. username: envUsername || config.credentials.username,
  763. password: envPassword || config.credentials.password
  764. };
  765. console.log(`[${requestId}] 凭据来源: ${envUsername ? '环境变量' : '配置文件'}`);
  766. console.log(`[${requestId}] 用户名: ${credentials.username}`);
  767. console.log(`[${requestId}] 密码: ${'*'.repeat(credentials.password.length)}`);
  768. if (!credentials.username || !credentials.password) {
  769. console.error(`[${requestId}] 错误: 登录凭据未配置`);
  770. return res.status(400).json({
  771. success: false,
  772. message: '登录凭据未配置'
  773. });
  774. }
  775. // 根据登录方法处理登录
  776. let loginResult;
  777. console.log(`[${requestId}] 开始执行登录...`);
  778. switch (config.loginMethod) {
  779. case 'rsa-encrypted-form':
  780. loginResult = await handleRSAEncryptedFormLogin(config, credentials);
  781. break;
  782. case 'plain-form':
  783. loginResult = await handlePlainFormLogin(config, credentials);
  784. break;
  785. case 'home-assistant':
  786. loginResult = await handleHomeAssistantLogin(config, credentials);
  787. break;
  788. case 'get-query-login':
  789. loginResult = await handleGetQueryLogin(config, credentials);
  790. break;
  791. default:
  792. console.error(`[${requestId}] 错误: 不支持的登录方法: ${config.loginMethod}`);
  793. return res.status(400).json({
  794. success: false,
  795. message: `不支持的登录方法: ${config.loginMethod}`
  796. });
  797. }
  798. if (!loginResult.success) {
  799. console.error(`[${requestId}] 登录失败:`, loginResult.message);
  800. console.error(`[${requestId}] 失败响应:`, JSON.stringify(loginResult.response, null, 2));
  801. const duration = Date.now() - startTime;
  802. console.log(`[${requestId}] 总耗时: ${duration}ms`);
  803. console.log('='.repeat(80) + '\n');
  804. // 返回错误页面而不是 JSON
  805. const errorHtml = `
  806. <!DOCTYPE html>
  807. <html lang="zh-CN">
  808. <head>
  809. <meta charset="UTF-8">
  810. <meta name="viewport" content="width=device-width, initial-scale=1.0">
  811. <title>自动登录失败</title>
  812. <style>
  813. body {
  814. display: flex;
  815. justify-content: center;
  816. align-items: center;
  817. height: 100vh;
  818. margin: 0;
  819. font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
  820. background: linear-gradient(135deg, #f5f7fa 0%, #c3cfe2 100%);
  821. }
  822. .error-container {
  823. background: white;
  824. padding: 40px;
  825. border-radius: 12px;
  826. box-shadow: 0 10px 30px rgba(0, 0, 0, 0.1);
  827. max-width: 600px;
  828. text-align: center;
  829. }
  830. .error-icon {
  831. font-size: 64px;
  832. margin-bottom: 20px;
  833. }
  834. .error-title {
  835. font-size: 24px;
  836. color: #e74c3c;
  837. margin-bottom: 15px;
  838. }
  839. .error-message {
  840. font-size: 16px;
  841. color: #666;
  842. margin-bottom: 20px;
  843. line-height: 1.6;
  844. }
  845. .error-details {
  846. background: #f8f9fa;
  847. padding: 15px;
  848. border-radius: 8px;
  849. margin-top: 20px;
  850. text-align: left;
  851. font-size: 14px;
  852. color: #555;
  853. }
  854. .error-details pre {
  855. margin: 0;
  856. white-space: pre-wrap;
  857. word-wrap: break-word;
  858. }
  859. </style>
  860. </head>
  861. <body>
  862. <div class="error-container">
  863. <div class="error-icon">❌</div>
  864. <div class="error-title">自动登录失败</div>
  865. <div class="error-message">${loginResult.message}</div>
  866. <div class="error-details">
  867. <strong>请求ID:</strong> ${requestId}<br>
  868. <strong>网站:</strong> ${config.name}<br>
  869. <strong>详细信息:</strong>
  870. <pre>${JSON.stringify(loginResult.response, null, 2)}</pre>
  871. </div>
  872. <button onclick="window.history.back()" style="margin-top: 20px; padding: 10px 20px; background: #3498db; color: white; border: none; border-radius: 6px; cursor: pointer;">返回</button>
  873. </div>
  874. </body>
  875. </html>
  876. `;
  877. return res.status(500).send(errorHtml);
  878. }
  879. console.log(`[${requestId}] 登录成功!`);
  880. // OAuth2 跨端口:调试页面方案
  881. if (config.loginMethod === 'home-assistant' && loginResult.useEnhancedRedirect) {
  882. console.log(`[${requestId}] 🚀 Home Assistant OAuth2 - 调试重定向方案`);
  883. console.log(`[${requestId}] Token 已获取: ${loginResult.tokens.access_token.substring(0, 30)}...`);
  884. console.log(`[${requestId}] Authorization Code: ${loginResult.redirectUrl.match(/code=([^&]+)/)?.[1]}`);
  885. console.log(`[${requestId}] 重定向 URL: ${loginResult.redirectUrl}`);
  886. const magicLink = loginResult.redirectUrl;
  887. const authCode = magicLink.match(/code=([^&]+)/)?.[1] || 'unknown';
  888. const targetBaseUrl = loginResult.targetBaseUrl || config.targetBaseUrl;
  889. // 生成调试页面
  890. const debugHtml = `
  891. <!DOCTYPE html>
  892. <html lang="zh-CN">
  893. <head>
  894. <meta charset="UTF-8">
  895. <meta name="viewport" content="width=device-width, initial-scale=1.0">
  896. <title>Home Assistant OAuth2 调试</title>
  897. <style>
  898. body {
  899. font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
  900. background: #1e1e1e;
  901. color: #d4d4d4;
  902. padding: 20px;
  903. margin: 0;
  904. }
  905. .container {
  906. max-width: 1000px;
  907. margin: 0 auto;
  908. }
  909. h1 {
  910. color: #4ec9b0;
  911. border-bottom: 2px solid #4ec9b0;
  912. padding-bottom: 10px;
  913. }
  914. .section {
  915. background: #252526;
  916. border: 1px solid #3e3e42;
  917. border-radius: 8px;
  918. padding: 20px;
  919. margin: 20px 0;
  920. }
  921. .section h2 {
  922. color: #569cd6;
  923. margin-top: 0;
  924. }
  925. pre {
  926. background: #1e1e1e;
  927. border: 1px solid #3e3e42;
  928. border-radius: 4px;
  929. padding: 15px;
  930. overflow-x: auto;
  931. }
  932. .success { color: #4ec9b0; }
  933. .warning { color: #ce9178; }
  934. .error { color: #f48771; }
  935. .button {
  936. background: #0e639c;
  937. color: white;
  938. border: none;
  939. padding: 12px 24px;
  940. border-radius: 6px;
  941. font-size: 16px;
  942. cursor: pointer;
  943. margin: 10px 5px;
  944. }
  945. .button:hover { background: #1177bb; }
  946. .button.secondary {
  947. background: #3e3e42;
  948. }
  949. .button.secondary:hover { background: #505050; }
  950. #log {
  951. background: #1e1e1e;
  952. border: 1px solid #3e3e42;
  953. border-radius: 4px;
  954. padding: 15px;
  955. max-height: 300px;
  956. overflow-y: auto;
  957. font-family: 'Consolas', 'Monaco', monospace;
  958. font-size: 12px;
  959. }
  960. .log-entry {
  961. margin: 5px 0;
  962. padding: 5px;
  963. border-left: 3px solid #569cd6;
  964. padding-left: 10px;
  965. }
  966. </style>
  967. </head>
  968. <body>
  969. <div class="container">
  970. <h1>🔍 Home Assistant OAuth2 登录调试</h1>
  971. <div class="section">
  972. <h2>✅ 后端登录成功</h2>
  973. <p>Authorization Code 已获取,Token 已验证。</p>
  974. <p><strong class="success">Authorization Code:</strong> <code>${authCode.substring(0, 20)}...</code></p>
  975. </div>
  976. <div class="section">
  977. <h2>📋 OAuth2 跨端口问题分析</h2>
  978. <p class="warning">⚠️ 检测到跨端口场景:</p>
  979. <ul>
  980. <li>Node.js 后端:<code>222.243.138.146:8889</code></li>
  981. <li>Home Assistant:<code>222.243.138.146:8123</code></li>
  982. <li>localStorage 隔离:不同端口无法共享 Token</li>
  983. </ul>
  984. </div>
  985. <div class="section">
  986. <h2>🎯 手动测试步骤</h2>
  987. <p>请按以下步骤测试,帮助我们诊断问题:</p>
  988. <h3>测试 1:直接访问魔术链接</h3>
  989. <p>复制下面的 URL 到新标签页,看是否能登录:</p>
  990. <pre>${magicLink}</pre>
  991. <button class="button" onclick="window.open('${magicLink}', '_blank')">
  992. 🔗 在新标签页打开
  993. </button>
  994. <h3>测试 2:在当前标签页跳转</h3>
  995. <p>让当前页面跳转过去(可能有更好的效果):</p>
  996. <button class="button secondary" onclick="window.location.href='${magicLink}'">
  997. ➡️ 当前标签页跳转
  998. </button>
  999. <h3>测试 3:iframe 预加载然后跳转</h3>
  1000. <p>使用 iframe 预加载,5秒后跳转:</p>
  1001. <button class="button secondary" onclick="testIframeMethod()">
  1002. 🔄 使用 iframe 方案
  1003. </button>
  1004. </div>
  1005. <div class="section">
  1006. <h2>📊 实时日志</h2>
  1007. <div id="log"></div>
  1008. </div>
  1009. <div class="section">
  1010. <h2>💡 建议</h2>
  1011. <p>如果以上测试都失败,强烈建议使用 <strong class="success">Trusted Networks</strong> 方案:</p>
  1012. <ul>
  1013. <li>✅ 官方支持,100% 可靠</li>
  1014. <li>✅ 无需复杂的 OAuth2 流程</li>
  1015. <li>✅ 零延迟,直接登录</li>
  1016. </ul>
  1017. <button class="button" onclick="alert('请在 Home Assistant 的 configuration.yaml 中配置:\\n\\nhomeassistant:\\n auth_providers:\\n - type: trusted_networks\\n trusted_networks:\\n - 118.251.191.88/32\\n trusted_users:\\n 118.251.191.88/32: YOUR_USER_ID\\n allow_bypass_login: true\\n - type: homeassistant')">
  1018. 📖 查看 Trusted Networks 配置
  1019. </button>
  1020. </div>
  1021. </div>
  1022. <iframe id="testFrame" style="display:none;"></iframe>
  1023. <script>
  1024. const logDiv = document.getElementById('log');
  1025. const iframe = document.getElementById('testFrame');
  1026. function addLog(msg, type = 'info') {
  1027. const entry = document.createElement('div');
  1028. entry.className = 'log-entry';
  1029. entry.textContent = new Date().toLocaleTimeString() + ' - ' + msg;
  1030. logDiv.appendChild(entry);
  1031. logDiv.scrollTop = logDiv.scrollHeight;
  1032. console.log('[调试] ' + msg);
  1033. }
  1034. function testIframeMethod() {
  1035. addLog('开始 iframe 测试...');
  1036. addLog('加载 URL: ${magicLink}');
  1037. let loaded = false;
  1038. iframe.onload = function() {
  1039. if (!loaded) {
  1040. loaded = true;
  1041. addLog('✓ iframe 加载完成');
  1042. addLog('等待 5 秒后跳转...');
  1043. let countdown = 5;
  1044. const timer = setInterval(function() {
  1045. countdown--;
  1046. addLog('倒计时: ' + countdown + '秒');
  1047. if (countdown <= 0) {
  1048. clearInterval(timer);
  1049. addLog('正在跳转到 Home Assistant...');
  1050. window.location.href = '${targetBaseUrl}';
  1051. }
  1052. }, 1000);
  1053. }
  1054. };
  1055. iframe.onerror = function(e) {
  1056. addLog('✗ iframe 加载失败: ' + e, 'error');
  1057. };
  1058. iframe.src = '${magicLink.replace(/'/g, "\\'")}';
  1059. }
  1060. addLog('后端 OAuth2 登录成功');
  1061. addLog('Authorization Code: ${authCode.substring(0, 20)}...');
  1062. addLog('请选择测试方法');
  1063. </script>
  1064. </body>
  1065. </html>
  1066. `;
  1067. console.log(`[${requestId}] 返回调试页面,供手动测试`);
  1068. console.log(`[${requestId}] 魔术链接: ${magicLink}`);
  1069. console.log(`[${requestId}] 目标地址: ${targetBaseUrl}`);
  1070. console.log(`[${requestId}] 总耗时: ${Date.now() - startTime}ms`);
  1071. console.log('='.repeat(80) + '\n');
  1072. return res.send(debugHtml);
  1073. }
  1074. // 对于 Home Assistant,如果使用传统 redirect 方式(降级方案)
  1075. if (config.loginMethod === 'home-assistant' && loginResult.redirectUrl) {
  1076. console.log(`[${requestId}] Home Assistant 登录成功,使用传统 redirect 方式(降级)`);
  1077. console.log(`[${requestId}] 重定向到: ${loginResult.redirectUrl}`);
  1078. // 使用中间页面而不是直接 redirect
  1079. // 这样可以添加延迟,让 HA 前端有时间处理 code
  1080. const intermediateHtml = `
  1081. <!DOCTYPE html>
  1082. <html lang="zh-CN">
  1083. <head>
  1084. <meta charset="UTF-8">
  1085. <meta name="viewport" content="width=device-width, initial-scale=1.0">
  1086. <title>正在登录 Home Assistant...</title>
  1087. <style>
  1088. body {
  1089. background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
  1090. color: white;
  1091. font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
  1092. display: flex;
  1093. justify-content: center;
  1094. align-items: center;
  1095. height: 100vh;
  1096. margin: 0;
  1097. }
  1098. .container { text-align: center; }
  1099. .loader {
  1100. border: 4px solid rgba(255, 255, 255, 0.3);
  1101. border-top: 4px solid white;
  1102. border-radius: 50%;
  1103. width: 50px;
  1104. height: 50px;
  1105. animation: spin 1s linear infinite;
  1106. margin: 0 auto 20px;
  1107. }
  1108. @keyframes spin {
  1109. 0% { transform: rotate(0deg); }
  1110. 100% { transform: rotate(360deg); }
  1111. }
  1112. h2 { margin: 0 0 10px 0; }
  1113. p { margin: 5px 0; opacity: 0.9; font-size: 14px; }
  1114. </style>
  1115. </head>
  1116. <body>
  1117. <div class="container">
  1118. <div class="loader"></div>
  1119. <h2>正在登录...</h2>
  1120. <p>准备进入 Home Assistant</p>
  1121. </div>
  1122. <iframe id="authFrame" style="display:none;"></iframe>
  1123. <script>
  1124. // 使用 iframe 预加载带有 code 的 URL
  1125. // 让 HA 前端在后台完成 code → token 的交换
  1126. const authUrl = "${loginResult.redirectUrl}";
  1127. const targetUrl = "${config.targetBaseUrl}";
  1128. const iframe = document.getElementById('authFrame');
  1129. console.log('[降级方案] 使用 iframe 预加载:', authUrl);
  1130. // 设置 iframe 超时
  1131. let loaded = false;
  1132. iframe.onload = function() {
  1133. if (!loaded) {
  1134. loaded = true;
  1135. console.log('[降级方案] iframe 加载完成,等待 HA 处理 code...');
  1136. // 给 HA 足够时间处理 code
  1137. setTimeout(function() {
  1138. console.log('[降级方案] 跳转到主页');
  1139. window.location.href = targetUrl;
  1140. }, 2000);
  1141. }
  1142. };
  1143. // 加载带有 code 的 URL
  1144. iframe.src = authUrl;
  1145. // 保险起见,10秒后强制跳转
  1146. setTimeout(function() {
  1147. if (!loaded) {
  1148. console.log('[降级方案] 超时,强制跳转');
  1149. window.location.href = targetUrl;
  1150. }
  1151. }, 10000);
  1152. </script>
  1153. </body>
  1154. </html>
  1155. `;
  1156. console.log(`[${requestId}] 总耗时: ${Date.now() - startTime}ms`);
  1157. console.log('='.repeat(80) + '\n');
  1158. return res.send(intermediateHtml);
  1159. }
  1160. // 对于 GET 查询登录,创建一个代理端点,返回 HTML 页面自动处理登录和跳转
  1161. if (config.loginMethod === 'get-query-login' && loginResult.redirectUrl) {
  1162. console.log(`[${requestId}] GET 查询登录成功,重定向到: ${loginResult.redirectUrl}`);
  1163. // 解析 Cookie
  1164. const cookieData = parseCookies(loginResult.cookies);
  1165. console.log(`[${requestId}] 解析到 ${cookieData.length} 个 Cookie:`);
  1166. cookieData.forEach((cookie, index) => {
  1167. console.log(`[${requestId}] Cookie ${index + 1}: ${cookie.name} = ${cookie.value.substring(0, 20)}...`);
  1168. });
  1169. // 重新构建登录 URL(带参数)
  1170. const { targetBaseUrl, loginUrl, loginMethodConfig } = config;
  1171. const { usernameParam, passwordParam, entCode, saveCookie, isOnly } = loginMethodConfig;
  1172. const params = new URLSearchParams();
  1173. params.append(usernameParam, credentials.username);
  1174. params.append(passwordParam, credentials.password);
  1175. params.append('ent_code', entCode);
  1176. params.append('code', 'undefined');
  1177. params.append('mySel', 'undefined');
  1178. params.append('saveCookie', saveCookie);
  1179. params.append('isOnly', isOnly);
  1180. params.append('_', Date.now().toString());
  1181. const loginUrlWithParams = `${targetBaseUrl}${loginUrl}?${params.toString()}`;
  1182. // 生成 HTML:使用 window.open 在新窗口打开登录 URL,然后在新窗口跳转
  1183. // 这是最可靠的方法,因为新窗口可以正常设置 Cookie
  1184. const html = `
  1185. <!DOCTYPE html>
  1186. <html lang="zh-CN">
  1187. <head>
  1188. <meta charset="UTF-8">
  1189. <meta name="viewport" content="width=device-width, initial-scale=1.0">
  1190. <title>自动登录中...</title>
  1191. <style>
  1192. body {
  1193. display: flex;
  1194. justify-content: center;
  1195. align-items: center;
  1196. height: 100vh;
  1197. margin: 0;
  1198. font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
  1199. background: linear-gradient(135deg, #f5f7fa 0%, #c3cfe2 100%);
  1200. }
  1201. .loading {
  1202. text-align: center;
  1203. }
  1204. .spinner {
  1205. border: 4px solid #f3f3f3;
  1206. border-top: 4px solid #3498db;
  1207. border-radius: 50%;
  1208. width: 50px;
  1209. height: 50px;
  1210. animation: spin 1s linear infinite;
  1211. margin: 0 auto 20px;
  1212. }
  1213. @keyframes spin {
  1214. 0% { transform: rotate(0deg); }
  1215. 100% { transform: rotate(360deg); }
  1216. }
  1217. .message {
  1218. color: #333;
  1219. font-size: 18px;
  1220. }
  1221. </style>
  1222. </head>
  1223. <body>
  1224. <div class="loading">
  1225. <div class="spinner"></div>
  1226. <div class="message">正在自动登录,请稍候...</div>
  1227. </div>
  1228. <script>
  1229. (function() {
  1230. const loginUrl = '${loginUrlWithParams.replace(/'/g, "\\'")}';
  1231. const targetUrl = '${loginResult.redirectUrl.replace(/'/g, "\\'")}';
  1232. console.log('[浏览器端] GET 查询登录 - 直接跳转方案');
  1233. console.log('[浏览器端] 登录 URL:', loginUrl);
  1234. console.log('[浏览器端] 目标 URL:', targetUrl);
  1235. // 最可靠的方案:直接跳转到登录 URL,让服务器设置 Cookie
  1236. // 然后立即跳转到目标页面(用户可能会短暂看到 "ok")
  1237. console.log('[浏览器端] 直接跳转到登录 URL(让服务器设置 Cookie)...');
  1238. console.log('[浏览器端] 登录 URL:', loginUrl);
  1239. console.log('[浏览器端] 目标 URL:', targetUrl);
  1240. // 直接跳转到登录 URL
  1241. // 登录 URL 返回 "ok" 后,Cookie 已设置
  1242. // 但由于登录 URL 返回的是纯文本,无法执行 JavaScript
  1243. // 所以我们需要在跳转前设置一个机制来自动跳转
  1244. // 方案:使用 window.location.replace 跳转到登录 URL
  1245. // 然后立即(在同一页面)跳转到目标页面
  1246. // 但由于页面会跳转,当前脚本会停止执行
  1247. // 实际上,最可靠的方案是:直接跳转到登录 URL
  1248. // 然后用户手动跳转到目标页面(或者等待自动跳转,如果服务器支持)
  1249. // 但这不好
  1250. // 让我们尝试一个变通方案:使用 window.location.href 跳转到登录 URL
  1251. // 然后立即(通过定时器)跳转到目标页面
  1252. // 但由于页面会跳转,定时器可能无法执行
  1253. // 所以,最可靠的方案是:直接跳转到登录 URL
  1254. // 然后用户手动跳转到目标页面
  1255. // 但我们可以尝试:先跳转到登录 URL,然后立即跳转到目标页面
  1256. // 使用 window.location.replace 避免在历史记录中留下登录页面
  1257. window.location.replace(loginUrl);
  1258. // 注意:由于登录 URL 返回的是纯文本 "ok",无法执行后续的 JavaScript
  1259. // 所以这个跳转可能无法自动完成
  1260. // 用户需要手动访问目标 URL,或者等待服务器重定向(如果支持)
  1261. })();
  1262. </script>
  1263. </body>
  1264. </html>
  1265. `;
  1266. const duration = Date.now() - startTime;
  1267. console.log(`[${requestId}] 总耗时: ${duration}ms`);
  1268. console.log(`[${requestId}] 返回直接跳转登录页面`);
  1269. console.log(`[${requestId}] 登录 URL: ${loginUrlWithParams}`);
  1270. console.log(`[${requestId}] 目标 URL: ${loginResult.redirectUrl}`);
  1271. console.log('='.repeat(80) + '\n');
  1272. return res.send(html);
  1273. }
  1274. // 解析 Cookie
  1275. const cookieData = parseCookies(loginResult.cookies);
  1276. console.log(`[${requestId}] 解析到 ${cookieData.length} 个 Cookie:`);
  1277. cookieData.forEach((cookie, index) => {
  1278. console.log(`[${requestId}] Cookie ${index + 1}: ${cookie.name} = ${cookie.value.substring(0, 20)}...`);
  1279. });
  1280. // 生成跳转 HTML
  1281. let redirectUrl = `http://${config.targetHost}/`;
  1282. console.log(`[${requestId}] 生成跳转页面,目标: ${redirectUrl}`);
  1283. const html = generateRedirectHTML(
  1284. cookieData,
  1285. config.targetHost,
  1286. config.targetDomain,
  1287. requestId,
  1288. redirectUrl,
  1289. null
  1290. );
  1291. // 在响应头中设置 Cookie
  1292. console.log(`[${requestId}] 设置响应头 Cookie...`);
  1293. loginResult.cookies.forEach((cookie, index) => {
  1294. // 修改 Cookie 的 Domain,移除端口号
  1295. let modifiedCookie = cookie.replace(/Domain=[^;]+/i, `Domain=${config.targetDomain}`);
  1296. res.setHeader('Set-Cookie', modifiedCookie);
  1297. console.log(`[${requestId}] 设置 Cookie ${index + 1}: ${modifiedCookie.substring(0, 80)}...`);
  1298. });
  1299. const duration = Date.now() - startTime;
  1300. console.log(`[${requestId}] 总耗时: ${duration}ms`);
  1301. console.log(`[${requestId}] 返回跳转页面`);
  1302. console.log('='.repeat(80) + '\n');
  1303. res.send(html);
  1304. } catch (error) {
  1305. const duration = Date.now() - startTime;
  1306. console.error(`[${requestId}] 自动登录异常:`, error.message);
  1307. console.error(`[${requestId}] 错误堆栈:`, error.stack);
  1308. if (error.response) {
  1309. console.error(`[${requestId}] 响应状态:`, error.response.status);
  1310. console.error(`[${requestId}] 响应头:`, JSON.stringify(error.response.headers, null, 2));
  1311. console.error(`[${requestId}] 响应数据:`, JSON.stringify(error.response.data, null, 2));
  1312. }
  1313. if (error.request) {
  1314. console.error(`[${requestId}] 请求信息:`, {
  1315. url: error.config?.url,
  1316. method: error.config?.method,
  1317. headers: error.config?.headers
  1318. });
  1319. }
  1320. console.log(`[${requestId}] 总耗时: ${duration}ms`);
  1321. console.log('='.repeat(80) + '\n');
  1322. res.status(500).json({
  1323. success: false,
  1324. message: '自动登录失败: ' + error.message,
  1325. error: process.env.NODE_ENV === 'development' ? error.stack : undefined
  1326. });
  1327. }
  1328. });
  1329. // Home Assistant 登录代理端点(解决浏览器 CORS 问题)
  1330. app.post('/api/home-assistant-proxy/login-flow', async (req, res) => {
  1331. try {
  1332. const targetBaseUrl = req.body.targetBaseUrl;
  1333. console.log('[代理] 创建 Home Assistant 登录流程:', targetBaseUrl);
  1334. const response = await axios.post(
  1335. `${targetBaseUrl}/auth/login_flow`,
  1336. {
  1337. client_id: `${targetBaseUrl}/`,
  1338. handler: ['homeassistant', null],
  1339. redirect_uri: `${targetBaseUrl}/`
  1340. },
  1341. {
  1342. headers: {
  1343. 'Content-Type': 'application/json'
  1344. }
  1345. }
  1346. );
  1347. res.json(response.data);
  1348. } catch (error) {
  1349. console.error('[代理] 创建登录流程失败:', error.message);
  1350. res.status(500).json({ error: error.message });
  1351. }
  1352. });
  1353. app.post('/api/home-assistant-proxy/login', async (req, res) => {
  1354. try {
  1355. const { targetBaseUrl, flowId, username, password } = req.body;
  1356. console.log('[代理] 提交 Home Assistant 登录凭据:', targetBaseUrl, flowId);
  1357. const response = await axios.post(
  1358. `${targetBaseUrl}/auth/login_flow/${flowId}`,
  1359. {
  1360. username: username,
  1361. password: password,
  1362. client_id: `${targetBaseUrl}/`
  1363. },
  1364. {
  1365. headers: {
  1366. 'Content-Type': 'application/json'
  1367. }
  1368. }
  1369. );
  1370. res.json(response.data);
  1371. } catch (error) {
  1372. console.error('[代理] 登录失败:', error.message);
  1373. res.status(500).json({ error: error.message });
  1374. }
  1375. });
  1376. // 获取所有配置的网站列表
  1377. app.get('/api/auto-login', (req, res) => {
  1378. const sites = Object.keys(autoLoginConfig).map(siteId => ({
  1379. id: siteId,
  1380. name: autoLoginConfig[siteId].name,
  1381. endpoint: `/api/auto-login/${siteId}`
  1382. }));
  1383. res.json({ sites });
  1384. });
  1385. // 健康检查端点
  1386. app.get('/api/health', (req, res) => {
  1387. res.json({
  1388. status: 'ok',
  1389. timestamp: new Date().toISOString(),
  1390. port: PORT,
  1391. configuredSites: Object.keys(autoLoginConfig)
  1392. });
  1393. });
  1394. // 测试端点 - 用于验证配置
  1395. app.get('/api/test/:siteId', (req, res) => {
  1396. const { siteId } = req.params;
  1397. const config = autoLoginConfig[siteId];
  1398. if (!config) {
  1399. return res.json({
  1400. success: false,
  1401. message: `未找到网站ID "${siteId}" 的配置`,
  1402. availableSites: Object.keys(autoLoginConfig)
  1403. });
  1404. }
  1405. const envUsername = process.env[config.credentials.envUsername];
  1406. const envPassword = process.env[config.credentials.envPassword];
  1407. const credentials = {
  1408. username: envUsername || config.credentials.username,
  1409. password: envPassword || config.credentials.password
  1410. };
  1411. res.json({
  1412. success: true,
  1413. siteId,
  1414. config: {
  1415. name: config.name,
  1416. targetBaseUrl: config.targetBaseUrl,
  1417. loginMethod: config.loginMethod,
  1418. loginUrl: config.loginUrl,
  1419. hasCredentials: !!(credentials.username && credentials.password),
  1420. credentialsSource: envUsername ? '环境变量' : '配置文件',
  1421. username: credentials.username,
  1422. passwordLength: credentials.password ? credentials.password.length : 0
  1423. }
  1424. });
  1425. });
  1426. app.listen(PORT, '0.0.0.0', () => {
  1427. console.log('\n' + '='.repeat(80));
  1428. console.log('🚀 后端服务器启动成功!');
  1429. console.log('='.repeat(80));
  1430. console.log(`📍 本地地址: http://localhost:${PORT}`);
  1431. console.log(`📍 服务器地址: http://0.0.0.0:${PORT}`);
  1432. console.log(`📍 外部访问: http://222.243.138.146:${PORT} (通过防火墙端口映射)`);
  1433. console.log(`\n📋 已配置的自动登录网站: ${Object.keys(autoLoginConfig).join(', ') || '无'}`);
  1434. console.log(`\n🔗 可用端点:`);
  1435. console.log(` - 健康检查: http://localhost:${PORT}/api/health`);
  1436. console.log(` - 测试配置: http://localhost:${PORT}/api/test/:siteId`);
  1437. console.log(` - 自动登录: http://localhost:${PORT}/api/auto-login/:siteId`);
  1438. console.log(`\n💡 提示: 确保防火墙已配置端口映射 (前端:8888, 后端:8889 -> 外网)`);
  1439. console.log('='.repeat(80) + '\n');
  1440. });