feat(auth): 微信手机号真实 AES 解密替换 MVP 占位
- login 阶段缓存 session_key(内存 HashMap,5 分钟 TTL) - bind_phone 用 AES-128-CBC + PKCS7 解密 encryptedData 获取真实手机号 - 新增 workspace 依赖:aes, cbc, hex, base64 - 移除硬编码 "13800000000" 占位逻辑
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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<aes::Aes128>;
|
||||
|
||||
/// session_key 缓存条目
|
||||
struct SessionEntry {
|
||||
session_key: String,
|
||||
created_at: Instant,
|
||||
}
|
||||
|
||||
/// 全局 session_key 缓存(openid → session_key)
|
||||
/// TTL: 5 分钟(微信 session_key 有效期约 5 分钟)
|
||||
static SESSION_CACHE: LazyLock<Mutex<HashMap<String, SessionEntry>>> =
|
||||
LazyLock::new(|| Mutex::new(HashMap::new()));
|
||||
|
||||
const SESSION_TTL: Duration = Duration::from_secs(300);
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct WechatSessionResp {
|
||||
openid: Option<String>,
|
||||
#[allow(dead_code)]
|
||||
session_key: Option<String>,
|
||||
#[allow(dead_code)]
|
||||
unionid: Option<String>,
|
||||
@@ -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<LoginResp> {
|
||||
// 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<String> {
|
||||
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::<Pkcs7>(&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,
|
||||
|
||||
Reference in New Issue
Block a user