From 2374f78a071d8670b39fead42b809ab76f37839b Mon Sep 17 00:00:00 2001 From: Max Hohlfeld Date: Tue, 3 Sep 2024 23:39:36 +0200 Subject: [PATCH] feat(WIP): registration of users --- Cargo.lock | 1 + Cargo.toml | 1 + migrations/20230609121618_initial.sql | 4 +- src/endpoints/mod.rs | 1 - src/endpoints/user/get_register.rs | 42 +++++++++ src/endpoints/user/get_reset.rs | 32 ++++--- src/endpoints/user/mod.rs | 14 ++- src/endpoints/user/patch.rs | 88 ------------------ src/endpoints/user/post_changepassword.rs | 10 ++- src/endpoints/user/post_login.rs | 6 +- src/endpoints/user/post_new.rs | 62 +++++++------ src/endpoints/user/post_register.rs | 85 ++++++++++++++++++ src/endpoints/user/post_reset.rs | 47 +--------- src/models/function.rs | 11 ++- src/models/mod.rs | 4 + src/models/password_reset.rs | 14 +-- src/models/registration.rs | 50 +++++++++++ src/models/role.rs | 9 +- src/models/user.rs | 49 +++++++--- src/utils/application_error.rs | 26 ++++++ src/utils/email.rs | 89 ++++++++++++++++++- src/utils/manage_commands.rs | 2 +- src/utils/mod.rs | 4 + src/utils/token_generation.rs | 15 ++++ ...set_password.html => change_password.html} | 10 +-- 25 files changed, 463 insertions(+), 213 deletions(-) create mode 100644 src/endpoints/user/get_register.rs delete mode 100644 src/endpoints/user/patch.rs create mode 100644 src/endpoints/user/post_register.rs create mode 100644 src/models/registration.rs create mode 100644 src/utils/application_error.rs create mode 100644 src/utils/token_generation.rs rename templates/user/{reset_password.html => change_password.html} (78%) diff --git a/Cargo.lock b/Cargo.lock index eca7a4cc..9273fe72 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -791,6 +791,7 @@ dependencies = [ "serde_json", "sqlx", "static-files", + "thiserror", "zxcvbn", ] diff --git a/Cargo.toml b/Cargo.toml index 6084ccff..b0bb228d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -29,6 +29,7 @@ quick-xml = { version = "0.31.0", features = ["serde", "serialize"] } actix-web-static-files = "4.0" static-files = "0.2.1" zxcvbn = "3.1.0" +thiserror = "1.0.63" [build-dependencies] built = "0.7.4" diff --git a/migrations/20230609121618_initial.sql b/migrations/20230609121618_initial.sql index 74490f00..6b0a6b5e 100644 --- a/migrations/20230609121618_initial.sql +++ b/migrations/20230609121618_initial.sql @@ -21,8 +21,8 @@ CREATE TABLE user_ id SERIAL PRIMARY KEY, name TEXT NOT NULL, email TEXT NOT NULL, - password TEXT NOT NULL, - salt TEXT NOT NULL, + password TEXT , + salt TEXT , role role NOT NULL, function function NOT NULL, areaId INTEGER NOT NULL REFERENCES area (id), diff --git a/src/endpoints/mod.rs b/src/endpoints/mod.rs index 5bafacfb..00834464 100644 --- a/src/endpoints/mod.rs +++ b/src/endpoints/mod.rs @@ -31,7 +31,6 @@ pub fn init(cfg: &mut ServiceConfig) { cfg.service(user::post_new::post_new); cfg.service(user::get_edit::get_edit); cfg.service(user::post_edit::post_edit); - cfg.service(user::patch::patch); cfg.service(user::delete::delete); cfg.service(user::get_logout::get); cfg.service(user::get_login::get); diff --git a/src/endpoints/user/get_register.rs b/src/endpoints/user/get_register.rs new file mode 100644 index 00000000..097d064c --- /dev/null +++ b/src/endpoints/user/get_register.rs @@ -0,0 +1,42 @@ +use actix_identity::Identity; +use actix_web::{get, http::header::LOCATION, web, HttpResponse, Responder}; +use askama_actix::TemplateToResponse; +use serde::Deserialize; +use sqlx::PgPool; + +use crate::models::Registration; + +use super::ResetPasswordTemplate; + +#[derive(Deserialize)] +struct TokenQuery { + token: String, +} + +#[get("/register")] +pub async fn get( + user: Option, + pool: web::Data, + query: web::Query, +) -> impl Responder { + if user.is_some() { + return HttpResponse::Found() + .insert_header((LOCATION, "/")) + .finish(); + } + + if let Ok(_) = Registration::does_token_exist(pool.get_ref(), &query.token).await { + let template = ResetPasswordTemplate { + token: &query.token, + title: "Brass - Registrierung", + endpoint: "/register", + new_password_label: "Passwort:", + retype_label: "Passwort wiederholen:", + submit_button_label: "Registrieren", + }; + + return template.to_response(); + } + + HttpResponse::NotFound().finish() +} diff --git a/src/endpoints/user/get_reset.rs b/src/endpoints/user/get_reset.rs index 0c8abf40..99f65024 100644 --- a/src/endpoints/user/get_reset.rs +++ b/src/endpoints/user/get_reset.rs @@ -3,33 +3,41 @@ use actix_web::{get, http::header::LOCATION, web, HttpResponse, Responder}; use askama::Template; use askama_actix::TemplateToResponse; use serde::Deserialize; +use sqlx::PgPool; + +use crate::models::PasswordReset; + +use super::ResetPasswordTemplate; #[derive(Template)] #[template(path = "user/forgot_password.html")] struct ForgotPasswordTemplate {} -#[derive(Template)] -#[template(path = "user/reset_password.html")] -struct ResetPasswordTemplate { - token: String -} - #[derive(Deserialize)] struct TokenQuery { - token: Option + token: Option, } #[get("/reset-password")] -pub async fn get(user: Option, query: web::Query) -> impl Responder { +pub async fn get( + user: Option, + pool: web::Data, + query: web::Query, +) -> impl Responder { if let Some(_) = user { return HttpResponse::Found() .insert_header((LOCATION, "/")) .finish(); } else if let Some(token) = &query.token { - let token_exists = true; - - if token_exists { - let template = ResetPasswordTemplate { token: token.to_string() }; + if let Ok(_) = PasswordReset::does_token_exist(pool.get_ref(), token).await { + let template = ResetPasswordTemplate { + token, + title: "Brass - Passwort zurücksetzen", + endpoint: "/reset-password", + new_password_label: "/reset-password", + retype_label: "neues Passwort wiederholen:", + submit_button_label: "Passwort zurücksetzen", + }; return template.to_response(); } diff --git a/src/endpoints/user/mod.rs b/src/endpoints/user/mod.rs index 0a6f68c5..ff8f8b08 100644 --- a/src/endpoints/user/mod.rs +++ b/src/endpoints/user/mod.rs @@ -11,13 +11,14 @@ pub mod get_new; pub mod get_overview; pub mod get_profile; pub mod get_reset; -pub mod patch; pub mod post_changepassword; pub mod post_edit; pub mod post_login; pub mod post_new; pub mod post_reset; pub mod post_toggle; +pub mod get_register; +pub mod post_register; #[derive(Template)] #[template(path = "user/new_or_edit.html")] @@ -31,3 +32,14 @@ pub struct NewOrEditUserTemplate { function: Option, area_id: Option, } + +#[derive(Template)] +#[template(path = "user/change_password.html")] +struct ResetPasswordTemplate<'a> { + token: &'a str, + title: &'a str, + endpoint: &'a str, + new_password_label: &'a str, + retype_label: &'a str, + submit_button_label: &'a str +} diff --git a/src/endpoints/user/patch.rs b/src/endpoints/user/patch.rs deleted file mode 100644 index fce3753d..00000000 --- a/src/endpoints/user/patch.rs +++ /dev/null @@ -1,88 +0,0 @@ -use actix_web::{web, HttpResponse, Responder}; -use serde::Deserialize; -use serde_json::value::Value; -use sqlx::PgPool; - -use crate::{ - endpoints::IdPath, - models::{Role, User}, -}; - -#[derive(Deserialize)] -pub struct JsonPatchDoc { - op: String, - path: String, - value: Value, -} - -// TODO: deprecated route -#[actix_web::patch("/users/edit/{id}")] -pub async fn patch( - user: web::ReqData, - pool: web::Data, - path: web::Path, - patch_docs: web::Json>, -) -> impl Responder { - - let is_superuser = user.role != Role::AreaManager && user.role != Role::Admin; - - if let Ok(user_in_db) = User::read_by_id(pool.get_ref(), path.id).await { - if user.role == Role::AreaManager && user.area_id != user_in_db.area_id { - return HttpResponse::Unauthorized().finish(); - } - - let mut changed = false; - - let mut locked: Option = None; - let mut receive_notifications: Option = None; - - for doc in patch_docs.iter() { - if doc.op.as_str() != "replace" { - continue; - } - - match doc.path.as_str() { - "/locked" => { - if !is_superuser { - return HttpResponse::Unauthorized().finish(); - } - changed = true; - if let Value::Bool(b) = doc.value { - locked = Some(b); - } - } - "/receiveNotifications" => { - changed = true; - if let Value::Bool(b) = doc.value { - receive_notifications = Some(b) - } - } - _ => return HttpResponse::BadRequest().body("Other PATCH paths are not supported!") - }; - } - - if changed { - if let Ok(_) = User::update( - pool.get_ref(), - path.id, - None, - None, - None, - None, - None, - None, - None, - receive_notifications, - locked, - ) - .await - { - return HttpResponse::Ok().body(""); - } - } else { - return HttpResponse::Ok().body(""); - } - } - - HttpResponse::BadRequest().body("Fehler bei User PATCH") -} diff --git a/src/endpoints/user/post_changepassword.rs b/src/endpoints/user/post_changepassword.rs index ddba251b..6c6e949d 100644 --- a/src/endpoints/user/post_changepassword.rs +++ b/src/endpoints/user/post_changepassword.rs @@ -17,9 +17,13 @@ async fn post( form: web::Form, pool: web::Data, ) -> impl Responder { - if user.password - == utils::hash_plain_password_with_salt(&form.currentpassword, &user.salt).unwrap() - { + if user.password.as_ref().is_some_and(|p| + p == &utils::hash_plain_password_with_salt( + &form.currentpassword, + user.salt.as_ref().unwrap(), + ) + .unwrap() + ) { if form.password != form.passwordretyped { return HttpResponse::BadRequest().body("Passwörter stimmen nicht überein!"); } diff --git a/src/endpoints/user/post_login.rs b/src/endpoints/user/post_login.rs index 6a1f9bd1..24361a45 100644 --- a/src/endpoints/user/post_login.rs +++ b/src/endpoints/user/post_login.rs @@ -18,8 +18,10 @@ async fn post( pool: web::Data, ) -> impl Responder { if let Ok(user) = User::read_for_login(pool.get_ref(), &form.email).await { - let hash = hash_plain_password_with_salt(&form.password, &user.salt).unwrap(); - if hash == user.password { + let salt = user.salt.unwrap(); + + let hash = hash_plain_password_with_salt(&form.password, &salt).unwrap(); + if hash == user.password.unwrap() { Identity::login(&request.extensions(), user.id.to_string()).unwrap(); User::update_login_timestamp(pool.get_ref(), user.id) diff --git a/src/endpoints/user/post_new.rs b/src/endpoints/user/post_new.rs index 9a12816d..d433b54a 100644 --- a/src/endpoints/user/post_new.rs +++ b/src/endpoints/user/post_new.rs @@ -1,46 +1,58 @@ -use actix_identity::Identity; use actix_web::{http::header::LOCATION, web, HttpResponse, Responder}; +use lettre::{SmtpTransport, Transport}; use serde::Deserialize; use sqlx::PgPool; -use crate::{auth::utils::generate_salt_and_hash_plain_password, models::{Function, Role, User}}; +use crate::{ + models::{Function, Registration, Role, User}, + utils::{email, ApplicationError}, +}; #[derive(Deserialize)] pub struct NewUserForm { email: String, name: String, - password: String, role: u8, function: u8, - area: Option + area: Option, } #[actix_web::post("/users/new")] -pub async fn post_new(user: Identity, pool: web::Data, form: web::Form) -> impl Responder { - let current_user = User::read_by_id(pool.get_ref(), user.id().unwrap().parse().unwrap()).await.unwrap(); - - if current_user.role != Role::AreaManager && current_user.role != Role::Admin { - return HttpResponse::Unauthorized().finish(); +pub async fn post_new( + user: web::ReqData, + pool: web::Data, + form: web::Form, + mailer: web::ReqData, +) -> Result { + if user.role != Role::AreaManager && user.role != Role::Admin { + return Err(ApplicationError::Unauthorized); } - let mut area_id = current_user.area_id; + let mut area_id = user.area_id; - if current_user.role == Role::Admin { - if let Some(id) = form.area { - area_id = id; - } + if user.role == Role::Admin && form.area.is_some() { + area_id = form.area.unwrap(); } - if let Ok((hash, salt)) = generate_salt_and_hash_plain_password(&form.password) { - if let Ok(role) = Role::try_from(form.role) { - if let Ok(function) = Function::try_from(form.function) { - match User::create(pool.get_ref(), &form.name, &form.email, &hash, &salt, role, function, area_id).await { - Ok(_) => return HttpResponse::Found().insert_header((LOCATION, "/users")).finish(), - Err(err) => println!("{}", err) - } - } - } - } + let role = Role::try_from(form.role)?; + let function = Function::try_from(form.function)?; - return HttpResponse::BadRequest().body("Fehler beim Erstellen des Nutzers"); + let id = User::create( + pool.get_ref(), + &form.name, + &form.email, + role, + function, + area_id, + ) + .await?; + + let registration = Registration::insert_new_for_user(pool.get_ref(), id).await?; + let message = email::build_registration_message(&user, ®istration.token); + + mailer.send(&message).unwrap(); + + Ok(HttpResponse::Found() + .insert_header((LOCATION, "/users")) + .finish()) } diff --git a/src/endpoints/user/post_register.rs b/src/endpoints/user/post_register.rs new file mode 100644 index 00000000..d6995900 --- /dev/null +++ b/src/endpoints/user/post_register.rs @@ -0,0 +1,85 @@ +use actix_web::{post, web, HttpResponse, Responder}; +use serde::Deserialize; +use sqlx::PgPool; +use zxcvbn::{zxcvbn, Score}; + +use crate::{ + auth, + models::{Registration, User}, + utils::{password_help, ApplicationError}, +}; + +#[derive(Deserialize)] +struct RegisterForm { + token: String, + password: String, + passwordretyped: String, + dry: Option, +} + +#[post("/register")] +async fn post( + form: web::Form, + pool: web::Data, +) -> Result { + let is_dry = form.dry.unwrap_or(false); + let token = Registration::does_token_exist(pool.get_ref(), &form.token).await?; + + if form.password.chars().count() > 256 { + if is_dry { + return Ok(HttpResponse::BadRequest().body("
Password darf nicht länger als 256 Zeichen sein.
")); + } else { + return Ok(HttpResponse::NoContent().finish()); + } + } + + let user = User::read_by_id(pool.get_ref(), token.userid).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(&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("
Sicheres Passwort.
")); + } else { + return Ok(HttpResponse::Ok() + .body("
Sehr sicheres Passwort.
")); + } + } + + 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(), + token.userid, + None, + None, + Some(&hash), + Some(&salt), + None, + None, + None, + None, + None, + ) + .await?; + + Registration::delete(pool.get_ref(), &token.token).await?; + + Ok(HttpResponse::Ok().body(r#"
Registrierung abgeschlossen.
Zum Login"#)) +} diff --git a/src/endpoints/user/post_reset.rs b/src/endpoints/user/post_reset.rs index 09950ef6..906569e0 100644 --- a/src/endpoints/user/post_reset.rs +++ b/src/endpoints/user/post_reset.rs @@ -1,10 +1,5 @@ -use std::env; - use actix_web::{web, HttpResponse, Responder}; -use lettre::{ - message::{header::ContentType, MultiPart, SinglePart}, - Message, SmtpTransport, Transport, -}; +use lettre::{SmtpTransport, Transport}; use serde::Deserialize; use sqlx::PgPool; use zxcvbn::{zxcvbn, Score}; @@ -12,7 +7,7 @@ use zxcvbn::{zxcvbn, Score}; use crate::{ auth::{self}, models::{PasswordReset, User}, - utils::password_help, + utils::{email, password_help}, }; #[derive(Deserialize, Debug)] @@ -40,43 +35,7 @@ async fn post( .await .unwrap(); - let hostname = env::var("HOSTNAME").unwrap(); - let reset_url = format!("https://{}/reset-password?token={}", hostname, reset.token); - - let message = Message::builder() - .from("noreply ".parse().unwrap()) - .reply_to("noreply ".parse().unwrap()) - .to(format!("{} <{}>", user.name, user.email).parse().unwrap()) - .subject("Brass: Zurücksetzen des Passworts angefordert") - .multipart( - MultiPart::alternative() - .singlepart( - SinglePart::builder() - .header(ContentType::TEXT_PLAIN) - .body(format!(r##"Hallo {}, - -du hast angefordert, dein Passwort zurückzusetzen. Kopiere dafür folgenden Link in deinen Browser: - -{} - -Bitte beachte, dass der Link nur 24 Stunden gültig ist. Solltest du nichts angefordert haben, dann musst du nichts weiter tun. - -Viele Grüße"##, user.name, reset_url)) - ) - .singlepart( - SinglePart::builder() - .header(ContentType::TEXT_HTML) - .body(format!(r##"

Hallo {},

- -

du hast angefordert, dein Passwort zurückzusetzen. Klicke dafür hier oder kopiere folgenden Link in deinen Browser:

- -

{}

- -

Bitte beachte, dass der Link nur 24 Stunden gültig ist. Solltest du nichts angefordert haben, dann musst du nichts weiter tun.

- -

Viele Grüße

"##, user.name, reset_url, reset_url)) - )) - .unwrap(); + let message = email::build_forgot_password_message(&user, &reset.token); mailer.send(&message).unwrap(); } diff --git a/src/models/function.rs b/src/models/function.rs index 81b09a23..95fd54aa 100644 --- a/src/models/function.rs +++ b/src/models/function.rs @@ -2,6 +2,8 @@ use std::fmt::Display; use serde::Serialize; +use crate::utils::ApplicationError; + #[derive(sqlx::Type, Debug, Clone, Copy, PartialEq, Eq, Serialize)] #[sqlx(type_name = "function", rename_all = "lowercase")] pub enum Function { @@ -13,19 +15,22 @@ impl Display for Function { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { Function::Posten => write!(f, "Posten"), - Function::Wachhabender => write!(f, "Wachhabender") + Function::Wachhabender => write!(f, "Wachhabender"), } } } impl TryFrom for Function { - type Error = (); + type Error = ApplicationError; fn try_from(value: u8) -> Result { match value { 1 => Ok(Function::Posten), 10 => Ok(Function::Wachhabender), - _ => Err(()), + _ => Err(ApplicationError::UnsupportedEnumValue { + value: value.to_string(), + enum_name: String::from("Function"), + }), } } } diff --git a/src/models/mod.rs b/src/models/mod.rs index 195c7305..4e6afa40 100644 --- a/src/models/mod.rs +++ b/src/models/mod.rs @@ -8,6 +8,7 @@ mod role; mod user; mod vehicle; mod password_reset; +mod registration; pub use area::Area; pub use availabillity::Availabillity; @@ -18,3 +19,6 @@ pub use role::Role; pub use user::User; pub use assignement::Assignment; pub use password_reset::PasswordReset; +pub use registration::Registration; + +type Result = std::result::Result; diff --git a/src/models/password_reset.rs b/src/models/password_reset.rs index 39dc8ccc..61b5b787 100644 --- a/src/models/password_reset.rs +++ b/src/models/password_reset.rs @@ -1,8 +1,9 @@ use anyhow::Result; -use chrono::{NaiveDateTime, TimeDelta, Utc}; -use rand::{distributions::Alphanumeric, rngs::OsRng, Rng as _}; +use chrono::{NaiveDateTime, TimeDelta}; use sqlx::{query_as, PgPool}; +use crate::utils::token_generation::generate_token_and_expiration; + #[derive(Debug)] pub struct PasswordReset { pub id: i32, @@ -13,14 +14,7 @@ pub struct PasswordReset { impl PasswordReset { pub async fn insert_new_for_user(pool: &PgPool, user_id: i32) -> Result{ - let value = std::iter::repeat(()) - .map(|()| OsRng.sample(Alphanumeric)) - .take(64) - .collect::>(); - - let token = String::from_utf8(value).unwrap().try_into().unwrap(); - - let expires = Utc::now().naive_utc() + TimeDelta::hours(24); + let (token, expires) = generate_token_and_expiration(64, TimeDelta::hours(24)); let inserted = query_as!( PasswordReset, diff --git a/src/models/registration.rs b/src/models/registration.rs new file mode 100644 index 00000000..f5371e30 --- /dev/null +++ b/src/models/registration.rs @@ -0,0 +1,50 @@ +use chrono::{NaiveDateTime, TimeDelta}; +use sqlx::{query_as, PgPool}; + +use crate::utils::token_generation::generate_token_and_expiration; + +#[derive(Debug)] +pub struct Registration { + pub id: i32, + pub token: String, + pub userid: i32, + pub expires: NaiveDateTime, +} + +use super::Result; + +impl Registration { + pub async fn insert_new_for_user(pool: &PgPool, user_id: i32) -> Result { + let (token, expires) = generate_token_and_expiration(64, TimeDelta::hours(24)); + + let inserted = query_as!( + Registration, + "INSERT INTO registration (token, userId, expires) VALUES ($1, $2, $3) RETURNING *;", + token, + user_id, + expires + ) + .fetch_one(pool) + .await?; + + Ok(inserted) + } + + pub async fn does_token_exist(pool: &PgPool, token: &str) -> Result { + query_as!( + Registration, + "SELECT * FROM registration WHERE token = $1 AND expires > NOW();", + token + ) + .fetch_one(pool) + .await + } + + pub async fn delete(pool: &PgPool, token: &str) -> Result<()> { + sqlx::query!("DELETE FROM registration WHERE token = $1;", token) + .execute(pool) + .await?; + + Ok(()) + } +} diff --git a/src/models/role.rs b/src/models/role.rs index 826ddf82..48201961 100644 --- a/src/models/role.rs +++ b/src/models/role.rs @@ -1,3 +1,5 @@ +use crate::utils::ApplicationError; + #[derive(sqlx::Type, Debug, Clone, Copy, PartialEq)] #[sqlx(type_name = "role", rename_all = "lowercase")] pub enum Role { @@ -7,14 +9,17 @@ pub enum Role { } impl TryFrom for Role { - type Error = (); + type Error = ApplicationError; fn try_from(value: u8) -> Result { match value { 1 => Ok(Role::Staff), 10 => Ok(Role::AreaManager), 100 => Ok(Role::Admin), - _ => Err(()), + _ => Err(ApplicationError::UnsupportedEnumValue { + value: value.to_string(), + enum_name: String::from("Role"), + }), } } } diff --git a/src/models/user.rs b/src/models/user.rs index b48dc606..febd0511 100644 --- a/src/models/user.rs +++ b/src/models/user.rs @@ -1,15 +1,15 @@ use chrono::{DateTime, Utc}; use sqlx::PgPool; -use super::{Area, Function, Role}; +use super::{Area, Function, Result, Role}; #[derive(Clone)] pub struct User { pub id: i32, pub name: String, pub email: String, - pub password: String, - pub salt: String, + pub password: Option, + pub salt: Option, pub role: Role, pub function: Function, pub area_id: i32, @@ -21,6 +21,31 @@ pub struct User { impl User { pub async fn create( + pool: &PgPool, + name: &str, + email: &str, + role: Role, + function: Function, + area_id: i32, + ) -> Result { + sqlx::query!( + r#" + INSERT INTO user_ (name, email, role, function, areaId) + VALUES ($1, $2, $3, $4, $5) + RETURNING id; + "#, + name, + email, + role as Role, + function as Function, + area_id + ) + .fetch_one(pool) + .await + .and_then(|r| Ok(r.id)) + } + + pub async fn create_with_password( pool: &PgPool, name: &str, email: &str, @@ -29,8 +54,8 @@ impl User { role: Role, function: Function, area_id: i32, - ) -> anyhow::Result { - let created = sqlx::query!( + ) -> Result { + let b = sqlx::query!( r#" INSERT INTO user_ (name, email, password, salt, role, function, areaId) VALUES ($1, $2, $3, $4, $5, $6, $7) @@ -45,15 +70,13 @@ impl User { area_id ) .fetch_one(pool) - .await; + .await + .and_then(|r| Ok(r.id)); - match created { - Ok(result) => Ok(result.id), - Err(err) => Err(err.into()), - } + b } - pub async fn read_by_id(pool: &PgPool, id: i32) -> anyhow::Result { + pub async fn read_by_id(pool: &PgPool, id: i32) -> Result { let record = sqlx::query!( r#" SELECT id, @@ -108,7 +131,7 @@ impl User { lastLogin, receiveNotifications FROM user_ - WHERE email = $1 AND locked = FALSE; + WHERE email = $1 AND locked = FALSE AND password IS NOT NULL AND salt IS NOT NULL; "#, email, ) @@ -277,7 +300,7 @@ impl User { area_id: Option, receive_notifications: Option, locked: Option, - ) -> anyhow::Result<()> { + ) -> Result<()> { let mut query_builder = sqlx::QueryBuilder::new("UPDATE user_ SET "); let mut separated = query_builder.separated(", "); diff --git a/src/utils/application_error.rs b/src/utils/application_error.rs new file mode 100644 index 00000000..f47454c0 --- /dev/null +++ b/src/utils/application_error.rs @@ -0,0 +1,26 @@ +use actix_web::{http::StatusCode, HttpResponse}; +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum ApplicationError { + #[error("unsupported value '{value}' for enum '{enum_name}'")] + UnsupportedEnumValue { value: String, enum_name: String }, + #[error("unauthorized")] + Unauthorized, + #[error("database error")] + Database(#[from] sqlx::Error), +} + +impl actix_web::error::ResponseError for ApplicationError { + fn status_code(&self) -> StatusCode { + match *self { + ApplicationError::UnsupportedEnumValue { .. } => StatusCode::BAD_REQUEST, + ApplicationError::Unauthorized { .. } => StatusCode::UNAUTHORIZED, + ApplicationError::Database(_) => StatusCode::INTERNAL_SERVER_ERROR, + } + } + + fn error_response(&self) -> HttpResponse { + HttpResponse::build(self.status_code()).body(self.to_string()) + } +} diff --git a/src/utils/email.rs b/src/utils/email.rs index 60b79ec4..dd86c053 100644 --- a/src/utils/email.rs +++ b/src/utils/email.rs @@ -1,10 +1,13 @@ use std::env; use lettre::{ + message::{header::ContentType, MultiPart, SinglePart}, transport::smtp::{authentication::Credentials, extension::ClientId}, - SmtpTransport, + Message, SmtpTransport, }; +use crate::models::User; + pub fn get_mailer() -> anyhow::Result { let server = &env::var("SMTP_SERVER")?; let port = &env::var("SMTP_PORT")?.parse()?; @@ -33,3 +36,87 @@ pub fn get_mailer() -> anyhow::Result { Ok(mailer) } + +pub fn build_forgot_password_message(user: &User, token: &str) -> Message { + let hostname = env::var("HOSTNAME").unwrap(); + let reset_url = format!("https://{hostname}/reset-password?token={token}"); + + let message = Message::builder() + .from("noreply ".parse().unwrap()) + .reply_to("noreply ".parse().unwrap()) + .to(format!("{} <{}>", user.name, user.email).parse().unwrap()) + .subject("Brass: Zurücksetzen des Passworts angefordert") + .multipart( + MultiPart::alternative() + .singlepart( + SinglePart::builder() + .header(ContentType::TEXT_PLAIN) + .body(format!(r##"Hallo {}, + +du hast angefordert, dein Passwort zurückzusetzen. Kopiere dafür folgenden Link in deinen Browser: + +{reset_url} + +Bitte beachte, dass der Link nur 24 Stunden gültig ist. Solltest du nichts angefordert haben, dann musst du nichts weiter tun. + +Viele Grüße"##, user.name)) + ) + .singlepart( + SinglePart::builder() + .header(ContentType::TEXT_HTML) + .body(format!(r##"

Hallo {},

+ +

du hast angefordert, dein Passwort zurückzusetzen. Klicke dafür hier oder kopiere folgenden Link in deinen Browser:

+ +

{reset_url}

+ +

Bitte beachte, dass der Link nur 24 Stunden gültig ist. Solltest du nichts angefordert haben, dann musst du nichts weiter tun.

+ +

Viele Grüße

"##, user.name)) + )) + .unwrap(); + + return message; +} + +pub fn build_registration_message(user: &User, token: &str) -> Message { + let hostname = env::var("HOSTNAME").unwrap(); + let register_url = format!("https://{hostname}/register?token={token}"); + + let message = Message::builder() + .from("noreply ".parse().unwrap()) + .reply_to("noreply ".parse().unwrap()) + .to(format!("{} <{}>", user.name, user.email).parse().unwrap()) + .subject("Brass: Registrierung deines Accounts") + .multipart( + MultiPart::alternative() + .singlepart( + SinglePart::builder() + .header(ContentType::TEXT_PLAIN) + .body(format!(r##"Hallo {}, + +dein Account für https:://{hostname} wurde erstellt. Du musst nur noch ein Passwort festlegen. Kopiere dafür folgenden Link in deinen Browser: + +{register_url} + +Bitte beachte, dass der Link nur 24 Stunden gültig ist. + +Viele Grüße"##, user.name)) + ) + .singlepart( + SinglePart::builder() + .header(ContentType::TEXT_HTML) + .body(format!(r##"

Hallo {},

+ +

dein Account für https://{hostname} wurde erstellt. Du musst nur noch ein Passwort festlegen. Klicke dafür hier oder kopiere folgenden Link in deinen Browser:

+ +

{register_url}

+ +

Bitte beachte, dass der Link nur 24 Stunden gültig ist.

+ +

Viele Grüße

"##, user.name)) + )) + .unwrap(); + + return message; +} diff --git a/src/utils/manage_commands.rs b/src/utils/manage_commands.rs index dcf940fc..eeb77c8e 100644 --- a/src/utils/manage_commands.rs +++ b/src/utils/manage_commands.rs @@ -67,7 +67,7 @@ pub async fn handle_command(command: Option, pool: &Pool) -> let (hash, salt) = generate_salt_and_hash_plain_password(&password)?; - User::create( + User::create_with_password( &pool, &name, &email, diff --git a/src/utils/mod.rs b/src/utils/mod.rs index 8ae39a65..9baeb982 100644 --- a/src/utils/mod.rs +++ b/src/utils/mod.rs @@ -1,3 +1,7 @@ pub mod email; pub mod manage_commands; pub mod password_help; +pub mod token_generation; +mod application_error; + +pub use application_error::ApplicationError; diff --git a/src/utils/token_generation.rs b/src/utils/token_generation.rs new file mode 100644 index 00000000..155af587 --- /dev/null +++ b/src/utils/token_generation.rs @@ -0,0 +1,15 @@ +use chrono::{NaiveDateTime, TimeDelta, Utc}; +use rand::{distributions::Alphanumeric, rngs::OsRng, Rng}; + +pub fn generate_token_and_expiration(token_length_bytes: usize, validity: TimeDelta) -> (String, NaiveDateTime) { + let value = std::iter::repeat(()) + .map(|()| OsRng.sample(Alphanumeric)) + .take(token_length_bytes) + .collect::>(); + + let token = String::from_utf8(value).unwrap().try_into().unwrap(); + + let expires = Utc::now().naive_utc() + validity; + + return (token, expires); +} diff --git a/templates/user/reset_password.html b/templates/user/change_password.html similarity index 78% rename from templates/user/reset_password.html rename to templates/user/change_password.html index 32c685c8..da5f6e08 100644 --- a/templates/user/reset_password.html +++ b/templates/user/change_password.html @@ -3,15 +3,15 @@ {% block body %}
-

Brass - Passwort zurücksetzen

-
{{ title }} +
- +
- +
@@ -30,7 +30,7 @@
- +