video-flow-b/docs/google-oauth-callback-flow-analysis.md
2025-09-23 15:25:49 +08:00

15 KiB
Raw Blame History

🔍 Google OAuth 回调逻辑全面梳理

📋 概述

Video Flow 项目的 Google OAuth 登录采用了授权码模式整个流程涉及前端、Java 认证服务和 Python 业务服务的协调配合。本文档详细梳理了从用户点击登录到最终完成认证的完整流程。

🏗️ 架构图

sequenceDiagram
    participant U as 用户
    participant F as 前端(Next.js)
    participant G as Google OAuth
    participant J as Java认证服务
    participant P as Python业务服务

    U->>F: 点击Google登录
    F->>F: 生成state参数(包含邀请码)
    F->>G: 重定向到Google授权页面
    G->>U: 显示授权页面
    U->>G: 确认授权
    G->>F: 回调到/api/auth/google/callback
    F->>F: API路由重定向到页面路由
    F->>J: 调用Java验证接口
    J->>G: 验证授权码获取用户信息
    J->>F: 返回验证结果
    F->>P: 调用Python注册接口
    P->>F: 返回用户信息和token
    F->>F: 保存用户信息到localStorage
    F->>U: 跳转到主页面

🔧 核心组件分析

1. 环境配置 (.env.production)

<augment_code_snippet path=".env.production" mode="EXCERPT">

# Java认证服务
NEXT_PUBLIC_JAVA_URL = https://auth.test.movieflow.ai
# Python业务服务
NEXT_PUBLIC_BASE_URL = https://77.smartvideo.py.qikongjian.com
# Google OAuth配置
NEXT_PUBLIC_GOOGLE_CLIENT_ID=847079918888-o1nne8d3ij80dn20qurivo987pv07225.apps.googleusercontent.com
NEXT_PUBLIC_GOOGLE_REDIRECT_URI=https://www.movieflow.net/api/auth/google/callback

</augment_code_snippet>

关键问题

  • 域名不一致redirect_uri 使用 movieflow.net,但实际部署可能在 movieflow.ai
  • 硬编码配置:多个环境的配置混杂在一起

2. Next.js 路由代理配置

<augment_code_snippet path="next.config.js" mode="EXCERPT">

// Google OAuth2 API代理 (排除callback让Next.js本地处理)
{
  source: '/api/auth/google/((?!callback).*)',
  destination: `${AUTH_API_URL}/api/auth/google/$1`,
},

</augment_code_snippet>

设计意图

  • 除了 callback 路由外,其他 Google OAuth API 都代理到 Java 服务
  • callback 路由由 Next.js 本地处理,便于前端控制流程

3. 登录发起流程

3.1 登录页面组件

<augment_code_snippet path="components/pages/login.tsx" mode="EXCERPT">

const handleGoogleSignIn = async () => {
  try {
    setGoogleLoading(true)
    setFormError('')

    // 获取邀请码从URL参数或其他来源
    const inviteCode = searchParams?.get('invite') || undefined

    // 使用Google GSI SDK进行登录
    await signInWithGoogle(inviteCode)
  } catch (error: any) {
    console.error('Google sign-in error:', error)
    setFormError(error.message || 'Google sign-in failed, please try again')
    setGoogleLoading(false)
  }
}

</augment_code_snippet>

3.2 Google 登录核心逻辑

<augment_code_snippet path="lib/auth.ts" mode="EXCERPT">

export const signInWithGoogle = async (inviteCode?: string): Promise<void> => {
  try {
    // 从环境变量获取配置
    const clientId = process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID
    const javaBaseUrl = process.env.NEXT_PUBLIC_JAVA_URL
    const redirectUri = process.env.NEXT_PUBLIC_GOOGLE_REDIRECT_URI

    // 生成随机nonce用于安全验证
    const nonce = Array.from(crypto.getRandomValues(new Uint8Array(32)))
      .map((b) => b.toString(16).padStart(2, '0'))
      .join('')

    // 构建state参数包含邀请码等信息
    const stateData = {
      inviteCode: finalInviteCode || '',
      timestamp: Date.now(),
      origin: window.location.pathname + window.location.search,
      nonce: nonce,
    }

    // 构建Google OAuth2授权URL
    const authParams = new URLSearchParams({
      access_type: 'online',
      client_id: clientId,
      nonce: nonce,
      redirect_uri: redirectUri,
      response_type: 'code', // 使用授权码模式
      scope: 'email openid profile',
      state: JSON.stringify(stateData),
      prompt: 'select_account', // 总是显示账号选择
    })

    const authUrl = `https://accounts.google.com/o/oauth2/v2/auth?${authParams.toString()}`

    // 保存state到sessionStorage用于验证
    sessionStorage.setItem(
      'google_oauth_state',
      JSON.stringify({
        nonce: nonce,
        timestamp: Date.now(),
        inviteCode: finalInviteCode || '',
      })
    )

    // 直接在当前页面跳转到Google
    window.location.href = authUrl
  } catch (error) {
    console.error('Google登录跳转失败:', error)
    throw error
  }
}

</augment_code_snippet>

关键特性

  • 使用 crypto.getRandomValues 生成安全的 nonce
  • state 参数包含邀请码、时间戳等信息
  • 使用 sessionStorage 保存状态用于后续验证
  • 硬编码的 redirect_uri 可能导致域名不匹配问题

4. 回调处理流程

4.1 API 路由回调处理

<augment_code_snippet path="app/api/auth/google/callback/route.ts" mode="EXCERPT">

export async function GET(request: NextRequest) {
  const { searchParams } = new URL(request.url)
  const code = searchParams.get('code')
  const state = searchParams.get('state')

  if (!code || !state) {
    return NextResponse.json(
      {
        success: false,
        message: 'Missing required parameters: code and state',
      },
      { status: 400 }
    )
  }

  // 重定向到页面路由让页面处理OAuth回调
  const callbackUrl = `/users/oauth/callback?code=${encodeURIComponent(
    code
  )}&state=${encodeURIComponent(state)}`

  // 修复:确保使用正确的域名进行重定向
  const host = request.headers.get('host') || 'www.movieflow.net'
  const protocol = request.headers.get('x-forwarded-proto') || 'https'
  const fullCallbackUrl = `${protocol}://${host}${callbackUrl}`

  return NextResponse.redirect(fullCallbackUrl)
}

</augment_code_snippet>

设计模式

  • API 路由仅作为中转,将 GET 请求重定向到页面路由
  • 动态构建重定向 URL避免硬编码域名
  • 默认域名仍然是硬编码的 movieflow.net

4.2 页面路由回调处理

<augment_code_snippet path="app/users/oauth/callback/page.tsx" mode="EXCERPT">

const handleOAuthCallback = async () => {
  try {
    // 获取URL参数
    const params: OAuthCallbackParams = {
      code: searchParams.get('code') || undefined,
      state: searchParams.get('state') || undefined,
      error: searchParams.get('error') || undefined,
      error_description: searchParams.get('error_description') || undefined,
    }

    // 解析state参数
    let stateData: any = {}
    if (params.state) {
      try {
        stateData = JSON.parse(params.state)
      } catch (e) {
        console.error('Failed to parse state parameter:', e)
      }
    }

    // 第一步调用Java验证接口只验证不创建用户
    const javaBaseUrl = 'https://auth.test.movieflow.ai'
    const verifyResponse = await fetch(
      `${javaBaseUrl}/api/auth/google/callback`,
      {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
          Accept: 'application/json',
        },
        body: JSON.stringify({
          code: params.code, // Google authorization code
          state: params.state, // state参数
          inviteCode: finalInviteCode, // 邀请码
          skipUserCreation: true, // 🔑 关键:只验证不创建用户
        }),
      }
    )

    // 第二步调用Python注册接口进行用户创建和积分发放
    const smartvideoBaseUrl = process.env.NEXT_PUBLIC_BASE_URL
    const registerResponse = await fetch(
      `${smartvideoBaseUrl}/api/user_fission/google_register`,
      {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
          Accept: 'application/json',
        },
        body: JSON.stringify({
          email: verifyResult.data.email,
          name: verifyResult.data.name,
          google_id: verifyResult.data.sub,
          invite_code: finalInviteCode,
        }),
      }
    )

    // 保存用户信息到localStorage
    const userData = {
      userId: registerResult.data.user_id,
      userName: registerResult.data.name,
      name: registerResult.data.name,
      email: registerResult.data.email,
      authType: registerResult.data.auth_type || 'GOOGLE',
      isNewUser: true,
      inviteCode: registerResult.data.invite_code,
    }

    localStorage.setItem('currentUser', JSON.stringify(userData))
    if (registerResult.data.token) {
      localStorage.setItem('token', registerResult.data.token)
    }

    // 2秒后跳转到主页
    setTimeout(() => {
      const returnUrl = '/movies'
      window.location.href = returnUrl
    }, 2000)
  } catch (error) {
    console.error('OAuth callback处理失败:', error)
    setStatus('error')
    setMessage(error.message || 'Authentication failed')
  }
}

</augment_code_snippet>

核心特性

  • 两步验证:先 Java 验证 Google Token再 Python 创建用户
  • 支持邀请码逻辑
  • 完整的错误处理和用户反馈
  • 硬编码的 Java 服务地址
  • 缺少 state 参数的安全验证

🚨 发现的问题

1. 域名配置不一致

  • 问题redirect_uri 配置为 movieflow.net,但实际可能部署在 movieflow.ai
  • 影响Google OAuth 回调失败
  • 建议:统一域名配置,使用环境变量管理

2. 硬编码配置过多

  • 问题Java 服务地址、域名等多处硬编码
  • 影响:环境切换困难,维护成本高
  • 建议:全部使用环境变量配置

3. 安全验证不完整

  • 问题:缺少 state 参数的 nonce 验证
  • 影响:存在 CSRF 攻击风险
  • 建议:完善 state 参数验证逻辑

4. 错误处理不够细致

  • 问题:某些错误场景处理不够详细
  • 影响:用户体验不佳,问题排查困难
  • 建议:增加更详细的错误分类和处理

📈 优化建议

1. 配置管理优化

// 建议的配置管理方式
const config = {
  googleClientId: process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID!,
  javaBaseUrl: process.env.NEXT_PUBLIC_JAVA_URL!,
  pythonBaseUrl: process.env.NEXT_PUBLIC_BASE_URL!,
  redirectUri: process.env.NEXT_PUBLIC_GOOGLE_REDIRECT_URI!,
  frontendUrl: process.env.NEXT_PUBLIC_FRONTEND_URL!,
}

2. 安全性增强

// 建议的 state 验证逻辑
const validateState = (receivedState: string): boolean => {
  try {
    const stateData = JSON.parse(receivedState)
    const storedState = sessionStorage.getItem('google_oauth_state')

    if (!storedState) return false

    const stored = JSON.parse(storedState)

    // 验证 nonce 和时间戳
    return (
      stateData.nonce === stored.nonce &&
      Math.abs(Date.now() - stored.timestamp) < 600000
    ) // 10分钟有效期
  } catch {
    return false
  }
}

3. 错误处理优化

// 建议的错误处理策略
enum OAuthError {
  INVALID_STATE = 'INVALID_STATE',
  EXPIRED_STATE = 'EXPIRED_STATE',
  GOOGLE_AUTH_FAILED = 'GOOGLE_AUTH_FAILED',
  USER_CREATION_FAILED = 'USER_CREATION_FAILED',
  NETWORK_ERROR = 'NETWORK_ERROR',
}

const handleOAuthError = (error: OAuthError, details?: any) => {
  const errorMessages = {
    [OAuthError.INVALID_STATE]: '安全验证失败,请重新登录',
    [OAuthError.EXPIRED_STATE]: '登录会话已过期,请重新登录',
    [OAuthError.GOOGLE_AUTH_FAILED]: 'Google 认证失败,请重试',
    [OAuthError.USER_CREATION_FAILED]: '用户创建失败,请联系客服',
    [OAuthError.NETWORK_ERROR]: '网络连接失败,请检查网络后重试',
  }

  setMessage(errorMessages[error] || '未知错误')
  console.error(`OAuth Error: ${error}`, details)
}

🔄 完整流程时序图

graph TD
    A[用户点击Google登录] --> B[检查Google登录是否启用]
    B --> C{启用状态}
    C -->|启用| D[生成state参数和nonce]
    C -->|未启用| E[隐藏Google登录按钮]

    D --> F[保存state到sessionStorage]
    F --> G[构建Google OAuth URL]
    G --> H[跳转到Google授权页面]

    H --> I[用户在Google页面授权]
    I --> J[Google回调到/api/auth/google/callback]

    J --> K[API路由获取code和state]
    K --> L{参数验证}
    L -->|失败| M[返回400错误]
    L -->|成功| N[重定向到/users/oauth/callback页面]

    N --> O[页面路由解析URL参数]
    O --> P[解析state参数获取邀请码]
    P --> Q[调用Java验证接口]

    Q --> R{Java验证结果}
    R -->|失败| S[显示错误信息]
    R -->|成功| T[调用Python注册接口]

    T --> U{Python注册结果}
    U -->|失败| V[显示注册失败]
    U -->|成功| W[保存用户信息到localStorage]

    W --> X[显示成功信息]
    X --> Y[2秒后跳转到/movies]

📊 数据流分析

1. State 参数结构

interface StateData {
  inviteCode: string // 邀请码
  timestamp: number // 时间戳
  origin: string // 原始页面路径
  nonce: string // 安全随机数
}

2. Java 验证接口请求

interface JavaVerifyRequest {
  code: string // Google授权码
  state: string // state参数
  inviteCode?: string // 邀请码
  skipUserCreation: true // 只验证不创建用户
}

3. Python 注册接口请求

interface PythonRegisterRequest {
  email: string // 用户邮箱
  name: string // 用户姓名
  google_id: string // Google用户ID
  invite_code?: string // 邀请码
}

🛡️ 安全机制分析

1. CSRF 防护

  • 使用 state 参数防止 CSRF 攻击
  • 生成随机 nonce 增强安全性
  • 缺少 state 参数的服务端验证

2. 会话管理

  • 使用 sessionStorage 临时存储状态
  • 设置时间戳用于过期检查
  • 缺少自动清理过期状态的机制

3. Token 安全

  • 使用授权码模式,不直接暴露 access_token
  • Token 存储在 localStorage
  • 缺少 Token 过期自动刷新机制

🔧 技术债务清单

高优先级

  1. 域名配置统一

    • 问题redirect_uri 域名不匹配
    • 影响OAuth 回调失败
    • 工作量1 天
  2. 硬编码配置清理

    • 问题:多处硬编码服务地址
    • 影响:环境切换困难
    • 工作量0.5 天

中优先级

  1. State 参数验证完善

    • 问题:缺少 nonce 验证
    • 影响:安全风险
    • 工作量1 天
  2. 错误处理优化

    • 问题:错误分类不够细致
    • 影响:用户体验和调试困难
    • 工作量1 天

低优先级

  1. Token 刷新机制

    • 问题:缺少自动刷新
    • 影响:用户需要重新登录
    • 工作量2 天
  2. 日志和监控完善

    • 问题:缺少结构化日志
    • 影响:问题排查困难
    • 工作量1 天

🎯 总结

Video Flow 的 Google OAuth 回调逻辑整体设计合理,采用了前后端分离的架构,支持邀请码功能,具备基本的安全防护。主要问题集中在配置管理和安全验证的细节上。

优势

  • 架构清晰,职责分离
  • 支持邀请码业务逻辑
  • 具备基本的错误处理
  • 使用标准的 OAuth 2.0 授权码模式

待改进

  • 配置管理需要优化
  • 安全验证需要加强
  • 错误处理需要细化
  • 监控和日志需要完善

建议按照技术债务清单的优先级逐步改进,优先解决影响功能正常运行的配置问题,然后逐步完善安全性和用户体验。