adding user authentication

This commit is contained in:
DioCrafts
2025-03-20 09:22:31 +01:00
parent 961545aef7
commit cafad0fbfd
69 changed files with 6483 additions and 106 deletions
+16
View File
@@ -50,6 +50,22 @@ pub struct File {
// Ya no necesitamos este módulo, ahora usamos un String directamente
impl Default for File {
fn default() -> Self {
Self {
id: "stub-id".to_string(),
name: "stub-file.txt".to_string(),
storage_path: StoragePath::from_string("/"),
path_string: "/".to_string(),
size: 0,
mime_type: "application/octet-stream".to_string(),
folder_id: None,
created_at: 0,
modified_at: 0,
}
}
}
impl File {
/// Crea un nuevo archivo con validación
pub fn new(
+14
View File
@@ -44,6 +44,20 @@ pub struct Folder {
// Ya no necesitamos este módulo, ahora usamos un String directamente
impl Default for Folder {
fn default() -> Self {
Self {
id: "stub-id".to_string(),
name: "stub-folder".to_string(),
storage_path: StoragePath::from_string("/"),
path_string: "/".to_string(),
parent_id: None,
created_at: 0,
modified_at: 0,
}
}
}
impl Folder {
/// Creates a new folder with validation
pub fn new(
+2
View File
@@ -1,3 +1,5 @@
pub mod file;
pub mod folder;
pub mod user;
pub mod session;
+70
View File
@@ -0,0 +1,70 @@
use serde::{Serialize, Deserialize};
use uuid::Uuid;
use chrono::{DateTime, Utc, Duration};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Session {
pub id: String,
pub user_id: String,
pub refresh_token: String,
pub expires_at: DateTime<Utc>,
pub ip_address: Option<String>,
pub user_agent: Option<String>,
pub created_at: DateTime<Utc>,
pub revoked: bool,
}
impl Session {
pub fn new(
user_id: String,
refresh_token: String,
ip_address: Option<String>,
user_agent: Option<String>,
expires_in_days: i64,
) -> Self {
let now = Utc::now();
Self {
id: Uuid::new_v4().to_string(),
user_id,
refresh_token,
expires_at: now + Duration::days(expires_in_days),
ip_address,
user_agent,
created_at: now,
revoked: false,
}
}
// Getters
pub fn id(&self) -> &str {
&self.id
}
pub fn user_id(&self) -> &str {
&self.user_id
}
pub fn refresh_token(&self) -> &str {
&self.refresh_token
}
pub fn expires_at(&self) -> DateTime<Utc> {
self.expires_at
}
pub fn created_at(&self) -> DateTime<Utc> {
self.created_at
}
pub fn is_expired(&self) -> bool {
Utc::now() > self.expires_at
}
pub fn is_revoked(&self) -> bool {
self.revoked
}
pub fn revoke(&mut self) {
self.revoked = true;
}
}
+232
View File
@@ -0,0 +1,232 @@
use serde::{Serialize, Deserialize};
use argon2::{Argon2, PasswordHash, PasswordHasher, PasswordVerifier};
use argon2::password_hash::SaltString;
use rand_core::OsRng;
use uuid::Uuid;
use chrono::{DateTime, Utc};
#[derive(Debug, thiserror::Error)]
pub enum UserError {
#[error("Username inválido: {0}")]
InvalidUsername(String),
#[error("Password inválido: {0}")]
InvalidPassword(String),
#[error("Error en la validación: {0}")]
ValidationError(String),
#[error("Error en la autenticación: {0}")]
AuthenticationError(String),
}
pub type UserResult<T> = Result<T, UserError>;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, sqlx::Type)]
#[sqlx(rename_all = "lowercase")]
pub enum UserRole {
Admin,
User,
}
impl std::fmt::Display for UserRole {
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
match self {
UserRole::Admin => write!(f, "admin"),
UserRole::User => write!(f, "user"),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct User {
id: String,
username: String,
email: String,
#[serde(skip_serializing)]
password_hash: String,
role: UserRole,
storage_quota_bytes: i64,
storage_used_bytes: i64,
created_at: DateTime<Utc>,
updated_at: DateTime<Utc>,
last_login_at: Option<DateTime<Utc>>,
active: bool,
}
impl User {
pub fn new(
username: String,
email: String,
password: String,
role: UserRole,
storage_quota_bytes: i64,
) -> UserResult<Self> {
// Validaciones
if username.is_empty() || username.len() < 3 || username.len() > 32 {
return Err(UserError::InvalidUsername(format!(
"Username debe tener entre 3 y 32 caracteres"
)));
}
if !email.contains('@') || email.len() < 5 {
return Err(UserError::ValidationError(format!(
"Email inválido"
)));
}
if password.len() < 8 {
return Err(UserError::InvalidPassword(format!(
"Password debe tener al menos 8 caracteres"
)));
}
// Generar hash con Argon2id (recomendado para 2023+)
let salt = SaltString::generate(&mut OsRng);
let argon2 = Argon2::default();
let password_hash = argon2.hash_password(password.as_bytes(), &salt)
.map_err(|e| UserError::ValidationError(format!("Error al generar hash: {}", e)))?
.to_string();
let now = Utc::now();
Ok(Self {
id: Uuid::new_v4().to_string(),
username,
email,
password_hash,
role,
storage_quota_bytes,
storage_used_bytes: 0,
created_at: now,
updated_at: now,
last_login_at: None,
active: true,
})
}
// Crear desde valores existentes (para reconstrucción desde BD)
pub fn from_data(
id: String,
username: String,
email: String,
password_hash: String,
role: UserRole,
storage_quota_bytes: i64,
storage_used_bytes: i64,
created_at: DateTime<Utc>,
updated_at: DateTime<Utc>,
last_login_at: Option<DateTime<Utc>>,
active: bool,
) -> Self {
Self {
id,
username,
email,
password_hash,
role,
storage_quota_bytes,
storage_used_bytes,
created_at,
updated_at,
last_login_at,
active,
}
}
// Getters
pub fn id(&self) -> &str {
&self.id
}
pub fn username(&self) -> &str {
&self.username
}
pub fn email(&self) -> &str {
&self.email
}
pub fn role(&self) -> UserRole {
self.role
}
pub fn storage_quota_bytes(&self) -> i64 {
self.storage_quota_bytes
}
pub fn storage_used_bytes(&self) -> i64 {
self.storage_used_bytes
}
pub fn created_at(&self) -> DateTime<Utc> {
self.created_at
}
pub fn updated_at(&self) -> DateTime<Utc> {
self.updated_at
}
pub fn last_login_at(&self) -> Option<DateTime<Utc>> {
self.last_login_at
}
pub fn is_active(&self) -> bool {
self.active
}
pub fn password_hash(&self) -> &str {
&self.password_hash
}
// Verificación de password
pub fn verify_password(&self, password: &str) -> UserResult<bool> {
let parsed_hash = PasswordHash::new(&self.password_hash)
.map_err(|e| UserError::AuthenticationError(format!("Error al procesar hash: {}", e)))?;
Ok(Argon2::default().verify_password(password.as_bytes(), &parsed_hash).is_ok())
}
// Cambiar contraseña
pub fn update_password(&mut self, new_password: String) -> UserResult<()> {
if new_password.len() < 8 {
return Err(UserError::InvalidPassword(format!(
"Password debe tener al menos 8 caracteres"
)));
}
let salt = SaltString::generate(&mut OsRng);
let argon2 = Argon2::default();
self.password_hash = argon2.hash_password(new_password.as_bytes(), &salt)
.map_err(|e| UserError::ValidationError(format!("Error al generar hash: {}", e)))?
.to_string();
self.updated_at = Utc::now();
Ok(())
}
// Actualizar uso de almacenamiento
pub fn update_storage_used(&mut self, storage_used_bytes: i64) {
self.storage_used_bytes = storage_used_bytes;
self.updated_at = Utc::now();
}
// Registrar login
pub fn register_login(&mut self) {
let now = Utc::now();
self.last_login_at = Some(now);
self.updated_at = now;
}
// Desactivar usuario
pub fn deactivate(&mut self) {
self.active = false;
self.updated_at = Utc::now();
}
// Activar usuario
pub fn activate(&mut self) {
self.active = true;
self.updated_at = Utc::now();
}
}
@@ -1,6 +1,7 @@
use async_trait::async_trait;
use crate::domain::entities::file::File;
use crate::domain::services::path_service::StoragePath;
use crate::common::errors::DomainError;
use futures::Stream;
use bytes::Bytes;
@@ -23,9 +24,15 @@ pub enum FileRepositoryError {
#[error("Mapping error: {0}")]
MappingError(String),
#[error("ID Mapping error: {0}")]
IdMappingError(String),
#[error("Timeout error: {0}")]
Timeout(String),
#[error("Domain error: {0}")]
DomainError(#[from] DomainError),
#[error("Other error: {0}")]
Other(String),
}
@@ -1,6 +1,7 @@
use async_trait::async_trait;
use crate::domain::entities::folder::Folder;
use crate::domain::services::path_service::StoragePath;
use crate::common::errors::DomainError;
/// Error types for folder repository operations
#[derive(Debug, thiserror::Error)]
@@ -24,6 +25,9 @@ pub enum FolderRepositoryError {
#[error("Validation error: {0}")]
ValidationError(String),
#[error("Domain error: {0}")]
DomainError(#[from] DomainError),
#[error("Other error: {0}")]
Other(String),
}
+2
View File
@@ -1,3 +1,5 @@
pub mod file_repository;
pub mod folder_repository;
pub mod user_repository;
pub mod session_repository;
@@ -0,0 +1,58 @@
use async_trait::async_trait;
use crate::domain::entities::session::Session;
use crate::common::errors::DomainError;
#[derive(Debug, thiserror::Error)]
pub enum SessionRepositoryError {
#[error("Sesión no encontrada: {0}")]
NotFound(String),
#[error("Error de base de datos: {0}")]
DatabaseError(String),
#[error("Error de tiempo de espera: {0}")]
Timeout(String),
}
pub type SessionRepositoryResult<T> = Result<T, SessionRepositoryError>;
// Conversión de SessionRepositoryError a DomainError
impl From<SessionRepositoryError> for DomainError {
fn from(err: SessionRepositoryError) -> Self {
match err {
SessionRepositoryError::NotFound(msg) => {
DomainError::not_found("Session", msg)
},
SessionRepositoryError::DatabaseError(msg) => {
DomainError::internal_error("Database", msg)
},
SessionRepositoryError::Timeout(msg) => {
DomainError::timeout("Database", msg)
},
}
}
}
#[async_trait]
pub trait SessionRepository: Send + Sync + 'static {
/// Crea una nueva sesión
async fn create_session(&self, session: Session) -> SessionRepositoryResult<Session>;
/// Obtiene una sesión por ID
async fn get_session_by_id(&self, id: &str) -> SessionRepositoryResult<Session>;
/// Obtiene una sesión por token de actualización
async fn get_session_by_refresh_token(&self, refresh_token: &str) -> SessionRepositoryResult<Session>;
/// Obtiene todas las sesiones de un usuario
async fn get_sessions_by_user_id(&self, user_id: &str) -> SessionRepositoryResult<Vec<Session>>;
/// Revoca una sesión específica
async fn revoke_session(&self, session_id: &str) -> SessionRepositoryResult<()>;
/// Revoca todas las sesiones de un usuario
async fn revoke_all_user_sessions(&self, user_id: &str) -> SessionRepositoryResult<u64>;
/// Elimina sesiones expiradas
async fn delete_expired_sessions(&self) -> SessionRepositoryResult<u64>;
}
@@ -0,0 +1,91 @@
use async_trait::async_trait;
use crate::domain::entities::user::{User, UserRole};
use crate::common::errors::DomainError;
#[derive(Debug, thiserror::Error)]
pub enum UserRepositoryError {
#[error("Usuario no encontrado: {0}")]
NotFound(String),
#[error("Usuario ya existe: {0}")]
AlreadyExists(String),
#[error("Error de base de datos: {0}")]
DatabaseError(String),
#[error("Error de validación: {0}")]
ValidationError(String),
#[error("Error de tiempo de espera: {0}")]
Timeout(String),
#[error("Operación no permitida: {0}")]
OperationNotAllowed(String),
}
pub type UserRepositoryResult<T> = Result<T, UserRepositoryError>;
// Conversión de UserRepositoryError a DomainError
impl From<UserRepositoryError> for DomainError {
fn from(err: UserRepositoryError) -> Self {
match err {
UserRepositoryError::NotFound(msg) => {
DomainError::not_found("User", msg)
},
UserRepositoryError::AlreadyExists(msg) => {
DomainError::already_exists("User", msg)
},
UserRepositoryError::DatabaseError(msg) => {
DomainError::internal_error("Database", msg)
},
UserRepositoryError::ValidationError(msg) => {
DomainError::validation_error("User", msg)
},
UserRepositoryError::Timeout(msg) => {
DomainError::timeout("Database", msg)
},
UserRepositoryError::OperationNotAllowed(msg) => {
DomainError::access_denied("User", msg)
},
}
}
}
#[async_trait]
pub trait UserRepository: Send + Sync + 'static {
/// Crea un nuevo usuario
async fn create_user(&self, user: User) -> UserRepositoryResult<User>;
/// Obtiene un usuario por ID
async fn get_user_by_id(&self, id: &str) -> UserRepositoryResult<User>;
/// Obtiene un usuario por nombre de usuario
async fn get_user_by_username(&self, username: &str) -> UserRepositoryResult<User>;
/// Obtiene un usuario por correo electrónico
async fn get_user_by_email(&self, email: &str) -> UserRepositoryResult<User>;
/// Actualiza un usuario existente
async fn update_user(&self, user: User) -> UserRepositoryResult<User>;
/// Actualiza solo el uso de almacenamiento de un usuario
async fn update_storage_usage(&self, user_id: &str, usage_bytes: i64) -> UserRepositoryResult<()>;
/// Actualiza la fecha de último inicio de sesión
async fn update_last_login(&self, user_id: &str) -> UserRepositoryResult<()>;
/// Lista usuarios con paginación
async fn list_users(&self, limit: i64, offset: i64) -> UserRepositoryResult<Vec<User>>;
/// Activa o desactiva un usuario
async fn set_user_active_status(&self, user_id: &str, active: bool) -> UserRepositoryResult<()>;
/// Cambia la contraseña de un usuario
async fn change_password(&self, user_id: &str, password_hash: &str) -> UserRepositoryResult<()>;
/// Cambia el rol de un usuario
async fn change_role(&self, user_id: &str, role: UserRole) -> UserRepositoryResult<()>;
/// Elimina un usuario
async fn delete_user(&self, user_id: &str) -> UserRepositoryResult<()>;
}
+134
View File
@@ -0,0 +1,134 @@
use jsonwebtoken::{encode, decode, Header, Validation, EncodingKey, DecodingKey, Algorithm};
use serde::{Serialize, Deserialize};
use uuid::Uuid;
use chrono::{Utc, DateTime};
use crate::domain::entities::user::{User, UserRole};
use crate::common::errors::{DomainError, ErrorKind};
// Reclamaciones JWT
#[derive(Debug, Serialize, Deserialize)]
pub struct TokenClaims {
pub sub: String, // user ID
pub exp: i64, // expiration timestamp
pub iat: i64, // issued at timestamp
pub jti: String, // JWT ID
pub username: String, // username
pub email: String, // email
pub role: String, // role as string
}
#[derive(Debug, thiserror::Error)]
pub enum AuthError {
#[error("Credenciales inválidas")]
InvalidCredentials,
#[error("Token expirado")]
TokenExpired,
#[error("Token inválido: {0}")]
InvalidToken(String),
#[error("Acceso denegado: {0}")]
AccessDenied(String),
#[error("Operación no permitida: {0}")]
OperationNotAllowed(String),
#[error("Error interno: {0}")]
InternalError(String),
}
impl From<AuthError> for DomainError {
fn from(err: AuthError) -> Self {
match err {
AuthError::InvalidCredentials => {
DomainError::new(ErrorKind::AccessDenied, "Auth", "Credenciales inválidas")
},
AuthError::TokenExpired => {
DomainError::new(ErrorKind::AccessDenied, "Auth", "Token expirado")
},
AuthError::InvalidToken(msg) => {
DomainError::new(ErrorKind::AccessDenied, "Auth", format!("Token inválido: {}", msg))
},
AuthError::AccessDenied(msg) => {
DomainError::new(ErrorKind::AccessDenied, "Auth", msg)
},
AuthError::OperationNotAllowed(msg) => {
DomainError::new(ErrorKind::AccessDenied, "Auth", msg)
},
AuthError::InternalError(msg) => {
DomainError::new(ErrorKind::InternalError, "Auth", msg)
},
}
}
}
pub struct AuthService {
jwt_secret: String,
access_token_expiry: i64, // segundos
refresh_token_expiry: i64, // segundos
}
impl AuthService {
pub fn new(jwt_secret: String, access_token_expiry_secs: i64, refresh_token_expiry_secs: i64) -> Self {
Self {
jwt_secret,
access_token_expiry: access_token_expiry_secs,
refresh_token_expiry: refresh_token_expiry_secs,
}
}
pub fn generate_access_token(&self, user: &User) -> Result<String, AuthError> {
let now = Utc::now().timestamp();
let claims = TokenClaims {
sub: user.id().to_string(),
exp: now + self.access_token_expiry,
iat: now,
jti: Uuid::new_v4().to_string(),
username: user.username().to_string(),
email: user.email().to_string(),
role: format!("{}", user.role()),
};
encode(
&Header::default(),
&claims,
&EncodingKey::from_secret(self.jwt_secret.as_bytes())
)
.map_err(|e| AuthError::InternalError(format!("Error al generar token: {}", e)))
}
pub fn generate_refresh_token(&self) -> String {
Uuid::new_v4().to_string()
}
pub fn validate_token(&self, token: &str) -> Result<TokenClaims, AuthError> {
let validation = Validation::new(Algorithm::HS256);
let token_data = decode::<TokenClaims>(
token,
&DecodingKey::from_secret(self.jwt_secret.as_bytes()),
&validation
)
.map_err(|e| {
match e.kind() {
jsonwebtoken::errors::ErrorKind::ExpiredSignature => AuthError::TokenExpired,
_ => AuthError::InvalidToken(format!("Error al validar token: {}", e)),
}
})?;
Ok(token_data.claims)
}
// Duración del refresh token en segundos
pub fn refresh_token_expiry_secs(&self) -> i64 {
self.refresh_token_expiry
}
// Duración del refresh token en días (para la entidad Session)
pub fn refresh_token_expiry_days(&self) -> i64 {
self.refresh_token_expiry / (24 * 3600)
}
}
+2 -1
View File
@@ -1,2 +1,3 @@
pub mod i18n_service;
pub mod path_service;
pub mod path_service;
pub mod auth_service;