Files
hms/crates/erp-auth/src/service/wechat_service.rs
iven 945ccd64ba
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
fix: 全面 QA 审计修复 — 安全加固/代码质量/跨平台一致性/测试覆盖
Phase 0 安全热修复 (CRITICAL):
- 外部化微信 appid/secret 到 ERP__WECHAT__APPID/SECRET 环境变量
- 正确连接 HealthCrypto 到 ERP__HEALTH__AES_KEY/HMAC_KEY 环境变量
- 外部化小程序加密密钥到 TARO_APP_ENCRYPTION_KEY 环境变量
- 移除小程序 auth store 中的敏感信息 console.log

Phase 1 安全加固:
- 微信自动注册 display_name 添加 sanitize 防止 XSS
- 测试数据库凭据改为从 TEST_DB_URL 环境变量读取

Phase 2 代码质量:
- 提取 useThemeMode hook 消除 22 处重复暗色模式检测
- 提取共享健康常量到 constants/health.ts
- 拆分 patient_service.rs 脱敏函数到 masking.rs
- 移除未使用的 i18next/react-i18next 依赖
- 移除未使用的 api/errors.ts 和 erp-auth/anyhow 依赖

Phase 3 测试覆盖:
- 新增 5 个患者模块集成测试 (CRUD/租户隔离/验证/软删除)

Phase 4 跨平台一致性:
- 统一小程序 Patient.birthday → birth_date 匹配后端
- 统一小程序 Appointment.time_slot → start_time/end_time 匹配后端

Phase 5 架构:
- 微信登录添加多租户 TODO 注释
- 更新 wiki/infrastructure.md 环境变量文档
2026-04-25 10:00:49 +08:00

385 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;
use erp_core::sanitize::sanitize_string;
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> {
tracing::info!(
appid = %state.wechat_appid,
code = %code,
"fetch_session 开始"
);
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(sanitize_string(&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();
tracing::error!(errcode, errmsg = %msg, "微信 jscode2session 返回错误");
return Err(AuthError::Validation(format!(
"微信登录失败 ({}): {}",
errcode, msg
)));
}
}
tracing::info!(
has_openid = session.openid.is_some(),
has_session_key = session.session_key.is_some(),
"微信 jscode2session 成功"
);
Ok(session)
}