Files
raisfast/docs/google-oauth-setup.md
T

13 KiB
Raw Blame History

Google OAuth 接入详细步骤

1. 创建 Google Cloud 项目

1.1 进入 Google Cloud Console

  1. 打开 https://console.cloud.google.com/
  2. 使用 Google 账号登录

1.2 创建新项目

  1. 点击顶部导航栏的项目选择器
  2. 点击 NEW PROJECT
  3. 填写:
    • Project name: raisfast
    • Organization: 保持默认
  4. 点击 CREATE
  5. 等待项目创建完成,选择该项目

2. 配置 OAuth 同意屏幕

Google 要求所有 OAuth 应用必须配置同意屏幕,否则无法使用。

2.1 进入配置页面

  1. 左侧菜单 → APIs & ServicesOAuth consent screen
  2. 直接链接:https://console.cloud.google.com/apis/credentials/consent

2.2 选择用户类型

用户类型 适用场景
External 任何 Google 账号都可登录(推荐)
Internal 仅组织内 G Suite 账号可登录

选择 External → 点击 CREATE

2.3 填写应用信息

OAuth consent screen 页面:

字段
App name raisfast
User support email 你的邮箱
App logo (可选,后续上传)

App domain(全部可选):

字段
Application home page http://localhost:3000
Application privacy policy link (可选)
Application terms of service link (可选)

Authorized domains

点击 ADD DOMAIN,添加:

域名
localhost

生产环境添加你的域名如 yourdomain.com

Developer contact information

字段
Email addresses 你的邮箱

点击 SAVE AND CONTINUE

2.4 配置 Scopes

  1. 点击 ADD OR REMOVE SCOPES
  2. 搜索并勾选以下 scope
Scope 说明
.../auth/userinfo.email 读取用户邮箱
.../auth/userinfo.profile 读取用户基本信息(名字、头像)
openid OpenID Connect
  1. 点击 UPDATESAVE AND CONTINUE

2.5 添加测试用户(发布前必须)

如果应用状态为 Testing,只有添加的测试用户可以登录。

  1. 点击 ADD USERS
  2. 输入你的 Gmail 邮箱
  3. 点击 ADDSAVE AND CONTINUE

2.6 发布应用(可选)

Testing 状态下只有测试用户能登录。如需所有人可登录:

  1. 回到 OAuth consent screen
  2. 点击 PUBLISH APP
  3. 确认发布

注意:发布后如果请求敏感 scope 可能需要 Google 审核。email + profile 是非敏感的,无需审核。

3. 创建 OAuth 2.0 凭证

3.1 进入凭证页面

  1. 左侧菜单 → APIs & ServicesCredentials
  2. 直接链接:https://console.cloud.google.com/apis/credentials

3.2 创建 OAuth 客户端

  1. 点击顶部 + CREATE CREDENTIALSOAuth client ID
  2. 填写:
字段
Application type Web application
Name raisfast-web

Authorized JavaScript origins

点击 ADD URI,添加:

环境 URI
本地开发 http://localhost:9000
生产环境 https://yourdomain.com

Authorized redirect URIs

点击 ADD URI,添加:

环境 URI
本地开发 http://localhost:9000/api/v1/auth/oauth/google/callback
生产环境 https://yourdomain.com/api/v1/auth/oauth/google/callback
  1. 点击 CREATE

3.3 获取凭证

创建完成后弹出窗口显示:

  • Your Client ID:形如 123456789-abcxxx.apps.googleusercontent.com
  • Your Client Secret:形如 GOCSPX-xxxxxxxxxx

点击 DOWNLOAD JSON 备份凭证(可选)。

Client Secret 可以在凭证详情页随时查看,不会像 GitHub 那样只显示一次。

4. 后端代码实现

Google OAuth 需要新增 Provider 实现。创建 src/oauth/google.rs

4.1 Google Provider 实现

Google 的 OAuth2 端点:

端点 URL
Authorization https://accounts.google.com/o/oauth2/v2/auth
Token Exchange https://oauth2.googleapis.com/token
User Info https://www.googleapis.com/oauth2/v2/userinfo

4.2 关键差异(对比 GitHub

对比项 GitHub Google
Scope user:email openid email profile
Token 响应 access_token 在 JSON body
用户信息 API GET /user + GET /user/emails GET /userinfo(一次请求)
用户 ID 数字字符串 数字字符串(sub 字段)
邮箱 可能需要额外请求 直接返回,有 verified_email 字段

4.3 实现步骤

步骤一:创建 Google Provider 文件

src/oauth.rs 中添加 Google Provider(与 GitHub 同文件):

// 在 src/oauth.rs 末尾添加

// ── Google Provider ────────────────────────────────────────────

pub struct GoogleProvider {
    client_id: String,
    client_secret: String,
}

impl GoogleProvider {
    pub fn new(client_id: String, client_secret: String) -> Self {
        Self { client_id, client_secret }
    }
}

#[async_trait::async_trait]
impl OAuthProvider for GoogleProvider {
    fn name(&self) -> &str {
        "google"
    }

    fn authorize_url(&self, state: &str, code_challenge: &str) -> String {
        format!(
            "https://accounts.google.com/o/oauth2/v2/auth?client_id={}&state={}&code_challenge={}&code_challenge_method=S256&scope=openid+email+profile&response_type=code&access_type=offline",
            self.client_id, state, code_challenge
        )
    }

    async fn exchange_code(&self, code: &str, code_verifier: &str)
        -> AppResult<OAuthTokenResponse>
    {
        let client = reqwest::Client::new();
        let resp = client
            .post("https://oauth2.googleapis.com/token")
            .header("Accept", "application/json")
            .json(&serde_json::json!({
                "client_id": self.client_id,
                "client_secret": self.client_secret,
                "code": code,
                "code_verifier": code_verifier,
                "grant_type": "authorization_code",
                "redirect_uri": "",  // PKCE 模式不需要
            }))
            .send()
            .await
            .map_err(|e| AppError::Internal(
                anyhow::anyhow!("Google token exchange failed: {e}")
            ))?;

        if !resp.status().is_success() {
            let status = resp.status();
            let body = resp.text().await.unwrap_or_default();
            return Err(AppError::Internal(anyhow::anyhow!(
                "Google token exchange returned {status}: {body}"
            )));
        }

        resp.json::<OAuthTokenResponse>().await.map_err(|e| {
            AppError::Internal(anyhow::anyhow!(
                "Google token response parse failed: {e}"
            ))
        })
    }

    async fn fetch_user_info(&self, access_token: &str)
        -> AppResult<OAuthUserInfo>
    {
        let client = reqwest::Client::new();
        let resp = client
            .get("https://www.googleapis.com/oauth2/v2/userinfo")
            .header("Authorization", format!("Bearer {access_token}"))
            .header("Accept", "application/json")
            .send()
            .await
            .map_err(|e| AppError::Internal(
                anyhow::anyhow!("Google user info request failed: {e}")
            ))?;

        if !resp.status().is_success() {
            let status = resp.status();
            let body = resp.text().await.unwrap_or_default();
            return Err(AppError::Internal(anyhow::anyhow!(
                "Google user info returned {status}: {body}"
            )));
        }

        let profile: serde_json::Value = resp.json().await.map_err(|e| {
            AppError::Internal(anyhow::anyhow!(
                "Google user info parse failed: {e}"
            ))
        })?;

        let provider_user_id = profile["sub"]
            .as_str()
            .unwrap_or_default()
            .to_string();
        let email = profile["email"].as_str().map(|s| s.to_string());
        let display_name = profile["name"].as_str()
            .or_else(|| profile["given_name"].as_str())
            .map(|s| s.to_string());
        let avatar_url = profile["picture"].as_str().map(|s| s.to_string());

        Ok(OAuthUserInfo {
            provider_user_id,
            email,
            display_name,
            avatar_url,
            raw_profile: profile,
        })
    }
}

步骤二:在 server.rs 注册 Google Provider

build_oauth_registry 函数中添加:

if let Some(google) = &config.oauth.google {
    registry.register(Box::new(
        crate::oauth::GoogleProvider::new(
            google.client_id.clone(),
            google.client_secret.clone(),
        ),
    ));
    tracing::info!("OAuth provider registered: google");
}

步骤三:更新用户名生成策略

ensure_unique_username 中的 fallback 前缀需要支持 google

async fn ensure_unique_username(pool: &crate::db::Pool, base: &str, provider: &str) -> AppResult<String> {
    // ...
    let prefixed = format!("{provider}_{username}");
    // ...
}

5. 配置环境变量

编辑 .env

# Google OAuth
OAUTH_GOOGLE_CLIENT_ID=123456789-abcxxx.apps.googleusercontent.com
OAUTH_GOOGLE_CLIENT_SECRET=GOCSPX-xxxxxxxxxx

OAUTH_ENABLED=true 已在 GitHub 配置时设置,无需重复。

6. 验证配置

重启后端,检查日志:

OAuth provider registered: github
OAuth provider registered: google

测试 API

# 查看已配置 Provider
curl http://localhost:9000/api/v1/auth/oauth/providers

# 预期返回:
# {"code":0,"message":"操作成功","data":[
#   {"name":"github","configured":true},
#   {"name":"google","configured":true}
# ]}

# 测试 Google 授权 URL
curl -v http://localhost:9000/api/v1/auth/oauth/google 2>&1 | grep "Location:"

# 应输出:
# Location: https://accounts.google.com/o/oauth2/v2/auth?client_id=...

7. 测试完整流程

7.1 发起 Google 登录

浏览器打开:

http://localhost:9000/api/v1/auth/oauth/google

预期:302 到 Google 登录页面

7.2 Google 授权

  1. 选择 Google 账号
  2. 如果是 Testing 模式,会看到 "This app isn't verified" 警告
  3. 点击 AdvancedGo to raisfast (unsafe)
  4. 点击 Continue 授权

7.3 回调处理

Google 回调到:

http://localhost:9000/api/v1/auth/oauth/google/callback?code=xxx&state=xxx&scope=email+profile+openid

后端处理后 302 到前端:

http://localhost:3000/auth/callback?access_token=...&refresh_token=...&expires_in=900

7.4 前端登录页添加 Google 按钮

<a href={`${API_BASE}/auth/oauth/google`}>
  <GoogleIcon />
  Google 登录
</a>

8. 常见问题

"This app isn't verified" 警告

原因:应用处于 Testing 状态,且请求了非敏感 scope。

解决(开发阶段)

  1. 确保你的邮箱已添加到 Test Users
  2. 点击 AdvancedGo to raisfast (unsafe) 即可继续

解决(生产阶段)

  1. 在 OAuth consent screen 页面点击 PUBLISH APP

Error 400: redirect_uri_mismatch

原因Google OAuth App 中配置的 redirect URI 与实际不匹配。

检查

  1. Google Console → Credentials → 你的 OAuth client → Authorized redirect URIs
  2. 确保包含 http://localhost:9000/api/v1/auth/oauth/google/callback
  3. 注意尾部斜杠、协议、端口号必须完全一致

Error 401: invalid_client

原因Client ID 或 Client Secret 不正确。

解决

  1. 复制 Google Console 中的完整 Client ID(很长的字符串)
  2. 确认 .env 中没有多余空格或换行

获取不到邮箱

检查

  1. OAuth consent screen 中是否添加了 email scope
  2. 授权 URL 中 scope=openid+email+profile 是否完整
  3. Google 账号是否设置了邮箱

用户名冲突

如果 Google 用户名与已有用户冲突,系统自动追加前缀:

alice → google_alice → google_alice_a3f8

9. 生产环境部署

9.1 更新 Google OAuth App

配置项 添加值
Authorized JavaScript origins https://yourdomain.com
Authorized redirect URIs https://yourdomain.com/api/v1/auth/oauth/google/callback

9.2 发布应用

  1. OAuth consent screen → PUBLISH APP
  2. 如果 scope 中包含敏感 scope,需要提交 Google 审核(通常 1-3 天)

9.3 隐私政策页面

Google 要求应用提供隐私政策链接:

  1. 在前端创建 /privacy 页面
  2. 在 OAuth consent screen 中添加 Privacy policy URL

9.4 环境变量

OAUTH_GOOGLE_CLIENT_ID=your-production-client-id
OAUTH_GOOGLE_CLIENT_SECRET=your-production-client-secret

10. Google vs GitHub 对比

对比项 GitHub Google
创建凭证 Settings → Developer settings Google Cloud Console
凭证数量 Client ID + Secret(同页面) Client ID + Secret(同页面)
回调 URL 配置 创建时设置,可修改 创建时设置,可修改
Scope user:email openid email profile
邮箱获取 可能需要额外 API 调用 直接返回
用户唯一标识 id(数字) sub(数字)
头像 avatar_url picture
昵称 login namegiven_name
应用审核 不需要 敏感 scope 需要审核
测试模式 Testing 模式限制用户
Consent screen 简单 复杂(需完整配置)