From 43f06af37f52b7c8d257f49c4d53392f415546de Mon Sep 17 00:00:00 2001 From: Mario Date: Thu, 27 Mar 2025 10:25:55 +0100 Subject: [PATCH] wip --- ...33_create_reset_passwords_tokens_table.sql | 7 + src/app.rs | 8 + src/common.rs | 1 + src/databases/database.rs | 3 + src/databases/mysql.rs | 8 + src/databases/sqlite.rs | 8 + src/errors.rs | 7 + src/mailer.rs | 67 +++++++ src/services/authorization.rs | 4 + src/services/user.rs | 176 +++++++++++++++++- src/web/api/server/v1/contexts/user/forms.rs | 7 + .../api/server/v1/contexts/user/handlers.rs | 63 ++++++- 12 files changed, 357 insertions(+), 2 deletions(-) create mode 100644 migrations/20250327010633_create_reset_passwords_tokens_table.sql diff --git a/migrations/20250327010633_create_reset_passwords_tokens_table.sql b/migrations/20250327010633_create_reset_passwords_tokens_table.sql new file mode 100644 index 00000000..b5463b5b --- /dev/null +++ b/migrations/20250327010633_create_reset_passwords_tokens_table.sql @@ -0,0 +1,7 @@ +-- Add migration script here +CREATE TABLE IF NOT EXISTS torrust_reset_passwords_tokens ( + user_id INTEGER NOT NULL PRIMARY KEY, + token INTEGER NOT NULL, + expiration_date DATE NOT NULL, + FOREIGN KEY(user_id) REFERENCES torrust_users(user_id) ON DELETE CASCADE +) \ No newline at end of file diff --git a/src/app.rs b/src/app.rs index 64e58dfc..e7fbd02d 100644 --- a/src/app.rs +++ b/src/app.rs @@ -167,6 +167,13 @@ pub async fn run(configuration: Configuration, api_version: &Version) -> Running )) .clone(); + let password_reset_service = Arc::new(user::PasswordResetService::new( + configuration.clone(), + user_profile_repository.clone(), + authorization_service.clone(), + )) + .clone(); + // Build app container let app_data = Arc::new(AppData::new( @@ -202,6 +209,7 @@ pub async fn run(configuration: Configuration, api_version: &Version) -> Running ban_service, about_service, listing_service, + password_reset_service, )); // Start cronjob to import tracker torrent data and updating diff --git a/src/common.rs b/src/common.rs index fb021138..b04c485c 100644 --- a/src/common.rs +++ b/src/common.rs @@ -53,6 +53,7 @@ pub struct AppData { pub ban_service: Arc, pub about_service: Arc, pub listing_service: Arc, + pub password_reset_service: Arc, } impl AppData { diff --git a/src/databases/database.rs b/src/databases/database.rs index 8884bf91..292fd2c1 100644 --- a/src/databases/database.rs +++ b/src/databases/database.rs @@ -214,6 +214,9 @@ pub trait Database: Sync + Send { /// Get `UserProfile` from `username`. async fn get_user_profile_from_username(&self, username: &str) -> Result; + /// Get `UserProfile` from `email`. + async fn get_user_profile_from_email(&self, email: &str) -> Result; + /// Get all user profiles in a paginated and sorted form as `UserProfilesResponse` from `search`, `filters`, `sort`, `offset` and `page_size`. async fn get_user_profiles_search_paginated( &self, diff --git a/src/databases/mysql.rs b/src/databases/mysql.rs index 6a7c3355..34bb358d 100644 --- a/src/databases/mysql.rs +++ b/src/databases/mysql.rs @@ -155,6 +155,14 @@ impl Database for Mysql { .map_err(|_| database::Error::UserNotFound) } + async fn get_user_profile_from_email(&self, email: &str) -> Result { + query_as::<_, UserProfile>("SELECT * FROM torrust_user_profiles WHERE email = ?") + .bind(email) + .fetch_one(&self.pool) + .await + .map_err(|_| database::Error::UserNotFound) + } + async fn get_user_profiles_search_paginated( &self, search: &Option, diff --git a/src/databases/sqlite.rs b/src/databases/sqlite.rs index 1a60569c..c4ba72d5 100644 --- a/src/databases/sqlite.rs +++ b/src/databases/sqlite.rs @@ -156,6 +156,14 @@ impl Database for Sqlite { .map_err(|_| database::Error::UserNotFound) } + async fn get_user_profile_from_email(&self, email: &str) -> Result { + query_as::<_, UserProfile>("SELECT * FROM torrust_user_profiles WHERE email = ?") + .bind(email) + .fetch_one(&self.pool) + .await + .map_err(|_| database::Error::UserNotFound) + } + async fn get_user_profiles_search_paginated( &self, search: &Option, diff --git a/src/errors.rs b/src/errors.rs index 4c26dc1b..4f36847e 100644 --- a/src/errors.rs +++ b/src/errors.rs @@ -22,6 +22,8 @@ pub enum ServiceError { #[display("Email is required")] //405j EmailMissing, + #[display("A verified email is required")] + VerifiedEmailMissing, #[display("Please enter a valid email address")] //405j EmailInvalid, @@ -60,6 +62,9 @@ pub enum ServiceError { #[display("Passwords don't match")] PasswordsDontMatch, + #[display("Couldn't send new password to the user")] + FailedToSendResetPassword, + /// when the a username is already taken #[display("Username not available")] UsernameTaken, @@ -290,6 +295,7 @@ pub fn http_status_code_for_service_error(error: &ServiceError) -> StatusCode { ServiceError::PasswordTooShort => StatusCode::BAD_REQUEST, ServiceError::PasswordTooLong => StatusCode::BAD_REQUEST, ServiceError::PasswordsDontMatch => StatusCode::BAD_REQUEST, + ServiceError::FailedToSendResetPassword => StatusCode::INTERNAL_SERVER_ERROR, ServiceError::UsernameTaken => StatusCode::BAD_REQUEST, ServiceError::UsernameInvalid => StatusCode::BAD_REQUEST, ServiceError::EmailTaken => StatusCode::BAD_REQUEST, @@ -318,6 +324,7 @@ pub fn http_status_code_for_service_error(error: &ServiceError) -> StatusCode { ServiceError::TagAlreadyExists => StatusCode::BAD_REQUEST, ServiceError::InternalServerError => StatusCode::INTERNAL_SERVER_ERROR, ServiceError::EmailMissing => StatusCode::NOT_FOUND, + ServiceError::VerifiedEmailMissing => StatusCode::NOT_FOUND, ServiceError::FailedToSendVerificationEmail => StatusCode::INTERNAL_SERVER_ERROR, ServiceError::WhitelistingError => StatusCode::INTERNAL_SERVER_ERROR, ServiceError::DatabaseError => StatusCode::INTERNAL_SERVER_ERROR, diff --git a/src/mailer.rs b/src/mailer.rs index c2d2b27c..ad8fff87 100644 --- a/src/mailer.rs +++ b/src/mailer.rs @@ -151,6 +151,29 @@ impl Service { format!("{base_url}/{API_VERSION_URL_PREFIX}/user/email/verify/{token}") } + + /// Send reset password email. + /// + /// # Errors + /// + /// This function will return an error if unable to send an email. + /// + /// # Panics + /// + /// This function will panic if the multipart builder had an error. + pub async fn send_reset_password_mail(&self, to: &str, username: &str, password: &str) -> Result<(), ServiceError> { + let builder = self.get_builder(to).await; + + let mail = build_letter(&password, username, builder)?; + + match self.mailer.send(mail).await { + Ok(_res) => Ok(()), + Err(e) => { + eprintln!("Failed to send email: {e}"); + Err(ServiceError::FailedToSendVerificationEmail) + } + } + } } fn build_letter(verification_url: &str, username: &str, builder: MessageBuilder) -> Result { @@ -195,6 +218,50 @@ fn build_content(verification_url: &str, username: &str) -> Result<(String, Stri Ok((plain_body, html_body)) } +fn build_reset_password_letter(password: &str, username: &str, builder: MessageBuilder) -> Result { + let (plain_body, html_body) = build_reset_password_content(password, username).map_err(|e| { + tracing::error!("{e}"); + ServiceError::InternalServerError + })?; + + Ok(builder + .subject("Torrust - Password reset") + .multipart( + MultiPart::alternative() + .singlepart( + SinglePart::builder() + .header(lettre::message::header::ContentType::TEXT_PLAIN) + .body(plain_body), + ) + .singlepart( + SinglePart::builder() + .header(lettre::message::header::ContentType::TEXT_HTML) + .body(html_body), + ), + ) + .expect("the `multipart` builder had an error")) +} + +fn build_reset_password_content(password: &str, username: &str) -> Result<(String, String), tera::Error> { + let plain_body = format!( + " + Hello, {username}! + + Your password has been reset. + + Find below your new password: + {password} + + We recommend replacing it as soon as possible with a new and strong password of your own. + " + ); + let mut context = Context::new(); + context.insert("password", &password); + context.insert("username", &username); + let html_body = TEMPLATES.render("html_reset_password", &context)?; + Ok((plain_body, html_body)) +} + pub type Mailer = AsyncSmtpTransport; #[cfg(test)] diff --git a/src/services/authorization.rs b/src/services/authorization.rs index fd6c9d2c..cf4ef217 100644 --- a/src/services/authorization.rs +++ b/src/services/authorization.rs @@ -53,6 +53,7 @@ pub enum ACTION { ChangePassword, BanUser, GenerateUserProfileSpecification, + SendPasswordResetLink, } pub struct Service { @@ -250,6 +251,7 @@ impl Default for CasbinConfiguration { admin, ChangePassword admin, BanUser admin, GenerateUserProfileSpecification + admin, SendPasswordResetLink registered, GetAboutPage registered, GetLicensePage registered, GetCategories @@ -263,6 +265,7 @@ impl Default for CasbinConfiguration { registered, GenerateTorrentInfoListing registered, GetCanonicalInfoHash registered, ChangePassword + registered, SendPasswordResetLink guest, GetAboutPage guest, GetLicensePage guest, GetCategories @@ -273,6 +276,7 @@ impl Default for CasbinConfiguration { guest, GetTorrentInfo guest, GenerateTorrentInfoListing guest, GetCanonicalInfoHash + guest, SendPasswordResetLink ", ), } diff --git a/src/services/user.rs b/src/services/user.rs index 3fb3fc37..ab66aad3 100644 --- a/src/services/user.rs +++ b/src/services/user.rs @@ -9,11 +9,13 @@ use jsonwebtoken::{decode, Algorithm, DecodingKey, Validation}; #[cfg(test)] use mockall::automock; use pbkdf2::password_hash::rand_core::OsRng; +use rand::seq::IteratorRandom; use serde_derive::Deserialize; use tracing::{debug, info}; use super::authentication::DbUserAuthenticationRepository; use super::authorization::{self, ACTION}; +use crate::config::v2::auth::Auth; use crate::config::{Configuration, PasswordConstraints}; use crate::databases::database::{Database, Error, UsersFilters, UsersSorting}; use crate::errors::ServiceError; @@ -22,7 +24,7 @@ use crate::models::response::UserProfilesResponse; use crate::models::user::{UserCompact, UserId, UserProfile, Username}; use crate::services::authentication::verify_password; use crate::utils::validation::validate_email_address; -use crate::web::api::server::v1::contexts::user::forms::{ChangePasswordForm, RegistrationForm}; +use crate::web::api::server::v1::contexts::user::forms::{ChangePasswordForm, RegistrationForm, SendPasswordLinkForm}; use crate::{mailer, AsCSV}; /// Since user email could be optional, we need a way to represent "no email" @@ -449,6 +451,138 @@ impl ListingService { } } +pub struct PasswordResetService { + user_profile_repository: Arc, + authorization_service: Arc, + password_reset_repository: Arc, +} + +impl PasswordResetService { + #[must_use] + pub fn new( + user_profile_repository: Arc, + authorization_service: Arc, + password_reset_repository: Arc, + ) -> Self { + Self { + user_profile_repository, + authorization_service, + password_reset_repository, + } + } + + /// Verified email + /// + /// # Errors + /// + /// + pub async fn verified_email(&self, send_password_link_form: &SendPasswordLinkForm) -> Result { + self.authorization_service + .authorize(ACTION::SendPasswordResetLink, maybe_user_id) + .await?; + + self.password_reset_repository + .verify_reset_password_email(SendPasswordLinkForm.email) + .await?; + } + + // Send reset password link with token for that users (token expires) + /// + /// + /// # Errors + /// + /// + pub async fn send_verification_email(&self, email: &str) -> Result { + self.authorization_service + .authorize(ACTION::SendPasswordResetLink, maybe_user_id) + .await?; + } + + pub async fn generate_token(&self, user_profile: &UserProfile) -> Result { + // genererate random token + + let random_token = "MaxVerstappenWC2021"; + // write token in DB with spiration date + + // return token + } +} + +// Send reset password link with token for that users (token expires) + +pub struct AdminActionsService { + authorization_service: Arc, + user_authentication_repository: Arc, + user_profile_repository: Arc, + mailer: Arc, +} + +impl AdminActionsService { + #[must_use] + pub fn new( + authorization_service: Arc, + user_authentication_repository: Arc, + user_profile_repository: Arc, + mailer: Arc, + ) -> Self { + Self { + authorization_service, + user_authentication_repository, + user_profile_repository, + mailer, + } + } + + /// Resets the password of the selected user. + /// + /// # Errors + /// + /// This function will return a: + /// + /// * `ServiceError::InvalidPassword` if the current password supplied is invalid. + /// * `ServiceError::PasswordsDontMatch` if the supplied passwords do not match. + /// * `ServiceError::PasswordTooShort` if the supplied password is too short. + /// * `ServiceError::PasswordTooLong` if the supplied password is too long. + /// * An error if unable to successfully hash the password. + /// * An error if unable to change the password in the database. + /// * An error if it is not possible to authorize the action + pub async fn reset_user_password( + &self, + maybe_admin_user_id: Option, + reset_password_user_id: UserId, + ) -> Result<(), ServiceError> { + self.authorization_service + .authorize(ACTION::ResetUserPassword, maybe_user_id) + .await?; + + if let Some(email) = Some(&user_info.email) { + if user_info.email_verified { + info!("Resetting user password for user ID: {}", user_info.username); + + let new_password = generate_random_password(); + + let password_hash = hash_password(&new_password)?; + + self.user_authentication_repository + .change_password(user_info.user_id, &password_hash) + .await?; + + let mail_res = self + .mailer + .send_reset_password_mail(email, &user_info.username, &new_password) + .await; + + if mail_res.is_err() { + return Err(ServiceError::FailedToSendResetPassword); + } + + () + } + return Err(ServiceError::VerifiedEmailMissing); + } + Err(ServiceError::EmailMissing) + } +} #[cfg_attr(test, automock)] #[async_trait] pub trait Repository: Sync + Send { @@ -595,6 +729,26 @@ impl DbBannedUserList { } } +pub struct DbPasswordResetRepository { + database: Arc>, +} + +impl DbPasswordResetRepository { + #[must_use] + pub fn new(database: Arc>) -> Self { + Self { database } + } + + /// Checks if the email provided belongs to an user + /// + /// # Errors + /// + /// It returns an error if there is a database error. + pub async fn verify_reset_password_email(&self, email: &str) -> Result { + self.database.get_user_profile_from_email(email).await? + } +} + fn validate_password_constraints( password: &str, confirm_password: &str, @@ -628,3 +782,23 @@ fn hash_password(password: &str) -> Result { Ok(password_hash) } + +//Generates a random password with numbers, letters and special characters with a length of the max length allow for users's passwords +fn generate_random_password() -> String { + let charset = "2A&,B;C8D!G?HIJ@KL5MN1OPQ#RST]U`VW*XYZ\ + {ab)c~d$ef=g.hqr/st6u+vw}xyz\ + |0-EF3^4[7(:9\ + "; + + let mut rng = rand::thread_rng(); + + let password_constraints = Auth::default().password_constraints; + + let password_length = password_constraints.max_password_length; + + let password: String = (0..password_length) + .map(|_| charset.chars().choose(&mut rng).unwrap()) + .collect(); + + password +} diff --git a/src/web/api/server/v1/contexts/user/forms.rs b/src/web/api/server/v1/contexts/user/forms.rs index 28238539..6302c380 100644 --- a/src/web/api/server/v1/contexts/user/forms.rs +++ b/src/web/api/server/v1/contexts/user/forms.rs @@ -31,3 +31,10 @@ pub struct ChangePasswordForm { pub password: String, pub confirm_password: String, } + +// Password reset + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct SendPasswordLinkForm { + pub email: String, +} diff --git a/src/web/api/server/v1/contexts/user/handlers.rs b/src/web/api/server/v1/contexts/user/handlers.rs index d869ff89..f9d5e366 100644 --- a/src/web/api/server/v1/contexts/user/handlers.rs +++ b/src/web/api/server/v1/contexts/user/handlers.rs @@ -7,7 +7,7 @@ use axum::response::{IntoResponse, Response}; use axum::Json; use serde::Deserialize; -use super::forms::{ChangePasswordForm, JsonWebToken, LoginForm, RegistrationForm}; +use super::forms::{ChangePasswordForm, JsonWebToken, LoginForm, RegistrationForm, SendPasswordLinkForm}; use super::responses::{self}; use crate::common::AppData; use crate::services::user::ListingRequest; @@ -151,6 +151,67 @@ pub async fn change_password_handler( } } +/* /// It changes the user's password. +/// +/// # Errors +/// +/// It returns an error if: +/// +/// - The user account is not found. +#[allow(clippy::unused_async)] +#[allow(clippy::missing_panics_doc)] +pub async fn reset_password_handler( + State(app_data): State>, + ExtractOptionalLoggedInUser(maybe_user_id): ExtractOptionalLoggedInUser, + extract::Json(change_password_form): extract::Json, +) -> Response { + match app_data + .profile_service + .change_password(maybe_user_id, &change_password_form) + .await + { + Ok(()) => Json(OkResponseData { + data: format!("Password changed for user with ID: {}", maybe_user_id.unwrap()), + }) + .into_response(), + Err(error) => error.into_response(), + } +} */ + +/// It changes the user's password. +/// +/// # Errors +/// +/// It returns an error if: +/// +/// - The user account is not found. +#[allow(clippy::unused_async)] +#[allow(clippy::missing_panics_doc)] +pub async fn send_reset_password_link_handler( + State(app_data): State>, + ExtractOptionalLoggedInUser(maybe_user_id): ExtractOptionalLoggedInUser, + extract::Json(send_password_link_form): extract::Json, +) -> Response { + let verified_email = app_data + .password_reset_service + .verified_email(&change_password_form.email) + .await; + + let reset_password_token = app_data + .password_reset_service + .generate_token(&change_password_form.email) + .await; + + /* match verified_email { + Ok(user_profile) => app_data + .password_reset_service + .send_verification_email(&send_password_link_form), + Err(_) => todo!(), + } */ + + DbPasswordResetRepository +} + /// It bans a user from the index. /// /// # Errors