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 查询对应用户(不含密码哈希,供会话缓存使用)。
|
/// 根据会话 token 查询对应用户(不含密码哈希,供会话缓存使用)。
|
||||||
///
|
///
|
||||||
/// 优先命中内存缓存,避免每次请求都执行 DB JOIN;未命中时回查数据库并回填缓存。
|
/// 优先命中内存缓存,避免每次请求都执行 DB JOIN;未命中时回查数据库并回填缓存。
|
||||||
/// 仅服务端内部使用,不会暴露给前端。
|
/// 缓存命中后仍回查 `users.session_generation`:若用户已被降级/封禁(generation 被
|
||||||
|
/// bump),缓存的旧 SessionUser.generation 不再匹配,此时逐出缓存并视为未登录,
|
||||||
|
/// 消除权限残留窗口(见 H2)。仅服务端内部使用,不会暴露给前端。
|
||||||
pub async fn get_user_by_token(token: &str) -> Result<Option<SessionUser>, 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(cached) = crate::cache::get_session_user(&token_hash).await {
|
||||||
return Ok(Some(user));
|
// 缓存命中后校验世代号: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 client = get_conn().await.map_err(AppError::db_conn)?;
|
||||||
|
|
||||||
let row = client
|
let row = client
|
||||||
.query_opt(
|
.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
|
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()",
|
||||||
@ -366,6 +386,7 @@ pub async fn get_user_by_token(token: &str) -> Result<Option<SessionUser>, Serve
|
|||||||
email: row.get("email"),
|
email: row.get("email"),
|
||||||
role,
|
role,
|
||||||
created_at: row.get("created_at"),
|
created_at: row.get("created_at"),
|
||||||
|
session_generation: row.get("session_generation"),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
None => None,
|
None => None,
|
||||||
@ -378,6 +399,26 @@ pub async fn get_user_by_token(token: &str) -> Result<Option<SessionUser>, Serve
|
|||||||
Ok(user)
|
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` 路径下。
|
/// Dioxus server function,注册在 `/api` 路径下。
|
||||||
|
|||||||
@ -679,6 +679,7 @@ mod tests {
|
|||||||
email: "cached@example.com".to_string(),
|
email: "cached@example.com".to_string(),
|
||||||
role: UserRole::Admin,
|
role: UserRole::Admin,
|
||||||
created_at: chrono::Utc::now(),
|
created_at: chrono::Utc::now(),
|
||||||
|
session_generation: 0,
|
||||||
};
|
};
|
||||||
let token_hash = "sha256_token_hash";
|
let token_hash = "sha256_token_hash";
|
||||||
|
|
||||||
|
|||||||
@ -42,6 +42,8 @@ pub struct User {
|
|||||||
pub role: UserRole,
|
pub role: UserRole,
|
||||||
/// 账户创建时间。
|
/// 账户创建时间。
|
||||||
pub created_at: DateTime<Utc>,
|
pub created_at: DateTime<Utc>,
|
||||||
|
/// 会话世代号,角色/封禁变更时 +1 使旧 session 失效。
|
||||||
|
pub session_generation: i32,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 会话缓存使用的轻量用户结构体,不含密码哈希。
|
/// 会话缓存使用的轻量用户结构体,不含密码哈希。
|
||||||
@ -57,6 +59,8 @@ pub struct SessionUser {
|
|||||||
pub role: UserRole,
|
pub role: UserRole,
|
||||||
/// 账户创建时间。
|
/// 账户创建时间。
|
||||||
pub created_at: DateTime<Utc>,
|
pub created_at: DateTime<Utc>,
|
||||||
|
/// 会话世代号,签发 session 时记录;与 users 表当前值不一致则 session 失效。
|
||||||
|
pub session_generation: i32,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 可公开的用户信息,从 User 转换而来,不含密码哈希。
|
/// 可公开的用户信息,从 User 转换而来,不含密码哈希。
|
||||||
@ -83,6 +87,7 @@ impl From<User> for SessionUser {
|
|||||||
email: u.email,
|
email: u.email,
|
||||||
role: u.role,
|
role: u.role,
|
||||||
created_at: u.created_at,
|
created_at: u.created_at,
|
||||||
|
session_generation: u.session_generation,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -126,6 +131,7 @@ mod tests {
|
|||||||
password_hash: "hash".to_string(),
|
password_hash: "hash".to_string(),
|
||||||
role: UserRole::Admin,
|
role: UserRole::Admin,
|
||||||
created_at: Utc.with_ymd_and_hms(2024, 1, 1, 0, 0, 0).unwrap(),
|
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