refactor(auth): store SessionUser instead of full User in session cache
This commit is contained in:
parent
c780247d17
commit
518b4e5d64
@ -20,7 +20,7 @@ use crate::auth::{password, session};
|
||||
#[cfg(feature = "server")]
|
||||
use crate::db::pool::get_conn;
|
||||
#[cfg(feature = "server")]
|
||||
use crate::models::user::{User, UserRole};
|
||||
use crate::models::user::{SessionUser, UserRole};
|
||||
use crate::models::user::PublicUser;
|
||||
|
||||
#[cfg(feature = "server")]
|
||||
@ -315,11 +315,11 @@ pub struct CurrentUserResponse {
|
||||
}
|
||||
|
||||
#[cfg(feature = "server")]
|
||||
/// 根据会话 token 查询对应用户(含密码哈希等完整信息)。
|
||||
/// 根据会话 token 查询对应用户(不含密码哈希,供会话缓存使用)。
|
||||
///
|
||||
/// 优先命中内存缓存,避免每次请求都执行 DB JOIN;未命中时回查数据库并回填缓存。
|
||||
/// 仅服务端内部使用,不会暴露给前端。
|
||||
pub async fn get_user_by_token(token: &str) -> Result<Option<User>, ServerFnError> {
|
||||
pub async fn get_user_by_token(token: &str) -> Result<Option<SessionUser>, ServerFnError> {
|
||||
let token_hash = session::hash_token(token);
|
||||
|
||||
if let Some(user) = crate::cache::get_session_user(&token_hash).await {
|
||||
@ -330,7 +330,7 @@ pub async fn get_user_by_token(token: &str) -> Result<Option<User>, ServerFnErro
|
||||
|
||||
let row = client
|
||||
.query_opt(
|
||||
"SELECT u.id, u.username, u.email, u.password_hash, u.role, u.created_at
|
||||
"SELECT u.id, u.username, u.email, u.role, u.created_at
|
||||
FROM sessions s
|
||||
JOIN users u ON s.user_id = u.id
|
||||
WHERE s.token_hash = $1 AND s.expires_at > NOW()",
|
||||
@ -343,11 +343,10 @@ pub async fn get_user_by_token(token: &str) -> Result<Option<User>, ServerFnErro
|
||||
Some(row) => {
|
||||
let role_str: String = row.get("role");
|
||||
let role = UserRole::from_str(&role_str).unwrap_or(UserRole::Blocked);
|
||||
Some(User {
|
||||
Some(SessionUser {
|
||||
id: row.get("id"),
|
||||
username: row.get("username"),
|
||||
email: row.get("email"),
|
||||
password_hash: row.get("password_hash"),
|
||||
role,
|
||||
created_at: row.get("created_at"),
|
||||
})
|
||||
@ -381,19 +380,19 @@ pub async fn get_current_user() -> Result<CurrentUserResponse, ServerFnError> {
|
||||
/// 获取当前登录用户并要求其为 admin,否则返回 401/403。
|
||||
///
|
||||
/// 供其它服务端接口内部调用。
|
||||
pub async fn get_current_admin_user() -> Result<User, AppError> {
|
||||
pub async fn get_current_admin_user() -> Result<SessionUser, AppError> {
|
||||
let token = get_session_from_ctx().ok_or(AppError::Unauthorized("未登录"))?;
|
||||
|
||||
let user = get_user_by_token(&token)
|
||||
let session_user = get_user_by_token(&token)
|
||||
.await
|
||||
.map_err(AppError::query)?
|
||||
.ok_or(AppError::Unauthorized("会话已过期"))?;
|
||||
|
||||
if user.role != UserRole::Admin {
|
||||
if session_user.role != UserRole::Admin {
|
||||
return Err(AppError::Forbidden("权限不足"));
|
||||
}
|
||||
|
||||
Ok(user)
|
||||
Ok(session_user)
|
||||
}
|
||||
|
||||
#[cfg(all(test, feature = "server"))]
|
||||
|
||||
26
src/cache.rs
26
src/cache.rs
@ -16,7 +16,7 @@ use crate::models::comment::PublicComment;
|
||||
#[cfg(feature = "server")]
|
||||
use crate::models::post::{Post, PostListItem, PostStats, Tag};
|
||||
#[cfg(feature = "server")]
|
||||
use crate::models::user::User;
|
||||
use crate::models::user::SessionUser;
|
||||
|
||||
// ============================================================================
|
||||
// 缓存 TTL 配置
|
||||
@ -173,7 +173,7 @@ static PENDING_COUNT_CACHE: LazyLock<Cache<CacheKey, i64>> = LazyLock::new(|| {
|
||||
|
||||
/// 会话用户缓存类型。
|
||||
#[cfg(feature = "server")]
|
||||
pub type SessionCache = Cache<String, User>;
|
||||
pub type SessionCache = Cache<String, SessionUser>;
|
||||
|
||||
/// 搜索结果缓存类型。
|
||||
#[cfg(feature = "server")]
|
||||
@ -181,7 +181,7 @@ pub type SearchCache = Cache<String, (Vec<PostListItem>, i64)>;
|
||||
|
||||
/// 全局会话用户缓存实例,最大容量 1000,TTL 5 分钟。
|
||||
#[cfg(feature = "server")]
|
||||
static SESSION_CACHE: LazyLock<SessionCache> = LazyLock::new(|| {
|
||||
pub static SESSION_CACHE: LazyLock<SessionCache> = LazyLock::new(|| {
|
||||
Cache::builder()
|
||||
.max_capacity(1000)
|
||||
.time_to_live(TTL_SESSION)
|
||||
@ -389,13 +389,13 @@ pub fn normalize_search_key(query: &str) -> String {
|
||||
|
||||
/// 读取会话用户缓存。
|
||||
#[cfg(feature = "server")]
|
||||
pub async fn get_session_user(token_hash: &str) -> Option<User> {
|
||||
pub async fn get_session_user(token_hash: &str) -> Option<SessionUser> {
|
||||
SESSION_CACHE.get(token_hash).await
|
||||
}
|
||||
|
||||
/// 写入会话用户缓存。
|
||||
#[cfg(feature = "server")]
|
||||
pub async fn set_session_user(token_hash: &str, user: User) {
|
||||
pub async fn set_session_user(token_hash: &str, user: SessionUser) {
|
||||
let _ = SESSION_CACHE.insert(token_hash.to_string(), user).await;
|
||||
}
|
||||
|
||||
@ -446,7 +446,7 @@ mod tests {
|
||||
use super::*;
|
||||
use crate::models::comment::PublicComment;
|
||||
use crate::models::post::PostStatus;
|
||||
use crate::models::user::{User, UserRole};
|
||||
use crate::models::user::{SessionUser, UserRole};
|
||||
use serial_test::serial;
|
||||
|
||||
#[test]
|
||||
@ -656,11 +656,10 @@ mod tests {
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn session_cache_roundtrip() {
|
||||
let user = User {
|
||||
let user = SessionUser {
|
||||
id: 42,
|
||||
username: "cached_user".to_string(),
|
||||
email: "cached@example.com".to_string(),
|
||||
password_hash: "argon2_hash".to_string(),
|
||||
role: UserRole::Admin,
|
||||
created_at: chrono::Utc::now(),
|
||||
};
|
||||
@ -674,7 +673,6 @@ mod tests {
|
||||
assert_eq!(cached_user.id, user.id);
|
||||
assert_eq!(cached_user.username, user.username);
|
||||
assert_eq!(cached_user.email, user.email);
|
||||
assert_eq!(cached_user.password_hash, user.password_hash);
|
||||
assert_eq!(cached_user.role, user.role);
|
||||
|
||||
invalidate_session_user(token_hash).await;
|
||||
@ -735,4 +733,14 @@ mod tests {
|
||||
assert!(get_search_results(query).await.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn search_cache_invalidation() {
|
||||
set_search_results("tokio", vec![], 0).await;
|
||||
assert!(get_search_results("tokio").await.is_some());
|
||||
|
||||
invalidate_search_results();
|
||||
assert!(get_search_results("tokio").await.is_none());
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@ -44,6 +44,21 @@ pub struct User {
|
||||
pub created_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
/// 会话缓存使用的轻量用户结构体,不含密码哈希。
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct SessionUser {
|
||||
/// 用户主键。
|
||||
pub id: i32,
|
||||
/// 用户名。
|
||||
pub username: String,
|
||||
/// 邮箱地址。
|
||||
pub email: String,
|
||||
/// 用户角色。
|
||||
pub role: UserRole,
|
||||
/// 账户创建时间。
|
||||
pub created_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
/// 可公开的用户信息,从 User 转换而来,不含密码哈希。
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PublicUser {
|
||||
@ -59,6 +74,32 @@ pub struct PublicUser {
|
||||
pub created_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
impl From<User> for SessionUser {
|
||||
/// 将 User 转换为 SessionUser,丢弃 password_hash 字段。
|
||||
fn from(u: User) -> Self {
|
||||
SessionUser {
|
||||
id: u.id,
|
||||
username: u.username,
|
||||
email: u.email,
|
||||
role: u.role,
|
||||
created_at: u.created_at,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<SessionUser> for PublicUser {
|
||||
/// 将 SessionUser 转换为 PublicUser。
|
||||
fn from(u: SessionUser) -> Self {
|
||||
PublicUser {
|
||||
id: u.id,
|
||||
username: u.username,
|
||||
email: u.email,
|
||||
role: u.role,
|
||||
created_at: u.created_at,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<User> for PublicUser {
|
||||
/// 将 User 转换为 PublicUser,丢弃 password_hash 字段。
|
||||
fn from(u: User) -> Self {
|
||||
@ -124,4 +165,24 @@ mod tests {
|
||||
UserRole::Admin
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn user_to_session_user_excludes_password_hash() {
|
||||
let user = sample_user();
|
||||
let session: SessionUser = user.clone().into();
|
||||
assert_eq!(session.id, user.id);
|
||||
assert_eq!(session.username, user.username);
|
||||
assert_eq!(session.email, user.email);
|
||||
assert_eq!(session.role, user.role);
|
||||
assert_eq!(session.created_at, user.created_at);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn session_user_to_public_user_excludes_password_hash() {
|
||||
let user = sample_user();
|
||||
let session: SessionUser = user.into();
|
||||
let public: PublicUser = session.into();
|
||||
let json = serde_json::to_string(&public).unwrap();
|
||||
assert!(!json.contains("password_hash"));
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user