From 15cfddafed7caddaa737e038b191198ddc3a7914 Mon Sep 17 00:00:00 2001
From: Mario <mariomnt945@gmail.com>
Date: Tue, 28 Jan 2025 14:54:39 +0100
Subject: [PATCH 1/5] feat: [#802] new authorization action

---
 src/services/authorization.rs | 2 ++
 1 file changed, 2 insertions(+)

diff --git a/src/services/authorization.rs b/src/services/authorization.rs
index df3d30c8..f265ac7e 100644
--- a/src/services/authorization.rs
+++ b/src/services/authorization.rs
@@ -53,6 +53,7 @@ pub enum ACTION {
     ChangePassword,
     BanUser,
     GenerateUserProfilesListing,
+    ResetUserPassword,
 }
 
 pub struct Service {
@@ -250,6 +251,7 @@ impl Default for CasbinConfiguration {
                 admin, ChangePassword
                 admin, BanUser
                 admin, GenerateUserProfilesListing
+                admin, ResetUserPassword
                 registered, GetAboutPage
                 registered, GetLicensePage
                 registered, GetCategories

From ecb15e662fbff5cfa988ab89a98a33e5bde17057 Mon Sep 17 00:00:00 2001
From: Mario <mariomnt945@gmail.com>
Date: Tue, 28 Jan 2025 14:59:41 +0100
Subject: [PATCH 2/5] feat: [#802] New admin action service and reset user
 password function

---
 src/services/user.rs | 60 ++++++++++++++++++++++++++++++++++++++++++++
 1 file changed, 60 insertions(+)

diff --git a/src/services/user.rs b/src/services/user.rs
index 083a0e2c..4918cd43 100644
--- a/src/services/user.rs
+++ b/src/services/user.rs
@@ -8,11 +8,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};
 use crate::errors::ServiceError;
@@ -405,6 +407,44 @@ impl ListingService {
     }
 }
 
+pub struct AdminActionsService {
+    authorization_service: Arc<authorization::Service>,
+    user_authentication_repository: Arc<DbUserAuthenticationRepository>,
+}
+
+impl AdminActionsService {
+    /// 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_user_id: Option<UserId>, user_info: UserProfile) -> Result<(), ServiceError> {
+        self.authorization_service
+            .authorize(ACTION::ResetUserPassword, maybe_user_id)
+            .await?;
+
+        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?;
+
+        Ok(())
+    }
+}
+
 #[cfg_attr(test, automock)]
 #[async_trait]
 pub trait Repository: Sync + Send {
@@ -578,3 +618,23 @@ fn hash_password(password: &str) -> Result<String, ServiceError> {
 
     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.h<i_jklmn%op>qr/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
+}

From 1e992ba4718c5e215c3797712e32ea2169a551b8 Mon Sep 17 00:00:00 2001
From: Mario <mariomnt945@gmail.com>
Date: Wed, 29 Jan 2025 22:15:26 +0100
Subject: [PATCH 3/5] feat: [#802] new functions to send reset password to user

---
 src/mailer.rs | 67 +++++++++++++++++++++++++++++++++++++++++++++++++++
 1 file changed, 67 insertions(+)

diff --git a/src/mailer.rs b/src/mailer.rs
index 0ab41682..39b2969a 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<Message, ServiceError> {
@@ -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<Message, ServiceError> {
+    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<Tokio1Executor>;
 
 #[cfg(test)]

From 45aba38160790dde54a11912df79db27f9af43d5 Mon Sep 17 00:00:00 2001
From: Mario <mariomnt945@gmail.com>
Date: Thu, 30 Jan 2025 00:38:55 +0100
Subject: [PATCH 4/5] feat: [#802] new errors

---
 src/errors.rs | 7 +++++++
 1 file changed, 7 insertions(+)

diff --git a/src/errors.rs b/src/errors.rs
index 36dd861c..f84c0518 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,
@@ -288,6 +293,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,
@@ -316,6 +322,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,

From 03f97ec2602bce98eaed7c4c4bf9997e1af90d90 Mon Sep 17 00:00:00 2001
From: Mario <mariomnt945@gmail.com>
Date: Thu, 30 Jan 2025 11:28:44 +0100
Subject: [PATCH 5/5] wip

---
 src/services/user.rs                          | 53 +++++++++++++++----
 .../api/server/v1/contexts/user/handlers.rs   | 27 ++++++++++
 2 files changed, 71 insertions(+), 9 deletions(-)

diff --git a/src/services/user.rs b/src/services/user.rs
index 4918cd43..43cf55ec 100644
--- a/src/services/user.rs
+++ b/src/services/user.rs
@@ -410,9 +410,26 @@ impl ListingService {
 pub struct AdminActionsService {
     authorization_service: Arc<authorization::Service>,
     user_authentication_repository: Arc<DbUserAuthenticationRepository>,
+    user_profile_repository: Arc<DbUserProfileRepository>,
+    mailer: Arc<mailer::Service>,
 }
 
 impl AdminActionsService {
+    #[must_use]
+    pub fn new(
+        authorization_service: Arc<authorization::Service>,
+        user_authentication_repository: Arc<DbUserAuthenticationRepository>,
+        user_profile_repository: Arc<DbUserProfileRepository>,
+        mailer: Arc<mailer::Service>,
+    ) -> Self {
+        Self {
+            authorization_service,
+            user_authentication_repository,
+            user_profile_repository,
+            mailer,
+        }
+    }
+
     /// Resets the password of the selected user.
     ///
     /// # Errors
@@ -426,25 +443,43 @@ impl AdminActionsService {
     /// * 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_user_id: Option<UserId>, user_info: UserProfile) -> Result<(), ServiceError> {
+    pub async fn reset_user_password(
+        &self,
+        maybe_admin_user_id: Option<UserId>,
+        reset_password_user_id: UserId,
+    ) -> Result<(), ServiceError> {
         self.authorization_service
             .authorize(ACTION::ResetUserPassword, maybe_user_id)
             .await?;
 
-        info!("Resetting user password for user ID: {}", user_info.username);
+        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 new_password = generate_random_password();
 
-        let password_hash = hash_password(&new_password)?;
+                let password_hash = hash_password(&new_password)?;
 
-        self.user_authentication_repository
-            .change_password(user_info.user_id, &password_hash)
-            .await?;
+                self.user_authentication_repository
+                    .change_password(user_info.user_id, &password_hash)
+                    .await?;
 
-        Ok(())
+                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 {
diff --git a/src/web/api/server/v1/contexts/user/handlers.rs b/src/web/api/server/v1/contexts/user/handlers.rs
index 3ab53dff..cc1c1613 100644
--- a/src/web/api/server/v1/contexts/user/handlers.rs
+++ b/src/web/api/server/v1/contexts/user/handlers.rs
@@ -151,6 +151,33 @@ 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<Arc<AppData>>,
+    ExtractOptionalLoggedInUser(maybe_user_id): ExtractOptionalLoggedInUser,
+    extract::Json(change_password_form): extract::Json<ChangePasswordForm>,
+) -> 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 bans a user from the index.
 ///
 /// # Errors