feat(auth): 微信 session_key 迁移到 Redis — 内存降级兜底
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

session_key 从全局 HashMap 迁移到 Redis(SET key EX 300 / GETDEL),
Redis 不可用时自动降级到内存缓存,提升多实例部署安全性。
This commit is contained in:
iven
2026-04-27 13:05:25 +08:00
parent a4daa8f49c
commit 2519ad8fee
5 changed files with 85 additions and 36 deletions

1
Cargo.lock generated
View File

@@ -1405,6 +1405,7 @@ dependencies = [
"erp-core",
"hex",
"jsonwebtoken",
"redis",
"reqwest",
"sea-orm",
"serde",

View File

@@ -25,3 +25,4 @@ aes.workspace = true
cbc.workspace = true
hex.workspace = true
base64 = "0.22"
redis.workspace = true

View File

@@ -23,6 +23,7 @@ pub struct AuthState {
pub default_tenant_id: Uuid,
pub wechat_appid: String,
pub wechat_secret: String,
pub redis: Option<redis::Client>,
}
/// Parse a human-readable TTL string (e.g. "15m", "7d", "1h", "900s") into seconds.

View File

@@ -8,7 +8,7 @@ use sea_orm::{
use serde::Deserialize;
use std::collections::HashMap;
use std::sync::LazyLock;
use std::time::{Duration, Instant};
use std::time::Instant;
use tokio::sync::Mutex;
use uuid::Uuid;
@@ -22,18 +22,19 @@ use erp_core::sanitize::sanitize_string;
type Aes128CbcDec = Decryptor<aes::Aes128>;
/// session_key 缓存条目
/// 内存降级缓存Redis 不可用时使用)
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>>> =
static MEMORY_FALLBACK: LazyLock<Mutex<HashMap<String, SessionEntry>>> =
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)]
struct WechatSessionResp {
@@ -65,16 +66,19 @@ impl WechatService {
.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(),
},
);
// 缓存 session_keyRedis 优先,内存降级)
if let Some(sk) = &session.session_key {
if let Err(e) = Self::store_session_key_redis(&state.redis, &openid, sk).await {
tracing::warn!(openid = %openid, error = %e, "Redis session_key 存储失败,降级内存");
let mut cache = MEMORY_FALLBACK.lock().await;
cache.insert(
openid.clone(),
SessionEntry {
session_key: sk.clone(),
created_at: Instant::now(),
},
);
}
}
let existing = wechat_user::Entity::find()
@@ -118,26 +122,8 @@ impl WechatService {
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(),
));
}
};
// 从 Redis 或内存获取 session_key
let session_key = Self::get_session_key(&state.redis, openid).await?;
let phone = decrypt_phone_number(&session_key, encrypted_data, iv)?;
@@ -235,6 +221,65 @@ impl WechatService {
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 解密微信手机号

View File

@@ -57,6 +57,7 @@ impl FromRef<AppState> for erp_auth::AuthState {
default_tenant_id: state.default_tenant_id,
wechat_appid: state.config.wechat.appid.clone(),
wechat_secret: state.config.wechat.secret.clone(),
redis: Some(state.redis.clone()),
}
}
}