From: Greg Burri Date: Wed, 6 Nov 2024 16:52:16 +0000 (+0100) Subject: Add a way to reset password (WIP) X-Git-Url: http://git.euphorik.ch/index.cgi?a=commitdiff_plain;h=5d343c273fb3e06653bb48f6425c735dd3a80bcf;p=recipes.git Add a way to reset password (WIP) --- diff --git a/.gitignore b/.gitignore index 0c9b103..2da7a47 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,7 @@ style.css.map backend/static/frontend.js backend/static/style.css backend/file.db +frontend/dist/ *.sqlite conf.ron diff --git a/backend/sql/version_1.sql b/backend/sql/version_1.sql index b1f231f..c7fdf19 100644 --- a/backend/sql/version_1.sql +++ b/backend/sql/version_1.sql @@ -16,6 +16,11 @@ CREATE TABLE [User] ( [creation_datetime] TEXT NOT NULL, -- Updated when the validation email is sent. [validation_token] TEXT, -- If not null then the user has not validated his account yet. + [password_reset_token] TEXT, -- If not null then the user can reset its password. + -- The time when the reset token has been created. + -- Password can only be reset during a certain duration after this time. + [password_reset_datetime] TEXT, + [is_admin] INTEGER NOT NULL DEFAULT FALSE ) STRICT; @@ -67,16 +72,15 @@ CREATE TABLE [RecipeTag] ( [recipe_id] INTEGER NOT NULL, [tag_id] INTEGER NOT NULL, + UNIQUE([recipe_id], [tag_id]), + FOREIGN KEY([recipe_id]) REFERENCES [Recipe]([id]) ON DELETE CASCADE, FOREIGN KEY([tag_id]) REFERENCES [Tag]([id]) ON DELETE CASCADE ) STRICT; CREATE TABLE [Tag] ( [id] INTEGER PRIMARY KEY, - [recipe_tag_id] INTEGER, - [name] TEXT NOT NULL, - - FOREIGN KEY([recipe_tag_id]) REFERENCES [RecipeTag]([id]) ON DELETE SET NULL + [name] TEXT NOT NULL ) STRICT; CREATE UNIQUE INDEX [Tag_name_index] ON [Tag] ([name]); diff --git a/backend/src/consts.rs b/backend/src/consts.rs index 3fff9d6..386ffb7 100644 --- a/backend/src/consts.rs +++ b/backend/src/consts.rs @@ -4,11 +4,14 @@ pub const FILE_CONF: &str = "conf.ron"; pub const DB_DIRECTORY: &str = "data"; pub const DB_FILENAME: &str = "recipes.sqlite"; pub const SQL_FILENAME: &str = "sql/version_{VERSION}.sql"; -pub const VALIDATION_TOKEN_DURATION: i64 = 1 * 60 * 60; // 1 hour. [s]. +pub const VALIDATION_TOKEN_DURATION: i64 = 1 * 60 * 60; // [s]. (1 jour). pub const COOKIE_AUTH_TOKEN_NAME: &str = "auth_token"; -// Number of alphanumeric characters for cookie authentication token. -pub const AUTHENTICATION_TOKEN_SIZE: usize = 32; +pub const VALIDATION_PASSWORD_RESET_TOKEN_DURATION: i64 = 1 * 60 * 60; // [s]. (1 jour). + +// Number of alphanumeric characters for tokens +// (cookie authentication, password reset, validation token). +pub const TOKEN_SIZE: usize = 32; pub const SEND_EMAIL_TIMEOUT: Duration = Duration::from_secs(60); diff --git a/backend/src/data/db.rs b/backend/src/data/db.rs index a2f7057..4412222 100644 --- a/backend/src/data/db.rs +++ b/backend/src/data/db.rs @@ -73,6 +73,12 @@ pub enum AuthenticationResult { Ok(i64), // Returns user id. } +#[derive(Debug)] +pub enum GetTokenResetPassword { + PasswordAlreadyReset, + Ok(String), +} + #[derive(Clone)] pub struct Connection { pool: Pool, @@ -316,6 +322,7 @@ VALUES ($1, $2, $3, $4) ) -> Result { let mut tx = self.tx().await?; + // There is no index on [validation_token]. Is it useful? let user_id = match sqlx::query_as::<_, (i64, DateTime)>( "SELECT [id], [creation_datetime] FROM [User] WHERE [validation_token] = $1", ) @@ -428,6 +435,104 @@ WHERE [id] = $1 Ok(()) } + pub async fn get_token_reset_password( + &self, + email: &str, + validation_time: Duration, + ) -> Result { + let mut tx = self.tx().await?; + + if let Some(db_datetime) = sqlx::query_scalar::<_, Option>>( + r#" +SELECT [password_reset_datetime] +FROM [User] +WHERE [email] = $1 + "#, + ) + .bind(email) + .fetch_one(&mut *tx) + .await? + { + if Utc::now() - db_datetime <= validation_time { + return Ok(GetTokenResetPassword::PasswordAlreadyReset); + } + } + + let token = generate_token(); + + sqlx::query( + r#" +UPDATE [User] +SET [password_reset_token] = $2, [password_reset_datetime] = $3 +WHERE [email] = $1 + "#, + ) + .bind(email) + .bind(&token) + .bind(Utc::now()) + .execute(&mut *tx) + .await?; + + tx.commit().await?; + + Ok(GetTokenResetPassword::Ok(token)) + } + + pub async fn reset_password( + &self, + new_password: &str, + token: &str, + validation_time: Duration, + ) -> Result<()> { + let mut tx = self.tx().await?; + // There is no index on [password_reset_token]. Is it useful? + if let (user_id, Some(db_datetime)) = sqlx::query_as::<_, (i64, Option>)>( + r#" +SELECT [id], [password_reset_datetime] +FROM [User] +WHERE [password_reset_token] = $1 + "#, + ) + .bind(token) + .fetch_one(&mut *tx) + .await? + { + if Utc::now() - db_datetime > validation_time { + return Err(DBError::Other( + "Can't reset password: validation time exceeded".to_string(), + )); + } + + // Remove all login tokens (for security reasons). + sqlx::query("DELETE FROM [UserLoginToken] WHERE [user_id] = $1") + .bind(user_id) + .execute(&mut *tx) + .await?; + + let hashed_new_password = hash(new_password).map_err(|e| DBError::from_dyn_error(e))?; + + sqlx::query( + r#" +UPDATE [User] +SET [password] = $2, [password_reset_token] = NULL, [password_reset_datetime] = NULL +WHERE [id] = $1 + "#, + ) + .bind(user_id) + .bind(hashed_new_password) + .execute(&mut *tx) + .await?; + + tx.commit().await?; + + Ok(()) + } else { + Err(DBError::Other( + "Can't reset password: stored token or datetime not set (NULL)".to_string(), + )) + } + } + pub async fn create_recipe(&self, user_id: i64) -> Result { let mut tx = self.tx().await?; @@ -549,7 +654,7 @@ fn load_sql_file + fmt::Display>(sql_file: P) -> Result { } fn generate_token() -> String { - Alphanumeric.sample_string(&mut rand::thread_rng(), consts::AUTHENTICATION_TOKEN_SIZE) + Alphanumeric.sample_string(&mut rand::thread_rng(), consts::TOKEN_SIZE) } #[cfg(test)] @@ -862,6 +967,80 @@ VALUES ( Ok(()) } + #[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?; + + let email = "paul@atreides.com"; + let password = "12345"; + let new_password = "54321"; + + // Sign up. + let validation_token = match connection.sign_up(email, password).await? { + SignUpResult::UserCreatedWaitingForValidation(token) => token, // Nominal case. + other => panic!("{:?}", other), + }; + + // Validation. + let (authentication_token_1, user_id_1) = match connection + .validation( + &validation_token, + Duration::hours(1), + "127.0.0.1", + "Mozilla", + ) + .await? + { + ValidationResult::Ok(token, user_id) => (token, user_id), + other => panic!("{:?}", other), + }; + + // Check user login information. + let user_login_info_1 = connection + .get_user_login_info(&authentication_token_1) + .await?; + assert_eq!(user_login_info_1.ip, "127.0.0.1"); + assert_eq!(user_login_info_1.user_agent, "Mozilla"); + + // Sign out. + connection.sign_out(&authentication_token_1).await?; + + // Ask for password reset. + let token = match connection + .get_token_reset_password(email, Duration::hours(1)) + .await? + { + GetTokenResetPassword::Ok(token) => token, + other => panic!("{:?}", other), + }; + + connection + .reset_password(&new_password, &token, Duration::hours(1)) + .await?; + + // Sign in. + let (authentication_token_2, user_id_2) = match connection + .sign_in(email, new_password, "192.168.1.1", "Chrome") + .await? + { + SignInResult::Ok(token, user_id) => (token, user_id), + other => panic!("{:?}", other), + }; + + assert_eq!(user_id_1, user_id_2); + assert_ne!(authentication_token_1, authentication_token_2); + + // Check user login information. + let user_login_info_2 = connection + .get_user_login_info(&authentication_token_2) + .await?; + + assert_eq!(user_login_info_2.ip, "192.168.1.1"); + assert_eq!(user_login_info_2.user_agent, "Chrome"); + + Ok(()) + } + #[tokio::test] async fn create_a_new_recipe_then_update_its_title() -> Result<()> { let connection = Connection::new_in_memory().await?; diff --git a/backend/src/main.rs b/backend/src/main.rs index 3d3fc56..66320c2 100644 --- a/backend/src/main.rs +++ b/backend/src/main.rs @@ -87,6 +87,11 @@ async fn main() { get(services::sign_in_get).post(services::sign_in_post), ) .route("/signout", get(services::sign_out)) + .route( + "/ask_reset_password", + get(services::ask_reset_password_get).post(services::ask_reset_password_post), + ) + .route("/reset_password", get(services::reset_password)) .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 db521b2..72dc2fc 100644 --- a/backend/src/services.rs +++ b/backend/src/services.rs @@ -249,7 +249,7 @@ pub async fn sign_up_post( } .to_string(), message: match error { - SignUpError::UserAlreadyExists => "This email is already taken", + SignUpError::UserAlreadyExists => "This email is not available", SignUpError::DatabaseError => "Database error", SignUpError::UnableSendEmail => "Unable to send the validation email", _ => "", @@ -491,8 +491,51 @@ pub async fn sign_out( Ok((jar, Redirect::to("/"))) } -///// 404 ///// +///// RESET PASSWORD ///// + +#[derive(Template)] +#[template(path = "ask_reset_password.html")] +struct AskResetPasswordTemplate { + user: Option, + email: String, + message: String, + message_email: String, +} + +#[debug_handler] +pub async fn ask_reset_password_get( + Extension(user): Extension>, +) -> Result { + if user.is_some() { + Ok(MessageTemplate { + user, + message: "Can't ask to reset password when already logged in", + } + .into_response()) + } else { + Ok(AskResetPasswordTemplate { + user, + email: String::new(), + message: String::new(), + message_email: String::new(), + } + .into_response()) + } +} +#[debug_handler] +pub async fn ask_reset_password_post( + Extension(user): Extension>, +) -> Result { + Ok("todo".into_response()) +} + +#[debug_handler] +pub async fn reset_password() -> Result { + Ok("todo".into_response()) +} + +///// 404 ///// #[debug_handler] pub async fn not_found() -> Result { Ok(MessageWithoutUser { diff --git a/backend/templates/ask_reset_password.html b/backend/templates/ask_reset_password.html new file mode 100644 index 0000000..4aa657e --- /dev/null +++ b/backend/templates/ask_reset_password.html @@ -0,0 +1,14 @@ +{% extends "base_with_header.html" %} + +{% block main_container %} +
+
+ + + {{ message_email }} + + +
+ {{ message }} +
+{% endblock %} diff --git a/backend/templates/base_with_header.html b/backend/templates/base_with_header.html index ea4a50d..4964b23 100644 --- a/backend/templates/base_with_header.html +++ b/backend/templates/base_with_header.html @@ -7,9 +7,13 @@ {% match user %} {% when Some with (user) %} Create a new recipe - {{ user.email }} / Sign out + {{ user.email }} / Sign out {% when None %} - Sign in / Sign up + + Sign in/ + Sign up/ + Lost password + {% endmatch %}