✨ Añade pagetop-user, con autenticación y RBAC

Identidad de usuario, sesiones en base de datos, roles y permisos (RBAC)
con "authenticated" implícito, contraseñas con Argon2id, tokens de
un solo uso para verificación de email y restablecimiento de contraseña,
y la UI de administración completa (usuarios, roles, permisos) sobre
pagetop-admin.

Incluye datos de demostración opcionales tras la feature `demo-data`.
This commit is contained in:
Manuel Cillero 2026-08-28 20:59:17 +02:00
parent 043873a954
commit def0513246
57 changed files with 7409 additions and 0 deletions

View file

@ -0,0 +1,125 @@
//! Generación y verificación de tokens de un solo uso (reset de contraseña, verificación de
//! email...).
use pagetop::datetime::{Duration, Utc};
use sha2::{Digest, Sha256};
use pagetop_seaorm::db::{
ActiveModelTrait, ActiveValue, ColumnTrait, EntityTrait, QueryFilter, Set, dbconn,
};
use crate::entity::user_token;
use crate::error::AuthError;
// **< Generación de tokens >***********************************************************************
/// Genera un token URL-safe de 43 caracteres (32 bytes -> base64url sin padding).
pub fn generate_token() -> String {
use rand_core::{OsRng, RngCore};
let mut bytes = [0u8; 32];
OsRng.fill_bytes(&mut bytes);
// base64url sin padding (a=, == al final) para usar en URLs de forma segura.
use base64ct::{Base64UrlUnpadded, Encoding};
Base64UrlUnpadded::encode_string(&bytes)
}
/// Calcula el hash SHA-256 de un token y lo devuelve en hexadecimal (64 chars).
///
/// El hash es lo que se almacena en BD; el token en claro se envía al usuario por email.
pub fn hash_token(token: &str) -> String {
let mut hasher = Sha256::new();
hasher.update(token.as_bytes());
let digest = hasher.finalize();
// Convertir a hex sin depender de crates adicionales.
use std::fmt::Write;
let mut s = String::with_capacity(64);
for b in digest {
write!(s, "{:02x}", b).unwrap();
}
s
}
// **< TokenKind >**********************************************************************************
/// Tipos de token que emite `pagetop-user`.
#[derive(Clone, Copy, Debug)]
pub enum TokenKind {
PasswordReset,
EmailVerification,
}
impl TokenKind {
pub fn as_str(self) -> &'static str {
match self {
TokenKind::PasswordReset => "password_reset",
TokenKind::EmailVerification => "email_verification",
}
}
/// TTL del token en segundos según su tipo.
pub fn ttl_secs(self) -> i64 {
match self {
TokenKind::PasswordReset => 3600, // 1 hora
TokenKind::EmailVerification => 86400 * 3, // 3 días
}
}
}
// **< create_token >*******************************************************************************
/// Crea un token en BD y devuelve el valor en claro para enviarlo al usuario.
///
/// Si el usuario ya tiene un token del mismo tipo vigente, lo reemplaza.
pub async fn create_token(user_id: i32, kind: TokenKind) -> Result<String, AuthError> {
// Invalidar tokens anteriores del mismo tipo para este usuario.
user_token::Entity::delete_many()
.filter(user_token::Column::UserId.eq(user_id))
.filter(user_token::Column::Kind.eq(kind.as_str()))
.exec(dbconn())
.await?;
let token = generate_token();
let now = Utc::now().naive_utc();
let expires_at = now + Duration::seconds(kind.ttl_secs());
let new_token = user_token::ActiveModel {
id: ActiveValue::NotSet,
user_id: Set(user_id),
kind: Set(kind.as_str().to_owned()),
token_hash: Set(hash_token(&token)),
expires_at: Set(expires_at),
consumed_at: Set(None),
created_at: Set(now),
updated_at: Set(now),
};
user_token::Entity::insert(new_token).exec(dbconn()).await?;
Ok(token)
}
// **< consume_token >******************************************************************************
/// Verifica que el token en claro sea válido (existe, no ha expirado y no ha sido consumido)
/// y lo marca como consumido. Devuelve el `user_id` asociado.
pub async fn consume_token(token: &str, kind: TokenKind) -> Result<i32, AuthError> {
let hash = hash_token(token);
let now = Utc::now().naive_utc();
let row = user_token::Entity::find()
.filter(user_token::Column::TokenHash.eq(&hash))
.filter(user_token::Column::Kind.eq(kind.as_str()))
.one(dbconn())
.await?
.ok_or(AuthError::InvalidToken)?;
if row.consumed_at.is_some() || row.expires_at < now {
return Err(AuthError::InvalidToken);
}
let user_id = row.user_id;
let mut active: user_token::ActiveModel = row.into();
active.consumed_at = Set(Some(now));
active.updated_at = Set(now);
active.update(dbconn()).await?;
Ok(user_id)
}