From d2dafabf653b044b23cbfe0e6f188e987fa797ae Mon Sep 17 00:00:00 2001 From: Haitao Pan Date: Wed, 5 Nov 2025 13:28:42 +0800 Subject: [PATCH] feat(api): improve MFA detection and update MFA routes to async config - Fix MFA detection logic to recognize mfa_code_required error - Remove strict mfaToken requirement when needMfa is determined by error code - Update mfa/status route to use async getAuthUrl() from runtime-loader - Update mfa/verify route to use async getAuthUrl() from runtime-loader - Add comprehensive structured logging across all MFA endpoints - Add timeout control (10s) for backend requests - Improve error handling with detailed console output - Add LOGIN_FLOW.md documentation in Chinese This completes the migration of all MFA-related endpoints to the new Deno native runtime configuration system. --- dashboard-fresh/docs/LOGIN_FLOW.md | 154 ++++++++++++++++++ dashboard-fresh/routes/api/auth/login.ts | 20 ++- .../routes/api/auth/mfa/status/index.ts | 64 +++++--- .../routes/api/auth/mfa/verify/index.ts | 34 +++- 4 files changed, 242 insertions(+), 30 deletions(-) create mode 100644 dashboard-fresh/docs/LOGIN_FLOW.md diff --git a/dashboard-fresh/docs/LOGIN_FLOW.md b/dashboard-fresh/docs/LOGIN_FLOW.md new file mode 100644 index 0000000..50b3018 --- /dev/null +++ b/dashboard-fresh/docs/LOGIN_FLOW.md @@ -0,0 +1,154 @@ + 1. 核心登录 API - routes/api/auth/login.ts + + - ✅ 重构为多步骤登录流程 + - ✅ 使用新的 getAuthUrl() 配置加载器 + - ✅ 添加详细的日志输出 + - ✅ 识别 mfa_code_required 错误 + - ✅ 即使没有 mfaToken 也正确返回 needMfa: true + + 2. MFA 验证 API - routes/api/auth/mfa/verify/index.ts + + - ✅ 更新使用 getAuthUrl() 替代旧的 getAccountServiceApiBaseUrl() + - ✅ 添加详细的日志输出 + - ✅ 添加 10 秒超时控制 + - ✅ 改进错误处理 + + 3. MFA 状态检查 API - routes/api/auth/mfa/status/index.ts + + - ✅ 更新使用 getAuthUrl() 替代旧的配置方式 + - ✅ 添加详细的日志输出 + - ✅ 添加 10 秒超时控制 + - ✅ 添加错误处理,失败时返回 totpEnabled: false + + 4. 运行时配置加载器 - server/runtime-loader.deno.ts + + - ✅ 纯 Deno 实现 + - ✅ 支持 SIT/PROD 环境切换 + - ✅ 支持多区域配置 + - ✅ 环境变量覆盖 + - ✅ 配置缓存 + + 5. 开发工具 + + - ✅ dev-local.sh - 本地开发启动脚本 + - ✅ test-login.sh - 登录 API 测试脚本 + + 📊 完整的登录流程 + + 情况 1:用户未启用 MFA + + 1. 前端预检:GET /api/auth/mfa/status?identifier=user@example.com + ← { mfa: { totpEnabled: false } } + + 2. 前端提交登录:POST /api/auth/login + { email, password } + ← { success: true } + session cookie + + 3. ✅ 登录成功 + + 情况 2:用户启用了 MFA(完整流程) + + 1. 前端预检:GET /api/auth/mfa/status?identifier=user@example.com + ← { mfa: { totpEnabled: true } } + + 2. 前端显示 TOTP 输入框 + + 3. 第一次提交(未输入 TOTP):POST /api/auth/login + { email, password } + ← { success: false, error: "mfa_code_required", needMfa: true } + + 4. 前端显示错误,要求输入 TOTP + + 5. 第二次提交(带 TOTP):POST /api/auth/login + { email, password, totp: "123456" } + + → 后端内部调用:POST /api/auth/login + ← { success: true } + session cookie + + 6. ✅ 登录成功 + + 情况 3:使用独立的 MFA 验证 API + + 1. 第一次登录(不带 TOTP):POST /api/auth/login + { email, password } + ← { success: false, error: "mfa_code_required", needMfa: true } + + Set-Cookie: mfa_token=xxx + + 2. MFA 验证:POST /api/auth/mfa/verify + Cookie: mfa_token=xxx + { code: "123456" } + ← { success: true } + session cookie + + 3. ✅ 登录成功 + + 🎯 后端 API 路径映射 + + | Fresh API | 后端 API + | 说明 | + |---------------------------|-------------------------------------|- + ----------| + | POST /api/auth/login | ${authUrl}/api/auth/login | + 用户登录 | + | GET /api/auth/mfa/status | ${authUrl}/api/auth/mfa/status | + 检查 MFA 状态 | + | POST /api/auth/mfa/verify | ${authUrl}/api/auth/mfa/totp/verify | + 验证 MFA 代码 | + + 📝 日志输出示例 + + 登录流程日志: + + [login] ===== Request received ===== + [login] Method: POST + [login] URL: http://localhost:8003/api/auth/login + [login] Step parameter: null (backward compatibility mode) + [login] Payload parsed, keys: [ "email", "password", "remember" ] + [login] → Backward compatibility: routing to handleLogin + [login/handleLogin] Starting login process + [login/handleLogin] Email: manbuzhe2009@qq.com + [login/handleLogin] Has password: true + [login/handleLogin] Remember: true + [login/handleLogin] Calling proxy to backend... + [login-proxy] → /api/auth/login { email: "manbuzhe2009@qq.com" } + [login-proxy] ← /api/auth/login [400] { ok: false, hasData: true } + [login/handleLogin] Backend response - ok: false status: 400 + [login/handleLogin] Error code: mfa_code_required Needs MFA: true + Has mfaToken: false + [login/handleLogin] → MFA required, but no mfaToken from backend + + MFA 状态检查日志: + + [mfa/status] Request received + [mfa/status] Identifier: user@example.com Has session: false + [mfa/status] Calling backend: http://localhost:8080/api/auth/mfa/sta + tus?identifier=user@example.com + [mfa/status] Backend response - status: 200 + + MFA 验证日志: + + [mfa/verify] ===== Request received ===== + [mfa/verify] Payload parsed, has code: true + [mfa/verify] Has token: true Code length: 6 + [mfa/verify] Calling backend: + http://localhost:8080/api/auth/mfa/totp/verify + [mfa/verify] Backend response - status: 200 ok: true + [mfa/verify] ✓ MFA verification successful + + 🚀 下一步 + + 现在所有 API 已经更新完成,你需要: + + 1. 测试登录流程: + # 确保后台进程已清理 + pkill -f "deno task dev" + + # 重新启动 + ./dev-local.sh + 2. 在浏览器中测试: + - 访问 http://localhost:8003/login + - 输入你的邮箱(manbuzhe2009@qq.com) + - 应该会显示 TOTP 输入框 + - 输入密码和 TOTP 代码 + - 点击登录 + 3. 检查日志: + 在服务器日志中应该能看到完整的请求流程 diff --git a/dashboard-fresh/routes/api/auth/login.ts b/dashboard-fresh/routes/api/auth/login.ts index 3b4e1b6..04bfe84 100644 --- a/dashboard-fresh/routes/api/auth/login.ts +++ b/dashboard-fresh/routes/api/auth/login.ts @@ -265,15 +265,25 @@ async function handleLogin(payload: LoginPayload): Promise { const needsMfa = Boolean( data?.needMfa || errorCode === 'mfa_required' || + errorCode === 'mfa_code_required' || errorCode === 'mfa_setup_required', ) - if (needsMfa && data?.mfaToken) { - const headers = new Headers() - applyMfaCookie(headers, data.mfaToken) - clearSessionCookie(headers) + console.log('[login/handleLogin] Error code:', errorCode, 'Needs MFA:', needsMfa, 'Has mfaToken:', !!data?.mfaToken) - console.log('[login/handleLogin] → MFA required, mfa_token set') + // If MFA is required, return appropriate response + if (needsMfa) { + const headers = new Headers() + + // If backend provided mfaToken, set it as cookie + if (data?.mfaToken) { + applyMfaCookie(headers, data.mfaToken) + console.log('[login/handleLogin] → MFA required, mfa_token set') + } else { + console.log('[login/handleLogin] → MFA required, but no mfaToken from backend') + } + + clearSessionCookie(headers) return jsonResponse( { diff --git a/dashboard-fresh/routes/api/auth/mfa/status/index.ts b/dashboard-fresh/routes/api/auth/mfa/status/index.ts index 6cffb90..82d09bd 100644 --- a/dashboard-fresh/routes/api/auth/mfa/status/index.ts +++ b/dashboard-fresh/routes/api/auth/mfa/status/index.ts @@ -8,12 +8,12 @@ import { Handlers } from '$fresh/server.ts' import { getCookies } from '$std/http/cookie.ts' import { MFA_COOKIE_NAME, SESSION_COOKIE_NAME } from '@/lib/authGateway.deno.ts' -import { getAccountServiceApiBaseUrl } from '@/server/serviceConfig.deno.ts' - -const ACCOUNT_API_BASE = getAccountServiceApiBaseUrl() +import { getAuthUrl } from '@/config/runtime-loader.ts' export const handler: Handlers = { async GET(req) { + console.log('[mfa/status] Request received') + const cookies = getCookies(req.headers) const sessionToken = cookies[SESSION_COOKIE_NAME] ?? '' const storedMfaToken = cookies[MFA_COOKIE_NAME] ?? '' @@ -25,6 +25,8 @@ export const handler: Handlers = { url.searchParams.get('identifier') ?? url.searchParams.get('email') ?? '', ).trim() + console.log('[mfa/status] Identifier:', identifier || 'none', 'Has session:', !!sessionToken) + const headers: Record = { Accept: 'application/json', } @@ -40,23 +42,47 @@ export const handler: Handlers = { params.set('identifier', identifier.toLowerCase()) } - const endpointParams = params.toString() - const endpoint = endpointParams - ? `${ACCOUNT_API_BASE}/mfa/status?${endpointParams}` - : `${ACCOUNT_API_BASE}/mfa/status` + try { + const authUrl = await getAuthUrl() + const endpointParams = params.toString() + const endpoint = endpointParams + ? `${authUrl}/api/auth/mfa/status?${endpointParams}` + : `${authUrl}/api/auth/mfa/status` - const response = await fetch(endpoint, { - method: 'GET', - headers, - cache: 'no-store', - }) + console.log('[mfa/status] Calling backend:', endpoint) - const payload = await response.json().catch(() => ({})) - return new Response(JSON.stringify(payload), { - status: response.status, - headers: { - 'Content-Type': 'application/json', - }, - }) + const response = await fetch(endpoint, { + method: 'GET', + headers, + cache: 'no-store', + signal: AbortSignal.timeout(10000), + }) + + const payload = await response.json().catch(() => ({})) + + console.log('[mfa/status] Backend response - status:', response.status) + + return new Response(JSON.stringify(payload), { + status: response.status, + headers: { + 'Content-Type': 'application/json', + }, + }) + } catch (error) { + console.error('[mfa/status] ✗ Exception:', error) + + return new Response( + JSON.stringify({ + error: 'account_service_unreachable', + mfa: { totpEnabled: false } + }), + { + status: 502, + headers: { + 'Content-Type': 'application/json', + }, + }, + ) + } }, } diff --git a/dashboard-fresh/routes/api/auth/mfa/verify/index.ts b/dashboard-fresh/routes/api/auth/mfa/verify/index.ts index 6869a4a..c134428 100644 --- a/dashboard-fresh/routes/api/auth/mfa/verify/index.ts +++ b/dashboard-fresh/routes/api/auth/mfa/verify/index.ts @@ -15,9 +15,7 @@ import { deriveMaxAgeFromExpires, MFA_COOKIE_NAME, } from '@/lib/authGateway.deno.ts' -import { getAccountServiceApiBaseUrl } from '@/server/serviceConfig.deno.ts' - -const ACCOUNT_API_BASE = getAccountServiceApiBaseUrl() +import { getAuthUrl } from '@/config/runtime-loader.ts' type VerifyPayload = { token?: string @@ -45,12 +43,16 @@ function normalizeCode(value: unknown) { export const handler: Handlers = { async POST(req) { + console.log('[mfa/verify] ===== Request received =====') + const cookies = getCookies(req.headers) let payload: VerifyPayload + try { payload = (await req.json()) as VerifyPayload + console.log('[mfa/verify] Payload parsed, has code:', !!(payload?.code || payload?.totp)) } catch (error) { - console.error('Failed to decode MFA verification payload', error) + console.error('[mfa/verify] Failed to decode payload:', error) return new Response( JSON.stringify({ success: false, error: 'invalid_request', needMfa: true }), { @@ -64,7 +66,10 @@ export const handler: Handlers = { const token = normalizeString(payload?.token || cookieToken) const code = normalizeCode(payload?.code ?? payload?.totp) + console.log('[mfa/verify] Has token:', !!token, 'Code length:', code.length) + if (!token) { + console.error('[mfa/verify] ✗ Missing MFA token') return new Response( JSON.stringify({ success: false, error: 'mfa_token_required', needMfa: true }), { @@ -75,6 +80,7 @@ export const handler: Handlers = { } if (!code) { + console.error('[mfa/verify] ✗ Missing MFA code') return new Response( JSON.stringify({ success: false, error: 'mfa_code_required', needMfa: true }), { @@ -85,21 +91,32 @@ export const handler: Handlers = { } try { - const response = await fetch(`${ACCOUNT_API_BASE}/mfa/totp/verify`, { + const authUrl = await getAuthUrl() + const endpoint = `${authUrl}/api/auth/mfa/totp/verify` + + console.log('[mfa/verify] Calling backend:', endpoint) + + const response = await fetch(endpoint, { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ token, code }), cache: 'no-store', + signal: AbortSignal.timeout(10000), }) const data = (await response.json().catch(() => ({}))) as AccountVerifyResponse + console.log('[mfa/verify] Backend response - status:', response.status, 'ok:', response.ok) + if (response.ok && typeof data?.token === 'string' && data.token.length > 0) { + console.log('[mfa/verify] ✓ MFA verification successful') + const responseHeaders = new Headers({ 'Content-Type': 'application/json' }) applySessionCookie(responseHeaders, data.token, deriveMaxAgeFromExpires(data?.expiresAt)) clearMfaCookie(responseHeaders) + return new Response( JSON.stringify({ success: true, error: null, needMfa: false, data }), { @@ -110,6 +127,8 @@ export const handler: Handlers = { } const errorCode = typeof data?.error === 'string' ? data.error : 'mfa_verification_failed' + console.log('[mfa/verify] ✗ MFA verification failed:', errorCode) + const responseHeaders = new Headers({ 'Content-Type': 'application/json' }) if (typeof data?.mfaToken === 'string' && data.mfaToken.trim()) { @@ -119,6 +138,7 @@ export const handler: Handlers = { } clearSessionCookie(responseHeaders) + return new Response( JSON.stringify({ success: false, error: errorCode, needMfa: true, data }), { @@ -127,10 +147,12 @@ export const handler: Handlers = { }, ) } catch (error) { - console.error('Account service MFA verification proxy failed', error) + console.error('[mfa/verify] ✗ Exception:', error) + const responseHeaders = new Headers({ 'Content-Type': 'application/json' }) applyMfaCookie(responseHeaders, token) clearSessionCookie(responseHeaders) + return new Response( JSON.stringify({ success: false, error: 'account_service_unreachable', needMfa: true }), {