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:
xfy 2026-06-18 13:26:09 +08:00
parent 82a3c12940
commit 53bfb1b7c0
4 changed files with 58 additions and 4 deletions

View 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 失效';

View File

@ -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` 路径下。

View File

@ -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";

View File

@ -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,
}
}