From f8afea4e709543ec13406f84ca89afde095f373e Mon Sep 17 00:00:00 2001 From: Philippe Loctaux Date: Wed, 3 May 2023 23:46:40 +0200 Subject: [PATCH] admin/users: force password reset (now lasts for 24 hours), send email, show expiration --- Cargo.lock | 10 + .../email/templates/password-reset.mjml.tera | 2 +- crates/ezidam/Cargo.toml | 1 + crates/ezidam/src/routes/admin.rs | 2 + crates/ezidam/src/routes/admin/users.rs | 195 +++++++++++++++++- .../ezidam/src/routes/root/forgot_password.rs | 12 +- .../ezidam/src/routes/root/reset_password.rs | 2 +- .../pages/admin/users/view.html.tera | 55 ++++- crates/users/src/password_reset.rs | 21 +- 9 files changed, 288 insertions(+), 12 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 43756a0..22fc676 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -273,6 +273,15 @@ dependencies = [ "winapi", ] +[[package]] +name = "chrono-humanize" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32dce1ea1988dbdf9f9815ff11425828523bd2a134ec0805d2ac8af26ee6096e" +dependencies = [ + "chrono", +] + [[package]] name = "chrono-tz" version = "0.6.1" @@ -771,6 +780,7 @@ dependencies = [ "apps", "authorization_codes", "base64 0.21.0", + "chrono-humanize", "chrono-tz 0.8.2", "database_pool", "email", diff --git a/crates/email/templates/password-reset.mjml.tera b/crates/email/templates/password-reset.mjml.tera index 4d743bc..d5c0aa4 100644 --- a/crates/email/templates/password-reset.mjml.tera +++ b/crates/email/templates/password-reset.mjml.tera @@ -18,7 +18,7 @@

Reset password

You recently requested to reset the password of your ezidam account.

-

Use the button below to reset it. This message will expire in {{ token_duration }} minutes.

+

Use the button below to reset it. This message will expire in {{ token_duration }} hours.

Reset your password diff --git a/crates/ezidam/Cargo.toml b/crates/ezidam/Cargo.toml index 9e49214..1507ac0 100644 --- a/crates/ezidam/Cargo.toml +++ b/crates/ezidam/Cargo.toml @@ -15,6 +15,7 @@ base64 = "0.21.0" rocket_cors = "0.6.0-alpha2" email_address = { workspace = true } chrono-tz = "0.8.2" +chrono-humanize = "0.2.2" # local crates database_pool = { path = "../database_pool" } diff --git a/crates/ezidam/src/routes/admin.rs b/crates/ezidam/src/routes/admin.rs index 5fc13f8..db06a8e 100644 --- a/crates/ezidam/src/routes/admin.rs +++ b/crates/ezidam/src/routes/admin.rs @@ -26,6 +26,7 @@ pub fn routes() -> Vec { admin_users_list, admin_users_view, admin_users_archive, + admin_users_password_reset, ] } @@ -96,5 +97,6 @@ pub mod content { pub user: JwtClaims, pub jwt_duration: i64, pub local: User, + pub password_recover_expiration: Option, } } diff --git a/crates/ezidam/src/routes/admin/users.rs b/crates/ezidam/src/routes/admin/users.rs index c1c79c9..eb85549 100644 --- a/crates/ezidam/src/routes/admin/users.rs +++ b/crates/ezidam/src/routes/admin/users.rs @@ -1,10 +1,14 @@ use crate::routes::prelude::*; +use crate::routes::root::forgot_password::ResetPasswordEmail; use crate::tokens::JWT_DURATION_MINUTES; use authorization_codes::AuthorizationCode; +use chrono_humanize::Humanize; +use rocket::State; use rocket::{get, post}; use settings::Settings; +use url::Url; use users::totp_login_request::TotpLoginRequest; -use users::User; +use users::{password_reset::PasswordResetToken, User}; #[get("/admin/users")] pub async fn admin_users_list( @@ -36,10 +40,17 @@ pub async fn admin_users_view( .await? .ok_or_else(|| Error::not_found(user_id.to_string()))?; + // If user has password reset token + let password_recover_expiration = user + .password_recover() + .map_err(|e| Error::internal_server_error(format!("Password recover: {e}")))? + .map(|token| token.expires_at().humanize()); + let page = Page::AdminUsersView(super::content::AdminUsersView { user: admin_not_current.0, jwt_duration: JWT_DURATION_MINUTES, local: user, + password_recover_expiration, }); Ok(flash @@ -136,3 +147,185 @@ pub async fn admin_users_archive( Ok(Flash::new(redirect, flash_kind, flash_message)) } + +#[derive(Debug, FromForm)] +pub struct PasswordResetForm<'r> { + pub send_email: Option<&'r str>, + pub start_process: Option, +} + +#[post("/admin/users//password_reset", data = "
")] +pub async fn admin_users_password_reset( + _admin_not_current: JwtAdminNotCurrent, + mut db: Connection, + id: RocketUserID, + form: Form>, + email_config: &State>, +) -> Result> { + match form.start_process { + Some(true) => {} + _ => { + return Ok(Flash::new( + Redirect::to(uri!(admin_users_view(id))), + FlashKind::Warning, + "Nothing to do.", + )); + } + } + + let mut transaction = db.begin().await?; + + // Get settings + let settings = Settings::get(&mut transaction).await?; + + // Get user + let user = User::get_by_id(&mut transaction, &id.0) + .await? + .ok_or_else(|| Error::not_found("Could not find user"))?; + + // Generate reset token + let token = task::spawn_blocking(PasswordResetToken::generate).await?; + + // Save in database + user.set_password_reset_token(&mut transaction, Some(&token)) + .await?; + + transaction.commit().await?; + + // Get server url + let url = settings + .url() + .ok_or_else(|| Error::not_found("Server url"))?; + let url = match Url::parse(url) { + Ok(url) => url, + Err(_) => { + return Ok(Flash::new( + Redirect::to(uri!(admin_users_view(id))), + FlashKind::Danger, + "Failed to parse server url", + )); + } + }; + + // Safety: safe to unwrap, the value is present + let token_duration = token.duration_hours().unwrap(); + + // Construct url to reset password + let token = RocketResetPasswordToken(token); + let uri = uri!(crate::routes::root::reset_password::reset_password_page( + token + )) + .to_string(); + let reset_url = match url.join(&uri) { + Ok(url) => url, + Err(_) => { + return Ok(Flash::new( + Redirect::to(uri!(admin_users_view(id))), + FlashKind::Danger, + "Failed to construct url to reset password", + )); + } + }; + + let email = match (user.email(), form.send_email) { + (Some(email), Some("on")) => { + // Send email + email + } + _ => { + // Don't send email + return Ok(Flash::new( + Redirect::to(uri!(admin_users_view(id))), + FlashKind::Success, + format!( + "Password reset has been generated. Give this link to the user:\ +
{reset_url}
" + ), + )); + } + }; + + // Url to logo + let logo_url = match url.join("/logo") { + Ok(url) => url, + Err(_) => { + return Ok(Flash::new( + Redirect::to(uri!(admin_users_view(id))), + FlashKind::Danger, + "Failed to construct url to business logo", + )); + } + }; + + let email_title = "Reset password - ezidam"; + + let content = ResetPasswordEmail { + title: email_title.into(), + ezidam_version: env!("CARGO_PKG_VERSION"), + logo_url: logo_url.to_string(), + token_duration, + reset_password_url: reset_url.to_string(), + user_email: email.to_string(), + user_timezone: user.timezone().into(), + }; + + // Render email template + let mjml = match email::render_template("password-reset", &content) { + Ok(mjml) => mjml, + Err(e) => { + return Ok(Flash::new( + Redirect::to(uri!(admin_users_view(id))), + FlashKind::Danger, + format!("Failed to create email: {e}"), + )); + } + }; + + // Create html email + let html = match email::render_email(&mjml) { + Ok(html) => html, + Err(e) => { + return Ok(Flash::new( + Redirect::to(uri!(admin_users_view(id))), + FlashKind::Danger, + format!("Failed to render email: {e}"), + )); + } + }; + + let user_for_email = match user.name() { + Some(name) => { + format!("{name} <{email}>") + } + None => email.to_string(), + }; + + // Send email + let (flash_kind, flash_message) = match email_config.inner() { + Some(email_config) => { + match email::send_email(email_config, &user_for_email, email_title, html).await { + Ok(okay) => { + if okay.is_positive() { + ( + FlashKind::Success, + format!("Email is on it's way to {email}"), + ) + } else { + ( + FlashKind::Warning, + "Email should be on it's way, but it might not arrive".into(), + ) + } + } + Err(e) => (FlashKind::Danger, e.to_string()), + } + } + None => (FlashKind::Warning, "Email sending is disabled".into()), + }; + + Ok(Flash::new( + Redirect::to(uri!(admin_users_view(id))), + flash_kind, + flash_message, + )) +} diff --git a/crates/ezidam/src/routes/root/forgot_password.rs b/crates/ezidam/src/routes/root/forgot_password.rs index bb5b84d..7bcc85f 100644 --- a/crates/ezidam/src/routes/root/forgot_password.rs +++ b/crates/ezidam/src/routes/root/forgot_password.rs @@ -97,8 +97,7 @@ pub async fn forgot_password_email_form( }; // Generate reset token - let token_duration = 15; - let token = task::spawn_blocking(move || PasswordResetToken::generate(token_duration)).await?; + let token = task::spawn_blocking(PasswordResetToken::generate).await?; // Save in database user.set_password_reset_token(&mut transaction, Some(&token)) @@ -106,6 +105,9 @@ pub async fn forgot_password_email_form( transaction.commit().await?; + // Safety: safe to unwrap, the value is present + let token_duration = token.duration_hours().unwrap(); + // Construct url to reset password let token = RocketResetPasswordToken(token); let uri = uri!(super::reset_password_page(token)).to_string(); @@ -115,7 +117,7 @@ pub async fn forgot_password_email_form( return Ok(Flash::new( Redirect::to(uri!(forgot_password_page)), FlashKind::Danger, - "Failed to construct url to reset password ", + "Failed to construct url to reset password", )); } }; @@ -260,7 +262,7 @@ pub async fn forgot_password_paper_key_form( } // Generate reset token - let token = task::spawn_blocking(|| PasswordResetToken::generate(15)).await?; + let token = task::spawn_blocking(PasswordResetToken::generate).await?; // Save in database user.set_password_reset_token(&mut transaction, Some(&token)) @@ -272,6 +274,6 @@ pub async fn forgot_password_paper_key_form( Ok(Flash::new( Redirect::to(uri!(super::reset_password_page(token))), FlashKind::Success, - SUCCESS_MESSAGE, + "You can reset your password", )) } diff --git a/crates/ezidam/src/routes/root/reset_password.rs b/crates/ezidam/src/routes/root/reset_password.rs index d8dc699..6b15765 100644 --- a/crates/ezidam/src/routes/root/reset_password.rs +++ b/crates/ezidam/src/routes/root/reset_password.rs @@ -14,7 +14,7 @@ pub async fn reset_password_page( let user = User::get_one_from_password_reset_token(&mut **db, &token.0) .await? - .ok_or_else(|| Error::not_found("Failed to find user from token"))?; + .ok_or_else(|| Error::not_found("Invalid or expired token"))?; let page = Page::ResetPassword(super::content::ResetPassword { username: user.username().into(), diff --git a/crates/ezidam/templates/pages/admin/users/view.html.tera b/crates/ezidam/templates/pages/admin/users/view.html.tera index 2096158..fc392b9 100644 --- a/crates/ezidam/templates/pages/admin/users/view.html.tera +++ b/crates/ezidam/templates/pages/admin/users/view.html.tera @@ -160,7 +160,7 @@ {% if local.password_recover %}
{% include "icons/progress" %} - Password reset has been requested + Password reset requested, expiration {{ password_recover_expiration }}
{% endif %} @@ -332,6 +332,59 @@ + + + {% endblock content %} {% block libs_js %} diff --git a/crates/users/src/password_reset.rs b/crates/users/src/password_reset.rs index e408748..2ffb034 100644 --- a/crates/users/src/password_reset.rs +++ b/crates/users/src/password_reset.rs @@ -18,17 +18,23 @@ pub enum Error { pub struct PasswordResetToken { token: String, expires_at: DateTime, + duration_hours: Option, } impl PasswordResetToken { - pub fn generate(duration_minutes: i64) -> Self { + pub fn generate() -> Self { use gen_passphrase::dictionary::EFF_LARGE; use gen_passphrase::generate; + let duration_hours = 24; let token = generate(&[EFF_LARGE], 10, None); - let expires_at = Utc::now() + Duration::minutes(duration_minutes); + let expires_at = Utc::now() + Duration::hours(duration_hours); - Self { token, expires_at } + Self { + token, + expires_at, + duration_hours: Some(duration_hours), + } } pub fn parse(raw: &str) -> Result { @@ -40,12 +46,21 @@ impl PasswordResetToken { Ok(Self { token: token.to_string(), expires_at, + duration_hours: None, }) } pub fn has_expired(&self) -> bool { self.expires_at < Utc::now() } + + pub fn duration_hours(&self) -> Option { + self.duration_hours + } + + pub fn expires_at(&self) -> DateTime { + self.expires_at + } } impl fmt::Display for PasswordResetToken {