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, 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 { @@ -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 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 diff --git a/src/services/user.rs b/src/services/user.rs index 083a0e2c..43cf55ec 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,79 @@ impl ListingService { } } +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 { @@ -578,3 +653,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/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>, + 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 bans a user from the index. /// /// # Errors