From 6776a82926c9f5819f678accc90318b7e4255514 Mon Sep 17 00:00:00 2001 From: iven Date: Fri, 24 Apr 2026 12:56:12 +0800 Subject: [PATCH] =?UTF-8?q?feat(auth):=20=E5=BE=AE=E4=BF=A1=E6=89=8B?= =?UTF-8?q?=E6=9C=BA=E5=8F=B7=E7=9C=9F=E5=AE=9E=20AES=20=E8=A7=A3=E5=AF=86?= =?UTF-8?q?=E6=9B=BF=E6=8D=A2=20MVP=20=E5=8D=A0=E4=BD=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - login 阶段缓存 session_key(内存 HashMap,5 分钟 TTL) - bind_phone 用 AES-128-CBC + PKCS7 解密 encryptedData 获取真实手机号 - 新增 workspace 依赖:aes, cbc, hex, base64 - 移除硬编码 "13800000000" 占位逻辑 --- Cargo.toml | 5 + crates/erp-auth/Cargo.toml | 4 + crates/erp-auth/src/service/wechat_service.rs | 116 ++++++++++++++++-- 3 files changed, 118 insertions(+), 7 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index c12888e..72c801a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -83,6 +83,11 @@ async-trait = "0.1" # HTTP client reqwest = { version = "0.12", features = ["json"] } +# Crypto +aes = "0.8" +cbc = "0.1" +hex = "0.4" + # CSV and Excel export csv = "1" rust_xlsxwriter = "0.82" diff --git a/crates/erp-auth/Cargo.toml b/crates/erp-auth/Cargo.toml index e1c0ff3..1bb4d83 100644 --- a/crates/erp-auth/Cargo.toml +++ b/crates/erp-auth/Cargo.toml @@ -22,3 +22,7 @@ validator.workspace = true utoipa.workspace = true async-trait.workspace = true reqwest.workspace = true +aes.workspace = true +cbc.workspace = true +hex.workspace = true +base64 = "0.22" diff --git a/crates/erp-auth/src/service/wechat_service.rs b/crates/erp-auth/src/service/wechat_service.rs index 84e9c01..390bcb1 100644 --- a/crates/erp-auth/src/service/wechat_service.rs +++ b/crates/erp-auth/src/service/wechat_service.rs @@ -1,8 +1,15 @@ +use aes::cipher::{block_padding::Pkcs7, BlockDecryptMut, KeyIvInit}; +use base64::Engine; use chrono::Utc; +use cbc::Decryptor; use sea_orm::{ ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, Set, }; use serde::Deserialize; +use std::collections::HashMap; +use std::sync::LazyLock; +use std::time::{Duration, Instant}; +use tokio::sync::Mutex; use uuid::Uuid; use crate::auth_state::AuthState; @@ -12,10 +19,24 @@ use crate::error::{AuthError, AuthResult}; use crate::service::auth_service::JwtConfig; use crate::service::token_service::TokenService; +type Aes128CbcDec = Decryptor; + +/// session_key 缓存条目 +struct SessionEntry { + session_key: String, + created_at: Instant, +} + +/// 全局 session_key 缓存(openid → session_key) +/// TTL: 5 分钟(微信 session_key 有效期约 5 分钟) +static SESSION_CACHE: LazyLock>> = + LazyLock::new(|| Mutex::new(HashMap::new())); + +const SESSION_TTL: Duration = Duration::from_secs(300); + #[derive(Debug, Deserialize)] struct WechatSessionResp { openid: Option, - #[allow(dead_code)] session_key: Option, #[allow(dead_code)] unionid: Option, @@ -35,8 +56,21 @@ impl WechatService { let openid = session .openid + .clone() .ok_or_else(|| AuthError::Validation("微信登录失败:未获取到 openid".to_string()))?; + // 缓存 session_key 供后续 bind_phone 使用 + if let Some(sk) = session.session_key { + let mut cache = SESSION_CACHE.lock().await; + cache.insert( + openid.clone(), + SessionEntry { + session_key: sk, + created_at: Instant::now(), + }, + ); + } + let existing = wechat_user::Entity::find() .filter(wechat_user::Column::Openid.eq(&openid)) .filter(wechat_user::Column::TenantId.eq(tenant_id)) @@ -75,11 +109,31 @@ impl WechatService { state: &AuthState, tenant_id: Uuid, openid: &str, - _encrypted_data: &str, - _iv: &str, + encrypted_data: &str, + iv: &str, ) -> AuthResult { - // MVP 占位:开发阶段使用固定手机号,上线前替换为真实微信手机号解密 - let phone = "13800000000"; + // 从缓存获取 session_key + let session_key = { + let mut cache = SESSION_CACHE.lock().await; + if let Some(entry) = cache.get(openid) { + if entry.created_at.elapsed() < SESSION_TTL { + let sk = entry.session_key.clone(); + cache.remove(openid); + sk + } else { + cache.remove(openid); + return Err(AuthError::Validation( + "session_key 已过期,请重新登录".to_string(), + )); + } + } else { + return Err(AuthError::Validation( + "未找到 session_key,请重新登录".to_string(), + )); + } + }; + + let phone = decrypt_phone_number(&session_key, encrypted_data, iv)?; let existing = wechat_user::Entity::find() .filter(wechat_user::Column::Openid.eq(openid)) @@ -94,7 +148,7 @@ impl WechatService { } let user_id = - Self::find_or_create_user_by_phone(&state.db, tenant_id, phone).await?; + Self::find_or_create_user_by_phone(&state.db, tenant_id, &phone).await?; let now = Utc::now(); let wu = wechat_user::ActiveModel { @@ -103,7 +157,7 @@ impl WechatService { openid: Set(openid.to_string()), union_id: Set(None), user_id: Set(user_id), - phone: Set(Some(phone.to_string())), + phone: Set(Some(phone.clone())), created_at: Set(now), updated_at: Set(now), created_by: Set(Some(user_id)), @@ -177,6 +231,54 @@ impl WechatService { } } +/// AES-128-CBC 解密微信手机号 +fn decrypt_phone_number( + session_key: &str, + encrypted_data: &str, + iv: &str, +) -> AuthResult { + let engine = base64::engine::general_purpose::STANDARD; + + let key_bytes = engine + .decode(session_key) + .map_err(|e| AuthError::Validation(format!("session_key base64 解码失败: {}", e)))?; + let iv_bytes = engine + .decode(iv) + .map_err(|e| AuthError::Validation(format!("iv base64 解码失败: {}", e)))?; + let ciphertext = engine + .decode(encrypted_data) + .map_err(|e| AuthError::Validation(format!("encrypted_data base64 解码失败: {}", e)))?; + + if key_bytes.len() != 16 { + return Err(AuthError::Validation( + "session_key 长度不正确".to_string(), + )); + } + if iv_bytes.len() != 16 { + return Err(AuthError::Validation("iv 长度不正确".to_string())); + } + + let decryptor = Aes128CbcDec::new_from_slices(&key_bytes, &iv_bytes) + .map_err(|e| AuthError::Validation(format!("AES 初始化失败: {}", e)))?; + + let mut buf = ciphertext; + let decrypted = decryptor + .decrypt_padded_mut::(&mut buf) + .map_err(|e| AuthError::Validation(format!("AES 解密失败: {}", e)))?; + + let plaintext = + String::from_utf8(decrypted.to_vec()).map_err(|_| AuthError::Validation("解密结果非 UTF-8".to_string()))?; + + // 微信返回的 JSON 包含 watermark 等字段,提取 phone_number + let info: serde_json::Value = serde_json::from_str(&plaintext) + .map_err(|e| AuthError::Validation(format!("解密结果 JSON 解析失败: {}", e)))?; + + info.get("phoneNumber") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()) + .ok_or_else(|| AuthError::Validation("解密结果中无 phoneNumber".to_string())) +} + async fn build_login_resp( db: &sea_orm::DatabaseConnection, user_id: Uuid,