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
|
# HTTP client
|
||||||
reqwest = { version = "0.12", features = ["json"] }
|
reqwest = { version = "0.12", features = ["json"] }
|
||||||
|
|
||||||
|
# Crypto
|
||||||
|
aes = "0.8"
|
||||||
|
cbc = "0.1"
|
||||||
|
hex = "0.4"
|
||||||
|
|
||||||
# CSV and Excel export
|
# CSV and Excel export
|
||||||
csv = "1"
|
csv = "1"
|
||||||
rust_xlsxwriter = "0.82"
|
rust_xlsxwriter = "0.82"
|
||||||
|
|||||||
@@ -22,3 +22,7 @@ validator.workspace = true
|
|||||||
utoipa.workspace = true
|
utoipa.workspace = true
|
||||||
async-trait.workspace = true
|
async-trait.workspace = true
|
||||||
reqwest.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 chrono::Utc;
|
||||||
|
use cbc::Decryptor;
|
||||||
use sea_orm::{
|
use sea_orm::{
|
||||||
ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, Set,
|
ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, Set,
|
||||||
};
|
};
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::sync::LazyLock;
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
use tokio::sync::Mutex;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::auth_state::AuthState;
|
use crate::auth_state::AuthState;
|
||||||
@@ -12,10 +19,24 @@ use crate::error::{AuthError, AuthResult};
|
|||||||
use crate::service::auth_service::JwtConfig;
|
use crate::service::auth_service::JwtConfig;
|
||||||
use crate::service::token_service::TokenService;
|
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)]
|
#[derive(Debug, Deserialize)]
|
||||||
struct WechatSessionResp {
|
struct WechatSessionResp {
|
||||||
openid: Option<String>,
|
openid: Option<String>,
|
||||||
#[allow(dead_code)]
|
|
||||||
session_key: Option<String>,
|
session_key: Option<String>,
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
unionid: Option<String>,
|
unionid: Option<String>,
|
||||||
@@ -35,8 +56,21 @@ impl WechatService {
|
|||||||
|
|
||||||
let openid = session
|
let openid = session
|
||||||
.openid
|
.openid
|
||||||
|
.clone()
|
||||||
.ok_or_else(|| AuthError::Validation("微信登录失败:未获取到 openid".to_string()))?;
|
.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()
|
let existing = wechat_user::Entity::find()
|
||||||
.filter(wechat_user::Column::Openid.eq(&openid))
|
.filter(wechat_user::Column::Openid.eq(&openid))
|
||||||
.filter(wechat_user::Column::TenantId.eq(tenant_id))
|
.filter(wechat_user::Column::TenantId.eq(tenant_id))
|
||||||
@@ -75,11 +109,31 @@ impl WechatService {
|
|||||||
state: &AuthState,
|
state: &AuthState,
|
||||||
tenant_id: Uuid,
|
tenant_id: Uuid,
|
||||||
openid: &str,
|
openid: &str,
|
||||||
_encrypted_data: &str,
|
encrypted_data: &str,
|
||||||
_iv: &str,
|
iv: &str,
|
||||||
) -> AuthResult<LoginResp> {
|
) -> AuthResult<LoginResp> {
|
||||||
// MVP 占位:开发阶段使用固定手机号,上线前替换为真实微信手机号解密
|
// 从缓存获取 session_key
|
||||||
let phone = "13800000000";
|
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()
|
let existing = wechat_user::Entity::find()
|
||||||
.filter(wechat_user::Column::Openid.eq(openid))
|
.filter(wechat_user::Column::Openid.eq(openid))
|
||||||
@@ -94,7 +148,7 @@ impl WechatService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let user_id =
|
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 now = Utc::now();
|
||||||
let wu = wechat_user::ActiveModel {
|
let wu = wechat_user::ActiveModel {
|
||||||
@@ -103,7 +157,7 @@ impl WechatService {
|
|||||||
openid: Set(openid.to_string()),
|
openid: Set(openid.to_string()),
|
||||||
union_id: Set(None),
|
union_id: Set(None),
|
||||||
user_id: Set(user_id),
|
user_id: Set(user_id),
|
||||||
phone: Set(Some(phone.to_string())),
|
phone: Set(Some(phone.clone())),
|
||||||
created_at: Set(now),
|
created_at: Set(now),
|
||||||
updated_at: Set(now),
|
updated_at: Set(now),
|
||||||
created_by: Set(Some(user_id)),
|
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(
|
async fn build_login_resp(
|
||||||
db: &sea_orm::DatabaseConnection,
|
db: &sea_orm::DatabaseConnection,
|
||||||
user_id: Uuid,
|
user_id: Uuid,
|
||||||
|
|||||||
Reference in New Issue
Block a user