feat(auth): 微信手机号真实 AES 解密替换 MVP 占位
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled

- login 阶段缓存 session_key(内存 HashMap,5 分钟 TTL)
- bind_phone 用 AES-128-CBC + PKCS7 解密 encryptedData 获取真实手机号
- 新增 workspace 依赖:aes, cbc, hex, base64
- 移除硬编码 "13800000000" 占位逻辑
This commit is contained in:
iven
2026-04-24 12:56:12 +08:00
parent 60a8a591a8
commit 6776a82926
3 changed files with 118 additions and 7 deletions

View File

@@ -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"

View File

@@ -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"

View File

@@ -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,