Files
hms/crates/erp-auth/src/service/wechat_service.rs
iven 6776a82926
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
feat(auth): 微信手机号真实 AES 解密替换 MVP 占位
- login 阶段缓存 session_key(内存 HashMap,5 分钟 TTL)
- bind_phone 用 AES-128-CBC + PKCS7 解密 encryptedData 获取真实手机号
- 新增 workspace 依赖:aes, cbc, hex, base64
- 移除硬编码 "13800000000" 占位逻辑
2026-04-24 12:56:12 +08:00

372 lines
12 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;
use crate::dto::{LoginResp, UserResp, WechatLoginResp};
use crate::entity::wechat_user;
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>,
session_key: Option<String>,
#[allow(dead_code)]
unionid: Option<String>,
errcode: Option<i32>,
errmsg: Option<String>,
}
pub struct WechatService;
impl WechatService {
pub async fn login(
state: &AuthState,
tenant_id: Uuid,
code: &str,
) -> AuthResult<WechatLoginResp> {
let session = fetch_session(&state.wechat_appid, &state.wechat_secret, code).await?;
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))
.filter(wechat_user::Column::DeletedAt.is_null())
.one(&state.db)
.await
.map_err(|e| AuthError::DbError(e.to_string()))?;
if let Some(wu) = existing {
let token = build_login_resp(
&state.db,
wu.user_id,
tenant_id,
&JwtConfig {
secret: &state.jwt_secret,
access_ttl_secs: state.access_ttl_secs,
refresh_ttl_secs: state.refresh_ttl_secs,
},
)
.await?;
Ok(WechatLoginResp {
bound: true,
openid,
token: Some(token),
})
} else {
Ok(WechatLoginResp {
bound: false,
openid,
token: None,
})
}
}
pub async fn bind_phone(
state: &AuthState,
tenant_id: Uuid,
openid: &str,
encrypted_data: &str,
iv: &str,
) -> AuthResult<LoginResp> {
// 从缓存获取 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))
.filter(wechat_user::Column::TenantId.eq(tenant_id))
.filter(wechat_user::Column::DeletedAt.is_null())
.one(&state.db)
.await
.map_err(|e| AuthError::DbError(e.to_string()))?;
if existing.is_some() {
return Err(AuthError::Validation("该微信已绑定账号".to_string()));
}
let user_id =
Self::find_or_create_user_by_phone(&state.db, tenant_id, &phone).await?;
let now = Utc::now();
let wu = wechat_user::ActiveModel {
id: Set(Uuid::now_v7()),
tenant_id: Set(tenant_id),
openid: Set(openid.to_string()),
union_id: Set(None),
user_id: Set(user_id),
phone: Set(Some(phone.clone())),
created_at: Set(now),
updated_at: Set(now),
created_by: Set(Some(user_id)),
updated_by: Set(Some(user_id)),
deleted_at: Set(None),
version: Set(1),
};
wu.insert(&state.db)
.await
.map_err(|e| AuthError::DbError(e.to_string()))?;
build_login_resp(
&state.db,
user_id,
tenant_id,
&JwtConfig {
secret: &state.jwt_secret,
access_ttl_secs: state.access_ttl_secs,
refresh_ttl_secs: state.refresh_ttl_secs,
},
)
.await
}
async fn find_or_create_user_by_phone(
db: &sea_orm::DatabaseConnection,
tenant_id: Uuid,
phone: &str,
) -> AuthResult<Uuid> {
use crate::entity::user;
let existing = user::Entity::find()
.filter(user::Column::Phone.eq(phone))
.filter(user::Column::TenantId.eq(tenant_id))
.filter(user::Column::DeletedAt.is_null())
.one(db)
.await
.map_err(|e| AuthError::DbError(e.to_string()))?;
if let Some(u) = existing {
return Ok(u.id);
}
let now = Utc::now();
let user_id = Uuid::now_v7();
let suffix = &phone[phone.len().saturating_sub(4)..];
let new_user = user::ActiveModel {
id: Set(user_id),
tenant_id: Set(tenant_id),
username: Set(format!("wx_{}", suffix)),
display_name: Set(Some(format!("微信用户{}", suffix))),
phone: Set(Some(phone.to_string())),
email: Set(None),
avatar_url: Set(None),
status: Set("active".to_string()),
last_login_at: Set(None),
created_at: Set(now),
updated_at: Set(now),
created_by: Set(user_id),
updated_by: Set(user_id),
deleted_at: Set(None),
version: Set(1),
};
new_user
.insert(db)
.await
.map_err(|e| AuthError::DbError(e.to_string()))?;
Ok(user_id)
}
}
/// 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,
tenant_id: Uuid,
jwt: &JwtConfig<'_>,
) -> AuthResult<LoginResp> {
use crate::entity::user;
use crate::service::auth_service::AuthService;
let user_model = user::Entity::find_by_id(user_id)
.one(db)
.await
.map_err(|e| AuthError::DbError(e.to_string()))?
.ok_or_else(|| AuthError::Validation("用户不存在".to_string()))?;
let roles = TokenService::get_user_roles(user_id, tenant_id, db).await?;
let permissions = TokenService::get_user_permissions(user_id, tenant_id, db).await?;
let access_token = TokenService::sign_access_token(
user_id,
tenant_id,
roles.clone(),
permissions,
jwt.secret,
jwt.access_ttl_secs,
)?;
let (refresh_token, _) = TokenService::sign_refresh_token(
user_id,
tenant_id,
db,
jwt.secret,
jwt.refresh_ttl_secs,
)
.await?;
let role_resps = AuthService::get_user_role_resps(user_id, tenant_id, db).await?;
Ok(LoginResp {
access_token,
refresh_token,
expires_in: jwt.access_ttl_secs as u64,
user: UserResp {
id: user_model.id,
username: user_model.username,
email: user_model.email,
phone: user_model.phone,
display_name: user_model.display_name,
avatar_url: user_model.avatar_url,
status: user_model.status,
roles: role_resps,
version: user_model.version,
},
})
}
async fn fetch_session(
appid: &str,
secret: &str,
code: &str,
) -> AuthResult<WechatSessionResp> {
let client = reqwest::Client::new();
let resp = client
.get("https://api.weixin.qq.com/sns/jscode2session")
.query(&[
("appid", appid),
("secret", secret),
("js_code", code),
("grant_type", "authorization_code"),
])
.send()
.await
.map_err(|e| AuthError::Validation(format!("微信 API 请求失败: {}", e)))?;
let session: WechatSessionResp = resp
.json()
.await
.map_err(|e| AuthError::Validation(format!("微信 API 响应解析失败: {}", e)))?;
if let Some(errcode) = session.errcode {
if errcode != 0 {
let msg = session.errmsg.clone().unwrap_or_default();
return Err(AuthError::Validation(format!(
"微信登录失败 ({}): {}",
errcode, msg
)));
}
}
Ok(session)
}