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.
This commit is contained in:
parent
66721ea54a
commit
d2dafabf65
154
dashboard-fresh/docs/LOGIN_FLOW.md
Normal file
154
dashboard-fresh/docs/LOGIN_FLOW.md
Normal file
@ -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. 检查日志:
|
||||
在服务器日志中应该能看到完整的请求流程
|
||||
@ -265,15 +265,25 @@ async function handleLogin(payload: LoginPayload): Promise<Response> {
|
||||
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(
|
||||
{
|
||||
|
||||
@ -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<string, string> = {
|
||||
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',
|
||||
},
|
||||
},
|
||||
)
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
@ -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 }),
|
||||
{
|
||||
|
||||
Loading…
Reference in New Issue
Block a user