From d8ef8104c6e3c985068688563c63075efc88a886 Mon Sep 17 00:00:00 2001 From: Max Hohlfeld Date: Mon, 20 May 2024 20:29:37 +0200 Subject: [PATCH] feat: password reset without email --- Cargo.toml | 2 +- migrations/20230609121618_initial.sql | 12 ++++- src/auth/redirect.rs | 2 +- src/endpoints/mod.rs | 2 + src/endpoints/user/get_reset.rs | 41 ++++++++++++++ src/endpoints/user/mod.rs | 2 + src/endpoints/user/patch.rs | 2 +- src/endpoints/user/post_edit.rs | 2 +- src/endpoints/user/post_login.rs | 20 +++---- src/endpoints/user/post_reset.rs | 78 +++++++++++++++++++++++++++ src/models/mod.rs | 2 + src/models/password_reset.rs | 55 +++++++++++++++++++ src/models/user.rs | 45 +++++++++------- templates/user/forgot_password.html | 27 ++++++++++ templates/user/login.html | 5 +- templates/user/reset_password.html | 32 +++++++++++ 16 files changed, 295 insertions(+), 34 deletions(-) create mode 100644 src/endpoints/user/get_reset.rs create mode 100644 src/endpoints/user/post_reset.rs create mode 100644 src/models/password_reset.rs create mode 100644 templates/user/forgot_password.html create mode 100644 templates/user/reset_password.html diff --git a/Cargo.toml b/Cargo.toml index c4613c9a..864189ed 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -21,5 +21,5 @@ askama_actix = "0.14.0" futures-util = "0.3.30" serde_json = "1.0.114" pico-args = "0.5.0" -rand = "0.8.5" +rand = { version = "0.8.5", features = ["getrandom"] } async-trait = "0.1.79" diff --git a/migrations/20230609121618_initial.sql b/migrations/20230609121618_initial.sql index 4c6c2baf..62d4463e 100644 --- a/migrations/20230609121618_initial.sql +++ b/migrations/20230609121618_initial.sql @@ -87,4 +87,14 @@ CREATE UNLOGGED TABLE session expires TIMESTAMP ); -CREATE INDEX idx_cache_key ON session (key); +CREATE INDEX session_key_idx ON session (key); + +CREATE UNLOGGED TABLE passwordReset +( + id SERIAL PRIMARY KEY, + token TEXT UNIQUE NOT NULL, + userId INTEGER NOT NULL REFERENCES user_ (id), + expires TIMESTAMP NOT NULL +); + +CREATE INDEX passwordReset_token_idx ON passwordReset (token); diff --git a/src/auth/redirect.rs b/src/auth/redirect.rs index b6e9c60a..8e5d6a13 100644 --- a/src/auth/redirect.rs +++ b/src/auth/redirect.rs @@ -48,7 +48,7 @@ where let is_logged_in = request.get_identity().is_ok(); // Don't forward to `/login` if we are already on `/login`. - if !is_logged_in && request.path() != "/login" && !request.path().starts_with("/static") { + if !is_logged_in && request.path() != "/login" && !request.path().starts_with("/reset-password") && !request.path().starts_with("/static") { let (request, _pl) = request.into_parts(); let response = HttpResponse::Found() diff --git a/src/endpoints/mod.rs b/src/endpoints/mod.rs index 99c82d3a..b5b5e1b1 100644 --- a/src/endpoints/mod.rs +++ b/src/endpoints/mod.rs @@ -32,6 +32,8 @@ pub fn init(cfg: &mut ServiceConfig) { cfg.service(user::get_logout::get); cfg.service(user::get_login::get); cfg.service(user::post_login::post); + cfg.service(user::get_reset::get); + cfg.service(user::post_reset::post); cfg.service(events::get_new::get); cfg.service(events::post_new::post); diff --git a/src/endpoints/user/get_reset.rs b/src/endpoints/user/get_reset.rs new file mode 100644 index 00000000..0c8abf40 --- /dev/null +++ b/src/endpoints/user/get_reset.rs @@ -0,0 +1,41 @@ +use actix_identity::Identity; +use actix_web::{get, http::header::LOCATION, web, HttpResponse, Responder}; +use askama::Template; +use askama_actix::TemplateToResponse; +use serde::Deserialize; + +#[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 +} + +#[get("/reset-password")] +pub async fn get(user: Option, 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() }; + + return template.to_response(); + } + } + + let template = ForgotPasswordTemplate {}; + + return template.to_response(); +} diff --git a/src/endpoints/user/mod.rs b/src/endpoints/user/mod.rs index 96a2f51c..69ee17f7 100644 --- a/src/endpoints/user/mod.rs +++ b/src/endpoints/user/mod.rs @@ -8,3 +8,5 @@ pub mod delete; pub mod get_logout; pub mod get_login; pub mod post_login; +pub mod get_reset; +pub mod post_reset; diff --git a/src/endpoints/user/patch.rs b/src/endpoints/user/patch.rs index 17fe91b8..a7dc0788 100644 --- a/src/endpoints/user/patch.rs +++ b/src/endpoints/user/patch.rs @@ -57,7 +57,7 @@ pub async fn patch( } if changed { - if let Ok(_) = User::update(pool.get_ref(), path.id, None, None, None, None, None, locked).await { + if let Ok(_) = User::update(pool.get_ref(), path.id, None, None, None, None, None, None, None, locked).await { return HttpResponse::Ok().body(""); } } else { diff --git a/src/endpoints/user/post_edit.rs b/src/endpoints/user/post_edit.rs index a0c1bc18..63605cf5 100644 --- a/src/endpoints/user/post_edit.rs +++ b/src/endpoints/user/post_edit.rs @@ -83,7 +83,7 @@ pub async fn post_edit( }; if changed { - match User::update(pool.get_ref(), path.id, email, name, role, function, area, None).await { + match User::update(pool.get_ref(), path.id, email, name, None, None, role, function, area, None).await { Ok(_) => return HttpResponse::Found().insert_header((LOCATION, "/users")).finish(), Err(err) => println!("{}", err) } diff --git a/src/endpoints/user/post_login.rs b/src/endpoints/user/post_login.rs index 7099a330..66b1cb03 100644 --- a/src/endpoints/user/post_login.rs +++ b/src/endpoints/user/post_login.rs @@ -17,18 +17,18 @@ async fn post( request: HttpRequest, pool: web::Data, ) -> impl Responder { - if let Ok(result) = User::read_for_login(pool.get_ref(), &form.email).await { - if let Some(user) = result { - let hash = hash_plain_password_with_salt(&form.password, &user.salt).unwrap(); - if hash == user.password { - Identity::login(&request.extensions(), user.id.to_string()).unwrap(); + 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 { + Identity::login(&request.extensions(), user.id.to_string()).unwrap(); - User::update_login_timestamp(pool.get_ref(), user.id).await.unwrap(); + User::update_login_timestamp(pool.get_ref(), user.id) + .await + .unwrap(); - return HttpResponse::Found() - .insert_header(("HX-LOCATION", "/")) - .finish(); - } + return HttpResponse::Found() + .insert_header(("HX-LOCATION", "/")) + .finish(); } } diff --git a/src/endpoints/user/post_reset.rs b/src/endpoints/user/post_reset.rs new file mode 100644 index 00000000..f0050984 --- /dev/null +++ b/src/endpoints/user/post_reset.rs @@ -0,0 +1,78 @@ +use actix_web::{web, HttpResponse, Responder}; +use serde::Deserialize; +use sqlx::PgPool; + +use crate::{ + auth, + models::{PasswordReset, User}, +}; + +#[derive(Deserialize)] +struct ResetPasswordForm { + email: Option, + token: Option, + password: Option, + passwordretyped: Option, +} + +#[actix_web::post("/reset-password")] +async fn post(form: web::Form, pool: web::Data) -> impl Responder { + if form.email.is_some() + && form.token.is_none() + && form.password.is_none() + && form.passwordretyped.is_none() + { + 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 + .unwrap(); + + // send email to user + println!("{reset:?}"); + } + + return HttpResponse::Ok().body("E-Mail versandt!"); + } else if form.email.is_none() + && form.token.is_some() + && form.password.is_some() + && form.passwordretyped.is_some() + { + let token = + PasswordReset::does_token_exist(pool.get_ref(), form.token.as_ref().unwrap()).await; + + if token.is_err() { + return HttpResponse::BadRequest().body("Token existiert nicht bzw. ist abgelaufen!"); + } + + if form.password.as_ref().unwrap() != 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(), + token.as_ref().unwrap().id, + None, + None, + Some(&hash), + Some(&salt), + None, + None, + None, + None, + ) + .await + .unwrap(); + + PasswordReset::delete(pool.get_ref(), &token.unwrap().token) + .await + .unwrap(); + + return HttpResponse::Ok().body(r#"
Passwort wurde geändert.
Zum Login"#); + } else { + return HttpResponse::BadRequest().finish(); + } +} diff --git a/src/models/mod.rs b/src/models/mod.rs index f9ef37c0..195c7305 100644 --- a/src/models/mod.rs +++ b/src/models/mod.rs @@ -7,6 +7,7 @@ mod location; mod role; mod user; mod vehicle; +mod password_reset; pub use area::Area; pub use availabillity::Availabillity; @@ -16,3 +17,4 @@ pub use location::Location; pub use role::Role; pub use user::User; pub use assignement::Assignment; +pub use password_reset::PasswordReset; diff --git a/src/models/password_reset.rs b/src/models/password_reset.rs new file mode 100644 index 00000000..39dc8ccc --- /dev/null +++ b/src/models/password_reset.rs @@ -0,0 +1,55 @@ +use anyhow::Result; +use chrono::{NaiveDateTime, TimeDelta, Utc}; +use rand::{distributions::Alphanumeric, rngs::OsRng, Rng as _}; +use sqlx::{query_as, PgPool}; + +#[derive(Debug)] +pub struct PasswordReset { + pub id: i32, + pub token: String, + pub userid: i32, + pub expires: NaiveDateTime +} + +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 inserted = query_as!( + PasswordReset, + "INSERT INTO passwordReset (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 { + let result = query_as!(PasswordReset, + "SELECT * FROM passwordReset WHERE token = $1 AND expires > NOW();", token + ) + .fetch_one(pool) + .await?; + + Ok(result) + } + + pub async fn delete(pool: &PgPool, token: &str) -> anyhow::Result<()> { + sqlx::query!("DELETE FROM passwordReset WHERE token = $1;", token) + .execute(pool) + .await?; + + Ok(()) + } +} diff --git a/src/models/user.rs b/src/models/user.rs index 801b4d02..3154e5ee 100644 --- a/src/models/user.rs +++ b/src/models/user.rs @@ -93,7 +93,7 @@ impl User { Ok(user) } - pub async fn read_for_login(pool: &PgPool, email: &str) -> anyhow::Result> { + pub async fn read_for_login(pool: &PgPool, email: &str) -> anyhow::Result { let record = sqlx::query!( r#" SELECT id, @@ -112,25 +112,22 @@ impl User { "#, email, ) - .fetch_optional(pool) + .fetch_one(pool) .await?; - let result = match record { - Some(record) => Some(User { - id: record.id, - name: record.name, - email: record.email, - password: record.password, - salt: record.salt, - role: record.role, - function: record.function, - area_id: record.areaid, - area: None, - locked: record.locked, - last_login: record.lastlogin, - receive_notifications: record.receivenotifications, - }), - None => None, + let result = User { + id: record.id, + name: record.name, + email: record.email, + password: record.password, + salt: record.salt, + role: record.role, + function: record.function, + area_id: record.areaid, + area: None, + locked: record.locked, + last_login: record.lastlogin, + receive_notifications: record.receivenotifications, }; Ok(result) @@ -273,6 +270,8 @@ impl User { id: i32, email: Option<&str>, name: Option<&str>, + password: Option<&str>, + salt: Option<&str>, role: Option, function: Option, area_id: Option, @@ -291,6 +290,16 @@ impl User { separated.push_bind_unseparated(name); } + if let Some(password) = password { + separated.push("password = "); + separated.push_bind_unseparated(password); + } + + if let Some(salt) = salt { + separated.push("salt = "); + separated.push_bind_unseparated(salt); + } + if let Some(role) = role { separated.push("role = "); separated.push_bind_unseparated(role as Role); diff --git a/templates/user/forgot_password.html b/templates/user/forgot_password.html new file mode 100644 index 00000000..8c43a722 --- /dev/null +++ b/templates/user/forgot_password.html @@ -0,0 +1,27 @@ +{% extends "base.html" %} + +{% block body %} +
+
+

Brass - Passwort zurücksetzen

+
+
+ Gib deine E-Mail Adresse ein und erhalte einen Link zum Zurücksetzen deines Passworts, sofern ein Account mit dieser Adresse existiert. Bei Problemen wende dich an mail@example.com. +
+
+
+
+ +
+ +
+
+ + +
+
+
+{% endblock %} diff --git a/templates/user/login.html b/templates/user/login.html index 69a7c976..4bfff2c6 100644 --- a/templates/user/login.html +++ b/templates/user/login.html @@ -21,7 +21,10 @@
- + diff --git a/templates/user/reset_password.html b/templates/user/reset_password.html new file mode 100644 index 00000000..2881a979 --- /dev/null +++ b/templates/user/reset_password.html @@ -0,0 +1,32 @@ +{% extends "base.html" %} + +{% block body %} +
+
+

Brass - Passwort zurücksetzen

+
+ + +
+ +
+ +
+
+ +
+ +
+ +
+
+ +
+ +
+ +
+
+
+
+{% endblock %}