refactor: use same logic for all password reset locations

This commit is contained in:
Max Hohlfeld 2024-09-09 23:21:07 +02:00
parent ea35f5475f
commit 77a71787bd
13 changed files with 185 additions and 158 deletions

View File

@ -14,7 +14,7 @@ pub fn generate_salt_and_hash_plain_password(plain: &str) -> anyhow::Result<(Str
Ok((hash, salt.to_string())) Ok((hash, salt.to_string()))
} }
pub fn hash_plain_password_with_salt(plain: &str, salt_string: &str) -> anyhow::Result<String> { pub fn hash_plain_password_with_salt(plain: &str, salt_string: &str) -> Result<String, argon2::password_hash::Error> {
let salt = SaltString::from_b64(salt_string)?; let salt = SaltString::from_b64(salt_string)?;
let hash = Argon2::default() let hash = Argon2::default()

View File

@ -34,7 +34,7 @@ pub async fn get(
token, token,
title: "Brass - Passwort zurücksetzen", title: "Brass - Passwort zurücksetzen",
endpoint: "/reset-password", endpoint: "/reset-password",
new_password_label: "/reset-password", new_password_label: "neues Passwort:",
retype_label: "neues Passwort wiederholen:", retype_label: "neues Passwort wiederholen:",
submit_button_label: "Passwort zurücksetzen", submit_button_label: "Passwort zurücksetzen",
}; };

View File

@ -1,6 +1,11 @@
use crate::filters; use crate::auth::utils::hash_plain_password_with_salt;
use crate::models::{Area, Role, User}; use crate::models::{Area, Role, Token, User};
use crate::utils::{password_help, ApplicationError};
use crate::{auth, filters};
use actix_web::HttpResponse;
use askama::Template; use askama::Template;
use sqlx::PgPool;
use zxcvbn::{zxcvbn, Score};
pub mod delete; pub mod delete;
pub mod get_changepassword; pub mod get_changepassword;
@ -10,15 +15,15 @@ pub mod get_logout;
pub mod get_new; pub mod get_new;
pub mod get_overview; pub mod get_overview;
pub mod get_profile; pub mod get_profile;
pub mod get_register;
pub mod get_reset; pub mod get_reset;
pub mod post_changepassword; pub mod post_changepassword;
pub mod post_edit; pub mod post_edit;
pub mod post_login; pub mod post_login;
pub mod post_new; pub mod post_new;
pub mod post_register;
pub mod post_reset; pub mod post_reset;
pub mod post_toggle; pub mod post_toggle;
pub mod get_register;
pub mod post_register;
#[derive(Template)] #[derive(Template)]
#[template(path = "user/new_or_edit.html")] #[template(path = "user/new_or_edit.html")]
@ -41,5 +46,96 @@ struct ResetPasswordTemplate<'a> {
endpoint: &'a str, endpoint: &'a str,
new_password_label: &'a str, new_password_label: &'a str,
retype_label: &'a str, retype_label: &'a str,
submit_button_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<HttpResponse, ApplicationError> {
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);
}
let user = User::read_by_id(pool, user_id).await?;
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) = auth::utils::generate_salt_and_hash_plain_password(password).unwrap();
User::update(
pool,
user_id,
None,
None,
Some(&hash),
Some(&salt),
None,
None,
None,
None,
None,
)
.await?;
if let Some(token) = token {
token.delete(pool).await?;
}
Ok(HttpResponse::Ok().body(r#"<div class="block">Registrierung abgeschlossen.</div><a class="block button is-primary" hx-boost="true" href="/login">Zum Login</a>"#))
} }

View File

@ -17,6 +17,7 @@ async fn post(
form: web::Form<ChangePasswordForm>, form: web::Form<ChangePasswordForm>,
pool: web::Data<PgPool>, pool: web::Data<PgPool>,
) -> impl Responder { ) -> impl Responder {
// TODO: hier weiter gleichziehen mit post reset und post register
if user.password.as_ref().is_some_and(|p| if user.password.as_ref().is_some_and(|p|
p == &utils::hash_plain_password_with_salt( p == &utils::hash_plain_password_with_salt(
&form.currentpassword, &form.currentpassword,

View File

@ -1,12 +1,9 @@
use actix_web::{post, web, HttpResponse, Responder}; use actix_web::{post, web, HttpResponse, Responder};
use serde::Deserialize; use serde::Deserialize;
use sqlx::PgPool; use sqlx::PgPool;
use zxcvbn::{zxcvbn, Score};
use crate::{ use crate::{
auth, endpoints::user::handle_password_change_request, models::Registration, utils::ApplicationError,
models::{Registration, User},
utils::{password_help, ApplicationError},
}; };
#[derive(Deserialize)] #[derive(Deserialize)]
@ -22,65 +19,25 @@ async fn post(
form: web::Form<RegisterForm>, form: web::Form<RegisterForm>,
pool: web::Data<PgPool>, pool: web::Data<PgPool>,
) -> Result<impl Responder, ApplicationError> { ) -> Result<impl Responder, ApplicationError> {
// TODO: refactor into check if HX-TARGET = #password-strength exists
let is_dry = form.dry.unwrap_or(false); let is_dry = form.dry.unwrap_or(false);
// TODO: flip unauthorized with not found or unwrap result in a other way let token =
let token = Registration::does_token_exist(pool.get_ref(), &form.token).await?.ok_or(ApplicationError::Unauthorized)?; if let Some(token) = Registration::does_token_exist(pool.get_ref(), &form.token).await? {
token
if form.password.chars().count() > 256 {
if is_dry {
return Ok(HttpResponse::BadRequest().body("<div id=\"password-strength\" class=\"mb-3 help content is-danger\">Password darf nicht länger als 256 Zeichen sein.</div>"));
} else { } else {
return Ok(HttpResponse::NoContent().finish()); return Ok(HttpResponse::NoContent().finish());
} };
}
let user = User::read_by_id(pool.get_ref(), token.userid).await?; let response = handle_password_change_request(
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(&form.password, &user_inputs);
if entropy.score() < Score::Three {
if is_dry {
let message = password_help::generate_for_entropy(&entropy);
return Ok(HttpResponse::BadRequest().body(message));
} else {
return Ok(HttpResponse::NoContent().finish());
}
}
if is_dry {
if entropy.score() == Score::Three {
return Ok(HttpResponse::Ok()
.body("<div id=\"password-strength\" class=\"mb-3 help content is-success\">Sicheres Passwort.</div>"));
} else {
return Ok(HttpResponse::Ok()
.body("<div id=\"password-strength\" class=\"mb-3 help content is-success\">Sehr sicheres Passwort.</div>"));
}
}
if form.password != form.passwordretyped {
return Ok(HttpResponse::BadRequest().body("Passwörter stimmen nicht überein!"));
}
let (hash, salt) = auth::utils::generate_salt_and_hash_plain_password(&form.password).unwrap();
User::update(
pool.get_ref(), pool.get_ref(),
token.userid, Some(&token),
None, token.id,
None, &form.password,
Some(&hash), &form.passwordretyped,
Some(&salt),
None,
None,
None,
None,
None, None,
is_dry,
) )
.await?; .await?;
Registration::delete(pool.get_ref(), &token.token).await?; Ok(response)
Ok(HttpResponse::Ok().body(r#"<div class="block">Registrierung abgeschlossen.</div><a class="block button is-primary" hx-boost="true" href="/login">Zum Login</a>"#))
} }

View File

@ -2,12 +2,11 @@ use actix_web::{web, HttpResponse, Responder};
use lettre::{SmtpTransport, Transport}; use lettre::{SmtpTransport, Transport};
use serde::Deserialize; use serde::Deserialize;
use sqlx::PgPool; use sqlx::PgPool;
use zxcvbn::{zxcvbn, Score};
use crate::{ use crate::{
auth::{self}, endpoints::user::handle_password_change_request,
models::{PasswordReset, User}, models::{PasswordReset, User},
utils::{email, password_help}, utils::{email, ApplicationError},
}; };
#[derive(Deserialize, Debug)] #[derive(Deserialize, Debug)]
@ -24,104 +23,50 @@ async fn post(
form: web::Form<ResetPasswordForm>, form: web::Form<ResetPasswordForm>,
pool: web::Data<PgPool>, pool: web::Data<PgPool>,
mailer: web::Data<SmtpTransport>, mailer: web::Data<SmtpTransport>,
) -> impl Responder { ) -> Result<impl Responder, ApplicationError> {
if form.email.is_some() if form.email.is_some()
&& form.token.is_none() && form.token.is_none()
&& form.password.is_none() && form.password.is_none()
&& form.passwordretyped.is_none() && form.passwordretyped.is_none()
{ {
if let Ok(user) = User::read_for_login(pool.get_ref(), form.email.as_ref().unwrap()).await { 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) let reset = PasswordReset::insert_new_for_user(pool.get_ref(), user.id).await?;
.await
.unwrap();
let message = email::build_forgot_password_message(&user, &reset.token); let message = email::build_forgot_password_message(&user, &reset.token);
mailer.send(&message).unwrap(); mailer.send(&message)?;
} }
return HttpResponse::Ok().body("E-Mail versandt!"); return Ok(HttpResponse::Ok().body("E-Mail versandt!"));
} else if form.email.is_none() } else if form.email.is_none()
&& form.token.is_some() && form.token.is_some()
&& form.password.is_some() && form.password.is_some()
&& form.passwordretyped.is_some() && form.passwordretyped.is_some()
{ {
let password = form.password.as_ref().unwrap(); // TODO: refactor into check if HX-TARGET = #password-strength exists
let is_dry = form.dry.is_some_and(|b| b); let is_dry = form.dry.is_some_and(|b| b);
let token = let token = if let Some(token) =
PasswordReset::does_token_exist(pool.get_ref(), form.token.as_ref().unwrap()).await; PasswordReset::does_token_exist(pool.get_ref(), &form.token.as_ref().unwrap()).await?
{
token
} else {
return Ok(HttpResponse::NoContent().finish());
};
if token.is_err() { let response = handle_password_change_request(
return HttpResponse::BadRequest().body("Token existiert nicht bzw. ist abgelaufen!");
}
if password.chars().count() > 256 {
if is_dry {
return HttpResponse::BadRequest().body("<div id=\"password-strength\" class=\"mb-3 help content is-danger\">Password darf nicht länger als 256 Zeichen sein.</div>");
} else {
return HttpResponse::NoContent().finish();
}
}
let user = User::read_by_id(pool.get_ref(), token.as_ref().unwrap().userid)
.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 is_dry {
let message = password_help::generate_for_entropy(&entropy);
return HttpResponse::BadRequest().body(message);
} else {
return HttpResponse::NoContent().finish();
}
}
if is_dry {
if entropy.score() == Score::Three {
return HttpResponse::Ok()
.body("<div id=\"password-strength\" class=\"mb-3 help content is-success\">Sicheres Passwort.</div>");
} else {
return HttpResponse::Ok()
.body("<div id=\"password-strength\" class=\"mb-3 help content is-success\">Sehr sicheres Passwort.</div>");
}
}
if password != form.passwordretyped.as_ref().unwrap() {
return HttpResponse::BadRequest().body("Passwörter stimmen nicht überein!");
}
let (hash, salt) =
auth::utils::generate_salt_and_hash_plain_password(form.password.as_ref().unwrap())
.unwrap();
User::update(
pool.get_ref(), pool.get_ref(),
token.as_ref().unwrap().userid, Some(&token),
None, token.id,
None, form.password.as_ref().unwrap(),
Some(&hash), form.passwordretyped.as_ref().unwrap(),
Some(&salt),
None,
None,
None,
None,
None, None,
is_dry,
) )
.await .await?;
.unwrap();
PasswordReset::delete(pool.get_ref(), &token.unwrap().token) return Ok(response);
.await
.unwrap();
return HttpResponse::Ok().body(r#"<div class="block">Passwort wurde geändert.</div><a class="block button is-primary" hx-boost="true" href="/login">Zum Login</a>"#);
} else { } else {
return HttpResponse::BadRequest().finish(); return Ok(HttpResponse::BadRequest().finish());
} }
} }

View File

@ -18,7 +18,7 @@ pub use location::Location;
pub use role::Role; pub use role::Role;
pub use user::User; pub use user::User;
pub use assignement::Assignment; pub use assignement::Assignment;
pub use password_reset::PasswordReset; pub use password_reset::{PasswordReset, Token};
pub use registration::Registration; pub use registration::Registration;
type Result<T> = std::result::Result<T, sqlx::Error>; type Result<T> = std::result::Result<T, sqlx::Error>;

View File

@ -1,19 +1,23 @@
use anyhow::Result;
use chrono::{NaiveDateTime, TimeDelta}; use chrono::{NaiveDateTime, TimeDelta};
use sqlx::{query_as, PgPool}; use sqlx::{query_as, PgPool};
use super::Result;
use crate::utils::token_generation::generate_token_and_expiration; use crate::utils::token_generation::generate_token_and_expiration;
pub trait Token {
async fn delete(&self, pool: &PgPool) -> Result<()>;
}
#[derive(Debug)] #[derive(Debug)]
pub struct PasswordReset { pub struct PasswordReset {
pub id: i32, pub id: i32,
pub token: String, pub token: String,
pub userid: i32, pub userid: i32,
pub expires: NaiveDateTime pub expires: NaiveDateTime,
} }
impl PasswordReset { impl PasswordReset {
pub async fn insert_new_for_user(pool: &PgPool, user_id: i32) -> Result<PasswordReset>{ pub async fn insert_new_for_user(pool: &PgPool, user_id: i32) -> Result<PasswordReset> {
let (token, expires) = generate_token_and_expiration(64, TimeDelta::hours(24)); let (token, expires) = generate_token_and_expiration(64, TimeDelta::hours(24));
let inserted = query_as!( let inserted = query_as!(
@ -29,17 +33,19 @@ impl PasswordReset {
Ok(inserted) Ok(inserted)
} }
pub async fn does_token_exist(pool: &PgPool, token: &str) -> Result<PasswordReset> { pub async fn does_token_exist(pool: &PgPool, token: &str) -> Result<Option<PasswordReset>> {
let result = query_as!(PasswordReset, let result = query_as!(
"SELECT * FROM passwordReset WHERE token = $1 AND expires > NOW();", token PasswordReset,
"SELECT * FROM passwordReset WHERE token = $1 AND expires > NOW();",
token
) )
.fetch_one(pool) .fetch_optional(pool)
.await?; .await?;
Ok(result) Ok(result)
} }
pub async fn delete(pool: &PgPool, token: &str) -> anyhow::Result<()> { pub async fn delete(pool: &PgPool, token: &str) -> Result<()> {
sqlx::query!("DELETE FROM passwordReset WHERE token = $1;", token) sqlx::query!("DELETE FROM passwordReset WHERE token = $1;", token)
.execute(pool) .execute(pool)
.await?; .await?;
@ -47,3 +53,9 @@ impl PasswordReset {
Ok(()) Ok(())
} }
} }
impl Token for PasswordReset {
async fn delete(&self, pool: &PgPool) -> Result<()> {
PasswordReset::delete(pool, &self.token).await
}
}

View File

@ -11,7 +11,7 @@ pub struct Registration {
pub expires: NaiveDateTime, pub expires: NaiveDateTime,
} }
use super::Result; use super::{password_reset::Token, Result};
impl Registration { impl Registration {
pub async fn insert_new_for_user(pool: &PgPool, user_id: i32) -> Result<Registration> { pub async fn insert_new_for_user(pool: &PgPool, user_id: i32) -> Result<Registration> {
@ -48,3 +48,9 @@ impl Registration {
Ok(()) Ok(())
} }
} }
impl Token for Registration {
async fn delete(&self, pool: &PgPool) -> Result<()> {
Registration::delete(pool, &self.token).await
}
}

View File

@ -17,6 +17,8 @@ pub enum ApplicationError {
Email(#[from] lettre::error::Error), Email(#[from] lettre::error::Error),
#[error("email transport not working")] #[error("email transport not working")]
EmailTransport(#[from] lettre::transport::smtp::Error), EmailTransport(#[from] lettre::transport::smtp::Error),
#[error("hashfunction failed")]
Hash(#[from] argon2::password_hash::Error)
} }
impl actix_web::error::ResponseError for ApplicationError { impl actix_web::error::ResponseError for ApplicationError {
@ -29,6 +31,7 @@ impl actix_web::error::ResponseError for ApplicationError {
ApplicationError::EmailAdress(_) => StatusCode::INTERNAL_SERVER_ERROR, ApplicationError::EmailAdress(_) => StatusCode::INTERNAL_SERVER_ERROR,
ApplicationError::Email(_) => StatusCode::INTERNAL_SERVER_ERROR, ApplicationError::Email(_) => StatusCode::INTERNAL_SERVER_ERROR,
ApplicationError::EmailTransport(_) => StatusCode::INTERNAL_SERVER_ERROR, ApplicationError::EmailTransport(_) => StatusCode::INTERNAL_SERVER_ERROR,
ApplicationError::Hash(_) => StatusCode::INTERNAL_SERVER_ERROR,
} }
} }
@ -36,13 +39,11 @@ impl actix_web::error::ResponseError for ApplicationError {
let mut response = HttpResponse::build(self.status_code()); let mut response = HttpResponse::build(self.status_code());
match self { match self {
ApplicationError::UnsupportedEnumValue { .. } => response.body(self.to_string()),
ApplicationError::Unauthorized { .. } => response.body(self.to_string()),
ApplicationError::Database(e) => response.body(format!("{self} - {e}")), ApplicationError::Database(e) => response.body(format!("{self} - {e}")),
ApplicationError::EnvVariable(_) => response.body(self.to_string()),
ApplicationError::EmailAdress(e) => response.body(format!("{self} - {e}")), ApplicationError::EmailAdress(e) => response.body(format!("{self} - {e}")),
ApplicationError::Email(e) => response.body(format!("{self} - {e}")), ApplicationError::Email(e) => response.body(format!("{self} - {e}")),
ApplicationError::EmailTransport(e) => response.body(format!("{self} - {e}")), ApplicationError::EmailTransport(e) => response.body(format!("{self} - {e}")),
_ => response.body(self.to_string()),
} }
} }
} }

View File

@ -84,7 +84,6 @@ Viele Grüße"##, user.name))
pub fn build_registration_message(user: &User, token: &str) -> Result<Message, ApplicationError> { pub fn build_registration_message(user: &User, token: &str) -> Result<Message, ApplicationError> {
let hostname = env::var("HOSTNAME")?; let hostname = env::var("HOSTNAME")?;
let register_url = format!("https://{hostname}/register?token={token}"); let register_url = format!("https://{hostname}/register?token={token}");
println!("{user:?}");
let message = Message::builder() let message = Message::builder()
.from("noreply <noreply@brasiwa-leipzig.de>".parse()?) .from("noreply <noreply@brasiwa-leipzig.de>".parse()?)

View File

@ -1,4 +1,7 @@
use zxcvbn::{feedback::{Suggestion, Warning}, Entropy}; use zxcvbn::{
feedback::{Suggestion, Warning},
Entropy,
};
pub fn generate_for_entropy(entropy: &Entropy) -> String { pub fn generate_for_entropy(entropy: &Entropy) -> String {
let feedback = entropy.feedback().unwrap(); let feedback = entropy.feedback().unwrap();
@ -72,3 +75,10 @@ pub fn generate_for_entropy(entropy: &Entropy) -> String {
format!("<div id=\"password-strength\" class=\"mb-3 help content is-danger\"><p>{warning}</p>{vorschlag_text}:<ul>{suggestion}</ul></div>") format!("<div id=\"password-strength\" class=\"mb-3 help content is-danger\"><p>{warning}</p>{vorschlag_text}:<ul>{suggestion}</ul></div>")
} }
pub fn format_message(level: &str, message: &str) -> String {
format!(
r#"<div id="password-strength" class="mb-3 help content is-{}">{}</div>"#,
level, message
)
}

View File

@ -28,7 +28,7 @@
<div class="field-body"> <div class="field-body">
<div class="field"> <div class="field">
<div class="control"> <div class="control">
<input class="input is-static" type="email" value="{{ user.name }}" readonly> <input class="input is-static" type="email" value="{{ user.email }}" readonly>
</div> </div>
</div> </div>
</div> </div>