224 lines
7.5 KiB
Rust
224 lines
7.5 KiB
Rust
//! Gestión de sesiones de usuario (creación, carga, destrucción).
|
|
|
|
use pagetop::prelude::*;
|
|
use pagetop_seaorm::db::{
|
|
ActiveModelTrait, ColumnTrait, DbErr, EntityTrait, QueryFilter, Set, dbconn,
|
|
};
|
|
|
|
use crate::AUTHENTICATED_ROLE_ID;
|
|
use crate::account::{Account, PermissionSet, UserStatus};
|
|
use crate::config::SETTINGS;
|
|
use crate::entity::{role, role_permission, session, user, user_role};
|
|
|
|
// **< Generación de session ID >*******************************************************************
|
|
|
|
/// Genera un session ID de 64 caracteres hex (32 bytes aleatorios via OsRng).
|
|
pub fn generate_sid() -> String {
|
|
use rand_core::{OsRng, RngCore};
|
|
let mut bytes = [0u8; 32];
|
|
OsRng.fill_bytes(&mut bytes);
|
|
bytes_to_hex(&bytes)
|
|
}
|
|
|
|
fn bytes_to_hex(bytes: &[u8]) -> String {
|
|
use std::fmt::Write;
|
|
let mut s = String::with_capacity(bytes.len() * 2);
|
|
for b in bytes {
|
|
write!(s, "{:02x}", b).unwrap();
|
|
}
|
|
s
|
|
}
|
|
|
|
// **< Cookie helpers >*****************************************************************************
|
|
|
|
/// Construye el valor de la cabecera `Set-Cookie` para la cookie de sesión.
|
|
pub fn build_cookie(sid: &str, remember: bool) -> String {
|
|
let mut cookie = util::join!(
|
|
&SETTINGS.session_cookie_name,
|
|
"=",
|
|
sid,
|
|
"; HttpOnly; SameSite=Lax; Path=/"
|
|
);
|
|
if SETTINGS.secure_cookie {
|
|
cookie.push_str("; Secure");
|
|
}
|
|
if remember {
|
|
cookie.push_str("; Max-Age=");
|
|
cookie.push_str(&SETTINGS.session_ttl_secs.to_string());
|
|
}
|
|
cookie
|
|
}
|
|
|
|
/// Construye la cookie de expiración (Max-Age=0) para borrar la sesión del navegador.
|
|
pub fn expiry_cookie() -> String {
|
|
util::join!(
|
|
&SETTINGS.session_cookie_name,
|
|
"=; HttpOnly; SameSite=Lax; Path=/; Max-Age=0"
|
|
)
|
|
}
|
|
|
|
/// Extrae el session ID de las cabeceras HTTP de la petición, si existe.
|
|
pub fn extract_sid(headers: Option<&web::http::HeaderMap>) -> Option<String> {
|
|
let cookie_str = headers?.get(web::http::header::COOKIE)?.to_str().ok()?;
|
|
|
|
let name = SETTINGS.session_cookie_name.as_str();
|
|
for part in cookie_str.split(';') {
|
|
let part = part.trim();
|
|
if let Some(value) = part.strip_prefix(name).and_then(|s| s.strip_prefix('=')) {
|
|
return Some(value.trim().to_owned());
|
|
}
|
|
}
|
|
None
|
|
}
|
|
|
|
// **< resolve_session >****************************************************************************
|
|
|
|
/// Lee la cookie de sesión de las cabeceras y resuelve el par `(CurrentUser, Option<Account>)`.
|
|
///
|
|
/// Si no hay cookie o la sesión ha expirado, devuelve `(CurrentUser::Anonymous, None)`.
|
|
/// Se llama desde el middleware de sesión, que es async.
|
|
pub async fn resolve_session(headers: &web::http::HeaderMap) -> (CurrentUser, Option<Account>) {
|
|
let Some(sid) = extract_sid(Some(headers)) else {
|
|
return (CurrentUser::Anonymous, None);
|
|
};
|
|
load_user_from_session(&sid).await
|
|
}
|
|
|
|
// **< load_user_from_session >*********************************************************************
|
|
|
|
/// Carga el par `(CurrentUser, Option<Account>)` a partir de un session ID.
|
|
///
|
|
/// Devuelve `(CurrentUser::Anonymous, None)` si la sesión no existe o ha expirado.
|
|
pub async fn load_user_from_session(sid: &str) -> (CurrentUser, Option<Account>) {
|
|
let now = Utc::now().naive_utc();
|
|
|
|
// Buscar sesión activa y no expirada.
|
|
let Ok(Some(sess)) = session::Entity::find_by_id(sid).one(dbconn()).await else {
|
|
return (CurrentUser::Anonymous, None);
|
|
};
|
|
if sess.expires_at < now {
|
|
return (CurrentUser::Anonymous, None);
|
|
}
|
|
|
|
// Cargar usuario con estado activo.
|
|
let Ok(Some(user_model)) = user::Entity::find_by_id(sess.user_id).one(dbconn()).await else {
|
|
return (CurrentUser::Anonymous, None);
|
|
};
|
|
if UserStatus::from_i16(user_model.status) != UserStatus::Active {
|
|
return (CurrentUser::Anonymous, None);
|
|
}
|
|
|
|
// Cargar roles explícitos del usuario.
|
|
let Ok(user_role_rows) = user_role::Entity::find()
|
|
.filter(user_role::Column::UserId.eq(sess.user_id))
|
|
.all(dbconn())
|
|
.await
|
|
else {
|
|
return (CurrentUser::Anonymous, None);
|
|
};
|
|
|
|
let role_ids: Vec<i32> = user_role_rows.iter().map(|ur| ur.role_id).collect();
|
|
|
|
let Ok(role_rows) = role::Entity::find()
|
|
.filter(role::Column::Id.is_in(role_ids.clone()))
|
|
.all(dbconn())
|
|
.await
|
|
else {
|
|
return (CurrentUser::Anonymous, None);
|
|
};
|
|
|
|
let is_admin = user_model.is_admin;
|
|
let role_names: Vec<String> = role_rows.iter().map(|r| r.machine_name.clone()).collect();
|
|
|
|
// Cargar permisos de todos los roles más los del rol implícito "authenticated", que no se
|
|
// asigna en `user_role` pero se aplica siempre.
|
|
let mut all_role_ids = role_ids;
|
|
if !all_role_ids.contains(&AUTHENTICATED_ROLE_ID) {
|
|
all_role_ids.push(AUTHENTICATED_ROLE_ID);
|
|
}
|
|
|
|
let permissions = if is_admin {
|
|
PermissionSet::default()
|
|
} else {
|
|
let Ok(perm_rows) = role_permission::Entity::find()
|
|
.filter(role_permission::Column::RoleId.is_in(all_role_ids))
|
|
.all(dbconn())
|
|
.await
|
|
else {
|
|
return (CurrentUser::Anonymous, None);
|
|
};
|
|
PermissionSet::new(perm_rows.into_iter().map(|p| p.permission_key))
|
|
};
|
|
|
|
// Actualizar last_activity_at con throttle: sólo una vez por minuto.
|
|
let throttle = Duration::minutes(1);
|
|
if sess
|
|
.last_activity_at
|
|
.map(|t| now - t > throttle)
|
|
.unwrap_or(true)
|
|
{
|
|
let mut active: session::ActiveModel = sess.into();
|
|
active.last_activity_at = Set(Some(now));
|
|
let _ = active.update(dbconn()).await;
|
|
}
|
|
|
|
let display_name = user_model.display_name.unwrap_or_default();
|
|
let visible_name = if display_name.is_empty() {
|
|
user_model.username.clone()
|
|
} else {
|
|
display_name.clone()
|
|
};
|
|
let account = Account {
|
|
id: user_model.id,
|
|
username: user_model.username,
|
|
email: user_model.email,
|
|
display_name,
|
|
status: UserStatus::from_i16(user_model.status),
|
|
roles: role_names,
|
|
permissions,
|
|
is_admin,
|
|
};
|
|
let current_user = CurrentUser::Authenticated {
|
|
id: account.id,
|
|
display_name: visible_name,
|
|
};
|
|
|
|
(current_user, Some(account))
|
|
}
|
|
|
|
// **< create_session >*****************************************************************************
|
|
|
|
/// Crea una nueva sesión en base de datos y devuelve el session ID.
|
|
pub async fn create_session(user_id: i32, remember: bool) -> Result<String, DbErr> {
|
|
let sid = generate_sid();
|
|
let now = Utc::now().naive_utc();
|
|
let ttl = Duration::seconds(SETTINGS.session_ttl_secs);
|
|
let idle = Duration::seconds(SETTINGS.session_idle_ttl_secs);
|
|
let expires_at = if remember { now + ttl } else { now + idle };
|
|
|
|
let new_session = session::ActiveModel {
|
|
sid: Set(sid.clone()),
|
|
user_id: Set(user_id),
|
|
data: Set("{}".into()),
|
|
last_activity_at: Set(Some(now)),
|
|
expires_at: Set(expires_at),
|
|
created_at: Set(now),
|
|
};
|
|
session::Entity::insert(new_session).exec(dbconn()).await?;
|
|
Ok(sid)
|
|
}
|
|
|
|
/// Destruye la sesión indicada (logout).
|
|
pub async fn destroy_session(sid: &str) -> Result<(), DbErr> {
|
|
session::Entity::delete_by_id(sid).exec(dbconn()).await?;
|
|
Ok(())
|
|
}
|
|
|
|
/// Destruye todas las sesiones de un usuario (p. ej. al cambiar contraseña).
|
|
pub async fn destroy_user_sessions(user_id: i32) -> Result<(), DbErr> {
|
|
session::Entity::delete_many()
|
|
.filter(session::Column::UserId.eq(user_id))
|
|
.exec(dbconn())
|
|
.await?;
|
|
Ok(())
|
|
}
|