From 751a21485fa43ffbe032dfff7f25be7dc21984bb Mon Sep 17 00:00:00 2001 From: Philippe Loctaux Date: Wed, 19 Apr 2023 18:03:38 +0200 Subject: [PATCH] forgot password: email and paper key, reset password --- Cargo.lock | 1 + Cargo.toml | 1 + .../get_one_from_password_reset_token.sql | 15 ++ .../users/set_password_reset_token.sql | 5 + crates/database/sqlx-data.json | 94 +++++++++++ crates/database/src/tables/users.rs | 28 ++++ crates/ezidam/src/guards.rs | 2 + .../ezidam/src/guards/reset_password_token.rs | 36 +++++ crates/ezidam/src/icons.rs | 8 +- crates/ezidam/src/page.rs | 10 ++ crates/ezidam/src/routes/root.rs | 16 ++ .../ezidam/src/routes/root/forgot_password.rs | 150 ++++++++++++++++++ .../ezidam/src/routes/root/reset_password.rs | 81 ++++++++++ .../templates/pages/forgot-password.html.tera | 96 +++++++++++ .../templates/pages/oauth/authorize.html.tera | 2 +- .../templates/pages/reset-password.html.tera | 56 +++++++ crates/hash/Cargo.toml | 2 +- crates/users/Cargo.toml | 1 + crates/users/src/database.rs | 29 +++- crates/users/src/lib.rs | 4 + crates/users/src/password_reset.rs | 55 +++++++ 21 files changed, 688 insertions(+), 4 deletions(-) create mode 100644 crates/database/queries/users/get_one_from_password_reset_token.sql create mode 100644 crates/database/queries/users/set_password_reset_token.sql create mode 100644 crates/ezidam/src/guards/reset_password_token.rs create mode 100644 crates/ezidam/src/routes/root/forgot_password.rs create mode 100644 crates/ezidam/src/routes/root/reset_password.rs create mode 100644 crates/ezidam/templates/pages/forgot-password.html.tera create mode 100644 crates/ezidam/templates/pages/reset-password.html.tera create mode 100644 crates/users/src/password_reset.rs diff --git a/Cargo.lock b/Cargo.lock index 374923d..d2d680d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3279,6 +3279,7 @@ dependencies = [ "chrono", "database", "email_address", + "gen_passphrase", "hash", "id", "serde", diff --git a/Cargo.toml b/Cargo.toml index a38a89b..c775884 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,6 +14,7 @@ serde_json = "1" nanoid = "0.4" nanoid-dictionary = "0.4" email_address = { version = "0.2", default-features = false } +gen_passphrase = "0.1.1" [profile.dev.package.sqlx-macros] opt-level = 3 diff --git a/crates/database/queries/users/get_one_from_password_reset_token.sql b/crates/database/queries/users/get_one_from_password_reset_token.sql new file mode 100644 index 0000000..e2ff3c7 --- /dev/null +++ b/crates/database/queries/users/get_one_from_password_reset_token.sql @@ -0,0 +1,15 @@ +select id, + created_at as "created_at: DateTime", + updated_at as "updated_at: DateTime", + is_admin as "is_admin: bool", + username, + name, + email, + password, + password_recover, + paper_key, + is_archived as "is_archived: bool", + timezone +from users + +where password_recover is (?) diff --git a/crates/database/queries/users/set_password_reset_token.sql b/crates/database/queries/users/set_password_reset_token.sql new file mode 100644 index 0000000..21cba34 --- /dev/null +++ b/crates/database/queries/users/set_password_reset_token.sql @@ -0,0 +1,5 @@ +update users + +set password_recover = ? + +where id is ? \ No newline at end of file diff --git a/crates/database/sqlx-data.json b/crates/database/sqlx-data.json index 4c076ba..69b1c18 100644 --- a/crates/database/sqlx-data.json +++ b/crates/database/sqlx-data.json @@ -110,6 +110,16 @@ }, "query": "update apps\n\nset is_archived = 1\n\nwhere id is ?" }, + "32d35bdd1f4cf64ce0ff7beb7a11591e0f35eab7211692bcde8230c68e4cedf3": { + "describe": { + "columns": [], + "nullable": [], + "parameters": { + "Right": 2 + } + }, + "query": "update users\n\nset password_recover = ?\n\nwhere id is ?" + }, "35de1a35e6cf6c683a1b2ca3605791aea9cbb852ac1d3df151cc21c341046361": { "describe": { "columns": [ @@ -284,6 +294,90 @@ }, "query": "update users\n\nset username = ?\n\nwhere id is ?" }, + "56a88e7e68cfa94a055008510e3bc4389d7a7f64b43479d5fc8e4495ade0f84a": { + "describe": { + "columns": [ + { + "name": "id", + "ordinal": 0, + "type_info": "Text" + }, + { + "name": "created_at: DateTime", + "ordinal": 1, + "type_info": "Text" + }, + { + "name": "updated_at: DateTime", + "ordinal": 2, + "type_info": "Text" + }, + { + "name": "is_admin: bool", + "ordinal": 3, + "type_info": "Int64" + }, + { + "name": "username", + "ordinal": 4, + "type_info": "Text" + }, + { + "name": "name", + "ordinal": 5, + "type_info": "Text" + }, + { + "name": "email", + "ordinal": 6, + "type_info": "Text" + }, + { + "name": "password", + "ordinal": 7, + "type_info": "Text" + }, + { + "name": "password_recover", + "ordinal": 8, + "type_info": "Text" + }, + { + "name": "paper_key", + "ordinal": 9, + "type_info": "Text" + }, + { + "name": "is_archived: bool", + "ordinal": 10, + "type_info": "Int64" + }, + { + "name": "timezone", + "ordinal": 11, + "type_info": "Text" + } + ], + "nullable": [ + false, + false, + false, + false, + false, + true, + true, + true, + true, + true, + false, + false + ], + "parameters": { + "Right": 1 + } + }, + "query": "select id,\n created_at as \"created_at: DateTime\",\n updated_at as \"updated_at: DateTime\",\n is_admin as \"is_admin: bool\",\n username,\n name,\n email,\n password,\n password_recover,\n paper_key,\n is_archived as \"is_archived: bool\",\n timezone\nfrom users\n\nwhere password_recover is (?)\n" + }, "56a9c0dff010858189a95087d014c7d0ce930da5d841b9d788a9c0e84b580bc6": { "describe": { "columns": [ diff --git a/crates/database/src/tables/users.rs b/crates/database/src/tables/users.rs index 0173cb3..46d348b 100644 --- a/crates/database/src/tables/users.rs +++ b/crates/database/src/tables/users.rs @@ -97,6 +97,20 @@ impl Users { .map_err(handle_error) } + pub async fn get_one_from_password_reset_token( + conn: impl SqliteExecutor<'_>, + token: &str, + ) -> Result, Error> { + sqlx::query_file_as!( + Self, + "queries/users/get_one_from_password_reset_token.sql", + token + ) + .fetch_optional(conn) + .await + .map_err(handle_error) + } + pub async fn get_all(conn: impl SqliteExecutor<'_>) -> Result, Error> { sqlx::query_file_as!(Self, "queries/users/get_all.sql") .fetch_all(conn) @@ -185,4 +199,18 @@ impl Users { Ok((query.rows_affected() == 1).then_some(())) } + + pub async fn set_password_reset_token( + conn: impl SqliteExecutor<'_>, + id: &str, + token: Option<&str>, + ) -> Result, Error> { + let query: SqliteQueryResult = + sqlx::query_file!("queries/users/set_password_reset_token.sql", token, id) + .execute(conn) + .await + .map_err(handle_error)?; + + Ok((query.rows_affected() == 1).then_some(())) + } } diff --git a/crates/ezidam/src/guards.rs b/crates/ezidam/src/guards.rs index bcecbfe..340d42e 100644 --- a/crates/ezidam/src/guards.rs +++ b/crates/ezidam/src/guards.rs @@ -4,6 +4,7 @@ mod completed_setup; mod jwt; mod need_setup; mod refresh_token; +mod reset_password_token; pub use self::jwt::*; pub use access_token::AccessToken; @@ -11,3 +12,4 @@ pub use basic_auth::BasicAuth; pub use completed_setup::CompletedSetup; pub use need_setup::NeedSetup; pub use refresh_token::RefreshToken; +pub use reset_password_token::RocketResetPasswordToken; \ No newline at end of file diff --git a/crates/ezidam/src/guards/reset_password_token.rs b/crates/ezidam/src/guards/reset_password_token.rs new file mode 100644 index 0000000..fef87ea --- /dev/null +++ b/crates/ezidam/src/guards/reset_password_token.rs @@ -0,0 +1,36 @@ +use rocket::form; +use rocket::form::{FromFormField, ValueField}; +use rocket::http::impl_from_uri_param_identity; +use rocket::http::uri::fmt::{Formatter, Query, UriDisplay}; +use rocket::request::FromParam; +use rocket::serde::{Deserialize, Serialize}; +use std::fmt; +use users::password_reset::{Error, PasswordResetToken}; + +#[derive(Serialize, Deserialize)] +#[serde(crate = "rocket::serde")] +pub struct RocketResetPasswordToken(pub PasswordResetToken); + +impl<'r> FromParam<'r> for RocketResetPasswordToken { + type Error = Error; + + fn from_param(param: &'r str) -> Result { + PasswordResetToken::parse(param).map(Self) + } +} + +impl UriDisplay for RocketResetPasswordToken { + fn fmt(&self, f: &mut Formatter) -> fmt::Result { + UriDisplay::fmt(&self.0.to_string(), f) + } +} +impl_from_uri_param_identity!([Query] RocketResetPasswordToken); + +#[rocket::async_trait] +impl<'r> FromFormField<'r> for RocketResetPasswordToken { + fn from_value(field: ValueField<'r>) -> form::Result<'r, Self> { + Ok(PasswordResetToken::parse(field.value) + .map(Self) + .map_err(|e| form::Error::validation(e.to_string()))?) + } +} diff --git a/crates/ezidam/src/icons.rs b/crates/ezidam/src/icons.rs index e663003..2a27661 100644 --- a/crates/ezidam/src/icons.rs +++ b/crates/ezidam/src/icons.rs @@ -40,8 +40,11 @@ impl Icon { "id-badge-2", IdBadge2, r#""#, "user", User, r#""#, "at", At, r#""#, + "paperclip", Paperclip, r#""#, "paperclip-large", PaperclipLarge, r#""#, - "users", Users, r#""# + "users", Users, r#""#, + "mail", Mail, r#""#, + "password", Password, r#""# } } @@ -62,8 +65,11 @@ pub fn icons_to_templates(tera: &mut Tera) { Icon::IdBadge2, Icon::User, Icon::At, + Icon::Paperclip, Icon::PaperclipLarge, Icon::Users, + Icon::Mail, + Icon::Password, ]; // For each icon, it will output: ("icons/name", "...") diff --git a/crates/ezidam/src/page.rs b/crates/ezidam/src/page.rs index 47f25dc..e79b178 100644 --- a/crates/ezidam/src/page.rs +++ b/crates/ezidam/src/page.rs @@ -25,6 +25,8 @@ pub enum Page { UserSecuritySettings(UserSecuritySettings), UserVisualSettings(UserVisualSettings), AdminUsersList(AdminUsersList), + ForgotPassword, + ResetPassword(ResetPassword), } impl Page { @@ -46,6 +48,8 @@ impl Page { Page::UserSecuritySettings(_) => "pages/settings/security", Page::UserVisualSettings(_) => "pages/settings/visual", Page::AdminUsersList(_) => "pages/admin/users/list", + Page::ForgotPassword => "pages/forgot-password", + Page::ResetPassword(_) => "pages/reset-password", } } @@ -67,6 +71,8 @@ impl Page { Page::UserSecuritySettings(_) => "Security settings", Page::UserVisualSettings(_) => "Visual settings", Page::AdminUsersList(_) => "Users", + Page::ForgotPassword => "Forgot password", + Page::ResetPassword(_) => "Reset password", } } @@ -90,6 +96,8 @@ impl Page { Page::UserSecuritySettings(_) => Some(UserMenu::Settings.into()), Page::UserVisualSettings(_) => Some(UserMenu::Settings.into()), Page::AdminUsersList(_) => Some(AdminMenu::Users.into()), + Page::ForgotPassword => None, + Page::ResetPassword(_) => None, } } @@ -111,6 +119,8 @@ impl Page { Page::UserSecuritySettings(security) => Box::new(security), Page::UserVisualSettings(visual) => Box::new(visual), Page::AdminUsersList(list) => Box::new(list), + Page::ForgotPassword => Box::new(()), + Page::ResetPassword(reset) => Box::new(reset), } } } diff --git a/crates/ezidam/src/routes/root.rs b/crates/ezidam/src/routes/root.rs index ddcaa92..a2bc456 100644 --- a/crates/ezidam/src/routes/root.rs +++ b/crates/ezidam/src/routes/root.rs @@ -1,11 +1,15 @@ use super::prelude::*; +use forgot_password::*; use home::*; use logo::*; use logout::*; +use reset_password::*; +pub mod forgot_password; pub mod home; pub mod logo; pub mod logout; +pub mod reset_password; pub fn routes() -> Vec { routes![ @@ -15,6 +19,11 @@ pub fn routes() -> Vec { homepage_redirect, redirect_to_setup, request_logout, + forgot_password_page, + forgot_password_email_form, + forgot_password_paper_key_form, + reset_password_page, + reset_password_form, ] } @@ -28,4 +37,11 @@ pub mod content { pub struct Homepage { pub user: JwtClaims, } + + #[derive(Serialize)] + #[serde(crate = "rocket::serde")] + #[derive(Clone)] + pub struct ResetPassword { + pub username: String, + } } diff --git a/crates/ezidam/src/routes/root/forgot_password.rs b/crates/ezidam/src/routes/root/forgot_password.rs new file mode 100644 index 0000000..2c1b6f7 --- /dev/null +++ b/crates/ezidam/src/routes/root/forgot_password.rs @@ -0,0 +1,150 @@ +use crate::routes::prelude::*; +use email_address::EmailAddress; +use hash::PaperKey; +use rocket::{get, post}; +use std::str::FromStr; +use users::{password_reset::PasswordResetToken, User}; + +#[get("/forgot-password")] +pub async fn forgot_password_page(flash: Option>) -> Template { + let page = Page::ForgotPassword; + + flash + .map(|flash| Page::with_flash(page.clone(), flash)) + .unwrap_or_else(|| page.into()) +} + +#[derive(Debug, FromForm)] +pub struct ForgotPasswordEmailForm<'r> { + pub email: &'r str, +} + +const SUCCESS_MESSAGE: &str = "An email is on the way with instructions to reset your password."; + +#[post("/forgot-password/email", data = "
")] +pub async fn forgot_password_email_form( + mut db: Connection, + form: Form>, +) -> Result> { + if form.email.is_empty() { + return Ok(Flash::new( + Redirect::to(uri!(forgot_password_page)), + FlashKind::Danger, + "Please enter an email address", + )); + } + + // Parse email address + let email = match EmailAddress::from_str(form.email) { + Ok(email) => email, + Err(e) => { + return Ok(Flash::new( + Redirect::to(uri!(forgot_password_page)), + FlashKind::Danger, + e.to_string(), + )); + } + }; + + let mut transaction = db.begin().await?; + + // Get user + let user = match User::get_by_email(&mut transaction, &email).await? { + Some(user) => user, + None => { + return Ok(Flash::new( + Redirect::to(uri!(forgot_password_page)), + FlashKind::Success, + SUCCESS_MESSAGE, + )); + } + }; + + // Generate reset token + let token = task::spawn_blocking(|| PasswordResetToken::generate(15)).await?; + + // Save in database + user.set_password_reset_token(&mut transaction, Some(&token)) + .await?; + + transaction.commit().await?; + + // TODO: send email here + + Ok(Flash::new( + Redirect::to(uri!(forgot_password_page)), + FlashKind::Success, + SUCCESS_MESSAGE, + )) +} + +#[derive(Debug, FromForm)] +pub struct ForgotPasswordPaperKeyForm<'r> { + pub login: &'r str, + pub paper_key: &'r str, +} + +#[post("/forgot-password/paper-key", data = "")] +pub async fn forgot_password_paper_key_form( + mut db: Connection, + form: Form>, +) -> Result> { + if form.login.is_empty() || form.paper_key.is_empty() { + return Ok(Flash::new( + Redirect::to(uri!(forgot_password_page)), + FlashKind::Danger, + "Please fill out the form", + )); + } + + let mut transaction = db.begin().await?; + + // Get user + let user = match User::get_by_login(&mut transaction, form.login).await? { + Some(user) => user, + None => { + return Ok(Flash::new( + Redirect::to(uri!(forgot_password_page)), + FlashKind::Danger, + "User not found", + )); + } + }; + + let paper_key = match user.paper_key_hashed() { + Some(paper_key) => PaperKey::from_hash(paper_key), + None => { + return Ok(Flash::new( + Redirect::to(uri!(forgot_password_page)), + FlashKind::Danger, + "Paper key not found", + )); + } + }; + + // Verify paper key + let paper_key_input = form.paper_key.to_string(); + if !task::spawn_blocking(move || paper_key.compare(&paper_key_input)).await?? { + return Ok(Flash::new( + Redirect::to(uri!(forgot_password_page)), + FlashKind::Danger, + "Invalid paper key", + )); + } + + // Generate reset token + let token = task::spawn_blocking(|| PasswordResetToken::generate(15)).await?; + + // Save in database + user.set_password_reset_token(&mut transaction, Some(&token)) + .await?; + + transaction.commit().await?; + + let token = RocketResetPasswordToken(token); + Ok(Flash::new( + Redirect::to(uri!(super::reset_password_page(token))), + FlashKind::Success, + SUCCESS_MESSAGE, + )) +} diff --git a/crates/ezidam/src/routes/root/reset_password.rs b/crates/ezidam/src/routes/root/reset_password.rs new file mode 100644 index 0000000..d8dc699 --- /dev/null +++ b/crates/ezidam/src/routes/root/reset_password.rs @@ -0,0 +1,81 @@ +use crate::routes::prelude::*; +use rocket::{get, post}; +use users::User; + +#[get("/reset-password?")] +pub async fn reset_password_page( + token: RocketResetPasswordToken, + mut db: Connection, + flash: Option>, +) -> Result