Add a way to reset password
authorGreg Burri <greg.burri@gmail.com>
Sat, 9 Nov 2024 10:22:53 +0000 (11:22 +0100)
committerGreg Burri <greg.burri@gmail.com>
Sat, 9 Nov 2024 10:22:53 +0000 (11:22 +0100)
12 files changed:
Cargo.lock
backend/Cargo.toml
backend/askama.toml [new file with mode: 0644]
backend/src/data/db.rs
backend/src/email.rs
backend/src/main.rs
backend/src/services.rs
backend/src/utils.rs
backend/templates/ask_reset_password.html
backend/templates/base_with_header.html
backend/templates/profile.html [new file with mode: 0644]
backend/templates/reset_password.html [new file with mode: 0644]

index 785972b..2e05eed 100644 (file)
@@ -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",
index a6c5bdf..18761b6 100644 (file)
@@ -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 (file)
index 0000000..019d574
--- /dev/null
@@ -0,0 +1,2 @@
+[general]
+whitespace = "suppress"
index 4412222..6694f03 100644 (file)
@@ -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<GetTokenResetPassword> {
         let mut tx = self.tx().await?;
 
-        if let Some(db_datetime) = sqlx::query_scalar::<_, Option<DateTime<Utc>>>(
+        if let Some(db_datetime_nullable) = sqlx::query_scalar::<_, Option<DateTime<Utc>>>(
             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?;
index 171555a..fb19d7b 100644 (file)
@@ -32,10 +32,9 @@ impl From<lettre::error::Error> 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());
 
index 66320c2..03e0977 100644 (file)
@@ -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(),
index 72dc2fc..8d4f41f 100644 (file)
@@ -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<u16> = 'p: {
-                    let split_port: Vec<&str> = host.split(':').collect();
-                    if split_port.len() == 2 {
-                        if let Ok(p) = split_port[1].parse::<u16>() {
-                            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<db::Connection>,
+    State(config): State<Config>,
+    Extension(user): Extension<Option<model::User>>,
+    Form(form_data): Form<AskResetPasswordForm>,
+) -> Result<Response> {
+    fn error_response(
+        error: AskResetPasswordError,
+        email: &str,
+        user: Option<model::User>,
+    ) -> Result<Response> {
+        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<model::User>,
+    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<Option<model::User>>,
+    Query(query): Query<HashMap<String, String>>,
 ) -> Result<Response> {
-    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<Response> {
-    Ok("todo".into_response())
+pub async fn reset_password_post(
+    State(connection): State<db::Connection>,
+    Extension(user): Extension<Option<model::User>>,
+    Form(form_data): Form<ResetPasswordForm>,
+) -> Result<Response> {
+    fn error_response(
+        error: ResetPasswordError,
+        form_data: &ResetPasswordForm,
+        user: Option<model::User>,
+    ) -> Result<Response> {
+        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 /////
index 3885ce9..f8715f3 100644 (file)
@@ -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<u16> = 'p: {
+        let split_port: Vec<&str> = host.split(':').collect();
+        if split_port.len() == 2 {
+            if let Ok(p) = split_port[1].parse::<u16>() {
+                break 'p Some(p);
+            }
+        }
+        None
+    };
+    format!(
+        "http{}://{}",
+        if port.is_some() && port.unwrap() != 443 {
+            ""
+        } else {
+            "s"
+        },
+        host
+    )
+}
index 4aa657e..4a5fc8d 100644 (file)
@@ -2,7 +2,7 @@
 
 {% block main_container %}
     <div class="content">
-        <form action="/signup" method="post">
+        <form action="/ask_reset_password" 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 }}
index 4964b23..8dc71bd 100644 (file)
@@ -10,9 +10,7 @@
             <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>/
-                <a href="/lost_password">Lost password</a>
+                <a href="/signin" >Sign in</a>/<a href="/signup">Sign up</a>/<a href="/ask_reset_password">Lost password</a>
             </span>
         {% endmatch %}
 
diff --git a/backend/templates/profile.html b/backend/templates/profile.html
new file mode 100644 (file)
index 0000000..f999c41
--- /dev/null
@@ -0,0 +1,23 @@
+{% extends "base_with_list.html" %}
+
+{% block content %}
+<label for="title_field">Title</label>
+<input
+    id="title_field"
+    type="text"
+    name="title"
+    value="{{ current_recipe.title }}"
+    autocapitalize="none"
+    autocomplete="title"
+    autofocus="autofocus" />
+
+<label for="description_field">Description</label>
+<input
+    id="title_field"
+    type="text"
+    name="title"
+    value="{{ current_recipe.description }}"
+    autocapitalize="none"
+    autocomplete="title"
+    autofocus="autofocus" />
+{% 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 (file)
index 0000000..e1e8429
--- /dev/null
@@ -0,0 +1,20 @@
+{% extends "base_with_header.html" %}
+
+{% block main_container %}
+    <div class="content">
+        <form action="/reset_password" method="post">
+            <label for="password_field_1">Choose a new password (minimum 8 characters)</label>
+            <input id="password_field_1" type="password" name="password_1" />
+
+            <label for="password_field_1">Re-enter password</label>
+            <input id="password_field_2" type="password" name="password_2" />
+
+            {{ message_password }}
+
+            <input type="hidden" name="reset_token" value="{{ reset_token }}" />
+
+            <input type="submit" name="commit" value="Reset password" />
+        </form>
+        {{ message }}
+    </div>
+{% endblock %}