From 6f04f32161549390760674e838d59e599e848822 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=8E=E8=BF=90=E5=AE=B6?= Date: Thu, 23 Jan 2025 11:45:36 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E6=94=B9google=20play=E7=99=BB?= =?UTF-8?q?=E5=BD=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Cargo.lock | 107 +++++++++++++++++- Cargo.toml | 1 + library/Cargo.toml | 1 + library/src/core/config.rs | 8 +- library/src/social/google.rs | 208 +++++++++++++++++++++++------------ 5 files changed, 254 insertions(+), 71 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 8c21bc4..61e3c77 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1,6 +1,6 @@ # This file is automatically @generated by Cargo. # It is not intended for manual editing. -version = 3 +version = 4 [[package]] name = "addr2line" @@ -264,6 +264,12 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + [[package]] name = "chrono" version = "0.4.39" @@ -1022,6 +1028,7 @@ dependencies = [ "tokio", "tokio-rustls", "tower-service", + "webpki-roots", ] [[package]] @@ -1368,6 +1375,7 @@ dependencies = [ "lazy_static", "macro", "moka", + "oauth2", "once_cell", "redis", "reqwest", @@ -1673,6 +1681,26 @@ dependencies = [ "libc", ] +[[package]] +name = "oauth2" +version = "5.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51e219e79014df21a225b1860a479e2dcd7cbd9130f4defd4bd0e191ea31d67d" +dependencies = [ + "base64 0.22.1", + "chrono", + "getrandom", + "http", + "rand", + "reqwest", + "serde", + "serde_json", + "serde_path_to_error", + "sha2", + "thiserror 1.0.69", + "url", +] + [[package]] name = "object" version = "0.36.7" @@ -1932,6 +1960,58 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "quinn" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62e96808277ec6f97351a2380e6c25114bc9e67037775464979f3037c92d05ef" +dependencies = [ + "bytes", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror 2.0.11", + "tokio", + "tracing", +] + +[[package]] +name = "quinn-proto" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2fe5ef3495d7d2e377ff17b1a8ce2ee2ec2a18cde8b6ad6619d65d0701c135d" +dependencies = [ + "bytes", + "getrandom", + "rand", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.11", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c40286217b4ba3a71d644d752e6a0b71f13f1b6a2c5311acfcbe0c2418ed904" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.59.0", +] + [[package]] name = "quote" version = "1.0.38" @@ -2078,7 +2158,10 @@ dependencies = [ "once_cell", "percent-encoding", "pin-project-lite", + "quinn", + "rustls", "rustls-pemfile", + "rustls-pki-types", "serde", "serde_json", "serde_urlencoded", @@ -2086,12 +2169,14 @@ dependencies = [ "system-configuration", "tokio", "tokio-native-tls", + "tokio-rustls", "tower", "tower-service", "url", "wasm-bindgen", "wasm-bindgen-futures", "web-sys", + "webpki-roots", "windows-registry", ] @@ -2136,6 +2221,12 @@ version = "0.1.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "719b953e2095829ee67db738b3bfa9fa368c94900df327b3f07fe6e794d2fe1f" +[[package]] +name = "rustc-hash" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7fb8039b3032c191086b10f11f319a6e99e1e82889c5cc6046f515c9db1d497" + [[package]] name = "rustc_version" version = "0.4.1" @@ -2165,6 +2256,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f287924602bf649d949c63dc8ac8b235fa5387d394020705b80c4eb597ce5b8" dependencies = [ "once_cell", + "ring", "rustls-pki-types", "rustls-webpki", "subtle", @@ -2185,6 +2277,9 @@ name = "rustls-pki-types" version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d2bf47e6ff922db3825eb750c4e2ff784c6ff8fb9e13046ef6a1d1c5401b0b37" +dependencies = [ + "web-time", +] [[package]] name = "rustls-webpki" @@ -3315,6 +3410,7 @@ dependencies = [ "form_urlencoded", "idna 1.0.3", "percent-encoding", + "serde", ] [[package]] @@ -3530,6 +3626,15 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "webpki-roots" +version = "0.26.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d642ff16b7e79272ae451b7322067cdc17cadf68c23264be9d94a32319efe7e" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "whoami" version = "1.5.2" diff --git a/Cargo.toml b/Cargo.toml index a57fde1..cdc9381 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -62,3 +62,4 @@ redis = "0.27.6" deadpool-redis = "0.18.0" chrono-tz = "0.10.0" inventory = "0.3.17" +oauth2 = "5.0.0" \ No newline at end of file diff --git a/library/Cargo.toml b/library/Cargo.toml index c558c61..1dae523 100644 --- a/library/Cargo.toml +++ b/library/Cargo.toml @@ -40,6 +40,7 @@ redis = { workspace = true, features = ["tokio-comp", "json"] } deadpool-redis = { workspace = true } chrono-tz = { workspace = true } inventory = { workspace = true } +oauth2 = { workspace = true, features = ["reqwest"]} domain = { path = "../domain" } i18n = { path = "../i18n" } diff --git a/library/src/core/config.rs b/library/src/core/config.rs index b50ce95..da08a45 100644 --- a/library/src/core/config.rs +++ b/library/src/core/config.rs @@ -62,11 +62,17 @@ pub struct Redis { #[derive(Clone, Debug, Deserialize)] pub struct Social { pub wechat: Wechat, - // pub google: google::GoogleSocial, + pub google: Google, // pub facebook: facebook::FacebookSocial, // pub apple: apple::AppleSocial, } +#[derive(Clone, Debug, Deserialize)] +pub struct Google { + pub client_id: String, + pub client_secret: String, +} + #[derive(Clone, Debug, Deserialize)] pub struct Wechat { pub app_id: String, diff --git a/library/src/social/google.rs b/library/src/social/google.rs index 7758490..db0bd21 100644 --- a/library/src/social/google.rs +++ b/library/src/social/google.rs @@ -4,10 +4,17 @@ use chrono::Utc; use futures_util::lock::Mutex; use jsonwebtoken::{decode, Algorithm, DecodingKey, Validation}; use lazy_static::lazy_static; +use oauth2::basic::BasicClient; +use oauth2::{reqwest, RevocationUrl}; +use oauth2::{ + AuthUrl, AuthorizationCode, ClientId, ClientSecret, CsrfToken, PkceCodeChallenge, RedirectUrl, + Scope, TokenResponse, TokenUrl, +}; use reqwest::Client; use serde::Deserialize; use serde_json::Value; +use crate::config; use crate::model::response::ResErr; use super::SocialResult; @@ -133,93 +140,110 @@ impl GoogleSocial { // 根据kid找到正确的公钥 let key = public_keys - .get(&kid) - .ok_or_else(|| Box::new(ResErr::social("校验Token失败,未找到正确的公钥")))?; + .get(&kid); - tracing::debug!("public key : {:?}", key); // TODO: // 如果是google game account,可能找不到正确的公钥,需要使用oauth2开发库来实现相应的校验 - // oauth2开发库参考https://github.com/ramosbugs/oauth2-rs/blob/main/examples/google.rs - // google game account账户校验参考:https://github.com/heroiclabs/nakama/blob/master/social/social.go - // 验证Token - let mut validation: Validation = Validation::new(Algorithm::RS256); - validation.set_issuer(&["https://accounts.google.com", "accounts.google.com"]); // 设置预期的发行者 - validation.validate_aud = false; + match key { + Some(key) => { + // 验证Token + let mut validation: Validation = Validation::new(Algorithm::RS256); + validation.set_issuer(&["https://accounts.google.com", "accounts.google.com"]); // 设置预期的发行者 + validation.validate_aud = false; - let decoded = decode::( - id_token, - &DecodingKey::from_rsa_components(key.n.as_str(), key.e.as_str()).unwrap(), - &validation, - )?; + let decoded = decode::( + id_token, + &DecodingKey::from_rsa_components(key.n.as_str(), key.e.as_str()).unwrap(), + &validation, + )?; - let claims: Value = decoded.claims; - let google_jwt_profile = GoogleJwtProfile::from(claims); + let claims: Value = decoded.claims; + let google_jwt_profile = GoogleJwtProfile::from(claims); - // 校验有效期 - if google_jwt_profile.exp < Utc::now().timestamp() { - return Err(Box::new(ResErr::social("校验Token失败,token有效期无效"))); + // 校验有效期 + if google_jwt_profile.exp < Utc::now().timestamp() { + return Err(Box::new(ResErr::social("校验Token失败,token有效期无效"))); + } + + Ok(google_jwt_profile) + } + None => { + self.verify_play_games_token(id_token).await + } } - - Ok(google_jwt_profile) } /// 验证 Google Play Games 的身份信息 - /// 参考: https://developers.google.com/games/services/android/signin - pub async fn verify_play_games_token(&self, play_games_token: &str) -> SocialResult { - // Google Play Games 的验证端点 - const GOOGLE_PLAY_GAMES_TOKEN_INFO_URL: &str = - "https://oauth2.googleapis.com/tokeninfo"; + /// 使用 OAuth2 token exchange 获取有效的 access token + /// oauth2开发库参考https://github.com/ramosbugs/oauth2-rs/blob/main/examples/google.rs + /// google game account账户校验参考:https://github.com/heroiclabs/nakama/blob/master/social/social.go + /// TODO: redirect_uri 需要改为实际的回调地址 + pub async fn verify_play_games_token( + &self, + play_games_token: &str, + ) -> SocialResult { + // 1. 创建 OAuth2 client + let client = BasicClient::new(ClientId::new(config!().social.google.client_id.clone())) + .set_client_secret(ClientSecret::new(config!().social.google.client_secret.clone())) + .set_auth_uri(AuthUrl::new( + "https://accounts.google.com/o/oauth2/v2/auth".to_string(), + )?) + .set_token_uri(TokenUrl::new( + "https://oauth2.googleapis.com/token".to_string(), + )?) + .set_redirect_uri(RedirectUrl::new( + "http://localhost:8080/auth/google/callback".to_string(), + )?) + // Google supports OAuth 2.0 Token Revocation (RFC-7009) + .set_revocation_url( + RevocationUrl::new("https://oauth2.googleapis.com/revoke".to_string()) + .expect("Invalid revocation endpoint URL"), + ); - let client = Client::new(); - let response = client - .get(GOOGLE_PLAY_GAMES_TOKEN_INFO_URL) - .query(&[("access_token", play_games_token)]) - .send() + // Google supports Proof Key for Code Exchange (PKCE - https://oauth.net/2/pkce/). + // Create a PKCE code verifier and SHA-256 encode it as a code challenge. + let (pkce_code_challenge, pkce_code_verifier) = PkceCodeChallenge::new_random_sha256(); + + // Generate the authorization URL to which we'll redirect the user. + let (_authorize_url, _csrf_state) = client + .authorize_url(CsrfToken::new_random) + // This example is requesting access to the "calendar" features and the user's profile. + .add_scope(Scope::new( + "https://www.googleapis.com/auth/calendar".to_string(), + )) + .add_scope(Scope::new( + "https://www.googleapis.com/auth/plus.me".to_string(), + )) + .set_pkce_challenge(pkce_code_challenge) + .url(); + + // 2. 执行 token exchange + let token_response = client + .exchange_code(AuthorizationCode::new(play_games_token.to_string())) + .set_pkce_verifier(pkce_code_verifier) + .request_async(&reqwest::Client::new()) .await?; - if !response.status().is_success() { - return Err(Box::new(ResErr::social("验证 Google Play Games Token 失败"))); + let access_token = token_response.access_token(); + + // 3. 使用新的 access token 获取用户信息 + let mut profile = self.fetch_play_games_profile(access_token.secret()).await?; + + // 4. 获取额外的用户信息(如果需要) + if let Some(additional_info) = self + .fetch_additional_player_info(access_token.secret()) + .await? + { + profile.google_play_games_profile = Some(serde_json::to_string(&additional_info)?); } - let token_info: Value = response.json().await?; - - // 验证 token 信息 - if let Some(error) = token_info.get("error") { - return Err(Box::new(ResErr::social(format!( - "Google Play Games Token 无效: {}", - error - )))); - } - - // 验证 audience 是否匹配你的应用 - if let Some(aud) = token_info.get("aud") { - // TODO: 验证 aud 是否匹配你的 Google Play Games 应用 ID - tracing::debug!("Google Play Games aud: {}", aud); - } - - // 构建用户资料 - let mut profile = GoogleJwtProfile::default(); - if let Some(sub) = token_info.get("sub").and_then(|v| v.as_str()) { - profile.sub = sub.to_string(); - } - if let Some(email) = token_info.get("email").and_then(|v| v.as_str()) { - profile.email = email.to_string(); - } - - // 获取 Google Play Games 特定的信息 - let play_games_info = self.fetch_play_games_profile(play_games_token).await?; - profile.google_play_games_id = play_games_info.get("playerId") - .and_then(|v| v.as_str()) - .map(String::from); - Ok(profile) } /// 获取 Google Play Games 用户资料 - async fn fetch_play_games_profile(&self, access_token: &str) -> SocialResult { - const PLAY_GAMES_PROFILE_URL: &str = - "https://games.googleapis.com/games/v1/players/me"; + async fn fetch_play_games_profile(&self, access_token: &str) -> SocialResult { + const PLAY_GAMES_PROFILE_URL: &str = "https://games.googleapis.com/games/v1/players/me"; let client = Client::new(); let response = client @@ -229,10 +253,56 @@ impl GoogleSocial { .await?; if !response.status().is_success() { - return Err(Box::new(ResErr::social("获取 Google Play Games 用户资料失败"))); + return Err(Box::new(ResErr::social( + "获取 Google Play Games 用户资料失败", + ))); + } + + let player_info: Value = response.json().await?; + + // 构建用户资料 + let mut profile = GoogleJwtProfile::default(); + + // 设置基本信息 + if let Some(player_id) = player_info.get("playerId").and_then(|v| v.as_str()) { + profile.sub = player_id.to_string(); + profile.google_play_games_id = Some(player_id.to_string()); + } + + // 设置名称 + if let Some(display_name) = player_info.get("displayName").and_then(|v| v.as_str()) { + profile.name = display_name.to_string(); + } + + // 设置头像 + if let Some(avatar_image_url) = player_info.get("avatarImageUrl").and_then(|v| v.as_str()) { + profile.picture = avatar_image_url.to_string(); } - let profile: Value = response.json().await?; Ok(profile) } + + /// 获取额外的玩家信息 + async fn fetch_additional_player_info( + &self, + access_token: &str, + ) -> SocialResult> { + const PLAY_GAMES_PLAYER_STATS_URL: &str = + "https://games.googleapis.com/games/v1/players/me/playerStates"; + + let client = Client::new(); + let response = client + .get(PLAY_GAMES_PLAYER_STATS_URL) + .header("Authorization", format!("Bearer {}", access_token)) + .send() + .await?; + + if !response.status().is_success() { + tracing::warn!("获取额外玩家信息失败: {}", response.status()); + return Ok(None); + } + + let stats: Value = response.json().await?; + Ok(Some(stats)) + } }