diff --git a/web/src/endpoints/user/mod.rs b/web/src/endpoints/user/mod.rs index f970b308..80f185dd 100644 --- a/web/src/endpoints/user/mod.rs +++ b/web/src/endpoints/user/mod.rs @@ -1,16 +1,9 @@ use crate::{ filters, - models::{Area, Role, Token, User}, - utils::{ - auth::{generate_salt_and_hash_plain_password, hash_plain_password_with_salt}, - password_help, ApplicationError, - }, + models::{Area, Role, User}, }; -use actix_web::HttpResponse; use rinja::Template; use serde::Deserialize; -use sqlx::PgPool; -use zxcvbn::{zxcvbn, Score}; pub mod delete; pub mod get_changepassword; @@ -71,82 +64,3 @@ struct ResetPasswordTemplate<'a> { retype_label: &'a str, submit_button_label: &'a str, } - -async fn handle_password_change_request( - pool: &PgPool, - token: Option<&impl Token>, - user_id: i32, - password: &str, - password_retyped: &str, - current_password: Option<&str>, - generate_message: bool, -) -> Result { - let no_message = HttpResponse::NoContent().finish(); - - if password.chars().count() > 256 { - if generate_message { - return Ok( - HttpResponse::BadRequest().body(password_help::format_message( - "danger", - "Password darf nicht länger als 256 Zeichen sein.", - )), - ); - } - - return Ok(no_message); - } - - // TODO: make sure this unwrap is safe - let user = User::read_by_id(pool, user_id).await?.unwrap(); - let mut split_names: Vec<&str> = user.name.as_str().split_whitespace().collect(); - let mut user_inputs = vec![user.email.as_str()]; - user_inputs.append(&mut split_names); - let entropy = zxcvbn(password, &user_inputs); - - if entropy.score() < Score::Three { - if generate_message { - let message = password_help::generate_for_entropy(&entropy); - return Ok(HttpResponse::BadRequest().body(message)); - } - - return Ok(no_message); - } - - if generate_message { - if entropy.score() == Score::Three { - return Ok(HttpResponse::Ok().body(password_help::format_message( - "success", - "Sicheres Passwort.", - ))); - } else { - return Ok(HttpResponse::Ok().body(password_help::format_message( - "success", - "Sehr sicheres Passwort.", - ))); - } - } - - if let Some(current_password) = current_password { - // unwraps are safe, as login only works with password and salt and this call site requires login - let hash = user.password.as_ref().unwrap(); - let salt = user.salt.as_ref().unwrap(); - - if hash != &hash_plain_password_with_salt(current_password, salt)? { - return Ok(HttpResponse::BadRequest().body("Aktuelles Passwort ist falsch.")); - } - } - - if password != password_retyped { - return Ok(HttpResponse::BadRequest().body("Passwörter stimmen nicht überein.")); - } - - let (hash, salt) = generate_salt_and_hash_plain_password(password).unwrap(); - - User::update_password(pool, user_id, &hash, &salt).await?; - - if let Some(token) = token { - token.delete(pool).await?; - } - - Ok(HttpResponse::Ok().body(r#"
Registrierung abgeschlossen.
Zum Login"#)) -} diff --git a/web/src/endpoints/user/post_changepassword.rs b/web/src/endpoints/user/post_changepassword.rs index bcf3b77b..eb248ef8 100644 --- a/web/src/endpoints/user/post_changepassword.rs +++ b/web/src/endpoints/user/post_changepassword.rs @@ -1,11 +1,11 @@ -use actix_web::{web, Responder}; +use actix_web::{web, HttpResponse, Responder}; +use maud::html; use serde::Deserialize; use sqlx::PgPool; use crate::{ - endpoints::user::handle_password_change_request, models::{NoneToken, User}, - utils::ApplicationError, + utils::{password_change::PasswordChangeBuilder, ApplicationError}, }; #[derive(Deserialize)] @@ -25,16 +25,30 @@ async fn post( // TODO: refactor into check if HX-TARGET = #password-strength exists let is_dry = form.dry.unwrap_or(false); - let response = handle_password_change_request( + let mut builder = PasswordChangeBuilder::::new( pool.get_ref(), - None::<&NoneToken>, user.id, &form.password, &form.passwordretyped, - Some(&form.currentpassword), - is_dry, ) - .await?; + .with_current_password(&form.currentpassword); + + let change = builder.build(); + + let response = if is_dry { + change.validate_for_input().await? + } else { + change.validate().await?; + change.commit().await?; + HttpResponse::Ok().body( + html! { + div class="block" { + "Passwort erfolgreich geändert." + } + } + .into_string(), + ) + }; Ok(response) } diff --git a/web/src/endpoints/user/post_register.rs b/web/src/endpoints/user/post_register.rs index b11b208e..68e806eb 100644 --- a/web/src/endpoints/user/post_register.rs +++ b/web/src/endpoints/user/post_register.rs @@ -1,9 +1,11 @@ use actix_web::{post, web, HttpResponse, Responder}; +use maud::html; use serde::Deserialize; use sqlx::PgPool; use crate::{ - endpoints::user::handle_password_change_request, models::Registration, utils::ApplicationError, + models::Registration, + utils::{password_change::PasswordChangeBuilder, ApplicationError}, }; #[derive(Deserialize)] @@ -28,16 +30,33 @@ async fn post( return Ok(HttpResponse::NoContent().finish()); }; - let response = handle_password_change_request( + let mut builder = PasswordChangeBuilder::::new( pool.get_ref(), - Some(&token), token.userid, &form.password, &form.passwordretyped, - None, - is_dry, ) - .await?; + .with_token(token); + + let change = builder.build(); + + let response = if is_dry { + change.validate_for_input().await? + } else { + change.validate().await?; + change.commit().await?; + HttpResponse::Ok().body( + html! { + div class="block mb-3" { + "Registrierung abgeschlossen." + } + a class="block button is-primary" hx-boost="true" href="/login"{ + "Zum Login" + } + } + .into_string(), + ) + }; Ok(response) } diff --git a/web/src/endpoints/user/post_reset.rs b/web/src/endpoints/user/post_reset.rs index d0770be0..f978ab9c 100644 --- a/web/src/endpoints/user/post_reset.rs +++ b/web/src/endpoints/user/post_reset.rs @@ -1,12 +1,12 @@ use actix_web::{web, HttpResponse, Responder}; +use maud::html; use serde::Deserialize; use sqlx::PgPool; use crate::{ - endpoints::user::handle_password_change_request, mail::Mailer, models::{PasswordReset, User}, - utils::ApplicationError, + utils::{password_change::PasswordChangeBuilder, ApplicationError}, }; #[derive(Deserialize, Debug)] @@ -31,7 +31,9 @@ async fn post( { if let Ok(user) = User::read_for_login(pool.get_ref(), form.email.as_ref().unwrap()).await { let reset = PasswordReset::insert_new_for_user(pool.get_ref(), user.id).await?; - mailer.send_forgot_password_mail(&user, &reset.token).await?; + mailer + .send_forgot_password_mail(&user, &reset.token) + .await?; } Ok(HttpResponse::Ok().body("E-Mail versandt!")) @@ -51,16 +53,33 @@ async fn post( return Ok(HttpResponse::NoContent().finish()); }; - let response = handle_password_change_request( + let mut builder = PasswordChangeBuilder::::new( pool.get_ref(), - Some(&token), token.userid, - form.password.as_ref().unwrap(), - form.passwordretyped.as_ref().unwrap(), - None, - is_dry, + &form.password.as_ref().unwrap(), + &form.passwordretyped.as_ref().unwrap(), ) - .await?; + .with_token(token); + + let change = builder.build(); + + let response = if is_dry { + change.validate_for_input().await? + } else { + change.validate().await?; + change.commit().await?; + HttpResponse::Ok().body( + html! { + div class="block mb-3" { + "Passwort erfolgreich geändert." + } + a class="block button is-primary" hx-boost="true" href="/login"{ + "Zum Login" + } + } + .into_string(), + ) + }; return Ok(response); } else { diff --git a/web/src/utils/application_error.rs b/web/src/utils/application_error.rs index 9a5c66c0..5829d8fd 100644 --- a/web/src/utils/application_error.rs +++ b/web/src/utils/application_error.rs @@ -1,6 +1,9 @@ use actix_web::{http::StatusCode, HttpResponse}; use thiserror::Error; +use super::password_change::PasswordChangeError; + + #[derive(Debug, Error)] pub enum ApplicationError { #[error("unsupported value '{value}' for enum '{enum_name}'")] @@ -23,6 +26,11 @@ pub enum ApplicationError { Hash(#[from] argon2::password_hash::Error), #[error("templating failed")] Template(#[from] rinja::Error), + #[error("{}", inner.message)] + PasswordChange { + #[from] + inner: PasswordChangeError, + }, } impl actix_web::error::ResponseError for ApplicationError { @@ -38,6 +46,7 @@ impl actix_web::error::ResponseError for ApplicationError { ApplicationError::Hash(_) => StatusCode::INTERNAL_SERVER_ERROR, ApplicationError::Template(_) => StatusCode::INTERNAL_SERVER_ERROR, ApplicationError::EmailStubTransport(_) => StatusCode::INTERNAL_SERVER_ERROR, + ApplicationError::PasswordChange { .. } => StatusCode::BAD_REQUEST, } } diff --git a/web/src/utils/mod.rs b/web/src/utils/mod.rs index 9afff1f2..084ef5b4 100644 --- a/web/src/utils/mod.rs +++ b/web/src/utils/mod.rs @@ -2,9 +2,9 @@ mod application_error; pub mod auth; pub mod event_planning_template; pub mod manage_commands; -pub mod password_help; pub mod token_generation; mod template_response_trait; +pub mod password_change; #[cfg(test)] pub mod test_helper; diff --git a/web/src/utils/password_help.rs b/web/src/utils/password_change/mod.rs similarity index 87% rename from web/src/utils/password_help.rs rename to web/src/utils/password_change/mod.rs index b777aaa0..95d25404 100644 --- a/web/src/utils/password_help.rs +++ b/web/src/utils/password_change/mod.rs @@ -1,9 +1,23 @@ +use maud::html; +use thiserror::Error; use zxcvbn::{ feedback::{Suggestion, Warning}, Entropy, }; -pub fn generate_for_entropy(entropy: &Entropy) -> String { +mod password_change; +mod password_change_builder; + +pub use password_change::PasswordChange; +pub use password_change_builder::PasswordChangeBuilder; + +#[derive(Debug, Error)] +#[error("{message}")] +pub struct PasswordChangeError { + pub message: String, +} + +fn generate_help_message_for_entropy(entropy: &Entropy) -> String { let feedback = entropy.feedback().unwrap(); let warning = match feedback.warning() { @@ -73,12 +87,17 @@ pub fn generate_for_entropy(entropy: &Entropy) -> String { .collect::>() .join(""); - format!("

{warning}

{vorschlag_text}:
    {suggestion}
") -} + html!( + { + div id="password-strength" class="mb-3 help content is-danger" { + p { ( warning )} + (vorschlag_text) ":" + ul { + (suggestion) + } -pub fn format_message(level: &str, message: &str) -> String { - format!( - r#"
{}
"#, - level, message + } + } ) + .into_string() } diff --git a/web/src/utils/password_change/password_change.rs b/web/src/utils/password_change/password_change.rs new file mode 100644 index 00000000..b3656a18 --- /dev/null +++ b/web/src/utils/password_change/password_change.rs @@ -0,0 +1,112 @@ +use actix_web::HttpResponse; +use maud::html; +use sqlx::PgPool; +use zxcvbn::{zxcvbn, Score}; + +use crate::{ + models::{Token, User}, + utils::{ + auth::{generate_salt_and_hash_plain_password, hash_plain_password_with_salt}, + ApplicationError, + }, +}; + +use super::{generate_help_message_for_entropy, PasswordChangeError}; + +pub struct PasswordChange<'a, T> +where + T: Token, +{ + pub(super) pool: &'a PgPool, + pub(super) user_id: i32, + pub(super) password: &'a str, + pub(super) password_retyped: &'a str, + pub(super) token: Option, + pub(super) current_password: Option<&'a str>, +} + +impl PasswordChange<'_, T> +where + T: Token, +{ + /// should be called after password input has changed to hint the user of any input related problems + pub async fn validate_for_input(&self) -> Result { + if self.password.chars().count() > 256 { + return Err(PasswordChangeError { + message: html! { + div id="password-strength" class="mb-3 help content is-danger"{ + "Password darf nicht länger als 256 Zeichen sein." + } + } + .into_string(), + } + .into()); + } + + // unwrap is safe, as either a token is present (tokens always require a userid present) or the current password is set (which requires a existing user) + let user = User::read_by_id(self.pool, self.user_id).await?.unwrap(); + let mut split_names: Vec<&str> = user.name.as_str().split_whitespace().collect(); + let mut user_inputs = vec![user.email.as_str()]; + user_inputs.append(&mut split_names); + let entropy = zxcvbn(self.password, &user_inputs); + + if entropy.score() < Score::Three { + let message = generate_help_message_for_entropy(&entropy); + return Err(PasswordChangeError { message }.into()); + } + + return Ok(HttpResponse::Ok().body( + html! { + div id="password-strength" class="mb-3 help content is-success" { + @if entropy.score() == Score::Three { + "Sicheres Passwort." + } @else { + "Sehr sicheres Passwort." + } + } + } + .into_string(), + )); + } + + /// should be called after the form is fully filled and submit is clicked + pub async fn validate(&self) -> Result<(), ApplicationError> { + self.validate_for_input().await?; + + if let Some(current_password) = self.current_password { + // unwraps are safe, as login only works with password and salt and this call site requires login + let user = User::read_by_id(self.pool, self.user_id).await?.unwrap(); + let hash = user.password.as_ref().unwrap(); + let salt = user.salt.as_ref().unwrap(); + + if hash != &hash_plain_password_with_salt(current_password, salt)? { + return Err(PasswordChangeError { + message: "Aktuelles Passwort ist falsch.".to_string(), + } + .into()); + } + } + + if self.password != self.password_retyped { + return Err(PasswordChangeError { + message: "Passwörter stimmen nicht überein.".to_string(), + } + .into()); + } + + Ok(()) + } + + /// commits the password change to the database + pub async fn commit(&self) -> Result<(), ApplicationError> { + let (hash, salt) = generate_salt_and_hash_plain_password(self.password).unwrap(); + + User::update_password(self.pool, self.user_id, &hash, &salt).await?; + + if let Some(token) = &self.token { + token.delete(self.pool).await?; + } + + Ok(()) + } +} diff --git a/web/src/utils/password_change/password_change_builder.rs b/web/src/utils/password_change/password_change_builder.rs new file mode 100644 index 00000000..bac243c6 --- /dev/null +++ b/web/src/utils/password_change/password_change_builder.rs @@ -0,0 +1,62 @@ +use sqlx::PgPool; + +use crate::models::Token; + +use super::PasswordChange; + +pub struct PasswordChangeBuilder<'a, T> +where + T: Token, +{ + pool: &'a PgPool, + user_id: i32, + password: &'a str, + password_retyped: &'a str, + token: Option, + current_password: Option, +} + +impl PasswordChangeBuilder<'_, T> +where + T: Token, +{ + pub fn new<'a>( + pool: &'a PgPool, + user_id: i32, + password: &'a str, + password_retyped: &'a str, + ) -> PasswordChangeBuilder<'a, T> + where + T: Token, + { + PasswordChangeBuilder { + pool, + user_id, + password, + password_retyped, + token: None, + current_password: None, + } + } + + pub fn with_token(mut self, token: T) -> Self { + self.token = Some(token); + self + } + + pub fn with_current_password(mut self, current_password: &str) -> Self { + self.current_password = Some(current_password.to_string()); + self + } + + pub fn build<'a>(&'a mut self) -> PasswordChange<'a, T> { + PasswordChange { + pool: self.pool, + user_id: self.user_id, + password: self.password, + password_retyped: self.password_retyped, + token: self.token.take(), + current_password: self.current_password.as_deref(), + } + } +}