diff --git a/crates/utopia-server/src/api/google_drive_routes.rs b/crates/utopia-server/src/api/google_drive_routes.rs index f8a995f79..12efe9740 100644 --- a/crates/utopia-server/src/api/google_drive_routes.rs +++ b/crates/utopia-server/src/api/google_drive_routes.rs @@ -42,6 +42,7 @@ const CONNECTION_KEYS: &[&str] = &[ "account_email", "connected_at", "connected_by", + "connected_by_name", "folders", ]; /// 一个来源最多选多少个文件夹。选得更多的,选它们共同的上级 @@ -98,7 +99,7 @@ pub(super) async fn grant_still_used( sqlx::query_scalar( "SELECT EXISTS (SELECT 1 FROM sources WHERE kind = 'google_drive' AND id <> $1 - AND config->>'account_email' = $2 AND config ? 'refresh_token')", + AND lower(config->>'account_email') = lower($2) AND config ? 'refresh_token')", ) .bind(source_id) .bind(account_email) @@ -106,6 +107,46 @@ pub(super) async fn grant_still_used( .await } +/// 重连之后还是不是同一个 Google 账号。邮箱不分大小写;有一边不知道就当换了: +/// 换了账号,上一个账号里选的文件夹不能照着新账号去读 +pub(super) fn same_account(previous: Option<&str>, now: Option<&str>) -> bool { + matches!((previous, now), (Some(a), Some(b)) if a.eq_ignore_ascii_case(b)) +} + +/// 重连之后作废不作废上一份授权:两边的账号都知道、而且不是同一个时才作废。 +/// 有一边不知道,就可能是同一个账号——作废会连刚换来的新令牌一起作废(0074 决定 5) +pub(super) fn revokes_previous(previous: Option<&str>, now: Option<&str>) -> bool { + previous.is_some() && now.is_some() && !same_account(previous, now) +} + +/// 请 Google 作废一枚刷新令牌,除非同一个账号还连着别的来源([`grant_still_used`])。 +/// 不知道是哪个账号就不作废。失败只记一笔:库里已经没有它了 +async fn revoke_unless_still_used( + pool: &sqlx::PgPool, + source_id: Uuid, + token: &str, + account: Option<&str>, +) -> Result<(), sqlx::Error> { + let Some(account) = account else { + return Ok(()); + }; + if grant_still_used(pool, source_id, account).await? { + return Ok(()); + } + let revoked = reqwest::Client::new() + .post("https://oauth2.googleapis.com/revoke") + .form(&[("token", token)]) + .timeout(std::time::Duration::from_secs(10)) + .send() + .await; + match revoked { + Ok(resp) if resp.status().is_success() => {} + Ok(resp) => tracing::warn!(status = %resp.status(), "Google 拒绝作废刷新令牌,库里已删除"), + Err(e) => tracing::warn!(error = %e, "Google 刷新令牌作废失败,库里已删除"), + } + Ok(()) +} + /// 落一次连接流程。总数与这个人的在途数都在同一条插入里判,满了就不插,回 `None` pub(super) async fn record_flow( pool: &sqlx::PgPool, @@ -280,16 +321,30 @@ pub(super) async fn finish( ) })?; + let previous_token = source.config["refresh_token"].as_str(); + let previous_account = source.config["account_email"].as_str(); + // 连上的人换了一个 Google 账号:上一个账号里选的文件夹不留,它的授权在没别人用时作废。 + // 同一个账号绝不作废——Google 作废的是整份授权,刚换来的新令牌也会跟着失效 + let changed = !same_account(previous_account, grant.account_email.as_deref()); let grant_keys = json!({ "refresh_token": grant.refresh_token, "account_email": grant.account_email, // 列表里看不见令牌,界面凭这一项知道「连上了」:没验证过的邮箱不显示,不能拿它当标志 "connected_at": chrono::Utc::now().to_rfc3339(), "connected_by": user.id, + // 断开前问一句时说清是谁的 Drive;库的成员列表只给管理员,编辑者那边查不到名字 + "connected_by_name": user.display_name, }); - utopia_store::sources::patch_config(&state.pool, source_id, &grant_keys, &[]) + let dropped: &[&str] = if changed { &["folders"] } else { &[] }; + utopia_store::sources::patch_config(&state.pool, source_id, &grant_keys, dropped) .await .map_err(|e| ("unavailable", format!("saving the grant: {e}")))?; + let revoke = revokes_previous(previous_account, grant.account_email.as_deref()); + if let (true, Some(token)) = (revoke, previous_token) { + revoke_unless_still_used(&state.pool, source_id, token, previous_account) + .await + .map_err(|e| ("unavailable", format!("checking the previous grant: {e}")))?; + } state.emit_source(source.kb_id); // 审计不落令牌,只记连上的是哪个 Google 账号 let _ = utopia_store::audit::record( @@ -305,8 +360,7 @@ pub(super) async fn finish( Ok(()) } -/// 断开:删掉刷新令牌,并请 Google 把它作废——除非同一个账号还连着别的来源 -///([`grant_still_used`]),或者不知道是哪个账号。作废失败不拦着:库里已经没有它了 +/// 断开:删掉刷新令牌与选的文件夹,再按 [`revoke_unless_still_used`] 请 Google 作废 pub async fn disconnect( State(state): State, AuthUser(user): AuthUser, @@ -314,32 +368,16 @@ pub async fn disconnect( ) -> ApiResult> { require_kb(&state, &user, kb_id, Role::Editor).await?; let source = drive_source(&state, kb_id, source_id).await?; - let token = source.config["refresh_token"].as_str().map(String::from); - let account = source.config["account_email"].as_str().map(String::from); utopia_store::sources::patch_config(&state.pool, source_id, &json!({}), CONNECTION_KEYS) .await?; - let revoke = match (token, account) { - (Some(token), Some(account)) - if !grant_still_used(&state.pool, source_id, &account).await? => - { - Some(token) - } - _ => None, - }; - if let Some(token) = revoke { - let revoked = reqwest::Client::new() - .post("https://oauth2.googleapis.com/revoke") - .form(&[("token", token.as_str())]) - .timeout(std::time::Duration::from_secs(10)) - .send() - .await; - match revoked { - Ok(resp) if resp.status().is_success() => {} - Ok(resp) => { - tracing::warn!(status = %resp.status(), "Google 拒绝作废刷新令牌,库里已删除") - } - Err(e) => tracing::warn!(error = %e, "Google 刷新令牌作废失败,库里已删除"), - } + if let Some(token) = source.config["refresh_token"].as_str() { + revoke_unless_still_used( + &state.pool, + source_id, + token, + source.config["account_email"].as_str(), + ) + .await?; } state.emit_source(kb_id); let _ = utopia_store::audit::record( diff --git a/crates/utopia-server/src/api/google_drive_routes_tests.rs b/crates/utopia-server/src/api/google_drive_routes_tests.rs index 29ab75a1b..bd79c4640 100644 --- a/crates/utopia-server/src/api/google_drive_routes_tests.rs +++ b/crates/utopia-server/src/api/google_drive_routes_tests.rs @@ -342,8 +342,12 @@ async fn a_grant_is_kept_while_another_source_uses_the_same_account() -> anyhow: ) .await? .id; - for id in [fx.source, other] { - let grant = json!({ "refresh_token": "1//grant", "account_email": account }); + // 同一个邮箱,大小写不同:Google 账号不分大小写 + for (id, email) in [ + (fx.source, account.clone()), + (other, account.to_uppercase()), + ] { + let grant = json!({ "refresh_token": "1//grant", "account_email": email }); utopia_store::sources::patch_config(&fx.pool, id, &grant, &[]).await?; } let while_other_connected = grant_still_used(&fx.pool, fx.source, &account).await?; @@ -381,3 +385,29 @@ async fn a_drive_source_cannot_be_created_without_an_oauth_client() -> anyhow::R assert_eq!(denied_code(created), Some("google_drive_not_configured")); Ok(()) } + +#[test] +fn a_reconnect_keeps_the_grant_only_for_the_same_account() { + assert_eq!( + [ + same_account(Some("Bruno@re-cinq.com"), Some("bruno@re-cinq.com")), + same_account(Some("bruno@re-cinq.com"), Some("ana@re-cinq.com")), + same_account(None, Some("bruno@re-cinq.com")), + same_account(None, None), + ], + [true, false, false, false] + ); +} + +#[test] +fn a_reconnect_revokes_the_previous_grant_only_when_both_accounts_are_known_and_differ() { + assert_eq!( + [ + revokes_previous(Some("bruno@re-cinq.com"), Some("ana@re-cinq.com")), + revokes_previous(Some("bruno@re-cinq.com"), Some("Bruno@re-cinq.com")), + revokes_previous(Some("bruno@re-cinq.com"), None), + revokes_previous(None, Some("ana@re-cinq.com")), + ], + [true, false, false, false] + ); +} diff --git a/crates/utopia-server/src/google_drive.rs b/crates/utopia-server/src/google_drive.rs index 1c02f7243..c48994e8a 100644 --- a/crates/utopia-server/src/google_drive.rs +++ b/crates/utopia-server/src/google_drive.rs @@ -272,6 +272,12 @@ fn id_token_email(id_token: &str, client_id: &str) -> anyhow::Result Utc::now().timestamp()), + "google id_token has expired" + ); Ok(match claims["email_verified"].as_bool() { Some(true) => claims["email"].as_str().map(String::from), _ => None, @@ -610,6 +616,11 @@ impl Drive { else { continue; }; + // Drive 回来的 id 之后要拼进查询和地址:与选中的文件夹同一个字符集,不合的不收 + if !valid_id(id) { + listing.skipped += 1; + continue; + } if mime == FOLDER_MIME { if depth + 1 > MAX_DEPTH { listing.truncated = true; @@ -703,6 +714,11 @@ impl Drive { /// 一个文件的字节:Google 格式导出,其余原样下载 pub async fn content(&self, file: &DriveFile) -> anyhow::Result { + anyhow::ensure!( + valid_id(&file.id), + "google drive: file id {:?} is not a Drive id", + file.id + ); if let Some((mime, ext)) = export_as(&file.mime) { let url = format!("{}/files/{}/export", self.api_root, file.id); let resp = self.get("export", &url, &[("mimeType", mime)]).await?; diff --git a/crates/utopia-server/src/google_drive_tests.rs b/crates/utopia-server/src/google_drive_tests.rs index 179ed0d52..c248057d8 100644 --- a/crates/utopia-server/src/google_drive_tests.rs +++ b/crates/utopia-server/src/google_drive_tests.rs @@ -50,7 +50,11 @@ async fn mount_children(server: &MockServer, parent: &str, files: serde_json::Va .await; } -fn id_token(claims: serde_json::Value) -> String { +/// Google 签的 ID token 一小时后过期;没写 `exp` 的按刚签发算 +fn id_token(mut claims: serde_json::Value) -> String { + if claims.get("exp").is_none() { + claims["exp"] = json!(Utc::now().timestamp() + 3600); + } format!( "{}.{}.sig", URL_SAFE_NO_PAD.encode(br#"{"alg":"RS256"}"#), @@ -113,6 +117,30 @@ async fn a_walk_descends_skips_what_cannot_be_read_and_counts_each_file_once() - Ok(()) } +/// Drive 回来的 id 要拼进查询与地址:不合字符表的文件夹不进、文件不收 +#[tokio::test] +async fn ids_from_drive_outside_the_id_alphabet_are_skipped() -> anyhow::Result<()> { + let server = MockServer::start().await; + mount_folder(&server, "top", false).await; + mount_children( + &server, + "top", + json!([ + {"id": "x' or name contains '", "name": "Sneaky", "mimeType": FOLDER_MIME}, + {"id": "../../about", "name": "notes.pdf", "mimeType": "application/pdf", + "size": "10", "md5Checksum": "a"}, + {"id": "ok1", "name": "fine.pdf", "mimeType": "application/pdf", + "size": "10", "md5Checksum": "b"} + ]), + ) + .await; + + let listing = drive(&server).walk(&["top".to_string()]).await?; + let ids: Vec<&str> = listing.files.iter().map(|f| f.id.as_str()).collect(); + assert_eq!((ids, listing.skipped), (vec!["ok1"], 2)); + Ok(()) +} + /// 别人组织的会议:文件夹里只有快捷方式,纪要住在组织者的 Drive 里。 /// 指向文件的按目标收(目标自己的标记与时间),指向文件夹的往里走; /// 同一个目标既被直接选到、又经快捷方式到达,只收一次 @@ -551,6 +579,11 @@ fn an_id_token_must_be_googles_and_ours() { let other_issuer = id_token(json!({"iss": "https://evil.example", "aud": ours, "email": "a@b.c", "email_verified": true})); assert!(id_token_email(&other_issuer, &ours).is_err()); + + let expired = id_token(json!({"iss": "accounts.google.com", "aud": ours, + "email": "a@b.c", "email_verified": true, + "exp": Utc::now().timestamp() - 60})); + assert!(id_token_email(&expired, &ours).is_err()); } /// 配置里的文件夹:对象或字符串都认,id 不合字符表的丢掉 diff --git a/docs/decisions/0074-a-google-drive-source-is-connected-by-a-person.md b/docs/decisions/0074-a-google-drive-source-is-connected-by-a-person.md index f6ae08abd..0ae245121 100644 --- a/docs/decisions/0074-a-google-drive-source-is-connected-by-a-person.md +++ b/docs/decisions/0074-a-google-drive-source-is-connected-by-a-person.md @@ -18,9 +18,9 @@ Every existing connector is configured with a key an administrator pastes in. Dr 3. **The flow is bound to the person who started it.** `POST …/google-drive/connect` records a single-use `state`, a PKCE verifier, the user and the source in `google_drive_flows` (ten minutes, at most 5 pending per person and 1000 in all, both checked in the insert itself), and returns Google's URL. The callback deletes the row before using it, requires the browser's session to be that same user, and checks again that the user is still an editor. Without the session check, someone could start a flow for their own source and get another person to click through consent, and the other person's Drive would be connected to their source. `prompt=consent` and `access_type=offline` are always sent, so a reconnect after revocation gets a new refresh token. The access token a sync works with lasts an hour and a run can last longer, so a 401 mid-run gets a new access token from the refresh token and the request is tried once more. -4. **The ID token names the account, and that is all it does.** `openid email` exists so the settings can say "connected as bruno@…". The ID token comes straight from Google's token endpoint over TLS, so issuer and audience are checked and the signature is not (OIDC Core 3.1.3.7). An unverified email is not shown. The email never identifies a Utopia account and never links one; that stays as 0056 decided. `connected_at` in the config is what the interface reads as "connected", because the email may legitimately be absent. +4. **The ID token names the account, and that is all it does.** `openid email` exists so the settings can say "connected as bruno@…". The ID token comes straight from Google's token endpoint over TLS, so issuer, audience and expiry are checked and the signature is not (OIDC Core 3.1.3.7). An unverified email is not shown. The email never identifies a Utopia account and never links one; that stays as 0056 decided. `connected_at` in the config is what the interface reads as "connected", because the email may legitimately be absent. -5. **The person who connected chooses the folders, and subfolders come with them.** The callback records `connected_by`. Only that person may browse (`GET …/google-drive/folders`) and save a selection (`PUT …/google-drive/folders`); another editor gets 403. Everyone in the base can read what is synced, so a colleague who could browse would be choosing what leaves someone else's Drive. For the same reason a Drive source's config is written only by its connect, select and disconnect routes: the general source create and update leave it alone. Only the person who connected may reconnect: anyone else gets 403 from `connect`, and a callback that finds someone else connected in the meantime is refused. Otherwise one click by a colleague would replace that person's Drive with their own. To change who reads, an editor disconnects first, which removes the selection with the grant (decision 11), and then connects. `config.folders` is `[{id, name}]`. The name is only for the interface. The browser lists My Drive, Shared with me and every shared drive. Saving the selection queues a sync at once. Ids are checked against `[A-Za-z0-9_-]` before they go into a Drive query, which is simpler to trust than escaping. Each of these routes changes only its own keys in one statement (`patch_config`), so a selection saved during a callback cannot drop the token the callback just wrote. +5. **The person who connected chooses the folders, and subfolders come with them.** The callback records `connected_by`. Only that person may browse (`GET …/google-drive/folders`) and save a selection (`PUT …/google-drive/folders`); another editor gets 403. Everyone in the base can read what is synced, so a colleague who could browse would be choosing what leaves someone else's Drive. For the same reason a Drive source's config is written only by its connect, select and disconnect routes: the general source create and update leave it alone. Only the person who connected may reconnect: anyone else gets 403 from `connect`, and a callback that finds someone else connected in the meantime is refused. Otherwise one click by a colleague would replace that person's Drive with their own. To change who reads, an editor disconnects first, which removes the selection with the grant (decision 11), and then connects. When the person who connected reconnects with a different Google account, the selection made in the old account is dropped and the old grant is revoked as in decision 11. A reconnect with the same account never revokes, because revoking ends the whole grant and would take the new token with it. Accounts compare by email ignoring case; when either email is unknown the account counts as changed, so the folders are dropped, but nothing is revoked. `config.folders` is `[{id, name}]`. The name is only for the interface. The browser lists My Drive, Shared with me and every shared drive. Saving the selection queues a sync at once. Ids are checked against `[A-Za-z0-9_-]` before they go into a Drive query or URL, which is simpler to trust than escaping. That holds for the ids Drive itself returns too: a child folder, file or shortcut target outside that alphabet is skipped. Each of these routes changes only its own keys in one statement (`patch_config`), so a selection saved during a callback cannot drop the token the callback just wrote. 6. **The grant reads only while its person is an editor.** A sync first checks that `connected_by` is still an active editor of the base. When they have left, been made a viewer or been deactivated, the run fails with "reconnect it in the source settings" and reads nothing. Their Google grant would otherwise keep pulling their files into a base they no longer belong to. @@ -32,7 +32,7 @@ Every existing connector is configured with a key an administrator pastes in. Dr 10. **Retraction follows a complete walk only.** Files not seen are marked "not in source" (`reconcile_missing`) only when every selected folder opened and the listing was complete. The download cap does not stop it, because the listing still saw every file. A folder that returns 404, because it was deleted or is no longer shared with the connected person, fails the run by name and marks nothing. Not being able to look is not the same as finding it empty. A file that cannot be read (Google refuses exports over 10 MB) keeps its stored document, the rest of the run continues, and the run is reported as failed with the names, as Notion does. -11. **Disconnecting forgets the grant, and revokes it at Google when nothing else uses it.** `DELETE …/google-drive/connection` removes the token and the selection from the config first. Revoking a refresh token at Google ends the person's whole grant to our OAuth client, every token issued under it, so it would also stop their other Drive sources. The revoke is therefore sent only when no other Google Drive source holds a token for the same account (`account_email`). When the account is unknown because the email was unverified, nothing is revoked: the token is gone from Utopia, and the person can still remove the grant in their Google account. A failed revoke, by network or by a non-2xx status, is logged and does not undo the disconnect. Any editor may disconnect, since stopping a read never exposes anything. Connecting and disconnecting are audited with the account email and never the token. +11. **Disconnecting forgets the grant, and revokes it at Google when nothing else uses it.** `DELETE …/google-drive/connection` removes the token and the selection from the config first. Revoking a refresh token at Google ends the person's whole grant to our OAuth client, every token issued under it, so it would also stop their other Drive sources. The revoke is therefore sent only when no other Google Drive source holds a token for the same account (`account_email`). When the account is unknown because the email was unverified, nothing is revoked: the token is gone from Utopia, and the person can still remove the grant in their Google account. A failed revoke, by network or by a non-2xx status, is logged and does not undo the disconnect. Any editor may disconnect, since stopping a read never exposes anything. Deleting a source does not revoke: its token goes with the row, and the grant stays at Google until the person removes it there or disconnects another source of the same account. Connecting and disconnecting are audited with the account email and never the token. ## Dead ends diff --git a/web/src/api.ts b/web/src/api.ts index dd9b6929d..1b91f3e99 100644 --- a/web/src/api.ts +++ b/web/src/api.ts @@ -253,6 +253,8 @@ export interface SourceView { connected_at?: string; /** google_drive:连上的那个 Utopia 用户;浏览与选文件夹只给他 */ connected_by?: string; + /** google_drive:连上的那个人连接时的名字,断开前的确认里用 */ + connected_by_name?: string; /** false = 这个来源下的文档只检索、不抽取(schema 文档,0035 决定 7);缺省抽取 */ extract?: boolean; } | null; diff --git a/web/src/i18n/en.ts b/web/src/i18n/en.ts index 645cde8c1..fef937b67 100644 --- a/web/src/i18n/en.ts +++ b/web/src/i18n/en.ts @@ -808,11 +808,12 @@ export const en = { remove: "Remove", saveFolders: "Save folders and sync", saved: "Folders saved. A sync has started.", - disconnectConfirm: (account: string, mine: boolean) => + disconnectConfirm: (account: string, mine: boolean, connector: string) => mine ? `Stop reading your Google Drive (${account})? Its access and the chosen folders are removed here, and connecting again starts from scratch.` - : `Stop reading ${account}'s Google Drive? Someone else connected it, and only they chose its folders. Its access and those folders are removed here.`, + : `Stop reading the Google Drive ${connector} connected (${account})? Only ${connector} chose its folders. Its access and those folders are removed here.`, unnamedAccount: "the connected account", + someoneElse: "someone else", cancel: "Cancel", disconnected: "Google Drive disconnected.", }, diff --git a/web/src/i18n/zh.ts b/web/src/i18n/zh.ts index 90db2a89a..0152ac611 100644 --- a/web/src/i18n/zh.ts +++ b/web/src/i18n/zh.ts @@ -737,11 +737,12 @@ export const zh: Strings = { remove: "移除", saveFolders: "保存并同步", saved: "文件夹已保存,同步已开始。", - disconnectConfirm: (account: string, mine: boolean) => + disconnectConfirm: (account: string, mine: boolean, connector: string) => mine ? `停止读取你的 Google Drive(${account})?这里会删掉它的授权和选好的文件夹,再连接要从头选。` - : `停止读取 ${account} 的 Google Drive?这是别人连上的,文件夹也是他选的。这里会删掉它的授权和那些文件夹。`, + : `停止读取 ${connector} 连上的 Google Drive(${account})?文件夹只有 ${connector} 选过。这里会删掉它的授权和那些文件夹。`, unnamedAccount: "已连接的账号", + someoneElse: "别人", cancel: "取消", disconnected: "Google Drive 已断开。", }, diff --git a/web/src/pages/GoogleDrivePanel.tsx b/web/src/pages/GoogleDrivePanel.tsx index 2d5511c57..4c79c34c6 100644 --- a/web/src/pages/GoogleDrivePanel.tsx +++ b/web/src/pages/GoogleDrivePanel.tsx @@ -166,7 +166,11 @@ export function GoogleDrivePanel({ kbId, source }: { kbId: string; source: Sourc {confirmingDisconnect && (