Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
94 changes: 66 additions & 28 deletions crates/utopia-server/src/api/google_drive_routes.rs
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ const CONNECTION_KEYS: &[&str] = &[
"account_email",
"connected_at",
"connected_by",
"connected_by_name",
"folders",
];
/// 一个来源最多选多少个文件夹。选得更多的,选它们共同的上级
Expand Down Expand Up @@ -98,14 +99,54 @@ 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)
.fetch_one(pool)
.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,
Expand Down Expand Up @@ -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(
Expand All @@ -305,41 +360,24 @@ pub(super) async fn finish(
Ok(())
}

/// 断开:删掉刷新令牌,并请 Google 把它作废——除非同一个账号还连着别的来源
///([`grant_still_used`]),或者不知道是哪个账号。作废失败不拦着:库里已经没有它了
/// 断开:删掉刷新令牌与选的文件夹,再按 [`revoke_unless_still_used`] 请 Google 作废
pub async fn disconnect(
State(state): State<AppState>,
AuthUser(user): AuthUser,
Path((kb_id, source_id)): Path<(Uuid, Uuid)>,
) -> ApiResult<Json<Value>> {
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(
Expand Down
34 changes: 32 additions & 2 deletions crates/utopia-server/src/api/google_drive_routes_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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?;
Expand Down Expand Up @@ -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]
);
}
16 changes: 16 additions & 0 deletions crates/utopia-server/src/google_drive.rs
Original file line number Diff line number Diff line change
Expand Up @@ -272,6 +272,12 @@ fn id_token_email(id_token: &str, client_id: &str) -> anyhow::Result<Option<Stri
claims["aud"].as_str() == Some(client_id),
"google id_token was issued to a different client"
);
anyhow::ensure!(
claims["exp"]
.as_i64()
.is_some_and(|exp| exp > 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,
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -703,6 +714,11 @@ impl Drive {

/// 一个文件的字节:Google 格式导出,其余原样下载
pub async fn content(&self, file: &DriveFile) -> anyhow::Result<Content> {
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?;
Expand Down
35 changes: 34 additions & 1 deletion crates/utopia-server/src/google_drive_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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"}"#),
Expand Down Expand Up @@ -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 里。
/// 指向文件的按目标收(目标自己的标记与时间),指向文件夹的往里走;
/// 同一个目标既被直接选到、又经快捷方式到达,只收一次
Expand Down Expand Up @@ -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 不合字符表的丢掉
Expand Down
Loading
Loading