Skip to content
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
Original file line number Diff line number Diff line change
@@ -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
)
8 changes: 8 additions & 0 deletions src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions src/common.rs
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ pub struct AppData {
pub ban_service: Arc<user::BanService>,
pub about_service: Arc<about::Service>,
pub listing_service: Arc<user::ListingService>,
pub password_reset_service: Arc<user::PasswordResetService>,
}

impl AppData {
Expand Down
3 changes: 3 additions & 0 deletions src/databases/database.rs
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,9 @@ pub trait Database: Sync + Send {
/// Get `UserProfile` from `username`.
async fn get_user_profile_from_username(&self, username: &str) -> Result<UserProfile, Error>;

/// Get `UserProfile` from `email`.
async fn get_user_profile_from_email(&self, email: &str) -> Result<UserProfile, Error>;

/// 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,
Expand Down
8 changes: 8 additions & 0 deletions src/databases/mysql.rs
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,14 @@ impl Database for Mysql {
.map_err(|_| database::Error::UserNotFound)
}

async fn get_user_profile_from_email(&self, email: &str) -> Result<UserProfile, Error> {
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<String>,
Expand Down
8 changes: 8 additions & 0 deletions src/databases/sqlite.rs
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,14 @@ impl Database for Sqlite {
.map_err(|_| database::Error::UserNotFound)
}

async fn get_user_profile_from_email(&self, email: &str) -> Result<UserProfile, Error> {
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<String>,
Expand Down
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 @@ -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,
Expand Down Expand Up @@ -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,
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
4 changes: 4 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,
GenerateUserProfileSpecification,
SendPasswordResetLink,
}

pub struct Service {
Expand Down Expand Up @@ -250,6 +251,7 @@ impl Default for CasbinConfiguration {
admin, ChangePassword
admin, BanUser
admin, GenerateUserProfileSpecification
admin, SendPasswordResetLink
registered, GetAboutPage
registered, GetLicensePage
registered, GetCategories
Expand All @@ -263,6 +265,7 @@ impl Default for CasbinConfiguration {
registered, GenerateTorrentInfoListing
registered, GetCanonicalInfoHash
registered, ChangePassword
registered, SendPasswordResetLink
guest, GetAboutPage
guest, GetLicensePage
guest, GetCategories
Expand All @@ -273,6 +276,7 @@ impl Default for CasbinConfiguration {
guest, GetTorrentInfo
guest, GenerateTorrentInfoListing
guest, GetCanonicalInfoHash
guest, SendPasswordResetLink
",
),
}
Expand Down
Loading
Loading