feat(auth): 微信 session_key 迁移到 Redis — 内存降级兜底
session_key 从全局 HashMap 迁移到 Redis(SET key EX 300 / GETDEL), Redis 不可用时自动降级到内存缓存,提升多实例部署安全性。
This commit is contained in:
1
Cargo.lock
generated
1
Cargo.lock
generated
@@ -1405,6 +1405,7 @@ dependencies = [
|
|||||||
"erp-core",
|
"erp-core",
|
||||||
"hex",
|
"hex",
|
||||||
"jsonwebtoken",
|
"jsonwebtoken",
|
||||||
|
"redis",
|
||||||
"reqwest",
|
"reqwest",
|
||||||
"sea-orm",
|
"sea-orm",
|
||||||
"serde",
|
"serde",
|
||||||
|
|||||||
@@ -25,3 +25,4 @@ aes.workspace = true
|
|||||||
cbc.workspace = true
|
cbc.workspace = true
|
||||||
hex.workspace = true
|
hex.workspace = true
|
||||||
base64 = "0.22"
|
base64 = "0.22"
|
||||||
|
redis.workspace = true
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ pub struct AuthState {
|
|||||||
pub default_tenant_id: Uuid,
|
pub default_tenant_id: Uuid,
|
||||||
pub wechat_appid: String,
|
pub wechat_appid: String,
|
||||||
pub wechat_secret: String,
|
pub wechat_secret: String,
|
||||||
|
pub redis: Option<redis::Client>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Parse a human-readable TTL string (e.g. "15m", "7d", "1h", "900s") into seconds.
|
/// Parse a human-readable TTL string (e.g. "15m", "7d", "1h", "900s") into seconds.
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ use sea_orm::{
|
|||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::sync::LazyLock;
|
use std::sync::LazyLock;
|
||||||
use std::time::{Duration, Instant};
|
use std::time::Instant;
|
||||||
use tokio::sync::Mutex;
|
use tokio::sync::Mutex;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
@@ -22,18 +22,19 @@ use erp_core::sanitize::sanitize_string;
|
|||||||
|
|
||||||
type Aes128CbcDec = Decryptor<aes::Aes128>;
|
type Aes128CbcDec = Decryptor<aes::Aes128>;
|
||||||
|
|
||||||
/// session_key 缓存条目
|
/// 内存降级缓存(Redis 不可用时使用)
|
||||||
struct SessionEntry {
|
struct SessionEntry {
|
||||||
session_key: String,
|
session_key: String,
|
||||||
created_at: Instant,
|
created_at: Instant,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 全局 session_key 缓存(openid → session_key)
|
static MEMORY_FALLBACK: LazyLock<Mutex<HashMap<String, SessionEntry>>> =
|
||||||
/// TTL: 5 分钟(微信 session_key 有效期约 5 分钟)
|
|
||||||
static SESSION_CACHE: LazyLock<Mutex<HashMap<String, SessionEntry>>> =
|
|
||||||
LazyLock::new(|| Mutex::new(HashMap::new()));
|
LazyLock::new(|| Mutex::new(HashMap::new()));
|
||||||
|
|
||||||
const SESSION_TTL: Duration = Duration::from_secs(300);
|
const SESSION_TTL_SECS: u64 = 300;
|
||||||
|
|
||||||
|
/// Redis key 前缀
|
||||||
|
const REDIS_KEY_PREFIX: &str = "wechat:session:";
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
struct WechatSessionResp {
|
struct WechatSessionResp {
|
||||||
@@ -65,16 +66,19 @@ impl WechatService {
|
|||||||
.clone()
|
.clone()
|
||||||
.ok_or_else(|| AuthError::Validation("微信登录失败:未获取到 openid".to_string()))?;
|
.ok_or_else(|| AuthError::Validation("微信登录失败:未获取到 openid".to_string()))?;
|
||||||
|
|
||||||
// 缓存 session_key 供后续 bind_phone 使用
|
// 缓存 session_key(Redis 优先,内存降级)
|
||||||
if let Some(sk) = session.session_key {
|
if let Some(sk) = &session.session_key {
|
||||||
let mut cache = SESSION_CACHE.lock().await;
|
if let Err(e) = Self::store_session_key_redis(&state.redis, &openid, sk).await {
|
||||||
cache.insert(
|
tracing::warn!(openid = %openid, error = %e, "Redis session_key 存储失败,降级内存");
|
||||||
openid.clone(),
|
let mut cache = MEMORY_FALLBACK.lock().await;
|
||||||
SessionEntry {
|
cache.insert(
|
||||||
session_key: sk,
|
openid.clone(),
|
||||||
created_at: Instant::now(),
|
SessionEntry {
|
||||||
},
|
session_key: sk.clone(),
|
||||||
);
|
created_at: Instant::now(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let existing = wechat_user::Entity::find()
|
let existing = wechat_user::Entity::find()
|
||||||
@@ -118,26 +122,8 @@ impl WechatService {
|
|||||||
encrypted_data: &str,
|
encrypted_data: &str,
|
||||||
iv: &str,
|
iv: &str,
|
||||||
) -> AuthResult<LoginResp> {
|
) -> AuthResult<LoginResp> {
|
||||||
// 从缓存获取 session_key
|
// 从 Redis 或内存获取 session_key
|
||||||
let session_key = {
|
let session_key = Self::get_session_key(&state.redis, openid).await?;
|
||||||
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 phone = decrypt_phone_number(&session_key, encrypted_data, iv)?;
|
||||||
|
|
||||||
@@ -235,6 +221,65 @@ impl WechatService {
|
|||||||
|
|
||||||
Ok(user_id)
|
Ok(user_id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn store_session_key_redis(
|
||||||
|
redis: &Option<redis::Client>,
|
||||||
|
openid: &str,
|
||||||
|
session_key: &str,
|
||||||
|
) -> AuthResult<()> {
|
||||||
|
let client = redis
|
||||||
|
.as_ref()
|
||||||
|
.ok_or_else(|| AuthError::DbError("Redis 未配置".into()))?;
|
||||||
|
let mut conn = client
|
||||||
|
.get_multiplexed_async_connection()
|
||||||
|
.await
|
||||||
|
.map_err(|e| AuthError::DbError(format!("Redis 连接失败: {e}")))?;
|
||||||
|
let key = format!("{}{}", REDIS_KEY_PREFIX, openid);
|
||||||
|
redis::cmd("SET")
|
||||||
|
.arg(&key)
|
||||||
|
.arg(session_key)
|
||||||
|
.arg("EX")
|
||||||
|
.arg(SESSION_TTL_SECS)
|
||||||
|
.query_async::<String>(&mut conn)
|
||||||
|
.await
|
||||||
|
.map_err(|e| AuthError::DbError(format!("Redis SET 失败: {e}")))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_session_key(
|
||||||
|
redis: &Option<redis::Client>,
|
||||||
|
openid: &str,
|
||||||
|
) -> AuthResult<String> {
|
||||||
|
// 1. 尝试 Redis
|
||||||
|
if let Some(client) = redis {
|
||||||
|
if let Ok(mut conn) = client.get_multiplexed_async_connection().await {
|
||||||
|
let key = format!("{}{}", REDIS_KEY_PREFIX, openid);
|
||||||
|
let result: Option<String> = redis::cmd("GETDEL")
|
||||||
|
.arg(&key)
|
||||||
|
.query_async::<Option<String>>(&mut conn)
|
||||||
|
.await
|
||||||
|
.unwrap_or(None);
|
||||||
|
if let Some(sk) = result {
|
||||||
|
return Ok(sk);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 降级到内存
|
||||||
|
let mut cache = MEMORY_FALLBACK.lock().await;
|
||||||
|
if let Some(entry) = cache.get(openid) {
|
||||||
|
if entry.created_at.elapsed().as_secs() < SESSION_TTL_SECS {
|
||||||
|
let sk = entry.session_key.clone();
|
||||||
|
cache.remove(openid);
|
||||||
|
return Ok(sk);
|
||||||
|
}
|
||||||
|
cache.remove(openid);
|
||||||
|
}
|
||||||
|
|
||||||
|
Err(AuthError::Validation(
|
||||||
|
"未找到 session_key,请重新登录".to_string(),
|
||||||
|
))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// AES-128-CBC 解密微信手机号
|
/// AES-128-CBC 解密微信手机号
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ impl FromRef<AppState> for erp_auth::AuthState {
|
|||||||
default_tenant_id: state.default_tenant_id,
|
default_tenant_id: state.default_tenant_id,
|
||||||
wechat_appid: state.config.wechat.appid.clone(),
|
wechat_appid: state.config.wechat.appid.clone(),
|
||||||
wechat_secret: state.config.wechat.secret.clone(),
|
wechat_secret: state.config.wechat.secret.clone(),
|
||||||
|
redis: Some(state.redis.clone()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user