From 16d6542911f488a1b152fd4ef18b0e009fbed60a Mon Sep 17 00:00:00 2001 From: Jacob Conley Date: Sun, 22 Oct 2023 13:06:24 -0400 Subject: [PATCH] Beginning password reset implementation - Email send is not implemented yet - All of this is still untested, clearly --- Cargo.lock | 6 +- Cargo.toml | 2 +- crates/_types/tokens/src/prefixes.rs | 4 + crates/_types/tokens/src/tokens/mod.rs | 1 + .../tokens/src/tokens/password_reset.rs | 14 +++ crates/lib/email/Cargo.toml | 14 +++ crates/lib/email/src/lib.rs | 27 +++++ .../user/create_password_reset_request.rs | 31 ++++++ .../user/lookup_user_for_login_by_email.rs | 3 +- .../user/lookup_user_for_login_by_username.rs | 3 +- .../user/lookup_user_for_login_result.rs | 1 + .../src/queries/users/user/mod.rs | 1 + crates/service/plugins/users/Cargo.toml | 3 + .../plugins/users/src/endpoints/mod.rs | 1 + .../src/endpoints/password_reset_handler.rs | 101 ++++++++++++++++++ 15 files changed, 206 insertions(+), 6 deletions(-) create mode 100644 crates/_types/tokens/src/tokens/password_reset.rs create mode 100644 crates/lib/email/Cargo.toml create mode 100644 crates/lib/email/src/lib.rs create mode 100644 crates/lib/mysql_queries/src/queries/users/user/create_password_reset_request.rs create mode 100644 crates/service/plugins/users/src/endpoints/password_reset_handler.rs diff --git a/Cargo.lock b/Cargo.lock index 4bc447730c..fcde783143 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1178,7 +1178,7 @@ dependencies = [ name = "buckets" version = "0.0.1" dependencies = [ - "crockford_deprecated", + "crockford", "errors", ] @@ -1654,7 +1654,7 @@ dependencies = [ ] [[package]] -name = "crockford_deprecated" +name = "crockford" version = "0.0.1" dependencies = [ "rand 0.8.5", @@ -2187,7 +2187,7 @@ dependencies = [ "concurrency", "config", "container_common", - "crockford_deprecated", + "crockford", "dotenv", "easyenv", "enums", diff --git a/Cargo.toml b/Cargo.toml index fc4c582678..6470185b75 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -28,7 +28,7 @@ members = [ "crates/lib/caching/redis_caching", "crates/lib/concurrency", "crates/lib/cookies", - "crates/lib/crockford_deprecated", + "crates/lib/crockford", "crates/lib/datetimes", "crates/lib/deprecated/config", # TODO(bt, 2023-02-13): Deprecate or repurpose "crates/lib/deprecated/container_common", # TODO(bt, 2023-02-13): Pull apart and rehome pieces diff --git a/crates/_types/tokens/src/prefixes.rs b/crates/_types/tokens/src/prefixes.rs index 80c4d17ae0..ff58072deb 100644 --- a/crates/_types/tokens/src/prefixes.rs +++ b/crates/_types/tokens/src/prefixes.rs @@ -30,6 +30,8 @@ pub(crate) enum TokenPrefix { MediaFile, MediaUpload, NewsStory, // NB: aichatbot / sqlite + PasswordReset, + TtsModel, TtsRenderTask, // NB: aichatbot / sqlite User, UserSession, @@ -92,6 +94,8 @@ impl PrefixGenerator for TokenPrefix { Self::MediaFile => "m_", Self::MediaUpload => "mu_", Self::NewsStory => "news_story_", + Self::PasswordReset => "pw_reset_", + Self::TtsModel => "TM:", // NB: Old-style prefix, do not use for future tokens. Self::TtsRenderTask => "tts_task_", Self::User => "user_", // NB: Previously "U:" Self::UserSession => "session_", diff --git a/crates/_types/tokens/src/tokens/mod.rs b/crates/_types/tokens/src/tokens/mod.rs index 0bc313489f..f9ecc1b3df 100644 --- a/crates/_types/tokens/src/tokens/mod.rs +++ b/crates/_types/tokens/src/tokens/mod.rs @@ -9,6 +9,7 @@ pub mod generic_inference_jobs; pub mod media_files; pub mod media_uploads; pub mod model_categories; +pub mod password_reset; pub mod sqlite; pub mod tts_inference_jobs; pub mod tts_model_upload_jobs; diff --git a/crates/_types/tokens/src/tokens/password_reset.rs b/crates/_types/tokens/src/tokens/password_reset.rs new file mode 100644 index 0000000000..e9904c7d8e --- /dev/null +++ b/crates/_types/tokens/src/tokens/password_reset.rs @@ -0,0 +1,14 @@ +use std::fmt::Debug; + +use serde::Deserialize; +use serde::Serialize; + +use crate::prefixes::EntityType; + +/// The primary key for `password_reset`s +#[derive(Clone, PartialEq, Eq, sqlx::Type, Debug, Serialize, Deserialize)] +#[sqlx(transparent)] +pub struct PasswordResetToken(pub String); + +impl_string_token!(PasswordResetToken); +impl_crockford_generator!(PasswordResetToken, 32usize, EntityType::PasswordReset, CrockfordUpper); diff --git a/crates/lib/email/Cargo.toml b/crates/lib/email/Cargo.toml new file mode 100644 index 0000000000..e2268defb5 --- /dev/null +++ b/crates/lib/email/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "email" +version = "0.1.0" +edition = "2021" + + +[lib] +path = "src/lib.rs" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +errors = { path = "../errors" } +mail-smtp = "0.3.0" \ No newline at end of file diff --git a/crates/lib/email/src/lib.rs b/crates/lib/email/src/lib.rs new file mode 100644 index 0000000000..ee03d6e488 --- /dev/null +++ b/crates/lib/email/src/lib.rs @@ -0,0 +1,27 @@ +use errors::AnyhowResult; + +pub async fn send_password_reset(email_address: &str, secret_key: String) -> AnyhowResult<()> { + todo!(); + // use futures::Future; + // use mail_headers::{ + // headers::*, + // header_components::Domain + // }; + // use mail_core::{Mail, default_impl::simple_context}; + // use mail_smtp::{self as smtp, ConnectionConfig}; + + // let ctx = simple_context::new(Domain::from_unchecked("example.com".to_owned()), "asdkds".parse().unwrap()) + // .unwrap(); + + // let mut mail = Mail::plain_text("Some body", &ctx); + // mail.insert_headers(headers! { + // _From: ["bla@example.com"], + // _To: ["blub@example.com"], + // Subject: "Some Mail" + // }.unwrap()); + + // let con_config = ConnectionConfig::builder_local_unencrypted().build(); + + // let fut = smtp::send(mail.into(), con_config, ctx); + // let results = fut.wait(); +} \ No newline at end of file diff --git a/crates/lib/mysql_queries/src/queries/users/user/create_password_reset_request.rs b/crates/lib/mysql_queries/src/queries/users/user/create_password_reset_request.rs new file mode 100644 index 0000000000..3a8ed17a59 --- /dev/null +++ b/crates/lib/mysql_queries/src/queries/users/user/create_password_reset_request.rs @@ -0,0 +1,31 @@ +use anyhow::Context; +use sqlx::MySqlPool; + +use errors::AnyhowResult; +use tokens::tokens::password_reset::PasswordResetToken; + +use super::lookup_user_for_login_result::UserRecordForLogin; + +// todo: ip_address_redemption nullable + +pub async fn create_password_reset(pool: &MySqlPool, user: &UserRecordForLogin, ip_address: String, secret_key: String) -> AnyhowResult<()> { + let token = PasswordResetToken::generate(); + + sqlx::query!( + r#" +INSERT INTO password_resets +(token, user_token, secret_key, current_password_version, ip_address_creation, expires_at) +VALUES (?, ?, ?, ?, ?, NOW() + INTERVAL 3 hour); + "#, + + token, + user.token, + secret_key, + user.password_version, + ip_address + ) + .execute(pool) + .await + .context("inserting password reset") + .map(|_| ()) +} \ No newline at end of file diff --git a/crates/lib/mysql_queries/src/queries/users/user/lookup_user_for_login_by_email.rs b/crates/lib/mysql_queries/src/queries/users/user/lookup_user_for_login_by_email.rs index eb7d54402f..fcd3331b82 100644 --- a/crates/lib/mysql_queries/src/queries/users/user/lookup_user_for_login_by_email.rs +++ b/crates/lib/mysql_queries/src/queries/users/user/lookup_user_for_login_by_email.rs @@ -14,7 +14,8 @@ SELECT username, email_address, password_hash, - is_banned + is_banned, + password_version FROM users WHERE email_address = ? LIMIT 1 diff --git a/crates/lib/mysql_queries/src/queries/users/user/lookup_user_for_login_by_username.rs b/crates/lib/mysql_queries/src/queries/users/user/lookup_user_for_login_by_username.rs index 24b474ed15..2375dc35c9 100644 --- a/crates/lib/mysql_queries/src/queries/users/user/lookup_user_for_login_by_username.rs +++ b/crates/lib/mysql_queries/src/queries/users/user/lookup_user_for_login_by_username.rs @@ -15,7 +15,8 @@ SELECT username, email_address, password_hash, - is_banned + is_banned, + password_version FROM users WHERE username = ? LIMIT 1 diff --git a/crates/lib/mysql_queries/src/queries/users/user/lookup_user_for_login_result.rs b/crates/lib/mysql_queries/src/queries/users/user/lookup_user_for_login_result.rs index 4a998b84bd..b0cf6f8379 100644 --- a/crates/lib/mysql_queries/src/queries/users/user/lookup_user_for_login_result.rs +++ b/crates/lib/mysql_queries/src/queries/users/user/lookup_user_for_login_result.rs @@ -8,4 +8,5 @@ pub struct UserRecordForLogin { pub email_address: String, pub password_hash: Vec, pub is_banned: i8, + pub password_version: i32, } diff --git a/crates/lib/mysql_queries/src/queries/users/user/mod.rs b/crates/lib/mysql_queries/src/queries/users/user/mod.rs index ca6d450fbf..cad31ea615 100644 --- a/crates/lib/mysql_queries/src/queries/users/user/mod.rs +++ b/crates/lib/mysql_queries/src/queries/users/user/mod.rs @@ -1,4 +1,5 @@ pub mod create_account; +pub mod create_password_reset_request; pub mod get_user_token_by_username; pub mod list_users_query_builder; pub mod lookup_user_for_login_by_email; diff --git a/crates/service/plugins/users/Cargo.toml b/crates/service/plugins/users/Cargo.toml index 8e5d603ea3..fc84ffbf37 100644 --- a/crates/service/plugins/users/Cargo.toml +++ b/crates/service/plugins/users/Cargo.toml @@ -17,6 +17,7 @@ path = "src/lib.rs" # Internal container_common = { path = "../../../lib/deprecated/container_common" } cookies = { path = "../../../lib/cookies" } +email = { path = "../../../lib/email" } enums = { path = "../../../_types/enums" } errors = { path = "../../../lib/errors" } http_server_common = { path = "../../../lib/deprecated/http_server_common" } @@ -65,3 +66,5 @@ sqlx = { version = "0.6.2", features = [ "mysql", "runtime-actix-native-tls", "o # TODO: Shouldn't really be a dependency, but need to add caching for the DDoS attack. # Redis Database r2d2_redis = "0.14.0" +crockford = "1.1.2" +byteorder = "1.5.0" diff --git a/crates/service/plugins/users/src/endpoints/mod.rs b/crates/service/plugins/users/src/endpoints/mod.rs index ddbd3ba54a..87c6d8c6ea 100644 --- a/crates/service/plugins/users/src/endpoints/mod.rs +++ b/crates/service/plugins/users/src/endpoints/mod.rs @@ -3,4 +3,5 @@ pub mod edit_profile_handler; pub mod get_profile_handler; pub mod login_handler; pub mod logout_handler; +pub mod password_reset_handler; pub mod session_info_handler; \ No newline at end of file diff --git a/crates/service/plugins/users/src/endpoints/password_reset_handler.rs b/crates/service/plugins/users/src/endpoints/password_reset_handler.rs new file mode 100644 index 0000000000..6ef1bf882d --- /dev/null +++ b/crates/service/plugins/users/src/endpoints/password_reset_handler.rs @@ -0,0 +1,101 @@ +/* + ~ B R A I N S T O R M ~ + + - redeem reset request + - needs a way to identify the user because only the tuple is unique (user + key) + username or email address + - provide new password + */ + +use actix_web::{HttpRequest, HttpResponse, web}; +use byteorder::ByteOrder; +use http_server_common::request::get_request_ip::get_request_ip; +use log::warn; +use mysql_queries::queries::users::user::create_password_reset_request::create_password_reset; +use mysql_queries::queries::users::user::lookup_user_for_login_by_email::lookup_user_for_login_by_email; +use mysql_queries::queries::users::user::lookup_user_for_login_by_username::lookup_user_for_login_by_username; +use rand::RngCore; +use serde::Deserialize; +use sqlx::MySqlPool; + +/// This can be reused in login requests in the future! +#[derive(Deserialize)] +pub enum UserLogin { + Email(String), + Username(String), +} + +#[derive(Deserialize)] +pub struct PasswordResetRequest { + login: UserLogin, +} + +#[derive(Serialize)] +pub struct PasswordResetResponse {} + +#[derive(Serialize)] +pub enum PasswordResetRequestError { + NoSuchUser, + Internal, +} + +#[derive(Serialize)] +pub struct PasswordResetErrorResponse { + kind: PasswordResetRequestError, +} +impl From for PasswordResetErrorResponse { + fn from(value: PasswordResetRequestError) -> Self { + Self { kind: value } + } +} +impl From for PasswordResetErrorResponse { + fn from(value: errors::AnyhowError) -> Self { + log::error!("Internal error: {value}"); + Self { kind: PasswordResetRequestError::Internal } + } +} + +/// +/// Non-authenticated! +/// +/// - create password reset request +/// - sends email? +/// - inserts record and stuff +pub async fn password_reset_request_handler( + http_request: HttpRequest, + request: web::Json, + // session_cookie_manager: web::Data, + mysql_pool: web::Data, +) -> Result { + + let ip_address = get_request_ip(&http_request); + + let user = match &request.login { + UserLogin::Email(email) => lookup_user_for_login_by_email(&email, &mysql_pool).await, + UserLogin::Username(username) => lookup_user_for_login_by_username(&username, &mysql_pool).await, + }.map_err(|e| { + warn!("Password reset user lookup: {:?}", e); + PasswordResetRequestError::NoSuchUser + //TODO: This could be anything, not necessarily a lookup. The name is misleading 🤷🏻 + })?; + + let mut rng = rand::thread_rng(); + let mut secret_key_bytes = [0u8;40]; + // Has to fit within (4 / 3) * string length (32 chars), + // but also be a multiple of 8 so we can read them as `u64`s + // (for Crockford) + rng.fill_bytes(&mut secret_key_bytes); + + let mut secret_key = String::new(); + secret_key_bytes.chunks_exact(8) + .map(|chunk| byteorder::LittleEndian::read_u64(chunk)) + .for_each(|chunk| crockford::encode_into::(chunk, &mut secret_key)); + + + //TODO: Handle banned users, they shouldn't be able to do this + + create_password_reset(&mysql_pool, &user, ip_address, secret_key.clone()).await?; + email::send_password_reset(&user.email_address, secret_key).await?; + + Ok(HttpResponse::Ok().finish()) +} \ No newline at end of file