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 环境变量文档
385 lines
12 KiB
Rust
385 lines
12 KiB
Rust
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)
|
||
}
|