|
|
@@ -582,13 +582,17 @@ async function handleHomeAssistantLogin(config, credentials) {
|
|
|
console.log(`Token 类型: ${tokens.token_type}`);
|
|
|
console.log(`过期时间: ${tokens.expires_in}秒`);
|
|
|
|
|
|
- // 返回 Token 和特殊标记
|
|
|
+ // OAuth2 跨端口方案:返回带有 code 的 URL,但使用增强的中间页面
|
|
|
+ // 虽然获取了 Token,但由于跨端口限制,我们仍然使用 code 方式
|
|
|
+ // 只是添加更好的处理逻辑
|
|
|
+ const magicLink = `${REDIRECT_URI}&code=${encodeURIComponent(authCode)}`;
|
|
|
+
|
|
|
return {
|
|
|
success: true,
|
|
|
- useTokenInjection: true, // 标记使用 Token 注入方式
|
|
|
- tokens: tokens,
|
|
|
+ useEnhancedRedirect: true, // 使用增强的重定向方案
|
|
|
+ redirectUrl: magicLink,
|
|
|
+ tokens: tokens, // 保留 Token 信息用于日志
|
|
|
targetBaseUrl: targetBaseUrl,
|
|
|
- clientId: CLIENT_ID,
|
|
|
cookies: [],
|
|
|
response: loginResponse.data
|
|
|
};
|
|
|
@@ -891,17 +895,18 @@ app.get('/api/auto-login/:siteId', async (req, res) => {
|
|
|
|
|
|
console.log(`[${requestId}] 登录成功!`);
|
|
|
|
|
|
- // 对于 Home Assistant,使用 Token 注入方式
|
|
|
- if (config.loginMethod === 'home-assistant' && loginResult.useTokenInjection) {
|
|
|
- console.log(`[${requestId}] Home Assistant 登录成功,使用 Token 注入方式`);
|
|
|
- console.log(`[${requestId}] 生成注入页面...`);
|
|
|
+ // OAuth2 跨端口增强方案
|
|
|
+ if (config.loginMethod === 'home-assistant' && loginResult.useEnhancedRedirect) {
|
|
|
+ console.log(`[${requestId}] 🚀 Home Assistant OAuth2 跨端口方案`);
|
|
|
+ console.log(`[${requestId}] 已获取 Token(用于验证)`);
|
|
|
+ console.log(`[${requestId}] 使用 iframe 预加载方案,防止前端路由抢跑`);
|
|
|
|
|
|
- const tokens = loginResult.tokens;
|
|
|
+ const magicLink = loginResult.redirectUrl;
|
|
|
const targetBaseUrl = loginResult.targetBaseUrl;
|
|
|
- const clientId = loginResult.clientId;
|
|
|
|
|
|
- // 生成 Token 注入 HTML 页面
|
|
|
- const tokenInjectionHtml = `
|
|
|
+ // 生成增强的 OAuth2 中间页面
|
|
|
+ // 核心策略:使用隐藏 iframe 先让 HA 处理 code,然后再显示跳转
|
|
|
+ const oauth2EnhancedHtml = `
|
|
|
<!DOCTYPE html>
|
|
|
<html lang="zh-CN">
|
|
|
<head>
|
|
|
@@ -914,105 +919,159 @@ app.get('/api/auto-login/:siteId', async (req, res) => {
|
|
|
color: white;
|
|
|
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
|
|
display: flex;
|
|
|
+ flex-direction: column;
|
|
|
justify-content: center;
|
|
|
align-items: center;
|
|
|
height: 100vh;
|
|
|
margin: 0;
|
|
|
}
|
|
|
- .container {
|
|
|
- text-align: center;
|
|
|
- }
|
|
|
+ .container { text-align: center; max-width: 500px; padding: 20px; }
|
|
|
.loader {
|
|
|
border: 4px solid rgba(255, 255, 255, 0.3);
|
|
|
border-top: 4px solid white;
|
|
|
border-radius: 50%;
|
|
|
- width: 50px;
|
|
|
- height: 50px;
|
|
|
+ width: 60px;
|
|
|
+ height: 60px;
|
|
|
animation: spin 1s linear infinite;
|
|
|
- margin: 0 auto 20px;
|
|
|
+ margin: 0 auto 30px;
|
|
|
}
|
|
|
@keyframes spin {
|
|
|
0% { transform: rotate(0deg); }
|
|
|
100% { transform: rotate(360deg); }
|
|
|
}
|
|
|
- h2 { margin: 0 0 10px 0; }
|
|
|
- p { margin: 5px 0; opacity: 0.9; }
|
|
|
- #status { font-size: 14px; margin-top: 20px; }
|
|
|
+ h2 { margin: 0 0 15px 0; font-size: 28px; }
|
|
|
+ .status { margin: 10px 0; opacity: 0.9; font-size: 16px; }
|
|
|
+ .step {
|
|
|
+ background: rgba(255,255,255,0.1);
|
|
|
+ padding: 10px 20px;
|
|
|
+ border-radius: 8px;
|
|
|
+ margin: 5px 0;
|
|
|
+ font-size: 14px;
|
|
|
+ }
|
|
|
+ .step.active { background: rgba(255,255,255,0.3); }
|
|
|
+ .step.done { background: rgba(76,175,80,0.5); }
|
|
|
+ #countdown {
|
|
|
+ font-size: 48px;
|
|
|
+ font-weight: bold;
|
|
|
+ margin: 20px 0;
|
|
|
+ text-shadow: 0 2px 10px rgba(0,0,0,0.3);
|
|
|
+ }
|
|
|
+ .info {
|
|
|
+ margin-top: 20px;
|
|
|
+ font-size: 12px;
|
|
|
+ opacity: 0.7;
|
|
|
+ }
|
|
|
</style>
|
|
|
</head>
|
|
|
<body>
|
|
|
<div class="container">
|
|
|
<div class="loader"></div>
|
|
|
- <h2>正在安全登录...</h2>
|
|
|
- <p>请稍候,正在完成身份验证</p>
|
|
|
- <div id="status"></div>
|
|
|
+ <h2>✓ OAuth2 认证成功</h2>
|
|
|
+ <div class="status">正在完成跨端口登录流程...</div>
|
|
|
+
|
|
|
+ <div class="step" id="step1">步骤 1: 获取授权码 ✓</div>
|
|
|
+ <div class="step active" id="step2">步骤 2: 预加载 Home Assistant</div>
|
|
|
+ <div class="step" id="step3">步骤 3: 等待 Token 交换</div>
|
|
|
+ <div class="step" id="step4">步骤 4: 跳转到仪表盘</div>
|
|
|
+
|
|
|
+ <div id="countdown">5</div>
|
|
|
+ <div class="info">请勿关闭此页面</div>
|
|
|
</div>
|
|
|
|
|
|
- <!-- 使用隐藏 iframe 来完成 OAuth2 流程 -->
|
|
|
- <iframe id="authFrame" style="display:none;"></iframe>
|
|
|
+ <!-- 隐藏 iframe 用于预加载 HA 并触发 code -> token 交换 -->
|
|
|
+ <iframe id="authFrame" style="position:absolute;width:0;height:0;border:0;"></iframe>
|
|
|
|
|
|
<script>
|
|
|
(function() {
|
|
|
- const statusDiv = document.getElementById('status');
|
|
|
+ const iframe = document.getElementById('authFrame');
|
|
|
+ const step1 = document.getElementById('step1');
|
|
|
+ const step2 = document.getElementById('step2');
|
|
|
+ const step3 = document.getElementById('step3');
|
|
|
+ const step4 = document.getElementById('step4');
|
|
|
+ const countdownEl = document.getElementById('countdown');
|
|
|
|
|
|
- function updateStatus(msg) {
|
|
|
- console.log('[Token注入] ' + msg);
|
|
|
- statusDiv.textContent = msg;
|
|
|
- }
|
|
|
+ const magicLink = "${magicLink}";
|
|
|
+ const targetUrl = "${targetBaseUrl}";
|
|
|
|
|
|
- // Token 数据
|
|
|
- const tokens = ${JSON.stringify(tokens)};
|
|
|
- const targetBaseUrl = "${targetBaseUrl}";
|
|
|
- const clientId = "${clientId}";
|
|
|
+ console.log('========================================');
|
|
|
+ console.log('[OAuth2 跨端口] 开始处理');
|
|
|
+ console.log('[OAuth2 跨端口] 魔术链接:', magicLink);
|
|
|
+ console.log('[OAuth2 跨端口] 目标地址:', targetUrl);
|
|
|
+ console.log('========================================');
|
|
|
|
|
|
- updateStatus('步骤 1/3: 准备 Token...');
|
|
|
+ let seconds = 5;
|
|
|
+ let iframeLoaded = false;
|
|
|
|
|
|
- // 构造 HA 需要的 Token 格式
|
|
|
- const hassTokens = {
|
|
|
- access_token: tokens.access_token,
|
|
|
- refresh_token: tokens.refresh_token,
|
|
|
- expires_in: tokens.expires_in,
|
|
|
- token_type: tokens.token_type,
|
|
|
- clientId: clientId,
|
|
|
- hassUrl: targetBaseUrl,
|
|
|
- expires: Date.now() + (tokens.expires_in * 1000)
|
|
|
- };
|
|
|
+ // 步骤 1: 已完成(后端已获取 code)
|
|
|
+ step1.classList.add('done');
|
|
|
+
|
|
|
+ // 步骤 2: 使用 iframe 预加载带有 code 的 URL
|
|
|
+ console.log('[步骤 2/4] 使用 iframe 预加载 HA...');
|
|
|
|
|
|
- console.log('[Token注入] Token 数据已准备:', hassTokens);
|
|
|
+ iframe.onload = function() {
|
|
|
+ if (!iframeLoaded) {
|
|
|
+ iframeLoaded = true;
|
|
|
+ console.log('[步骤 2/4] ✓ iframe 加载完成');
|
|
|
+ step2.classList.remove('active');
|
|
|
+ step2.classList.add('done');
|
|
|
+ step3.classList.add('active');
|
|
|
+ console.log('[步骤 3/4] 等待 HA 前端完成 Token 交换...');
|
|
|
+ console.log('[步骤 3/4] HA 前端正在后台处理 OAuth2 code');
|
|
|
+ }
|
|
|
+ };
|
|
|
|
|
|
- // 方案:使用 iframe + URL hash 传递 Token
|
|
|
- // 因为 localStorage 跨端口不共享,我们使用 URL hash 作为中转
|
|
|
- updateStatus('步骤 2/3: 加载 Home Assistant...');
|
|
|
+ iframe.onerror = function(e) {
|
|
|
+ console.error('[步骤 2/4] ✗ iframe 加载失败:', e);
|
|
|
+ console.log('[步骤 2/4] 将继续倒计时后跳转...');
|
|
|
+ };
|
|
|
|
|
|
- // 将 Token 编码到 URL hash 中
|
|
|
- const tokenData = btoa(JSON.stringify(hassTokens));
|
|
|
- const iframe = document.getElementById('authFrame');
|
|
|
+ // 加载带有 authorization code 的 URL
|
|
|
+ // HA 前端会在 iframe 中处理这个 code 并换取 token
|
|
|
+ console.log('[步骤 2/4] 加载 URL:', magicLink);
|
|
|
+ iframe.src = magicLink;
|
|
|
|
|
|
- // 先加载一个带有 Token 的特殊 URL
|
|
|
- // HA 前端可以从 hash 中提取 Token
|
|
|
- iframe.onload = function() {
|
|
|
- updateStatus('步骤 3/3: 正在跳转...');
|
|
|
- console.log('[Token注入] iframe 加载完成,准备跳转');
|
|
|
+ // 步骤 3 & 4: 倒计时后跳转
|
|
|
+ const countdown = setInterval(function() {
|
|
|
+ seconds--;
|
|
|
+ countdownEl.textContent = seconds;
|
|
|
|
|
|
- // 延迟跳转,确保 Token 已经被处理
|
|
|
- setTimeout(function() {
|
|
|
- // 直接跳转到 HA,希望 Token 已经生效
|
|
|
- // 如果 HA 支持从 hash 读取 Token,这里会成功
|
|
|
- // 否则我们需要其他方案
|
|
|
- window.location.href = targetBaseUrl + '/lovelace';
|
|
|
- }, 1000);
|
|
|
- };
|
|
|
+ if (seconds === 2 && iframeLoaded) {
|
|
|
+ console.log('[步骤 3/4] ✓ 假设 Token 交换已完成');
|
|
|
+ step3.classList.remove('active');
|
|
|
+ step3.classList.add('done');
|
|
|
+ step4.classList.add('active');
|
|
|
+ console.log('[步骤 4/4] 准备跳转到主界面...');
|
|
|
+ }
|
|
|
+
|
|
|
+ if (seconds <= 0) {
|
|
|
+ clearInterval(countdown);
|
|
|
+ step4.classList.remove('active');
|
|
|
+ step4.classList.add('done');
|
|
|
+ console.log('[步骤 4/4] ✓ 跳转中...');
|
|
|
+ console.log('========================================');
|
|
|
+
|
|
|
+ // 最终跳转到 HA 主页
|
|
|
+ // 由于 iframe 已经处理了 code,HA 应该已经有了 token
|
|
|
+ window.location.href = targetUrl;
|
|
|
+ }
|
|
|
+ }, 1000);
|
|
|
|
|
|
- // 尝试通过 hash 传递 Token(某些版本的 HA 可能支持)
|
|
|
- iframe.src = targetBaseUrl + '/#token=' + encodeURIComponent(tokenData);
|
|
|
+ // 允许用户点击立即跳转(如果等不及)
|
|
|
+ document.body.style.cursor = 'pointer';
|
|
|
+ document.body.onclick = function() {
|
|
|
+ if (seconds <= 3) { // 至少等待 2 秒
|
|
|
+ console.log('[用户操作] 提前跳转');
|
|
|
+ clearInterval(countdown);
|
|
|
+ window.location.href = targetUrl;
|
|
|
+ }
|
|
|
+ };
|
|
|
|
|
|
- // 备用方案:如果 5 秒后还没跳转,直接跳转到登录页
|
|
|
+ // 保险机制:10秒后强制跳转
|
|
|
setTimeout(function() {
|
|
|
- if (window.location.href.indexOf(targetBaseUrl) === -1) {
|
|
|
- console.log('[Token注入] 超时,直接跳转');
|
|
|
- window.location.href = targetBaseUrl;
|
|
|
- }
|
|
|
- }, 5000);
|
|
|
+ console.log('[超时保护] 10秒后强制跳转');
|
|
|
+ clearInterval(countdown);
|
|
|
+ window.location.href = targetUrl;
|
|
|
+ }, 10000);
|
|
|
|
|
|
})();
|
|
|
</script>
|
|
|
@@ -1023,18 +1082,101 @@ app.get('/api/auto-login/:siteId', async (req, res) => {
|
|
|
console.log(`[${requestId}] 总耗时: ${Date.now() - startTime}ms`);
|
|
|
console.log('='.repeat(80) + '\n');
|
|
|
|
|
|
- return res.send(tokenInjectionHtml);
|
|
|
+ return res.send(oauth2EnhancedHtml);
|
|
|
}
|
|
|
|
|
|
- // 对于 Home Assistant,如果使用传统 redirect 方式
|
|
|
+ // 对于 Home Assistant,如果使用传统 redirect 方式(降级方案)
|
|
|
if (config.loginMethod === 'home-assistant' && loginResult.redirectUrl) {
|
|
|
- console.log(`[${requestId}] Home Assistant 登录成功,使用传统 redirect 方式`);
|
|
|
+ console.log(`[${requestId}] Home Assistant 登录成功,使用传统 redirect 方式(降级)`);
|
|
|
console.log(`[${requestId}] 重定向到: ${loginResult.redirectUrl}`);
|
|
|
+
|
|
|
+ // 使用中间页面而不是直接 redirect
|
|
|
+ // 这样可以添加延迟,让 HA 前端有时间处理 code
|
|
|
+ const intermediateHtml = `
|
|
|
+<!DOCTYPE html>
|
|
|
+<html lang="zh-CN">
|
|
|
+<head>
|
|
|
+ <meta charset="UTF-8">
|
|
|
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
|
+ <title>正在登录 Home Assistant...</title>
|
|
|
+ <style>
|
|
|
+ body {
|
|
|
+ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
|
|
+ color: white;
|
|
|
+ font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
|
|
+ display: flex;
|
|
|
+ justify-content: center;
|
|
|
+ align-items: center;
|
|
|
+ height: 100vh;
|
|
|
+ margin: 0;
|
|
|
+ }
|
|
|
+ .container { text-align: center; }
|
|
|
+ .loader {
|
|
|
+ border: 4px solid rgba(255, 255, 255, 0.3);
|
|
|
+ border-top: 4px solid white;
|
|
|
+ border-radius: 50%;
|
|
|
+ width: 50px;
|
|
|
+ height: 50px;
|
|
|
+ animation: spin 1s linear infinite;
|
|
|
+ margin: 0 auto 20px;
|
|
|
+ }
|
|
|
+ @keyframes spin {
|
|
|
+ 0% { transform: rotate(0deg); }
|
|
|
+ 100% { transform: rotate(360deg); }
|
|
|
+ }
|
|
|
+ h2 { margin: 0 0 10px 0; }
|
|
|
+ p { margin: 5px 0; opacity: 0.9; font-size: 14px; }
|
|
|
+ </style>
|
|
|
+</head>
|
|
|
+<body>
|
|
|
+ <div class="container">
|
|
|
+ <div class="loader"></div>
|
|
|
+ <h2>正在登录...</h2>
|
|
|
+ <p>准备进入 Home Assistant</p>
|
|
|
+ </div>
|
|
|
+ <iframe id="authFrame" style="display:none;"></iframe>
|
|
|
+ <script>
|
|
|
+ // 使用 iframe 预加载带有 code 的 URL
|
|
|
+ // 让 HA 前端在后台完成 code → token 的交换
|
|
|
+ const authUrl = "${loginResult.redirectUrl}";
|
|
|
+ const targetUrl = "${config.targetBaseUrl}";
|
|
|
+ const iframe = document.getElementById('authFrame');
|
|
|
+
|
|
|
+ console.log('[降级方案] 使用 iframe 预加载:', authUrl);
|
|
|
+
|
|
|
+ // 设置 iframe 超时
|
|
|
+ let loaded = false;
|
|
|
+ iframe.onload = function() {
|
|
|
+ if (!loaded) {
|
|
|
+ loaded = true;
|
|
|
+ console.log('[降级方案] iframe 加载完成,等待 HA 处理 code...');
|
|
|
+ // 给 HA 足够时间处理 code
|
|
|
+ setTimeout(function() {
|
|
|
+ console.log('[降级方案] 跳转到主页');
|
|
|
+ window.location.href = targetUrl;
|
|
|
+ }, 2000);
|
|
|
+ }
|
|
|
+ };
|
|
|
+
|
|
|
+ // 加载带有 code 的 URL
|
|
|
+ iframe.src = authUrl;
|
|
|
+
|
|
|
+ // 保险起见,10秒后强制跳转
|
|
|
+ setTimeout(function() {
|
|
|
+ if (!loaded) {
|
|
|
+ console.log('[降级方案] 超时,强制跳转');
|
|
|
+ window.location.href = targetUrl;
|
|
|
+ }
|
|
|
+ }, 10000);
|
|
|
+ </script>
|
|
|
+</body>
|
|
|
+</html>
|
|
|
+ `;
|
|
|
+
|
|
|
console.log(`[${requestId}] 总耗时: ${Date.now() - startTime}ms`);
|
|
|
console.log('='.repeat(80) + '\n');
|
|
|
|
|
|
- // 直接重定向到带有 auth code 的 URL
|
|
|
- return res.redirect(loginResult.redirectUrl);
|
|
|
+ return res.send(intermediateHtml);
|
|
|
}
|
|
|
|
|
|
// 解析 Cookie
|