From 57c3efb70e5d38a81a57269a979125a73f772f56 Mon Sep 17 00:00:00 2001 From: agent Date: Tue, 14 Apr 2026 20:28:52 +0800 Subject: [PATCH] =?UTF-8?q?feat(auth):=20=E6=9C=8D=E5=8A=A1=E7=AB=AF?= =?UTF-8?q?=E6=89=98=E7=AE=A1=20Google=20OAuth=EF=BC=9B=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=E6=9C=AA=E8=A7=A3=E9=94=81=20vault=20=E6=97=B6=20bootstrap?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - API:桌面登录 session、Google 托管回调与轮询 - Desktop:轮询登录;bootstrap 在 vault 未解锁时不返回 shell,避免跳过主密码 - 文档与 deploy/.env.example 对齐 GOOGLE_OAUTH_* 与 SECRETS_PUBLIC_BASE_URL --- AGENTS.md | 19 +- Cargo.lock | 3 + README.md | 96 ++-- apps/api/Cargo.toml | 3 + apps/api/src/main.rs | 585 ++++++++++++++++++++++-- apps/desktop/dist/index.html | 2 +- apps/desktop/dist/main.js | 2 + apps/desktop/src-tauri/src/main.rs | 259 +++-------- crates/infrastructure-db/src/migrate.rs | 22 + deploy/.env.example | 13 +- 10 files changed, 738 insertions(+), 266 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 2467345..aba467e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -113,18 +113,23 @@ secrets/ 当前登录流为 **Google Desktop OAuth**: - 桌面端使用系统浏览器拉起 Google 授权 -- 使用本地 loopback callback -- 使用 `PKCE` -- 桌面端换取 Google token 后调用 API 的桌面登录接口 +- API 服务端持有 Google OAuth client 配置并处理 callback / token exchange +- desktop 创建一次性 login session,打开托管登录页后轮询状态 - API 校验 Google userinfo 后发放本地 device token -桌面端优先读取: +官网 DMG 正式分发时,服务端至少需要配置: -- `GOOGLE_OAUTH_CLIENT_FILE` +- `SECRETS_PUBLIC_BASE_URL` +- `GOOGLE_OAUTH_CLIENT_ID` +- `GOOGLE_OAUTH_CLIENT_SECRET` +- `GOOGLE_OAUTH_REDIRECT_URI` -默认开发文件名: +推荐约束: -- `client_secret_738964258008-0svfo4g7ta347iedrf6r9see87a8u3hn.apps.googleusercontent.com.json` +- `SECRETS_PUBLIC_BASE_URL` 使用用户浏览器实际访问的 HTTPS 官网地址 +- `GOOGLE_OAUTH_REDIRECT_URI` 配置为 `${SECRETS_PUBLIC_BASE_URL}/auth/google/callback` +- `GOOGLE_OAUTH_CLIENT_SECRET` 只保留在服务端环境变量或密钥管理系统中,不入库 +- Google Cloud Console 中登记的 callback URL 必须与 `GOOGLE_OAUTH_REDIRECT_URI` 完全一致 ## MCP diff --git a/Cargo.lock b/Cargo.lock index 46af67f..84001ee 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3669,6 +3669,7 @@ version = "0.1.0" dependencies = [ "anyhow", "axum", + "base64 0.22.1", "chrono", "dotenvy", "reqwest 0.12.28", @@ -3678,10 +3679,12 @@ dependencies = [ "secrets-infrastructure-db", "serde", "serde_json", + "sha2", "sqlx", "tokio", "tracing", "tracing-subscriber", + "url", "uuid", ] diff --git a/README.md b/README.md index 89af08c..e23b146 100644 --- a/README.md +++ b/README.md @@ -27,8 +27,42 @@ cargo run -p secrets-desktop - `apps/desktop/src-tauri/tauri.conf.json` 中 `build.frontendDist` 指向 `apps/desktop/dist` - 当前仓库会直接提交 `apps/desktop/dist/` 下的桌面端静态资源 - 因此新机器 clone 后,无需额外前端构建步骤即可启动 desktop -- Google Desktop OAuth 的 `client_secret_*.json` **不会入库** -- 新机器需要自行提供该文件,并通过 `GOOGLE_OAUTH_CLIENT_FILE` 指向它;推荐使用绝对路径 +- 官网 DMG 正式分发不依赖本地 `client_secret_*.json` +- Google OAuth 凭据只配置在 API 服务端,desktop 通过浏览器完成托管登录 + +## 官网 DMG 的服务端 OAuth 配置 + +官网 DMG 正式分发时,**Google OAuth 只配置在 API 服务端**。桌面端不需要本地 `client_secret_*.json`,也不直接向 Google 换 token。 + +建议先复制 `deploy/.env.example` 为 `.env`,然后至少配置以下变量: + +```bash +SECRETS_PUBLIC_BASE_URL=https://secrets.example.com +GOOGLE_OAUTH_CLIENT_ID=your-google-oauth-client-id.apps.googleusercontent.com +GOOGLE_OAUTH_CLIENT_SECRET=your-google-oauth-client-secret +GOOGLE_OAUTH_REDIRECT_URI=https://secrets.example.com/auth/google/callback +``` + +变量含义: + +- `SECRETS_PUBLIC_BASE_URL`:桌面端打开浏览器时访问的 API 外网基地址,必须是用户浏览器能访问到的公开地址 +- `GOOGLE_OAUTH_CLIENT_ID`:Google Cloud Console 中为服务端登录流程配置的 OAuth Client ID +- `GOOGLE_OAUTH_CLIENT_SECRET`:对应的 Client Secret,只能保留在服务端 +- `GOOGLE_OAUTH_REDIRECT_URI`:Google 登录完成后回调到 API 的地址,必须与 Google Console 中登记的回调地址完全一致 + +配置步骤建议: + +1. 在 Google Cloud Console 创建或选择 OAuth Client +2. 把授权回调地址加入允许列表,例如 `https://secrets.example.com/auth/google/callback` +3. 把上面的 4 个变量配置到 API 服务的运行环境中 +4. 确认 `SECRETS_PUBLIC_BASE_URL` 与 `GOOGLE_OAUTH_REDIRECT_URI` 使用同一公开域名 +5. 重启 API 服务后,再用 desktop / DMG 验证浏览器登录流程 + +注意: + +- `GOOGLE_OAUTH_CLIENT_SECRET` 不要提交到仓库 +- `GOOGLE_OAUTH_REDIRECT_URI` 不要写成 `localhost`,正式分发应使用官网可访问域名 +- 如果 API 部署在反向代理后面,`SECRETS_PUBLIC_BASE_URL` 应填写用户实际访问的 HTTPS 地址,而不是内网监听地址 ## 当前能力 @@ -69,25 +103,27 @@ cargo test --locked - 桌面端登录态仅在当前进程内有效,不持久化 `device token` - 本地 daemon 默认监听 `http://127.0.0.1:9515/mcp` - daemon 通过活跃 desktop 进程提供的本地会话转发访问 API;desktop 进程退出后所有工具不可用 -- `target_exec` 会显式读取真实 secret 值后再生成 `TARGET_*` 环境变量 +- `target_exec` 会显式读取真实 secret 值后再生成 `TARGET_`* 环境变量 - 不保留 `secrets_env_map` ### Canonical MCP 工具 -| 工具 | 说明 | -| --- | --- | -| `secrets_entry_find` | 从 desktop 已解锁本地 vault 搜索对象,支持 `query` / `folder` / `type` | -| `secrets_entry_get` | 读取单条本地对象,并返回当前 secrets 的真实值 | -| `secrets_entry_add` | 在本地 vault 创建对象,可选附带初始 secrets | -| `secrets_entry_update` | 更新本地对象的 folder / type / name / metadata | -| `secrets_entry_delete` | 将本地对象标记为删除 | -| `secrets_entry_restore` | 恢复本地已删除对象 | -| `secrets_secret_add` | 向已有本地对象新增 secret | -| `secrets_secret_update` | 更新本地 secret 名称、类型或内容 | -| `secrets_secret_delete` | 删除单个本地 secret | -| `secrets_secret_history` | 查看单个本地 secret 的历史版本 | -| `secrets_secret_rollback` | 将单个本地 secret 回滚到指定版本 | -| `target_exec` | 用本地对象的 metadata 和 secrets 生成 `TARGET_*` 环境变量并执行本地命令 | + +| 工具 | 说明 | +| ------------------------- | --------------------------------------------------------- | +| `secrets_entry_find` | 从 desktop 已解锁本地 vault 搜索对象,支持 `query` / `folder` / `type` | +| `secrets_entry_get` | 读取单条本地对象,并返回当前 secrets 的真实值 | +| `secrets_entry_add` | 在本地 vault 创建对象,可选附带初始 secrets | +| `secrets_entry_update` | 更新本地对象的 folder / type / name / metadata | +| `secrets_entry_delete` | 将本地对象标记为删除 | +| `secrets_entry_restore` | 恢复本地已删除对象 | +| `secrets_secret_add` | 向已有本地对象新增 secret | +| `secrets_secret_update` | 更新本地 secret 名称、类型或内容 | +| `secrets_secret_delete` | 删除单个本地 secret | +| `secrets_secret_history` | 查看单个本地 secret 的历史版本 | +| `secrets_secret_rollback` | 将单个本地 secret 回滚到指定版本 | +| `target_exec` | 用本地对象的 metadata 和 secrets 生成 `TARGET_`* 环境变量并执行本地命令 | + ## AI 客户端配置 @@ -115,7 +151,7 @@ cargo test --locked - 服务端保存 `vault_objects` 与 `vault_object_revisions` - desktop 本地保存 `vault_objects`、`vault_object_history`、`pending_changes`、`sync_state` - 搜索、详情、reveal、history 主要在本地已解锁 vault 上完成 -- 服务端负责 `auth/device` 与 `/sync/*`,不再承担明文搜索与明文 reveal +- 服务端负责 `auth/device` 与 `/sync/`*,不再承担明文搜索与明文 reveal 主要表: @@ -129,23 +165,25 @@ cargo test --locked 字段职责: -| 位置 | 字段 | 说明 | -| --- | --- | --- | -| `vault_objects` | `object_id` | 同步对象标识 | -| `vault_objects` | `object_kind` | 当前对象类别,当前主要为 `cipher` | -| `vault_objects` | `revision` | 服务端对象版本 | -| `vault_objects` | `ciphertext` | 密文对象载荷 | -| `vault_objects` | `content_hash` | 密文摘要 | -| `vault_objects` | `deleted_at` | 对象级删除标记 | -| `vault_object_revisions` | `revision` / `ciphertext` | 服务端对象历史版本 | + +| 位置 | 字段 | 说明 | +| ------------------------ | ------------------------- | --------------------- | +| `vault_objects` | `object_id` | 同步对象标识 | +| `vault_objects` | `object_kind` | 当前对象类别,当前主要为 `cipher` | +| `vault_objects` | `revision` | 服务端对象版本 | +| `vault_objects` | `ciphertext` | 密文对象载荷 | +| `vault_objects` | `content_hash` | 密文摘要 | +| `vault_objects` | `deleted_at` | 对象级删除标记 | +| `vault_object_revisions` | `revision` / `ciphertext` | 服务端对象历史版本 | + ## 认证与事件 当前登录流为 Google Desktop OAuth: - 桌面端使用系统浏览器拉起 Google 授权 -- 使用本地 loopback callback + PKCE -- API 校验 Google userinfo 后发放 `device token` +- API 服务端负责发起 OAuth、处理 callback、校验 Google userinfo +- desktop 通过创建一次性 login session 并轮询状态获取 `device token` - 登录与设备活动写入 `auth_events` ## 项目结构 diff --git a/apps/api/Cargo.toml b/apps/api/Cargo.toml index 5286c28..1d7ceb8 100644 --- a/apps/api/Cargo.toml +++ b/apps/api/Cargo.toml @@ -20,6 +20,9 @@ tracing-subscriber.workspace = true uuid.workspace = true chrono.workspace = true reqwest.workspace = true +sha2.workspace = true +url.workspace = true +base64 = "0.22.1" secrets-application = { path = "../../crates/application" } secrets-device-auth = { path = "../../crates/device-auth" } diff --git a/apps/api/src/main.rs b/apps/api/src/main.rs index 74fc382..b5cc0d5 100644 --- a/apps/api/src/main.rs +++ b/apps/api/src/main.rs @@ -1,23 +1,25 @@ use anyhow::{Context, Result as AnyResult}; use axum::{ Json, Router, - extract::{Path, State}, + extract::{Path, Query, State}, http::{HeaderMap, StatusCode, header}, + response::{Html, IntoResponse, Redirect}, routing::{get, post}, }; -use chrono::{DateTime, Utc}; +use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD}; +use chrono::{DateTime, Duration, Utc}; use reqwest::Client; use secrets_application::sync::{fetch_object, sync_pull, sync_push}; -use secrets_device_auth::{ - hash_device_login_token, new_device_fingerprint, new_device_login_token, -}; +use secrets_device_auth::{hash_device_login_token, new_device_fingerprint, new_device_login_token}; use secrets_domain::{ SyncPullRequest, SyncPullResponse, SyncPushRequest, SyncPushResponse, VaultObjectEnvelope, }; use serde::{Deserialize, Serialize}; use serde_json::json; -use sqlx::PgPool; +use sha2::{Digest, Sha256}; +use sqlx::{PgPool, Postgres, Transaction}; use tracing_subscriber::EnvFilter; +use url::Url; use uuid::Uuid; #[derive(Clone)] @@ -32,20 +34,79 @@ struct DemoLoginResponse { } #[derive(Debug, Deserialize)] -struct DesktopGoogleLoginRequest { - access_token: String, +struct DesktopLoginStartRequest { device_name: String, platform: String, client_version: String, device_fingerprint: String, } +#[derive(Debug, Deserialize)] +struct DesktopLoginPollQuery { + session_id: String, +} + +#[derive(Debug, Deserialize)] +struct GoogleStartQuery { + session_id: String, +} + +#[derive(Debug, Deserialize)] +struct GoogleCallbackQuery { + state: Option, + code: Option, + error: Option, +} + #[derive(Debug, Deserialize)] struct GoogleUserInfo { + sub: String, email: String, name: Option, } +#[derive(Clone)] +struct GoogleOAuthConfig { + client_id: String, + client_secret: String, + auth_uri: String, + token_uri: String, + redirect_uri: String, +} + +#[derive(Serialize)] +struct DesktopLoginStartResponse { + session_id: String, + auth_url: String, + expires_at: String, +} + +#[derive(Serialize)] +struct DesktopLoginPollResponse { + status: String, + device_token: Option, + error: Option, +} + +#[derive(Debug, Deserialize)] +struct GoogleTokenResponse { + access_token: String, +} + +#[derive(Debug, sqlx::FromRow)] +struct DesktopLoginSessionRow { + session_id: String, + oauth_state: String, + pkce_verifier: String, + device_name: String, + platform: String, + client_version: String, + device_fingerprint: String, + status: String, + error_message: Option, + expires_at: DateTime, +} + #[derive(Serialize)] struct DeviceView { name: String, @@ -84,6 +145,13 @@ struct ObjectResponse { object: VaultObjectEnvelope, } +const LOGIN_STATUS_PENDING: &str = "pending"; +const LOGIN_STATUS_SUCCEEDED: &str = "succeeded"; +const LOGIN_STATUS_FAILED: &str = "failed"; +const LOGIN_STATUS_EXPIRED: &str = "expired"; +const LOGIN_STATUS_CONSUMED: &str = "consumed"; +const DESKTOP_LOGIN_SESSION_TTL_MINUTES: i64 = 10; + #[tokio::main] async fn main() -> AnyResult<()> { let _ = dotenvy::dotenv(); @@ -104,7 +172,10 @@ async fn main() -> AnyResult<()> { let app = Router::new() .route("/healthz", get(|| async { "ok" })) .route("/auth/demo-login", post(api_demo_login)) - .route("/auth/google/desktop-login", post(api_google_desktop_login)) + .route("/auth/desktop/start", post(api_desktop_login_start)) + .route("/auth/desktop/poll", get(api_desktop_login_poll)) + .route("/auth/google/start", get(api_google_login_start)) + .route("/auth/google/callback", get(api_google_login_callback)) .route("/me", get(api_me)) .route("/sync/pull", post(api_sync_pull)) .route("/sync/push", post(api_sync_push)) @@ -174,33 +245,130 @@ async fn api_demo_login( Ok(Json(DemoLoginResponse { device_token })) } -async fn api_google_desktop_login( +async fn api_desktop_login_start( State(state): State, - Json(payload): Json, -) -> std::result::Result, (StatusCode, Json)> { - let google_user = state - .http - .get("https://openidconnect.googleapis.com/v1/userinfo") - .bearer_auth(&payload.access_token) - .send() + Json(payload): Json, +) -> std::result::Result, (StatusCode, Json)> { + let session_id = new_session_secret(); + let oauth_state = new_session_secret(); + let pkce_verifier = new_session_secret(); + let expires_at = Utc::now() + Duration::minutes(DESKTOP_LOGIN_SESSION_TTL_MINUTES); + let auth_url = format!( + "{}/auth/google/start?session_id={}", + public_base_url().map_err(internal_error)?, + session_id + ); + + sqlx::query( + r#" + INSERT INTO desktop_login_sessions ( + session_id, oauth_state, pkce_verifier, device_name, platform, client_version, + device_fingerprint, status, expires_at + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) + "#, + ) + .bind(&session_id) + .bind(&oauth_state) + .bind(&pkce_verifier) + .bind(&payload.device_name) + .bind(&payload.platform) + .bind(&payload.client_version) + .bind(&payload.device_fingerprint) + .bind(LOGIN_STATUS_PENDING) + .bind(expires_at) + .execute(&state.pool) + .await + .map_err(internal_error)?; + + Ok(Json(DesktopLoginStartResponse { + session_id, + auth_url, + expires_at: expires_at.to_rfc3339(), + })) +} + +async fn api_google_login_start( + State(state): State, + Query(query): Query, +) -> std::result::Result)> { + let session = fetch_desktop_login_session(&state.pool, &query.session_id) .await .map_err(internal_error)? - .error_for_status() + .ok_or_else(|| unauthorized("desktop login session not found"))?; + ensure_login_session_pending(&session).map_err(unauthorized)?; + let google = google_oauth_config().map_err(internal_error)?; + let challenge = pkce_challenge(&session.pkce_verifier); + + let mut auth_url = Url::parse(&google.auth_uri).map_err(internal_error)?; + auth_url + .query_pairs_mut() + .append_pair("client_id", &google.client_id) + .append_pair("redirect_uri", &google.redirect_uri) + .append_pair("response_type", "code") + .append_pair("scope", "openid email profile") + .append_pair("state", &session.oauth_state) + .append_pair("code_challenge", &challenge) + .append_pair("code_challenge_method", "S256") + .append_pair("access_type", "offline") + .append_pair("prompt", "consent"); + + Ok(Redirect::temporary(auth_url.as_str())) +} + +async fn api_google_login_callback( + State(state): State, + Query(query): Query, +) -> std::result::Result)> { + let oauth_state = query + .state + .as_deref() + .filter(|value| !value.is_empty()) + .ok_or_else(|| unauthorized("missing oauth state"))?; + let mut tx = state.pool.begin().await.map_err(internal_error)?; + let session = fetch_desktop_login_session_by_state(&mut tx, oauth_state) + .await .map_err(internal_error)? - .json::() + .ok_or_else(|| unauthorized("desktop login session not found"))?; + + if let Some(error) = query.error.as_deref().filter(|value| !value.is_empty()) { + mark_login_session_failed(&mut tx, &session.session_id, &format!("google oauth error: {error}")) + .await + .map_err(internal_error)?; + tx.commit().await.map_err(internal_error)?; + return Ok(Html(login_result_html( + "登录未完成", + "你已取消 Google 授权或授权未成功,可以返回 Secrets 重试。", + ))); + } + + ensure_login_session_pending(&session).map_err(unauthorized)?; + let code = query + .code + .as_deref() + .filter(|value| !value.is_empty()) + .ok_or_else(|| unauthorized("missing google auth code"))?; + let google = google_oauth_config().map_err(internal_error)?; + let google_token = exchange_google_auth_code(&state.http, &google, code, &session.pkce_verifier) + .await + .map_err(internal_error)?; + let google_user = fetch_google_userinfo(&state.http, &google_token.access_token) .await .map_err(internal_error)?; let user_id = upsert_user_from_google(&state.pool, &google_user) .await .map_err(internal_error)?; + upsert_google_oauth_account(&state.pool, user_id, &google_user) + .await + .map_err(internal_error)?; let device_id = upsert_device_for_login( &state.pool, user_id, - &payload.device_name, - &payload.platform, - &payload.client_version, - &payload.device_fingerprint, + &session.device_name, + &session.platform, + &session.client_version, + &session.device_fingerprint, ) .await .map_err(internal_error)?; @@ -209,14 +377,115 @@ async fn api_google_desktop_login( &state.pool, user_id, device_id, - &payload.device_name, - &payload.platform, - &payload.client_version, + &session.device_name, + &session.platform, + &session.client_version, ) .await .map_err(internal_error)?; - Ok(Json(DemoLoginResponse { device_token })) + mark_login_session_succeeded( + &mut tx, + &session.session_id, + user_id, + device_id, + device_token.clone(), + hash_device_login_token(&device_token), + ) + .await + .map_err(internal_error)?; + tx.commit().await.map_err(internal_error)?; + + Ok(Html(login_result_html( + "登录成功", + "Google 授权已完成,可以返回 Secrets 桌面端继续。", + ))) +} + +async fn api_desktop_login_poll( + State(state): State, + Query(query): Query, +) -> std::result::Result, (StatusCode, Json)> { + let mut tx = state.pool.begin().await.map_err(internal_error)?; + let session = fetch_desktop_login_session_for_update(&mut tx, &query.session_id) + .await + .map_err(internal_error)? + .ok_or_else(|| unauthorized("desktop login session not found"))?; + let now = Utc::now(); + + if session.expires_at < now && session.status == LOGIN_STATUS_PENDING { + mark_login_session_expired(&mut tx, &session.session_id) + .await + .map_err(internal_error)?; + tx.commit().await.map_err(internal_error)?; + return Ok(Json(DesktopLoginPollResponse { + status: LOGIN_STATUS_EXPIRED.to_string(), + device_token: None, + error: Some("login session expired".to_string()), + })); + } + + match session.status.as_str() { + LOGIN_STATUS_PENDING => { + tx.commit().await.map_err(internal_error)?; + Ok(Json(DesktopLoginPollResponse { + status: LOGIN_STATUS_PENDING.to_string(), + device_token: None, + error: None, + })) + } + LOGIN_STATUS_FAILED => { + tx.commit().await.map_err(internal_error)?; + Ok(Json(DesktopLoginPollResponse { + status: LOGIN_STATUS_FAILED.to_string(), + device_token: None, + error: session.error_message, + })) + } + LOGIN_STATUS_EXPIRED => { + tx.commit().await.map_err(internal_error)?; + Ok(Json(DesktopLoginPollResponse { + status: LOGIN_STATUS_EXPIRED.to_string(), + device_token: None, + error: session.error_message.or(Some("login session expired".to_string())), + })) + } + LOGIN_STATUS_CONSUMED => { + tx.commit().await.map_err(internal_error)?; + Ok(Json(DesktopLoginPollResponse { + status: LOGIN_STATUS_CONSUMED.to_string(), + device_token: None, + error: Some("login session already consumed".to_string()), + })) + } + LOGIN_STATUS_SUCCEEDED => { + let device_token = consume_device_token_for_poll(&mut tx, &session.session_id) + .await + .map_err(internal_error)?; + sqlx::query( + "UPDATE desktop_login_sessions SET status = $2, consumed_at = NOW(), updated_at = NOW() WHERE session_id = $1", + ) + .bind(&session.session_id) + .bind(LOGIN_STATUS_CONSUMED) + .execute(&mut *tx) + .await + .map_err(internal_error)?; + tx.commit().await.map_err(internal_error)?; + Ok(Json(DesktopLoginPollResponse { + status: LOGIN_STATUS_SUCCEEDED.to_string(), + device_token: Some(device_token), + error: None, + })) + } + _ => { + tx.commit().await.map_err(internal_error)?; + Ok(Json(DesktopLoginPollResponse { + status: LOGIN_STATUS_FAILED.to_string(), + device_token: None, + error: Some("invalid login session status".to_string()), + })) + } + } } async fn api_sync_pull( @@ -313,6 +582,215 @@ async fn api_me( })) } +fn public_base_url() -> AnyResult { + std::env::var("SECRETS_PUBLIC_BASE_URL") + .or_else(|_| std::env::var("SECRETS_API_BASE")) + .context("SECRETS_PUBLIC_BASE_URL or SECRETS_API_BASE must be set") +} + +fn google_oauth_config() -> AnyResult { + Ok(GoogleOAuthConfig { + client_id: std::env::var("GOOGLE_OAUTH_CLIENT_ID") + .context("GOOGLE_OAUTH_CLIENT_ID is not set")?, + client_secret: std::env::var("GOOGLE_OAUTH_CLIENT_SECRET") + .context("GOOGLE_OAUTH_CLIENT_SECRET is not set")?, + auth_uri: std::env::var("GOOGLE_OAUTH_AUTH_URI") + .unwrap_or_else(|_| "https://accounts.google.com/o/oauth2/v2/auth".to_string()), + token_uri: std::env::var("GOOGLE_OAUTH_TOKEN_URI") + .unwrap_or_else(|_| "https://oauth2.googleapis.com/token".to_string()), + redirect_uri: std::env::var("GOOGLE_OAUTH_REDIRECT_URI") + .context("GOOGLE_OAUTH_REDIRECT_URI is not set")?, + }) +} + +fn new_session_secret() -> String { + new_device_login_token() +} + +fn pkce_challenge(verifier: &str) -> String { + let digest = Sha256::digest(verifier.as_bytes()); + URL_SAFE_NO_PAD.encode(digest) +} + +fn login_result_html(title: &str, message: &str) -> String { + format!( + "

{}

{}

现在可以返回 Secrets 桌面端。

", + title, message + ) +} + +fn ensure_login_session_pending(session: &DesktopLoginSessionRow) -> Result<(), &'static str> { + if session.expires_at < Utc::now() { + return Err("desktop login session expired"); + } + if session.status != LOGIN_STATUS_PENDING { + return Err("desktop login session is no longer pending"); + } + Ok(()) +} + +async fn fetch_desktop_login_session( + pool: &PgPool, + session_id: &str, +) -> AnyResult> { + sqlx::query_as::<_, DesktopLoginSessionRow>( + r#" + SELECT + session_id, oauth_state, pkce_verifier, device_name, platform, client_version, + device_fingerprint, status, error_message, user_id, device_id, device_token, + device_token_hash, expires_at + FROM desktop_login_sessions + WHERE session_id = $1 + "#, + ) + .bind(session_id) + .fetch_optional(pool) + .await + .context("failed to load desktop login session") +} + +async fn fetch_desktop_login_session_for_update( + tx: &mut Transaction<'_, Postgres>, + session_id: &str, +) -> AnyResult> { + sqlx::query_as::<_, DesktopLoginSessionRow>( + r#" + SELECT + session_id, oauth_state, pkce_verifier, device_name, platform, client_version, + device_fingerprint, status, error_message, user_id, device_id, device_token, + device_token_hash, expires_at + FROM desktop_login_sessions + WHERE session_id = $1 + FOR UPDATE + "#, + ) + .bind(session_id) + .fetch_optional(&mut **tx) + .await + .context("failed to lock desktop login session") +} + +async fn fetch_desktop_login_session_by_state( + tx: &mut Transaction<'_, Postgres>, + oauth_state: &str, +) -> AnyResult> { + sqlx::query_as::<_, DesktopLoginSessionRow>( + r#" + SELECT + session_id, oauth_state, pkce_verifier, device_name, platform, client_version, + device_fingerprint, status, error_message, user_id, device_id, device_token, + device_token_hash, expires_at + FROM desktop_login_sessions + WHERE oauth_state = $1 + FOR UPDATE + "#, + ) + .bind(oauth_state) + .fetch_optional(&mut **tx) + .await + .context("failed to load desktop login session by oauth state") +} + +async fn mark_login_session_failed( + tx: &mut Transaction<'_, Postgres>, + session_id: &str, + message: &str, +) -> AnyResult<()> { + sqlx::query( + "UPDATE desktop_login_sessions SET status = $2, error_message = $3, updated_at = NOW() WHERE session_id = $1", + ) + .bind(session_id) + .bind(LOGIN_STATUS_FAILED) + .bind(message) + .execute(&mut **tx) + .await?; + Ok(()) +} + +async fn mark_login_session_expired( + tx: &mut Transaction<'_, Postgres>, + session_id: &str, +) -> AnyResult<()> { + sqlx::query( + "UPDATE desktop_login_sessions SET status = $2, error_message = $3, updated_at = NOW() WHERE session_id = $1", + ) + .bind(session_id) + .bind(LOGIN_STATUS_EXPIRED) + .bind("login session expired") + .execute(&mut **tx) + .await?; + Ok(()) +} + +async fn mark_login_session_succeeded( + tx: &mut Transaction<'_, Postgres>, + session_id: &str, + user_id: Uuid, + device_id: Uuid, + device_token: String, + device_token_hash: String, +) -> AnyResult<()> { + sqlx::query( + r#" + UPDATE desktop_login_sessions + SET status = $2, + user_id = $3, + device_id = $4, + device_token = $5, + device_token_hash = $6, + updated_at = NOW() + WHERE session_id = $1 + "#, + ) + .bind(session_id) + .bind(LOGIN_STATUS_SUCCEEDED) + .bind(user_id) + .bind(device_id) + .bind(device_token) + .bind(device_token_hash) + .execute(&mut **tx) + .await?; + Ok(()) +} + +async fn exchange_google_auth_code( + http: &Client, + google: &GoogleOAuthConfig, + code: &str, + code_verifier: &str, +) -> AnyResult { + http.post(&google.token_uri) + .form(&[ + ("client_id", google.client_id.clone()), + ("client_secret", google.client_secret.clone()), + ("code", code.to_string()), + ("code_verifier", code_verifier.to_string()), + ("grant_type", "authorization_code".to_string()), + ("redirect_uri", google.redirect_uri.clone()), + ]) + .send() + .await + .context("failed to exchange google auth code")? + .error_for_status() + .context("google token exchange failed")? + .json::() + .await + .context("failed to decode google token response") +} + +async fn fetch_google_userinfo(http: &Client, access_token: &str) -> AnyResult { + http.get("https://openidconnect.googleapis.com/v1/userinfo") + .bearer_auth(access_token) + .send() + .await + .context("failed to request google userinfo")? + .error_for_status() + .context("google userinfo request failed")? + .json::() + .await + .context("failed to decode google userinfo") +} + async fn require_auth( pool: &PgPool, headers: &HeaderMap, @@ -465,6 +943,37 @@ async fn upsert_user_from_google(pool: &PgPool, google_user: &GoogleUserInfo) -> .context("failed to create user from google login") } +async fn upsert_google_oauth_account( + pool: &PgPool, + user_id: Uuid, + google_user: &GoogleUserInfo, +) -> AnyResult<()> { + sqlx::query( + r#" + INSERT INTO oauth_accounts (user_id, provider, provider_id, email, name) + VALUES ($1, 'google', $2, $3, $4) + ON CONFLICT (provider, provider_id) + DO UPDATE SET + user_id = EXCLUDED.user_id, + email = EXCLUDED.email, + name = EXCLUDED.name + "#, + ) + .bind(user_id) + .bind(&google_user.sub) + .bind(&google_user.email) + .bind( + google_user + .name + .clone() + .unwrap_or_else(|| google_user.email.clone()), + ) + .execute(pool) + .await + .context("failed to upsert google oauth account")?; + Ok(()) +} + async fn upsert_device_for_login( pool: &PgPool, user_id: Uuid, @@ -556,6 +1065,28 @@ async fn issue_device_login_token( Ok(device_token) } +async fn consume_device_token_for_poll( + tx: &mut Transaction<'_, Postgres>, + session_id: &str, +) -> AnyResult { + let token = sqlx::query_scalar::<_, Option>( + "SELECT device_token FROM desktop_login_sessions WHERE session_id = $1 FOR UPDATE", + ) + .bind(session_id) + .fetch_one(&mut **tx) + .await? + .context("device token already consumed")?; + + sqlx::query( + "UPDATE desktop_login_sessions SET device_token = NULL, updated_at = NOW() WHERE session_id = $1", + ) + .bind(session_id) + .execute(&mut **tx) + .await?; + + Ok(token) +} + fn internal_error(error: E) -> (StatusCode, Json) { ( StatusCode::INTERNAL_SERVER_ERROR, diff --git a/apps/desktop/dist/index.html b/apps/desktop/dist/index.html index c492f99..3baf2d5 100644 --- a/apps/desktop/dist/index.html +++ b/apps/desktop/dist/index.html @@ -31,7 +31,7 @@ - 使用 Google 登录 + 前往浏览器登录 diff --git a/apps/desktop/dist/main.js b/apps/desktop/dist/main.js index 08cef8b..74f62d2 100644 --- a/apps/desktop/dist/main.js +++ b/apps/desktop/dist/main.js @@ -510,6 +510,7 @@ async function doDemoLogin() { if (!invoke) return; setLoginError(""); loginButton.disabled = true; + loginButton.textContent = "正在打开浏览器..."; try { let data = await invoke("continue_demo_login"); data = await ensureUnlockedShell(data); @@ -518,6 +519,7 @@ async function doDemoLogin() { } catch (error) { setLoginError(String(error)); } finally { + loginButton.textContent = "前往浏览器登录"; loginButton.disabled = false; } } diff --git a/apps/desktop/src-tauri/src/main.rs b/apps/desktop/src-tauri/src/main.rs index 0411763..9bda04a 100644 --- a/apps/desktop/src-tauri/src/main.rs +++ b/apps/desktop/src-tauri/src/main.rs @@ -2,7 +2,6 @@ mod local_vault; mod session_api; use anyhow::{Context, Result as AnyResult, anyhow}; -use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD}; use local_vault::{ LocalDetailField, LocalEntryDetail, LocalEntryDraft, LocalEntryQuery, LocalHistoryItem, LocalSecretDraft, LocalSecretUpdateDraft, LocalSecretValue, LocalVault, @@ -24,7 +23,6 @@ use secrets_device_auth::new_device_fingerprint; use secrets_domain::KdfConfig; use serde::{Deserialize, Serialize}; use session_api::start_desktop_session_server; -use sha2::{Digest, Sha256}; use std::{ fs, path::PathBuf, @@ -32,11 +30,7 @@ use std::{ sync::{Arc, RwLock}, }; use tauri::Manager; -use tokio::{ - io::{AsyncReadExt, AsyncWriteExt}, - net::TcpListener, -}; -use url::Url; +use tokio::time::{Duration, sleep}; #[derive(Clone)] struct DesktopState { @@ -200,33 +194,28 @@ struct DemoLoginResponse { device_token: String, } -#[derive(Deserialize)] -struct GoogleDesktopClientFile { - installed: GoogleDesktopClient, -} - -#[derive(Deserialize)] -struct GoogleDesktopClient { - client_id: String, - client_secret: Option, - auth_uri: String, - token_uri: String, -} - -#[derive(Deserialize)] -struct GoogleTokenResponse { - access_token: String, -} - #[derive(Serialize)] -struct GoogleDesktopLoginRequest { - access_token: String, +struct DesktopLoginStartRequest { device_name: String, platform: String, client_version: String, device_fingerprint: String, } +#[derive(Deserialize)] +struct DesktopLoginStartResponse { + session_id: String, + auth_url: String, + expires_at: String, +} + +#[derive(Deserialize)] +struct DesktopLoginPollResponse { + status: String, + device_token: Option, + error: Option, +} + #[derive(Serialize, Deserialize)] struct EntryListQuery { folder: Option, @@ -320,8 +309,7 @@ async fn continue_demo_login( window: tauri::Window, state: tauri::State<'_, DesktopState>, ) -> Result { - let google_client = load_google_desktop_client().map_err(|err| err.to_string())?; - let payload = complete_google_desktop_login(&state, &google_client) + let payload = complete_hosted_google_desktop_login(&state) .await .map_err(|err| err.to_string())?; @@ -599,21 +587,9 @@ async fn bootstrap_from_disk(state: &DesktopState) -> AnyResult { } if !vault_state.unlocked { - let user = authorized_get(state, "/me") - .await? - .json::() - .await - .context("failed to decode current user")?; return Ok(AppBootstrap { logged_in: true, - shell: Some(ShellData { - user, - folders: Vec::new(), - entry_types: Vec::new(), - entries: Vec::new(), - selected_entry_id: None, - selected_entry: None, - }), + shell: None, vault: Some(VaultStatus { unlocked: false, has_master_password: vault_state.has_master_password, @@ -929,118 +905,11 @@ fn apply_mcp_config_to_all() -> AnyResult<()> { Ok(()) } -fn workspace_root() -> PathBuf { - PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../..") -} - -fn default_google_oauth_client_filename() -> &'static str { - "client_secret_738964258008-0svfo4g7ta347iedrf6r9see87a8u3hn.apps.googleusercontent.com.json" -} - -fn resolve_google_oauth_client_path() -> AnyResult { - let configured = std::env::var("GOOGLE_OAUTH_CLIENT_FILE").ok(); - let mut candidates = Vec::new(); - let repo_root = workspace_root(); - - if let Some(raw_path) = configured.as_deref().map(str::trim).filter(|value| !value.is_empty()) { - let path = PathBuf::from(raw_path); - if path.is_absolute() { - candidates.push(path); - } else { - if let Ok(cwd) = std::env::current_dir() { - candidates.push(cwd.join(&path)); - } - candidates.push(repo_root.join(&path)); - } - } else { - candidates.push(repo_root.join(default_google_oauth_client_filename())); - } - - candidates.dedup(); - - candidates.into_iter().find(|path| path.exists()).with_context(|| { - let configured_hint = configured - .as_deref() - .map(str::trim) - .filter(|value| !value.is_empty()) - .map(|value| format!("当前 GOOGLE_OAUTH_CLIENT_FILE={value}。")) - .unwrap_or_else(|| "当前未设置 GOOGLE_OAUTH_CLIENT_FILE。".to_string()); - format!( - "google oauth client file not found. {} 请把 {} 放到仓库根目录,或把 GOOGLE_OAUTH_CLIENT_FILE 设置为该文件的绝对路径", - configured_hint, - default_google_oauth_client_filename(), - ) - }) -} - -fn load_google_desktop_client() -> AnyResult { - let configured = resolve_google_oauth_client_path()?; - let raw = fs::read_to_string(&configured) - .with_context(|| format!("failed to read {}", configured.display()))?; - let parsed: GoogleDesktopClientFile = - serde_json::from_str(&raw).context("failed to parse google desktop client file")?; - Ok(parsed.installed) -} - -async fn complete_google_desktop_login( - state: &DesktopState, - google_client: &GoogleDesktopClient, -) -> AnyResult { - let listener = TcpListener::bind("127.0.0.1:0") - .await - .context("failed to bind local oauth callback listener")?; - let port = listener - .local_addr() - .context("failed to read callback listener address")? - .port(); - let redirect_uri = format!("http://localhost:{port}/oauth/callback"); - let verifier = new_device_fingerprint(); - let challenge = pkce_challenge(&verifier); - - let mut auth_url = Url::parse(&google_client.auth_uri).context("invalid google auth uri")?; - auth_url - .query_pairs_mut() - .append_pair("client_id", &google_client.client_id) - .append_pair("redirect_uri", &redirect_uri) - .append_pair("response_type", "code") - .append_pair("scope", "openid email profile") - .append_pair("code_challenge", &challenge) - .append_pair("code_challenge_method", "S256") - .append_pair("access_type", "offline") - .append_pair("prompt", "consent"); - - open_system_browser(auth_url.as_str())?; - let auth_code = wait_for_google_callback(listener).await?; - - let mut form = vec![ - ("client_id", google_client.client_id.clone()), - ("code", auth_code), - ("code_verifier", verifier), - ("grant_type", "authorization_code".to_string()), - ("redirect_uri", redirect_uri), - ]; - if let Some(secret) = google_client.client_secret.clone() { - form.push(("client_secret", secret)); - } - - let google_token = state +async fn complete_hosted_google_desktop_login(state: &DesktopState) -> AnyResult { + let start = state .client - .post(&google_client.token_uri) - .form(&form) - .send() - .await - .context("failed to exchange google auth code")? - .error_for_status() - .context("google token exchange failed")? - .json::() - .await - .context("failed to decode google token response")?; - - let response = state - .client - .post(format!("{}/auth/google/desktop-login", state.api_base)) - .json(&GoogleDesktopLoginRequest { - access_token: google_token.access_token, + .post(format!("{}/auth/desktop/start", state.api_base)) + .json(&DesktopLoginStartRequest { device_name: current_device_name(), platform: std::env::consts::OS.to_string(), client_version: env!("CARGO_PKG_VERSION").to_string(), @@ -1048,56 +917,50 @@ async fn complete_google_desktop_login( }) .send() .await - .context("failed to call google desktop login API")?; - - response + .context("failed to create desktop login session")? .error_for_status() - .context("google desktop login failed")? - .json::() + .context("desktop login session creation failed")? + .json::() .await - .context("failed to decode desktop login response") -} + .context("failed to decode desktop login session")?; -async fn wait_for_google_callback(listener: TcpListener) -> AnyResult { - let (mut socket, _) = listener - .accept() - .await - .context("failed to accept oauth callback connection")?; - let mut buffer = [0_u8; 4096]; - let read = socket - .read(&mut buffer) - .await - .context("failed to read oauth callback request")?; - let request = String::from_utf8_lossy(&buffer[..read]); - let path = request - .lines() - .next() - .and_then(|line| line.split_whitespace().nth(1)) - .context("invalid oauth callback request line")?; - let callback_url = Url::parse(&format!("http://localhost{path}")) - .context("failed to parse oauth callback url")?; - let code = callback_url - .query_pairs() - .find(|(key, _)| key == "code") - .map(|(_, value)| value.into_owned()) - .context("oauth callback missing code")?; + let _ = &start.expires_at; + open_system_browser(&start.auth_url)?; - let body = "

登录成功,可以返回 Secrets。

"; - let response = format!( - "HTTP/1.1 200 OK\r\nContent-Type: text/html; charset=utf-8\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", - body.len(), - body - ); - socket - .write_all(response.as_bytes()) - .await - .context("failed to respond to oauth callback")?; - Ok(code) -} + for _ in 0..120 { + sleep(Duration::from_secs(1)).await; + let poll = state + .client + .get(format!("{}/auth/desktop/poll", state.api_base)) + .query(&[("session_id", start.session_id.as_str())]) + .send() + .await + .context("failed to poll desktop login session")? + .error_for_status() + .context("desktop login poll failed")? + .json::() + .await + .context("failed to decode desktop login poll response")?; -fn pkce_challenge(verifier: &str) -> String { - let digest = Sha256::digest(verifier.as_bytes()); - URL_SAFE_NO_PAD.encode(digest) + match poll.status.as_str() { + "pending" => continue, + "succeeded" => { + let device_token = poll + .device_token + .context("desktop login succeeded without device token")?; + return Ok(DemoLoginResponse { device_token }); + } + "failed" | "expired" | "consumed" => { + return Err(anyhow!( + "{}", + poll.error.unwrap_or_else(|| format!("desktop login {}", poll.status)) + )); + } + other => return Err(anyhow!("unexpected desktop login status: {other}")), + } + } + + Err(anyhow!("desktop login timed out")) } fn open_system_browser(url: &str) -> AnyResult<()> { diff --git a/crates/infrastructure-db/src/migrate.rs b/crates/infrastructure-db/src/migrate.rs index b2148d5..407846d 100644 --- a/crates/infrastructure-db/src/migrate.rs +++ b/crates/infrastructure-db/src/migrate.rs @@ -69,6 +69,28 @@ pub async fn migrate_current_schema(pool: &PgPool) -> Result<()> { CREATE INDEX IF NOT EXISTS idx_auth_events_device_id_created_at ON auth_events(device_id, created_at DESC); + CREATE TABLE IF NOT EXISTS desktop_login_sessions ( + session_id TEXT PRIMARY KEY, + oauth_state TEXT NOT NULL UNIQUE, + pkce_verifier TEXT NOT NULL, + device_name VARCHAR(256) NOT NULL, + platform VARCHAR(64) NOT NULL, + client_version VARCHAR(64) NOT NULL, + device_fingerprint TEXT NOT NULL, + status VARCHAR(32) NOT NULL DEFAULT 'pending', + error_message TEXT, + user_id UUID REFERENCES users(id) ON DELETE SET NULL, + device_id UUID REFERENCES devices(id) ON DELETE SET NULL, + device_token TEXT, + device_token_hash TEXT, + expires_at TIMESTAMPTZ NOT NULL, + consumed_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ); + CREATE INDEX IF NOT EXISTS idx_desktop_login_sessions_status_expires + ON desktop_login_sessions(status, expires_at); + CREATE TABLE IF NOT EXISTS vault_objects ( object_id UUID PRIMARY KEY, user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, diff --git a/deploy/.env.example b/deploy/.env.example index 9999b9d..bf22dd9 100644 --- a/deploy/.env.example +++ b/deploy/.env.example @@ -17,10 +17,15 @@ SECRETS_DAEMON_BIND=127.0.0.1:9515 SECRETS_API_BASE=http://127.0.0.1:9415 SECRETS_DAEMON_URL=http://127.0.0.1:9515/mcp -# ─── Google OAuth ───────────────────────────────────────────────────── -# 桌面端优先从这个 installed client JSON 读取 Desktop OAuth 配置 -# 推荐填写绝对路径;若使用相对路径,则以仓库根目录为基准解析 -GOOGLE_OAUTH_CLIENT_FILE=/absolute/path/to/client_secret_738964258008-0svfo4g7ta347iedrf6r9see87a8u3hn.apps.googleusercontent.com.json +# ─── Google OAuth(服务端托管)────────────────────────────────────────── +# 官网 DMG 正式分发时,Google OAuth 凭据只配置在 API 服务端 +SECRETS_PUBLIC_BASE_URL=http://127.0.0.1:9415 +GOOGLE_OAUTH_CLIENT_ID=your-google-oauth-client-id.apps.googleusercontent.com +GOOGLE_OAUTH_CLIENT_SECRET=your-google-oauth-client-secret +GOOGLE_OAUTH_REDIRECT_URI=http://127.0.0.1:9415/auth/google/callback +# 可选:如不配置则使用 Google 默认公开端点 +# GOOGLE_OAUTH_AUTH_URI=https://accounts.google.com/o/oauth2/v2/auth +# GOOGLE_OAUTH_TOKEN_URI=https://oauth2.googleapis.com/token # 若仍无法换 token(仅提供端口代理、无系统代理):可取消注释并改为本机代理地址 # HTTPS_PROXY=http://127.0.0.1:7890 # NO_PROXY=localhost,127.0.0.1