Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

New API endpoint to reset user's passwords #803

Draft
wants to merge 5 commits into
base: develop
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions src/errors.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
67 changes: 67 additions & 0 deletions src/mailer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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> {
Expand Down Expand Up @@ -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)]
Expand Down
2 changes: 2 additions & 0 deletions src/services/authorization.rs
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ pub enum ACTION {
ChangePassword,
BanUser,
GenerateUserProfilesListing,
ResetUserPassword,
}

pub struct Service {
Expand Down Expand Up @@ -250,6 +251,7 @@ impl Default for CasbinConfiguration {
admin, ChangePassword
admin, BanUser
admin, GenerateUserProfilesListing
admin, ResetUserPassword
registered, GetAboutPage
registered, GetLicensePage
registered, GetCategories
Expand Down
95 changes: 95 additions & 0 deletions src/services/user.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -405,6 +407,79 @@ 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
///
/// 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<UserId>,
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 {
Expand Down Expand Up @@ -578,3 +653,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
}
27 changes: 27 additions & 0 deletions src/web/api/server/v1/contexts/user/handlers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading