From ed979719b5991353293375510ec67cda9c034120 Mon Sep 17 00:00:00 2001 From: Greg Burri Date: Sat, 9 Nov 2024 11:22:53 +0100 Subject: [PATCH] Add a way to reset password --- Cargo.lock | 38 +++- backend/Cargo.toml | 2 +- backend/askama.toml | 2 + backend/src/data/db.rs | 33 ++- backend/src/email.rs | 10 +- backend/src/main.rs | 5 +- backend/src/services.rs | 249 +++++++++++++++++++--- backend/src/utils.rs | 21 ++ backend/templates/ask_reset_password.html | 2 +- backend/templates/base_with_header.html | 4 +- backend/templates/profile.html | 23 ++ backend/templates/reset_password.html | 20 ++ 12 files changed, 352 insertions(+), 57 deletions(-) create mode 100644 backend/askama.toml create mode 100644 backend/templates/profile.html create mode 100644 backend/templates/reset_password.html diff --git a/Cargo.lock b/Cargo.lock index 785972b..2e05eed 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1297,9 +1297,9 @@ dependencies = [ [[package]] name = "libc" -version = "0.2.161" +version = "0.2.162" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e9489c2807c139ffd9c1794f4af0ebe86a828db53ecdc7fea2111d0fed085d1" +checksum = "18d287de67fe55fd7e1581fe933d965a5a9477b38e949cfa9f8574ef01506398" [[package]] name = "libm" @@ -1712,7 +1712,7 @@ dependencies = [ "ron", "serde", "sqlx", - "thiserror", + "thiserror 2.0.1", "tokio", "tower", "tower-http", @@ -2121,7 +2121,7 @@ dependencies = [ "sha2", "smallvec", "sqlformat", - "thiserror", + "thiserror 1.0.68", "tokio", "tokio-stream", "tracing", @@ -2205,7 +2205,7 @@ dependencies = [ "smallvec", "sqlx-core", "stringprep", - "thiserror", + "thiserror 1.0.68", "tracing", "whoami", ] @@ -2244,7 +2244,7 @@ dependencies = [ "smallvec", "sqlx-core", "stringprep", - "thiserror", + "thiserror 1.0.68", "tracing", "whoami", ] @@ -2368,7 +2368,16 @@ version = "1.0.68" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "02dd99dc800bbb97186339685293e1cc5d9df1f8fae2d0aecd9ff1c77efea892" dependencies = [ - "thiserror-impl", + "thiserror-impl 1.0.68", +] + +[[package]] +name = "thiserror" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07c1e40dd48a282ae8edc36c732cbc219144b87fb6a4c7316d611c6b1f06ec0c" +dependencies = [ + "thiserror-impl 2.0.1", ] [[package]] @@ -2382,6 +2391,17 @@ dependencies = [ "syn", ] +[[package]] +name = "thiserror-impl" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "874aa7e446f1da8d9c3a5c95b1c5eb41d800045252121dc7f8e0ba370cee55f5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "thread_local" version = "1.1.8" @@ -2450,9 +2470,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" -version = "1.41.0" +version = "1.41.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "145f3413504347a2be84393cc8a7d2fb4d863b375909ea59f2158261aa258bbb" +checksum = "22cfb5bee7a6a52939ca9224d6ac897bb669134078daa8735560897f69de4d33" dependencies = [ "backtrace", "bytes", diff --git a/backend/Cargo.toml b/backend/Cargo.toml index a6c5bdf..18761b6 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -49,4 +49,4 @@ lettre = { version = "0.11", default-features = false, features = [ ] } derive_more = { version = "1", features = ["full"] } -thiserror = "1" +thiserror = "2" diff --git a/backend/askama.toml b/backend/askama.toml new file mode 100644 index 0000000..019d574 --- /dev/null +++ b/backend/askama.toml @@ -0,0 +1,2 @@ +[general] +whitespace = "suppress" diff --git a/backend/src/data/db.rs b/backend/src/data/db.rs index 4412222..6694f03 100644 --- a/backend/src/data/db.rs +++ b/backend/src/data/db.rs @@ -29,8 +29,8 @@ pub enum DBError { Sqlx(#[from] sqlx::Error), #[error( - "Unsupported database version: {0} (code version: {})", - CURRENT_DB_VERSION + "Unsupported database version: {0} (application version: {current})", + current = CURRENT_DB_VERSION )] UnsupportedVersion(u32), @@ -76,6 +76,7 @@ pub enum AuthenticationResult { #[derive(Debug)] pub enum GetTokenResetPassword { PasswordAlreadyReset, + EmailUnknown, Ok(String), } @@ -442,7 +443,7 @@ WHERE [id] = $1 ) -> Result { let mut tx = self.tx().await?; - if let Some(db_datetime) = sqlx::query_scalar::<_, Option>>( + if let Some(db_datetime_nullable) = sqlx::query_scalar::<_, Option>>( r#" SELECT [password_reset_datetime] FROM [User] @@ -450,12 +451,16 @@ WHERE [email] = $1 "#, ) .bind(email) - .fetch_one(&mut *tx) + .fetch_optional(&mut *tx) .await? { - if Utc::now() - db_datetime <= validation_time { - return Ok(GetTokenResetPassword::PasswordAlreadyReset); + if let Some(db_datetime) = db_datetime_nullable { + if Utc::now() - db_datetime <= validation_time { + return Ok(GetTokenResetPassword::PasswordAlreadyReset); + } } + } else { + return Ok(GetTokenResetPassword::EmailUnknown); } let token = generate_token(); @@ -967,6 +972,22 @@ VALUES ( Ok(()) } + #[tokio::test] + async fn ask_to_reset_password_for_unknown_email() -> Result<()> { + let connection = Connection::new_in_memory().await?; + + let email = "paul@atreides.com"; + + // Ask for password reset. + match connection + .get_token_reset_password(email, Duration::hours(1)) + .await? + { + GetTokenResetPassword::EmailUnknown => Ok(()), // Nominal case. + other => panic!("{:?}", other), + } + } + #[tokio::test] async fn sign_up_then_send_validation_then_sign_out_then_ask_to_reset_password() -> Result<()> { let connection = Connection::new_in_memory().await?; diff --git a/backend/src/email.rs b/backend/src/email.rs index 171555a..fb19d7b 100644 --- a/backend/src/email.rs +++ b/backend/src/email.rs @@ -32,10 +32,9 @@ impl From for Error { } } -pub async fn send_validation( - site_url: &str, +pub async fn send_email( email: &str, - token: &str, + message: &str, smtp_relay_address: &str, smtp_login: &str, smtp_password: &str, @@ -45,10 +44,7 @@ pub async fn send_validation( .from("recipes@gburri.org".parse()?) .to(email.parse()?) .subject("recipes.gburri.org account validation") - .body(format!( - "Follow this link to confirm your inscription: {}/validation?validation_token={}", - site_url, token - ))?; + .body(message.to_string())?; let credentials = Credentials::new(smtp_login.to_string(), smtp_password.to_string()); diff --git a/backend/src/main.rs b/backend/src/main.rs index 66320c2..03e0977 100644 --- a/backend/src/main.rs +++ b/backend/src/main.rs @@ -91,7 +91,10 @@ async fn main() { "/ask_reset_password", get(services::ask_reset_password_get).post(services::ask_reset_password_post), ) - .route("/reset_password", get(services::reset_password)) + .route( + "/reset_password", + get(services::reset_password_get).post(services::reset_password_post), + ) .layer(TraceLayer::new_for_http()) .route_layer(middleware::from_fn_with_state( state.clone(), diff --git a/backend/src/services.rs b/backend/src/services.rs index 72dc2fc..8d4f41f 100644 --- a/backend/src/services.rs +++ b/backend/src/services.rs @@ -4,7 +4,7 @@ use askama::Template; use axum::{ body::Body, debug_handler, - extract::{ConnectInfo, Extension, Host, Path, Query, Request, State}, + extract::{connect_info, ConnectInfo, Extension, Host, Path, Query, Request, State}, http::{HeaderMap, StatusCode}, response::{IntoResponse, Redirect, Response, Result}, Form, @@ -12,8 +12,14 @@ use axum::{ use axum_extra::extract::cookie::{Cookie, CookieJar}; use chrono::Duration; use serde::Deserialize; +use tracing::{event, Level}; -use crate::{config::Config, consts, data::db, email, model, utils, AppState}; +use crate::{ + config::Config, + consts::{self, VALIDATION_PASSWORD_RESET_TOKEN_DURATION}, + data::db, + email, model, utils, AppState, +}; pub mod webapi; @@ -284,32 +290,15 @@ pub async fn sign_up_post( error_response(SignUpError::UserAlreadyExists, &form_data, user) } Ok(db::SignUpResult::UserCreatedWaitingForValidation(token)) => { - let url = { - let port: Option = 'p: { - let split_port: Vec<&str> = host.split(':').collect(); - if split_port.len() == 2 { - if let Ok(p) = split_port[1].parse::() { - break 'p Some(p); - } - } - None - }; - format!( - "http{}://{}", - if port.is_some() && port.unwrap() != 443 { - "" - } else { - "s" - }, - host - ) - }; + let url = utils::get_url_from_host(&host); let email = form_data.email.clone(); - match email::send_validation( - &url, + match email::send_email( &email, - &token, + &format!( + "Follow this link to confirm your inscription: {}/validation?validation_token={}", + url, token + ), &config.smtp_relay_address, &config.smtp_login, &config.smtp_password, @@ -523,16 +512,218 @@ pub async fn ask_reset_password_get( } } -#[debug_handler] +#[derive(Deserialize, Debug)] +pub struct AskResetPasswordForm { + email: String, +} + +enum AskResetPasswordError { + InvalidEmail, + EmailAlreadyReset, + EmailUnknown, + UnableSendEmail, + DatabaseError, +} + +#[debug_handler(state = AppState)] pub async fn ask_reset_password_post( + Host(host): Host, + State(connection): State, + State(config): State, + Extension(user): Extension>, + Form(form_data): Form, +) -> Result { + fn error_response( + error: AskResetPasswordError, + email: &str, + user: Option, + ) -> Result { + Ok(AskResetPasswordTemplate { + user, + email: email.to_string(), + message_email: match error { + AskResetPasswordError::InvalidEmail => "Invalid email", + _ => "", + } + .to_string(), + message: match error { + AskResetPasswordError::EmailAlreadyReset => { + "The password has already been reset for this email" + } + AskResetPasswordError::EmailUnknown => "Email unknown", + AskResetPasswordError::UnableSendEmail => "Unable to send the reset password email", + AskResetPasswordError::DatabaseError => "Database error", + _ => "", + } + .to_string(), + } + .into_response()) + } + + // Validation of email. + if let common::utils::EmailValidation::NotValid = + common::utils::validate_email(&form_data.email) + { + return error_response(AskResetPasswordError::InvalidEmail, &form_data.email, user); + } + + match connection + .get_token_reset_password( + &form_data.email, + Duration::seconds(consts::VALIDATION_PASSWORD_RESET_TOKEN_DURATION), + ) + .await + { + Ok(db::GetTokenResetPassword::PasswordAlreadyReset) => error_response( + AskResetPasswordError::EmailAlreadyReset, + &form_data.email, + user, + ), + Ok(db::GetTokenResetPassword::EmailUnknown) => { + error_response(AskResetPasswordError::EmailUnknown, &form_data.email, user) + } + Ok(db::GetTokenResetPassword::Ok(token)) => { + let url = utils::get_url_from_host(&host); + match email::send_email( + &form_data.email, + &format!( + "Follow this link to reset your password: {}/reset_password?reset_token={}", + url, token + ), + &config.smtp_relay_address, + &config.smtp_login, + &config.smtp_password, + ) + .await + { + Ok(()) => Ok(MessageTemplate { + user, + message: "An email has been sent, follow the link to reset your password.", + } + .into_response()), + Err(_) => { + // error!("Email validation error: {}", error); // TODO: log + error_response( + AskResetPasswordError::UnableSendEmail, + &form_data.email, + user, + ) + } + } + } + Err(error) => { + event!(Level::ERROR, "{}", error); + error_response(AskResetPasswordError::DatabaseError, &form_data.email, user) + } + } +} + +#[derive(Template)] +#[template(path = "reset_password.html")] +struct ResetPasswordTemplate { + user: Option, + reset_token: String, + password_1: String, + password_2: String, + message: String, + message_password: String, +} + +#[debug_handler] +pub async fn reset_password_get( Extension(user): Extension>, + Query(query): Query>, ) -> Result { - Ok("todo".into_response()) + if let Some(reset_token) = query.get("reset_token") { + Ok(ResetPasswordTemplate { + user, + reset_token: reset_token.to_string(), + password_1: String::new(), + password_2: String::new(), + message: String::new(), + message_password: String::new(), + } + .into_response()) + } else { + Ok(MessageTemplate { + user, + message: "Reset token missing", + } + .into_response()) + } +} + +#[derive(Deserialize, Debug)] +pub struct ResetPasswordForm { + password_1: String, + password_2: String, + reset_token: String, +} + +enum ResetPasswordError { + PasswordsNotEqual, + InvalidPassword, + DatabaseError, } #[debug_handler] -pub async fn reset_password() -> Result { - Ok("todo".into_response()) +pub async fn reset_password_post( + State(connection): State, + Extension(user): Extension>, + Form(form_data): Form, +) -> Result { + fn error_response( + error: ResetPasswordError, + form_data: &ResetPasswordForm, + user: Option, + ) -> Result { + Ok(ResetPasswordTemplate { + user, + reset_token: form_data.reset_token.clone(), + password_1: String::new(), + password_2: String::new(), + message_password: match error { + ResetPasswordError::PasswordsNotEqual => "Passwords don't match", + ResetPasswordError::InvalidPassword => { + "Password must have at least eight characters" + } + _ => "", + } + .to_string(), + message: match error { + ResetPasswordError::DatabaseError => "Database error", + _ => "", + } + .to_string(), + } + .into_response()) + } + + if form_data.password_1 != form_data.password_2 { + return error_response(ResetPasswordError::PasswordsNotEqual, &form_data, user); + } + + if let common::utils::PasswordValidation::TooShort = + common::utils::validate_password(&form_data.password_1) + { + return error_response(ResetPasswordError::InvalidPassword, &form_data, user); + } + + match connection + .reset_password( + &form_data.password_1, + &form_data.reset_token, + Duration::seconds(consts::VALIDATION_PASSWORD_RESET_TOKEN_DURATION), + ) + .await + { + Ok(_) => Ok(MessageTemplate { + user, + message: "Your password has been reset", + } + .into_response()), + Err(_) => error_response(ResetPasswordError::DatabaseError, &form_data, user), + } } ///// 404 ///// diff --git a/backend/src/utils.rs b/backend/src/utils.rs index 3885ce9..f8715f3 100644 --- a/backend/src/utils.rs +++ b/backend/src/utils.rs @@ -18,3 +18,24 @@ pub fn get_ip_and_user_agent(headers: &HeaderMap, remote_address: SocketAddr) -> (ip, user_agent) } + +pub fn get_url_from_host(host: &str) -> String { + let port: Option = 'p: { + let split_port: Vec<&str> = host.split(':').collect(); + if split_port.len() == 2 { + if let Ok(p) = split_port[1].parse::() { + break 'p Some(p); + } + } + None + }; + format!( + "http{}://{}", + if port.is_some() && port.unwrap() != 443 { + "" + } else { + "s" + }, + host + ) +} diff --git a/backend/templates/ask_reset_password.html b/backend/templates/ask_reset_password.html index 4aa657e..4a5fc8d 100644 --- a/backend/templates/ask_reset_password.html +++ b/backend/templates/ask_reset_password.html @@ -2,7 +2,7 @@ {% block main_container %}
-
+ {{ message_email }} diff --git a/backend/templates/base_with_header.html b/backend/templates/base_with_header.html index 4964b23..8dc71bd 100644 --- a/backend/templates/base_with_header.html +++ b/backend/templates/base_with_header.html @@ -10,9 +10,7 @@ {{ user.email }} / Sign out {% when None %} - Sign in/ - Sign up/ - Lost password + Sign in/Sign up/Lost password {% endmatch %} diff --git a/backend/templates/profile.html b/backend/templates/profile.html new file mode 100644 index 0000000..f999c41 --- /dev/null +++ b/backend/templates/profile.html @@ -0,0 +1,23 @@ +{% extends "base_with_list.html" %} + +{% block content %} + + + + + +{% endblock %} \ No newline at end of file diff --git a/backend/templates/reset_password.html b/backend/templates/reset_password.html new file mode 100644 index 0000000..e1e8429 --- /dev/null +++ b/backend/templates/reset_password.html @@ -0,0 +1,20 @@ +{% extends "base_with_header.html" %} + +{% block main_container %} +
+ + + + + + + + {{ message_password }} + + + + + + {{ message }} +
+{% endblock %} -- 2.45.2