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")]
|
#[cfg(feature = "server")]
|
||||||
use crate::db::pool::get_conn;
|
use crate::db::pool::get_conn;
|
||||||
#[cfg(feature = "server")]
|
#[cfg(feature = "server")]
|
||||||
use crate::models::user::{User, UserRole};
|
use crate::models::user::{SessionUser, UserRole};
|
||||||
use crate::models::user::PublicUser;
|
use crate::models::user::PublicUser;
|
||||||
|
|
||||||
#[cfg(feature = "server")]
|
#[cfg(feature = "server")]
|
||||||
@ -315,11 +315,11 @@ pub struct CurrentUserResponse {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(feature = "server")]
|
#[cfg(feature = "server")]
|
||||||
/// 根据会话 token 查询对应用户(含密码哈希等完整信息)。
|
/// 根据会话 token 查询对应用户(不含密码哈希,供会话缓存使用)。
|
||||||
///
|
///
|
||||||
/// 优先命中内存缓存,避免每次请求都执行 DB JOIN;未命中时回查数据库并回填缓存。
|
/// 优先命中内存缓存,避免每次请求都执行 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);
|
let token_hash = session::hash_token(token);
|
||||||
|
|
||||||
if let Some(user) = crate::cache::get_session_user(&token_hash).await {
|
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
|
let row = client
|
||||||
.query_opt(
|
.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
|
FROM sessions s
|
||||||
JOIN users u ON s.user_id = u.id
|
JOIN users u ON s.user_id = u.id
|
||||||
WHERE s.token_hash = $1 AND s.expires_at > NOW()",
|
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) => {
|
Some(row) => {
|
||||||
let role_str: String = row.get("role");
|
let role_str: String = row.get("role");
|
||||||
let role = UserRole::from_str(&role_str).unwrap_or(UserRole::Blocked);
|
let role = UserRole::from_str(&role_str).unwrap_or(UserRole::Blocked);
|
||||||
Some(User {
|
Some(SessionUser {
|
||||||
id: row.get("id"),
|
id: row.get("id"),
|
||||||
username: row.get("username"),
|
username: row.get("username"),
|
||||||
email: row.get("email"),
|
email: row.get("email"),
|
||||||
password_hash: row.get("password_hash"),
|
|
||||||
role,
|
role,
|
||||||
created_at: row.get("created_at"),
|
created_at: row.get("created_at"),
|
||||||
})
|
})
|
||||||
@ -381,19 +380,19 @@ pub async fn get_current_user() -> Result<CurrentUserResponse, ServerFnError> {
|
|||||||
/// 获取当前登录用户并要求其为 admin,否则返回 401/403。
|
/// 获取当前登录用户并要求其为 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 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
|
.await
|
||||||
.map_err(AppError::query)?
|
.map_err(AppError::query)?
|
||||||
.ok_or(AppError::Unauthorized("会话已过期"))?;
|
.ok_or(AppError::Unauthorized("会话已过期"))?;
|
||||||
|
|
||||||
if user.role != UserRole::Admin {
|
if session_user.role != UserRole::Admin {
|
||||||
return Err(AppError::Forbidden("权限不足"));
|
return Err(AppError::Forbidden("权限不足"));
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(user)
|
Ok(session_user)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(all(test, feature = "server"))]
|
#[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")]
|
#[cfg(feature = "server")]
|
||||||
use crate::models::post::{Post, PostListItem, PostStats, Tag};
|
use crate::models::post::{Post, PostListItem, PostStats, Tag};
|
||||||
#[cfg(feature = "server")]
|
#[cfg(feature = "server")]
|
||||||
use crate::models::user::User;
|
use crate::models::user::SessionUser;
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// 缓存 TTL 配置
|
// 缓存 TTL 配置
|
||||||
@ -173,7 +173,7 @@ static PENDING_COUNT_CACHE: LazyLock<Cache<CacheKey, i64>> = LazyLock::new(|| {
|
|||||||
|
|
||||||
/// 会话用户缓存类型。
|
/// 会话用户缓存类型。
|
||||||
#[cfg(feature = "server")]
|
#[cfg(feature = "server")]
|
||||||
pub type SessionCache = Cache<String, User>;
|
pub type SessionCache = Cache<String, SessionUser>;
|
||||||
|
|
||||||
/// 搜索结果缓存类型。
|
/// 搜索结果缓存类型。
|
||||||
#[cfg(feature = "server")]
|
#[cfg(feature = "server")]
|
||||||
@ -181,7 +181,7 @@ pub type SearchCache = Cache<String, (Vec<PostListItem>, i64)>;
|
|||||||
|
|
||||||
/// 全局会话用户缓存实例,最大容量 1000,TTL 5 分钟。
|
/// 全局会话用户缓存实例,最大容量 1000,TTL 5 分钟。
|
||||||
#[cfg(feature = "server")]
|
#[cfg(feature = "server")]
|
||||||
static SESSION_CACHE: LazyLock<SessionCache> = LazyLock::new(|| {
|
pub static SESSION_CACHE: LazyLock<SessionCache> = LazyLock::new(|| {
|
||||||
Cache::builder()
|
Cache::builder()
|
||||||
.max_capacity(1000)
|
.max_capacity(1000)
|
||||||
.time_to_live(TTL_SESSION)
|
.time_to_live(TTL_SESSION)
|
||||||
@ -389,13 +389,13 @@ pub fn normalize_search_key(query: &str) -> String {
|
|||||||
|
|
||||||
/// 读取会话用户缓存。
|
/// 读取会话用户缓存。
|
||||||
#[cfg(feature = "server")]
|
#[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
|
SESSION_CACHE.get(token_hash).await
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 写入会话用户缓存。
|
/// 写入会话用户缓存。
|
||||||
#[cfg(feature = "server")]
|
#[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;
|
let _ = SESSION_CACHE.insert(token_hash.to_string(), user).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -446,7 +446,7 @@ mod tests {
|
|||||||
use super::*;
|
use super::*;
|
||||||
use crate::models::comment::PublicComment;
|
use crate::models::comment::PublicComment;
|
||||||
use crate::models::post::PostStatus;
|
use crate::models::post::PostStatus;
|
||||||
use crate::models::user::{User, UserRole};
|
use crate::models::user::{SessionUser, UserRole};
|
||||||
use serial_test::serial;
|
use serial_test::serial;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@ -656,11 +656,10 @@ mod tests {
|
|||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
#[serial]
|
#[serial]
|
||||||
async fn session_cache_roundtrip() {
|
async fn session_cache_roundtrip() {
|
||||||
let user = User {
|
let user = SessionUser {
|
||||||
id: 42,
|
id: 42,
|
||||||
username: "cached_user".to_string(),
|
username: "cached_user".to_string(),
|
||||||
email: "cached@example.com".to_string(),
|
email: "cached@example.com".to_string(),
|
||||||
password_hash: "argon2_hash".to_string(),
|
|
||||||
role: UserRole::Admin,
|
role: UserRole::Admin,
|
||||||
created_at: chrono::Utc::now(),
|
created_at: chrono::Utc::now(),
|
||||||
};
|
};
|
||||||
@ -674,7 +673,6 @@ mod tests {
|
|||||||
assert_eq!(cached_user.id, user.id);
|
assert_eq!(cached_user.id, user.id);
|
||||||
assert_eq!(cached_user.username, user.username);
|
assert_eq!(cached_user.username, user.username);
|
||||||
assert_eq!(cached_user.email, user.email);
|
assert_eq!(cached_user.email, user.email);
|
||||||
assert_eq!(cached_user.password_hash, user.password_hash);
|
|
||||||
assert_eq!(cached_user.role, user.role);
|
assert_eq!(cached_user.role, user.role);
|
||||||
|
|
||||||
invalidate_session_user(token_hash).await;
|
invalidate_session_user(token_hash).await;
|
||||||
@ -735,4 +733,14 @@ mod tests {
|
|||||||
assert!(get_search_results(query).await.is_none());
|
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>,
|
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 转换而来,不含密码哈希。
|
/// 可公开的用户信息,从 User 转换而来,不含密码哈希。
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct PublicUser {
|
pub struct PublicUser {
|
||||||
@ -59,6 +74,32 @@ pub struct PublicUser {
|
|||||||
pub created_at: DateTime<Utc>,
|
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 {
|
impl From<User> for PublicUser {
|
||||||
/// 将 User 转换为 PublicUser,丢弃 password_hash 字段。
|
/// 将 User 转换为 PublicUser,丢弃 password_hash 字段。
|
||||||
fn from(u: User) -> Self {
|
fn from(u: User) -> Self {
|
||||||
@ -124,4 +165,24 @@ mod tests {
|
|||||||
UserRole::Admin
|
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