feat(session): invalidate all sessions on role/status change via generation
users 表加 session_generation 列。get_user_by_token 缓存命中后回查 DB generation,不匹配则逐出缓存并视为未登录,消除降级/封禁用户的权限残留 窗口(H2)。新增 invalidate_user_sessions 用于 bump generation,当前仓库 无运行时角色变更入口,作为未来用户管理功能的基础设施预留。 - 迁移 012:ADD COLUMN IF NOT EXISTS session_generation INT DEFAULT 0 - User/SessionUser 同步加字段,From<User> 如实传递 - 缓存校验走主键查询,亚毫秒级
This commit is contained in:
parent
82a3c12940
commit
53bfb1b7c0
6
migrations/012_session_generation.sql
Normal file
6
migrations/012_session_generation.sql
Normal file
@ -0,0 +1,6 @@
|
||||
-- 会话世代号:用户角色/封禁状态变更时 bump 此列,使该用户所有已签发 session
|
||||
-- 立即失效(get_user_by_token 校验世代不匹配则视为未登录)。
|
||||
-- 默认 0,向后兼容。
|
||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS session_generation INT NOT NULL DEFAULT 0;
|
||||
|
||||
COMMENT ON COLUMN users.session_generation IS '会话世代号,变更时 +1 使旧 session 失效';
|
||||
@ -335,19 +335,39 @@ pub struct CurrentUserResponse {
|
||||
/// 根据会话 token 查询对应用户(不含密码哈希,供会话缓存使用)。
|
||||
///
|
||||
/// 优先命中内存缓存,避免每次请求都执行 DB JOIN;未命中时回查数据库并回填缓存。
|
||||
/// 仅服务端内部使用,不会暴露给前端。
|
||||
/// 缓存命中后仍回查 `users.session_generation`:若用户已被降级/封禁(generation 被
|
||||
/// bump),缓存的旧 SessionUser.generation 不再匹配,此时逐出缓存并视为未登录,
|
||||
/// 消除权限残留窗口(见 H2)。仅服务端内部使用,不会暴露给前端。
|
||||
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 {
|
||||
return Ok(Some(user));
|
||||
if let Some(cached) = crate::cache::get_session_user(&token_hash).await {
|
||||
// 缓存命中后校验世代号:bump 后该用户所有 session 应失效。
|
||||
// 查询走主键,亚毫秒级,代价可接受。
|
||||
let current_gen: Option<i32> = get_conn()
|
||||
.await
|
||||
.map_err(AppError::db_conn)?
|
||||
.query_opt(
|
||||
"SELECT session_generation FROM users WHERE id = $1",
|
||||
&[&cached.id],
|
||||
)
|
||||
.await
|
||||
.map_err(AppError::query)?
|
||||
.map(|r| r.get::<_, i32>(0));
|
||||
match current_gen {
|
||||
Some(gen) if gen == cached.session_generation => return Ok(Some(cached)),
|
||||
_ => {
|
||||
// 世代不匹配或用户已删:逐出缓存,落入下方重新查询。
|
||||
crate::cache::invalidate_session_user(&token_hash).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let client = get_conn().await.map_err(AppError::db_conn)?;
|
||||
|
||||
let row = client
|
||||
.query_opt(
|
||||
"SELECT u.id, u.username, u.email, u.role, u.created_at
|
||||
"SELECT u.id, u.username, u.email, u.role, u.created_at, u.session_generation
|
||||
FROM sessions s
|
||||
JOIN users u ON s.user_id = u.id
|
||||
WHERE s.token_hash = $1 AND s.expires_at > NOW()",
|
||||
@ -366,6 +386,7 @@ pub async fn get_user_by_token(token: &str) -> Result<Option<SessionUser>, Serve
|
||||
email: row.get("email"),
|
||||
role,
|
||||
created_at: row.get("created_at"),
|
||||
session_generation: row.get("session_generation"),
|
||||
})
|
||||
}
|
||||
None => None,
|
||||
@ -378,6 +399,26 @@ pub async fn get_user_by_token(token: &str) -> Result<Option<SessionUser>, Serve
|
||||
Ok(user)
|
||||
}
|
||||
|
||||
#[cfg(feature = "server")]
|
||||
/// 使指定用户的所有 session 立即失效:bump `session_generation`。
|
||||
///
|
||||
/// 用于角色降级、封禁、密码修改等场景。bump 后该用户所有已签发 session 在下次
|
||||
/// `get_user_by_token` 时因世代不匹配被逐出缓存并视为未登录。内存缓存无需主动清,
|
||||
/// 惰性逐出即可。当前仓库无运行时角色变更入口,本函数是为未来「用户管理」功能
|
||||
/// 预备的基础设施,一旦引入降级/封禁的 server function,必须在 UPDATE 后调用。
|
||||
#[allow(dead_code)] // 预留给未来的用户管理功能(角色变更/封禁触发全量 session 失效)
|
||||
pub async fn invalidate_user_sessions(user_id: i32) -> Result<(), ServerFnError> {
|
||||
let client = get_conn().await.map_err(AppError::db_conn)?;
|
||||
client
|
||||
.execute(
|
||||
"UPDATE users SET session_generation = session_generation + 1 WHERE id = $1",
|
||||
&[&user_id],
|
||||
)
|
||||
.await
|
||||
.map_err(AppError::query)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 获取当前登录用户的公开信息。
|
||||
///
|
||||
/// Dioxus server function,注册在 `/api` 路径下。
|
||||
|
||||
@ -679,6 +679,7 @@ mod tests {
|
||||
email: "cached@example.com".to_string(),
|
||||
role: UserRole::Admin,
|
||||
created_at: chrono::Utc::now(),
|
||||
session_generation: 0,
|
||||
};
|
||||
let token_hash = "sha256_token_hash";
|
||||
|
||||
|
||||
@ -42,6 +42,8 @@ pub struct User {
|
||||
pub role: UserRole,
|
||||
/// 账户创建时间。
|
||||
pub created_at: DateTime<Utc>,
|
||||
/// 会话世代号,角色/封禁变更时 +1 使旧 session 失效。
|
||||
pub session_generation: i32,
|
||||
}
|
||||
|
||||
/// 会话缓存使用的轻量用户结构体,不含密码哈希。
|
||||
@ -57,6 +59,8 @@ pub struct SessionUser {
|
||||
pub role: UserRole,
|
||||
/// 账户创建时间。
|
||||
pub created_at: DateTime<Utc>,
|
||||
/// 会话世代号,签发 session 时记录;与 users 表当前值不一致则 session 失效。
|
||||
pub session_generation: i32,
|
||||
}
|
||||
|
||||
/// 可公开的用户信息,从 User 转换而来,不含密码哈希。
|
||||
@ -83,6 +87,7 @@ impl From<User> for SessionUser {
|
||||
email: u.email,
|
||||
role: u.role,
|
||||
created_at: u.created_at,
|
||||
session_generation: u.session_generation,
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -126,6 +131,7 @@ mod tests {
|
||||
password_hash: "hash".to_string(),
|
||||
role: UserRole::Admin,
|
||||
created_at: Utc.with_ymd_and_hms(2024, 1, 1, 0, 0, 0).unwrap(),
|
||||
session_generation: 0,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user