Add a way to reset password (WIP)
authorGreg Burri <greg.burri@gmail.com>
Wed, 6 Nov 2024 16:52:16 +0000 (17:52 +0100)
committerGreg Burri <greg.burri@gmail.com>
Wed, 6 Nov 2024 16:52:16 +0000 (17:52 +0100)
.gitignore
backend/sql/version_1.sql
backend/src/consts.rs
backend/src/data/db.rs
backend/src/main.rs
backend/src/services.rs
backend/templates/ask_reset_password.html [new file with mode: 0644]
backend/templates/base_with_header.html

index 0c9b103..2da7a47 100644 (file)
@@ -6,6 +6,7 @@ style.css.map
 backend/static/frontend.js
 backend/static/style.css
 backend/file.db
+frontend/dist/
 *.sqlite
 conf.ron
 
index b1f231f..c7fdf19 100644 (file)
@@ -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]);
index 3fff9d6..386ffb7 100644 (file)
@@ -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);
 
index a2f7057..4412222 100644 (file)
@@ -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<Sqlite>,
@@ -316,6 +322,7 @@ VALUES ($1, $2, $3, $4)
     ) -> Result<ValidationResult> {
         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<Utc>)>(
             "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<GetTokenResetPassword> {
+        let mut tx = self.tx().await?;
+
+        if let Some(db_datetime) = sqlx::query_scalar::<_, Option<DateTime<Utc>>>(
+            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<DateTime<Utc>>)>(
+            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<i64> {
         let mut tx = self.tx().await?;
 
@@ -549,7 +654,7 @@ fn load_sql_file<P: AsRef<Path> + fmt::Display>(sql_file: P) -> Result<String> {
 }
 
 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?;
index 3d3fc56..66320c2 100644 (file)
@@ -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(),
index db521b2..72dc2fc 100644 (file)
@@ -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<model::User>,
+    email: String,
+    message: String,
+    message_email: String,
+}
+
+#[debug_handler]
+pub async fn ask_reset_password_get(
+    Extension(user): Extension<Option<model::User>>,
+) -> Result<Response> {
+    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<Option<model::User>>,
+) -> Result<Response> {
+    Ok("todo".into_response())
+}
+
+#[debug_handler]
+pub async fn reset_password() -> Result<Response> {
+    Ok("todo".into_response())
+}
+
+///// 404 /////
 #[debug_handler]
 pub async fn not_found() -> Result<impl IntoResponse> {
     Ok(MessageWithoutUser {
diff --git a/backend/templates/ask_reset_password.html b/backend/templates/ask_reset_password.html
new file mode 100644 (file)
index 0000000..4aa657e
--- /dev/null
@@ -0,0 +1,14 @@
+{% extends "base_with_header.html" %}
+
+{% block main_container %}
+    <div class="content">
+        <form action="/signup" method="post">
+            <label for="email_field">Your email address</label>
+            <input id="email_field" type="text" name="email" value="{{ email }}" autocapitalize="none" autocomplete="email" autofocus="autofocus" />
+            {{ message_email }}
+
+            <input type="submit" name="commit" value="Ask reset" />
+        </form>
+        {{ message }}
+    </div>
+{% endblock %}
index ea4a50d..4964b23 100644 (file)
@@ -7,9 +7,13 @@
         {% match user %}
         {% when Some with (user) %}
             <a class="create-recipe" href="/recipe/new" >Create a new recipe</a>
-            <span>{{ user.email }} / <a href="/signout" />Sign out</a></span>
+            <span><a href="/edit_profile">{{ user.email }}</a> / <a href="/signout" />Sign out</a></span>
         {% when None %}
-            <span><a href="/signin" >Sign in</a> / <a href="/signup">Sign up</a></span>
+            <span>
+                <a href="/signin" >Sign in</a>/
+                <a href="/signup">Sign up</a>/
+                <a href="/lost_password">Lost password</a>
+            </span>
         {% endmatch %}
 
     </div>