diff --git a/Cargo.lock b/Cargo.lock index fcde783143..bab7f29ad6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -690,6 +690,12 @@ dependencies = [ "alloc-no-stdlib", ] +[[package]] +name = "allocator-api2" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0942ffc6dcaadf03badf6e6a2d0228460359d5e34b57ccdc720b7382dfbd5ec5" + [[package]] name = "analytics-job" version = "0.0.1" @@ -1196,9 +1202,9 @@ checksum = "aaa3a8d9a1ca92e282c96a32d6511b695d7d994d1d102ba85d279f9b2756947f" [[package]] name = "byteorder" -version = "1.4.3" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "14c189c53d098945499cdfa7ecc63567cf3886b3332b312a5b4585d8d3a6a610" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" [[package]] name = "bytes" @@ -1314,6 +1320,16 @@ dependencies = [ "winapi 0.3.9", ] +[[package]] +name = "chumsky" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8eebd66744a15ded14960ab4ccdbfb51ad3b81f51f3f04a80adac98c985396c9" +dependencies = [ + "hashbrown 0.14.2", + "stacker", +] + [[package]] name = "cidr-utils" version = "0.5.11" @@ -1389,7 +1405,7 @@ version = "4.4.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf9804afaaf59a91e75b022a30fb7229a7901f60c755489cc61c9b423b836442" dependencies = [ - "heck 0.4.0", + "heck 0.4.1", "proc-macro2", "quote", "syn 2.0.26", @@ -2339,7 +2355,7 @@ dependencies = [ "serde_json", "sqlx", "storyteller_root", - "strum", + "strum 0.24.1", "tokens", "tokio 1.34.0", ] @@ -2358,6 +2374,30 @@ dependencies = [ "tokens", ] +[[package]] +name = "email-encoding" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbfb21b9878cf7a348dcb8559109aabc0ec40d69924bd706fa5149846c4fef75" +dependencies = [ + "base64 0.21.0", + "memchr", +] + +[[package]] +name = "email_address" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2153bd83ebc09db15bcbdc3e2194d901804952e3dc96967e1cd3b0c5c32d112" + +[[package]] +name = "email_sender" +version = "0.1.0" +dependencies = [ + "errors", + "lettre", +] + [[package]] name = "encoding_rs" version = "0.8.28" @@ -2388,7 +2428,7 @@ dependencies = [ "serde_json", "sqlx", "sqlx-core", - "strum", + "strum 0.24.1", "toml", ] @@ -2477,6 +2517,12 @@ dependencies = [ "instant", ] +[[package]] +name = "fastrand" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25cbce373ec4653f1a01a31e8a5e5ec0c622dc27ff9c4e6606eefef5cbbed4a5" + [[package]] name = "fdeflate" version = "0.3.0" @@ -2665,7 +2711,7 @@ version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7694489acd39452c77daa48516b894c153f192c3578d5a839b62c58099fcbf48" dependencies = [ - "fastrand", + "fastrand 1.7.0", "futures-core", "futures-io", "memchr", @@ -2857,6 +2903,16 @@ dependencies = [ "ahash 0.7.6", ] +[[package]] +name = "hashbrown" +version = "0.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f93e7192158dbcda357bdec5fb5788eebf8bbac027f3f33e719d29135ae84156" +dependencies = [ + "ahash 0.8.3", + "allocator-api2", +] + [[package]] name = "hashing" version = "0.0.1" @@ -2874,7 +2930,7 @@ version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69fe1fcf8b4278d860ad0548329f892a3631fb63f82574df68275f34cdbe0ffa" dependencies = [ - "hashbrown", + "hashbrown 0.12.3", ] [[package]] @@ -2888,9 +2944,9 @@ dependencies = [ [[package]] name = "heck" -version = "0.4.0" +version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2540771e65fc8cb83cd6e8a237f70c319bd5c29f78ed1084ba5d50eeac86f7f9" +checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" dependencies = [ "unicode-segmentation", ] @@ -3169,7 +3225,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1885e79c1fc4b10f0e172c475f458b7f7b93061064d98c3293e98c5ba0c8b399" dependencies = [ "autocfg", - "hashbrown", + "hashbrown 0.12.3", ] [[package]] @@ -3450,6 +3506,31 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "03087c2bad5e1034e8cace5926dec053fb3790248370865f5117a7d0213354c8" +[[package]] +name = "lettre" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a466bc111374ccf4d90877dba636924a2185e67e5be4b35d32043199365097b2" +dependencies = [ + "base64 0.21.0", + "chumsky", + "email-encoding", + "email_address", + "fastrand 2.0.1", + "futures-util", + "hostname", + "httpdate", + "idna 0.4.0", + "mime", + "native-tls", + "nom", + "once_cell", + "quoted_printable", + "socket2 0.5.5", + "tokio 1.34.0", + "url", +] + [[package]] name = "lexical-sort" version = "0.3.1" @@ -4194,7 +4275,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccd746e37177e1711c20dd619a1620f34f5c8b569c53590a72dedd5344d8924a" dependencies = [ "dlv-list", - "hashbrown", + "hashbrown 0.12.3", ] [[package]] @@ -4277,6 +4358,14 @@ dependencies = [ "windows-sys 0.45.0", ] +[[package]] +name = "password" +version = "0.0.1" +dependencies = [ + "bcrypt", + "errors", +] + [[package]] name = "password-hash" version = "0.4.2" @@ -4531,6 +4620,15 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "psm" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5787f7cda34e3033a72192c018bc5883100330f362ef279a8cbccfce8bb4e874" +dependencies = [ + "cc", +] + [[package]] name = "pulldown-cmark" version = "0.9.3" @@ -4587,6 +4685,12 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "quoted_printable" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79ec282e887b434b68c18fe5c121d38e72a5cf35119b59e54ec5b992ea9c8eb0" + [[package]] name = "r2d2" version = "0.8.9" @@ -5724,7 +5828,7 @@ checksum = "b850fa514dc11f2ee85be9d055c512aa866746adfacd1cb42d867d68e6a5b0d9" dependencies = [ "dotenvy", "either", - "heck 0.4.0", + "heck 0.4.1", "hex", "once_cell", "proc-macro2", @@ -5764,6 +5868,19 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3" +[[package]] +name = "stacker" +version = "0.1.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c886bd4480155fd3ef527d45e9ac8dd7118a898a46530b7b94c3e21866259fce" +dependencies = [ + "cc", + "cfg-if 1.0.0", + "libc", + "psm", + "winapi 0.3.9", +] + [[package]] name = "standback" version = "0.2.17" @@ -5863,6 +5980,7 @@ dependencies = [ "easyenv", "elasticsearch", "elasticsearch_schema", + "email_sender", "enums", "errors", "futures 0.1.31", @@ -5995,22 +6113,41 @@ version = "0.24.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "063e6045c0e62079840579a7e47a355ae92f60eb74daaf156fb1e84ba164e63f" dependencies = [ - "strum_macros", + "strum_macros 0.24.3", ] +[[package]] +name = "strum" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290d54ea6f91c969195bdbcd7442c8c2a2ba87da8bf60a7ee86a235d4bc1e125" + [[package]] name = "strum_macros" version = "0.24.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e385be0d24f186b4ce2f9982191e7101bb737312ad61c1f2f984f34bcf85d59" dependencies = [ - "heck 0.4.0", + "heck 0.4.1", "proc-macro2", "quote", "rustversion", "syn 1.0.107", ] +[[package]] +name = "strum_macros" +version = "0.25.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23dc1fa9ac9c169a78ba62f0b841814b7abae11bdd047b9c58f893439e309ea0" +dependencies = [ + "heck 0.4.1", + "proc-macro2", + "quote", + "rustversion", + "syn 2.0.26", +] + [[package]] name = "subprocess" version = "0.2.9" @@ -6290,7 +6427,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5cdb1ef4eaeeaddc8fbd371e5017057064af0911902ef36b39801f67cc6d79e4" dependencies = [ "cfg-if 1.0.0", - "fastrand", + "fastrand 1.7.0", "libc", "redox_syscall 0.2.13", "remove_dir_all", @@ -6483,7 +6620,7 @@ dependencies = [ "serde_json", "sqlx", "sqlx-core", - "strum", + "strum 0.24.1", "toml", ] @@ -7180,11 +7317,13 @@ dependencies = [ "actix-web 4.0.1", "anyhow", "base64 0.21.0", - "bcrypt", + "byteorder", "chrono", "container_common", "cookies", + "crockford", "dotenv", + "email_sender", "enums", "errors", "hmac 0.10.1", @@ -7195,6 +7334,7 @@ dependencies = [ "md-5", "mysql_queries", "once_cell", + "password", "r2d2_redis", "rand 0.8.5", "redis_caching", @@ -7207,6 +7347,8 @@ dependencies = [ "sha2 0.9.5", "sqlx", "sqlx_mysql_helpers", + "strum 0.25.0", + "strum_macros 0.25.3", "time 0.2.26", "tokens", "toml", @@ -7477,7 +7619,7 @@ dependencies = [ "serde", "serde_derive", "serde_json", - "strum", + "strum 0.24.1", "toml", ] diff --git a/Cargo.toml b/Cargo.toml index 6470185b75..de75c6d0d4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -35,6 +35,7 @@ members = [ "crates/lib/deprecated/http_server_common", # TODO(bt, 2023-03-20): Move useful pieces to actix_helpers "crates/lib/easyenv", # NB: Vendored version of https://github.com/echelon/easyenv.rs that we'll improve. "crates/lib/elasticsearch_schema", + "crates/lib/email_sender", "crates/lib/errors", "crates/lib/files/archives", "crates/lib/files/filesys", @@ -46,6 +47,7 @@ members = [ "crates/lib/jobs_common", "crates/lib/logging", "crates/lib/mysql_queries", + "crates/lib/password", "crates/lib/redis_common", "crates/lib/server_environment", "crates/lib/sqlx_mysql_helpers", diff --git a/_sql/migrations/2023-11-12-005723_alter_password_resets_rename_table/down.sql b/_sql/migrations/2023-11-12-005723_alter_password_resets_rename_table/down.sql new file mode 100644 index 0000000000..a8950d53e0 --- /dev/null +++ b/_sql/migrations/2023-11-12-005723_alter_password_resets_rename_table/down.sql @@ -0,0 +1,5 @@ +-- noinspection SqlDialectInspectionForFile +-- noinspection SqlNoDataSourceInspectionForFile +-- noinspection SqlResolveForFile + +RENAME TABLE user_password_resets TO password_resets; diff --git a/_sql/migrations/2023-11-12-005723_alter_password_resets_rename_table/up.sql b/_sql/migrations/2023-11-12-005723_alter_password_resets_rename_table/up.sql new file mode 100644 index 0000000000..ef0a5a370f --- /dev/null +++ b/_sql/migrations/2023-11-12-005723_alter_password_resets_rename_table/up.sql @@ -0,0 +1,5 @@ +-- noinspection SqlDialectInspectionForFile +-- noinspection SqlNoDataSourceInspectionForFile +-- noinspection SqlResolveForFile + +RENAME TABLE password_resets TO user_password_resets; \ No newline at end of file diff --git a/_sql/migrations/2023-11-12-005759_alter_password_resets_to_unsigned_ints/down.sql b/_sql/migrations/2023-11-12-005759_alter_password_resets_to_unsigned_ints/down.sql new file mode 100644 index 0000000000..6faef36f66 --- /dev/null +++ b/_sql/migrations/2023-11-12-005759_alter_password_resets_to_unsigned_ints/down.sql @@ -0,0 +1,11 @@ +-- noinspection SqlDialectInspectionForFile +-- noinspection SqlNoDataSourceInspectionForFile +-- noinspection SqlResolveForFile + +-- revert +ALTER TABLE user_password_resets + MODIFY COLUMN version INT NOT NULL DEFAULT 0; + +-- revert +ALTER TABLE user_password_resets + MODIFY COLUMN current_password_version INT NOT NULL DEFAULT 0; diff --git a/_sql/migrations/2023-11-12-005759_alter_password_resets_to_unsigned_ints/up.sql b/_sql/migrations/2023-11-12-005759_alter_password_resets_to_unsigned_ints/up.sql new file mode 100644 index 0000000000..3eb243cb9b --- /dev/null +++ b/_sql/migrations/2023-11-12-005759_alter_password_resets_to_unsigned_ints/up.sql @@ -0,0 +1,9 @@ +-- noinspection SqlDialectInspectionForFile +-- noinspection SqlNoDataSourceInspectionForFile +-- noinspection SqlResolveForFile + +ALTER TABLE user_password_resets + MODIFY COLUMN current_password_version INT UNSIGNED NOT NULL DEFAULT 0; + +ALTER TABLE user_password_resets + MODIFY COLUMN version INT UNSIGNED NOT NULL DEFAULT 0; diff --git a/_sql/migrations/2023-11-12-005851_alter_users_to_unsigned_ints/down.sql b/_sql/migrations/2023-11-12-005851_alter_users_to_unsigned_ints/down.sql new file mode 100644 index 0000000000..7d46ba9b47 --- /dev/null +++ b/_sql/migrations/2023-11-12-005851_alter_users_to_unsigned_ints/down.sql @@ -0,0 +1,11 @@ +-- noinspection SqlDialectInspectionForFile +-- noinspection SqlNoDataSourceInspectionForFile +-- noinspection SqlResolveForFile + +-- revert +ALTER TABLE users + MODIFY COLUMN password_version INT NOT NULL DEFAULT 0; + +-- revert +ALTER TABLE users + MODIFY COLUMN version INT NOT NULL DEFAULT 0; diff --git a/_sql/migrations/2023-11-12-005851_alter_users_to_unsigned_ints/up.sql b/_sql/migrations/2023-11-12-005851_alter_users_to_unsigned_ints/up.sql new file mode 100644 index 0000000000..9e3521bf2d --- /dev/null +++ b/_sql/migrations/2023-11-12-005851_alter_users_to_unsigned_ints/up.sql @@ -0,0 +1,10 @@ +-- noinspection SqlDialectInspectionForFile +-- noinspection SqlNoDataSourceInspectionForFile +-- noinspection SqlResolveForFile + +ALTER TABLE users + MODIFY COLUMN password_version INT UNSIGNED NOT NULL DEFAULT 0; + +ALTER TABLE users + MODIFY COLUMN version INT UNSIGNED NOT NULL DEFAULT 0; + diff --git a/_sql/migrations/2023-11-12-010940_alter_password_resets_secret_field_name/down.sql b/_sql/migrations/2023-11-12-010940_alter_password_resets_secret_field_name/down.sql new file mode 100644 index 0000000000..67b5870d98 --- /dev/null +++ b/_sql/migrations/2023-11-12-010940_alter_password_resets_secret_field_name/down.sql @@ -0,0 +1,6 @@ +-- noinspection SqlDialectInspectionForFile +-- noinspection SqlNoDataSourceInspectionForFile +-- noinspection SqlResolveForFile + +ALTER TABLE user_password_resets + RENAME COLUMN public_reset_token TO secret_key; diff --git a/_sql/migrations/2023-11-12-010940_alter_password_resets_secret_field_name/up.sql b/_sql/migrations/2023-11-12-010940_alter_password_resets_secret_field_name/up.sql new file mode 100644 index 0000000000..35ddb56db2 --- /dev/null +++ b/_sql/migrations/2023-11-12-010940_alter_password_resets_secret_field_name/up.sql @@ -0,0 +1,6 @@ +-- noinspection SqlDialectInspectionForFile +-- noinspection SqlNoDataSourceInspectionForFile +-- noinspection SqlResolveForFile + +ALTER TABLE user_password_resets + RENAME COLUMN secret_key TO public_reset_token; diff --git a/_sql/migrations/2023-11-12-010954_alter_password_resets_change_composite_key/down.sql b/_sql/migrations/2023-11-12-010954_alter_password_resets_change_composite_key/down.sql new file mode 100644 index 0000000000..f0978a6636 --- /dev/null +++ b/_sql/migrations/2023-11-12-010954_alter_password_resets_change_composite_key/down.sql @@ -0,0 +1,12 @@ +-- noinspection SqlDialectInspectionForFile +-- noinspection SqlNoDataSourceInspectionForFile +-- noinspection SqlResolveForFile + +ALTER TABLE user_password_resets + DROP INDEX public_reset_token; + +ALTER TABLE user_password_resets + ADD UNIQUE INDEX unique_user_token_and_secret_key (user_token, public_reset_token); + +ALTER TABLE user_password_resets + ADD INDEX index_secret_key (public_reset_token); diff --git a/_sql/migrations/2023-11-12-010954_alter_password_resets_change_composite_key/up.sql b/_sql/migrations/2023-11-12-010954_alter_password_resets_change_composite_key/up.sql new file mode 100644 index 0000000000..17dd2f91a0 --- /dev/null +++ b/_sql/migrations/2023-11-12-010954_alter_password_resets_change_composite_key/up.sql @@ -0,0 +1,12 @@ +-- noinspection SqlDialectInspectionForFile +-- noinspection SqlNoDataSourceInspectionForFile +-- noinspection SqlResolveForFile + +ALTER TABLE user_password_resets + DROP INDEX unique_user_token_and_secret_key; + +ALTER TABLE user_password_resets + ADD UNIQUE INDEX public_reset_token (public_reset_token); + +ALTER TABLE user_password_resets + DROP INDEX index_secret_key; diff --git a/_sql/migrations_squashed/user_tables/password_resets.sql b/_sql/migrations_squashed/user_tables/user_password_resets.sql similarity index 90% rename from _sql/migrations_squashed/user_tables/password_resets.sql rename to _sql/migrations_squashed/user_tables/user_password_resets.sql index 1be17e0f53..37ac3ea572 100644 --- a/_sql/migrations_squashed/user_tables/password_resets.sql +++ b/_sql/migrations_squashed/user_tables/user_password_resets.sql @@ -16,7 +16,7 @@ -- Also when creating the password hash, be sure to set `expires_at` to be "NOW() + 3 hours", or -- some other small, reasonable time frame. -- -CREATE TABLE password_resets ( +CREATE TABLE user_password_resets ( -- Not used for anything except replication. id BIGINT(20) NOT NULL AUTO_INCREMENT, @@ -29,12 +29,12 @@ CREATE TABLE password_resets ( -- Secret "key" for the password reset -- This will be shared via URL or user text input. - secret_key VARCHAR(32) NOT NULL, + public_reset_token VARCHAR(32) NOT NULL, -- Copied from the user record at the time of password reset issuance. -- If the user's password version is greater than this value, then this -- reset is no longer valid. - current_password_version INT NOT NULL DEFAULT 0, + current_password_version INT UNSIGNED NOT NULL DEFAULT 0, -- Whether the password reset has been consumed. is_redeemed BOOLEAN NOT NULL DEFAULT false, @@ -45,7 +45,7 @@ CREATE TABLE password_resets ( ip_address_redemption VARCHAR(40) DEFAULT NULL, -- Incremented with every update. - version INT NOT NULL DEFAULT 0, + version INT UNSIGNED NOT NULL DEFAULT 0, -- ========== TIMESTAMPS ========== @@ -58,8 +58,7 @@ CREATE TABLE password_resets ( -- INDICES -- PRIMARY KEY (id), UNIQUE KEY (token), - - UNIQUE KEY (user_token, secret_key), + UNIQUE KEY (public_reset_token), KEY fk_user_token (user_token), KEY index_secret_key (secret_key), diff --git a/_sql/migrations_squashed/user_tables/users.sql b/_sql/migrations_squashed/user_tables/users.sql index 124a5b8b43..43b6805adb 100644 --- a/_sql/migrations_squashed/user_tables/users.sql +++ b/_sql/migrations_squashed/user_tables/users.sql @@ -41,7 +41,7 @@ CREATE TABLE users ( password_hash BINARY(60) NOT NULL, -- Incremented with every update to the password. - password_version INT NOT NULL DEFAULT 0, + password_version INT UNSIGNED NOT NULL DEFAULT 0, -- ========== ABUSE TRACKING ========== @@ -145,7 +145,7 @@ CREATE TABLE users ( -- ========== VECTOR CLOCK ========== -- Incremented with every update. - version INT NOT NULL DEFAULT 0, + version INT UNSIGNED NOT NULL DEFAULT 0, -- ========== TIMESTAMPS ========== diff --git a/crates/_types/tokens/src/prefixes.rs b/crates/_types/tokens/src/prefixes.rs index 80c4d17ae0..991a42ec3e 100644 --- a/crates/_types/tokens/src/prefixes.rs +++ b/crates/_types/tokens/src/prefixes.rs @@ -30,6 +30,7 @@ pub(crate) enum TokenPrefix { MediaFile, MediaUpload, NewsStory, // NB: aichatbot / sqlite + PasswordReset, TtsRenderTask, // NB: aichatbot / sqlite User, UserSession, @@ -92,6 +93,7 @@ impl PrefixGenerator for TokenPrefix { Self::MediaFile => "m_", Self::MediaUpload => "mu_", Self::NewsStory => "news_story_", + Self::PasswordReset => "pw_reset_", Self::TtsRenderTask => "tts_task_", Self::User => "user_", // NB: Previously "U:" Self::UserSession => "session_", @@ -288,7 +290,7 @@ mod tests { #[test] fn test_all_prefixes_end_with_separator_length_one() { for prefix in get_all_prefixes().iter().map(|s| *s) { - if prefix == "news_story_" || prefix == "tts_task_" || prefix == "INT_API:" { + if prefix == "news_story_" || prefix == "tts_task_" || prefix == "INT_API:" || prefix == "pw_reset_" { // TODO/FIXME: I'm too tired at 5AM to replacen from the left. Make this test valid. // These tokens are from the AIChatBot sidecar, so asserting their validity is less important. continue; 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..fb84250ec1 --- /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::TokenPrefix; + +/// 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, TokenPrefix::PasswordReset, CrockfordMixed); diff --git a/crates/lib/email_sender/Cargo.toml b/crates/lib/email_sender/Cargo.toml new file mode 100644 index 0000000000..8f2287d989 --- /dev/null +++ b/crates/lib/email_sender/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "email_sender" +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" } +lettre = "0.11.0" diff --git a/crates/lib/email_sender/src/lib.rs b/crates/lib/email_sender/src/lib.rs new file mode 100644 index 0000000000..a9894c3ce0 --- /dev/null +++ b/crates/lib/email_sender/src/lib.rs @@ -0,0 +1,13 @@ +//! email_sender +//! +//! Provide SMTP and other email sending capabilities to the rest of our services. +//! + +/// Public re-exports +pub mod letter_exports { + pub use lettre::Message; + pub use lettre::message::header::ContentType; + pub use lettre::message::Mailbox; +} + +pub mod smtp_email_sender; diff --git a/crates/lib/email_sender/src/smtp_email_sender.rs b/crates/lib/email_sender/src/smtp_email_sender.rs new file mode 100644 index 0000000000..f2bf2ab445 --- /dev/null +++ b/crates/lib/email_sender/src/smtp_email_sender.rs @@ -0,0 +1,33 @@ +use lettre::{Message, SmtpTransport, Transport}; +use lettre::transport::smtp::authentication::Credentials; +use lettre::transport::smtp::response::Response; + +use errors::AnyhowResult; + +#[derive(Clone)] +pub struct SmtpEmailSender { + mailer: SmtpTransport, +} + +impl SmtpEmailSender { + + // NB: To set this up for Gmail: + // + // - https://support.google.com/a/answer/2956491?hl=en&fl=1&sjid=8333642123536524043-NA + // + // "Less secure apps" setting, which must be enabled at the organization level and within the + // individual user account: + // - https://support.google.com/mail/thread/5621336/bad-credentials-using-gmail-smtp?hl=en + // + pub fn new(relay: &str, username: String, password: String) -> AnyhowResult { + Ok(Self { + mailer: SmtpTransport::relay(relay)? + .credentials(Credentials::new(username, password)) + .build() + }) + } + + pub fn send_message(&self, message: &Message) -> AnyhowResult { + Ok(self.mailer.send(message)?) + } +} diff --git a/crates/lib/mysql_queries/src/queries/users/mod.rs b/crates/lib/mysql_queries/src/queries/users/mod.rs index 57802a1b2b..1b5e04bc70 100644 --- a/crates/lib/mysql_queries/src/queries/users/mod.rs +++ b/crates/lib/mysql_queries/src/queries/users/mod.rs @@ -1,5 +1,6 @@ pub mod user; pub mod user_badges; +pub mod user_password_resets; pub mod user_profiles; pub mod user_ratings; pub mod user_roles; 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..ad473e162a 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,6 +14,7 @@ SELECT username, email_address, password_hash, + password_version, is_banned FROM users WHERE email_address = ? 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..13a1ffba20 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,6 +15,7 @@ SELECT username, email_address, password_hash, + password_version, is_banned FROM users WHERE username = ? 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..b495304348 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 @@ -7,5 +7,6 @@ pub struct UserRecordForLogin { pub username: String, pub email_address: String, pub password_hash: Vec, + pub password_version: u32, pub is_banned: i8, } diff --git a/crates/lib/mysql_queries/src/queries/users/user_password_resets/change_password_from_password_reset.rs b/crates/lib/mysql_queries/src/queries/users/user_password_resets/change_password_from_password_reset.rs new file mode 100644 index 0000000000..a28bc3687d --- /dev/null +++ b/crates/lib/mysql_queries/src/queries/users/user_password_resets/change_password_from_password_reset.rs @@ -0,0 +1,70 @@ +use anyhow::anyhow; +use sqlx::{MySql, Transaction}; + +use errors::AnyhowResult; +use tokens::tokens::password_reset::PasswordResetToken; +use tokens::tokens::users::UserToken; + +pub struct ChangePasswordFromPasswordResetArgs<'a, 'b> { + pub password_reset_token: &'a PasswordResetToken, + pub user_token: &'a UserToken, + pub new_password_hash: &'a str, + pub ip_address: &'a str, + pub mysql_transaction: Transaction<'b, MySql>, +} + +pub async fn change_password_from_password_reset<'a, 'b>( + mut args: ChangePasswordFromPasswordResetArgs<'a, 'b> +) -> AnyhowResult<()> { + + let query = sqlx::query!( + r#" +UPDATE users +SET + password_hash = ?, + ip_address_last_update = ?, + password_version = password_version + 1, + version = version + 1 +WHERE + token = ? +LIMIT 1 + "#, + args.new_password_hash, + args.ip_address, + args.user_token, + ); + + let query_result = query.execute(&mut args.mysql_transaction).await; + + if let Err(err) = query_result { + let _r = args.mysql_transaction.rollback().await.ok(); + return Err(anyhow!("Error updating user password from password reset request: {err}")); + } + + let query = sqlx::query!( + r#" +UPDATE user_password_resets +SET + is_redeemed = true, + ip_address_redemption = ?, + version = version + 1 +WHERE + token = ? +LIMIT 1 + "#, + args.ip_address, + args.password_reset_token, + ); + + let query_result = query.execute(&mut args.mysql_transaction).await; + + if let Err(err) = query_result { + let _r = args.mysql_transaction.rollback().await.ok(); + return Err(anyhow!("Error updating user password from password reset request: {err}")); + } + + match args.mysql_transaction.commit().await { + Ok(_) => Ok(()), + Err(err) => Err(anyhow!("Error committing password reset changes: {err}")), + } +} diff --git a/crates/lib/mysql_queries/src/queries/users/user_password_resets/create_password_reset_request.rs b/crates/lib/mysql_queries/src/queries/users/user_password_resets/create_password_reset_request.rs new file mode 100644 index 0000000000..c534c49803 --- /dev/null +++ b/crates/lib/mysql_queries/src/queries/users/user_password_resets/create_password_reset_request.rs @@ -0,0 +1,29 @@ +use anyhow::Context; +use sqlx::MySqlPool; + +use errors::AnyhowResult; +use tokens::tokens::password_reset::PasswordResetToken; + +use crate::queries::users::user::lookup_user_for_login_result::UserRecordForLogin; + +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 user_password_resets +(token, user_token, public_reset_token, 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(|_| ()) +} diff --git a/crates/lib/mysql_queries/src/queries/users/user_password_resets/lookup_password_reset_request.rs b/crates/lib/mysql_queries/src/queries/users/user_password_resets/lookup_password_reset_request.rs new file mode 100644 index 0000000000..4e3d9e6b18 --- /dev/null +++ b/crates/lib/mysql_queries/src/queries/users/user_password_resets/lookup_password_reset_request.rs @@ -0,0 +1,55 @@ +use anyhow::anyhow; +use sqlx::{Error, MySql, MySqlPool, Transaction}; + +use errors::AnyhowResult; +use tokens::tokens::password_reset::PasswordResetToken; +use tokens::tokens::users::UserToken; + +pub struct PasswordResetStateAndTransaction <'a> { + pub transaction: Transaction<'a, MySql>, + pub reset_state: PasswordResetLookupInfo, +} + +pub struct PasswordResetLookupInfo { + pub password_reset_token: PasswordResetToken, + pub user_token: UserToken, +} + +/// Begin a lookup transaction. +/// `select for update` ensures the record won't change, blocking race conditions. +pub async fn lookup_password_reset_request<'a>(password_reset_token: &str, pool: &'a MySqlPool) -> AnyhowResult>> { + let mut transaction = pool.begin().await?; + + let result = sqlx::query_as!( + PasswordResetLookupInfo, + r#" +SELECT + pw.token as `password_reset_token: tokens::tokens::password_reset::PasswordResetToken`, + pw.user_token as `user_token: tokens::tokens::users::UserToken` +FROM user_password_resets AS pw +JOIN users AS u + ON pw.user_token = u.token +WHERE public_reset_token = ? + AND pw.expires_at > NOW() + AND pw.current_password_version = u.password_version + AND NOT pw.is_redeemed + AND NOT u.is_banned +LIMIT 1 +FOR UPDATE + "#, + password_reset_token, + ) + .fetch_one(&mut transaction) + .await; + + match result { + Ok(record) => Ok(Some(PasswordResetStateAndTransaction { + transaction, + reset_state: record, + })), + Err(err) => match err { + Error::RowNotFound => Ok(None), + _ => Err(anyhow!(err)), + }, + } +} diff --git a/crates/lib/mysql_queries/src/queries/users/user_password_resets/mod.rs b/crates/lib/mysql_queries/src/queries/users/user_password_resets/mod.rs new file mode 100644 index 0000000000..4deb8ed56f --- /dev/null +++ b/crates/lib/mysql_queries/src/queries/users/user_password_resets/mod.rs @@ -0,0 +1,3 @@ +pub mod change_password_from_password_reset; +pub mod create_password_reset_request; +pub mod lookup_password_reset_request; diff --git a/crates/lib/password/Cargo.toml b/crates/lib/password/Cargo.toml new file mode 100644 index 0000000000..6838578472 --- /dev/null +++ b/crates/lib/password/Cargo.toml @@ -0,0 +1,27 @@ +[package] +name = "password" +edition = "2018" +version = "0.0.1" +authors = [ + "Brandon Thomas ", + "Brandon Thomas ", +] +publish = false + +[lib] +# NB: Public "password" crate is squatted for over eight years +name = "password" +path = "src/lib.rs" + +[dependencies] +# Internal +errors = { path = "../errors" } + +# External +bcrypt = "0.8" # password hashes +#hmac = "0.10.0" # jwt signing +#jwt = "0.13.0" +#sha2 = "0.9.5" # hmac signing + +[dev-dependencies] +# None yet diff --git a/crates/lib/password/src/bcrypt_confirm_password.rs b/crates/lib/password/src/bcrypt_confirm_password.rs new file mode 100644 index 0000000000..1319e0f4fc --- /dev/null +++ b/crates/lib/password/src/bcrypt_confirm_password.rs @@ -0,0 +1,28 @@ +use errors::AnyhowResult; + +pub fn bcrypt_confirm_password(password: String, bcrypt_hash: &str) -> AnyhowResult { + let verified = bcrypt::verify(&password, &bcrypt_hash)?; + Ok(verified) +} + +#[cfg(test)] +mod tests { + use crate::bcrypt_confirm_password::bcrypt_confirm_password; + + #[test] + fn test_confirmation() { + // NB: This is to make sure we don't migrate the algorithm to a state where it won't work anymore. + // NB: Be mindful that these are expensive to compute and don't add too many test cases. + let hash_to_passwords = [ + ("$2b$12$ieU6.dygzbiZjtED5Qz8Vu8fSjDjE9R4AykzpGI5hL666AjV753Iu", "password"), + ("$2b$12$nGHAaxDCXj0oC/C5vbnhAOQhZHprKmJ8eTnX.uVKXvQNFildSAIAa", "hunter2"), + ("$2b$12$USIc/tDDOobgW96sBdw13u2Ts9bmddMHDrrixBdwASxur2h4oy8iC", "hunter2"), // hunter2 again + ("$2b$12$9418BXjnbbJwfu1YFe4r3OOsePo6kflnqypfOFnA1QsID06mqn3X.", "&*KA@jh2l9*2!!2"), + ]; + + for (hash, password) in hash_to_passwords { + let valid = bcrypt_confirm_password(password.to_string(), hash).unwrap(); + assert!(valid); + } + } +} \ No newline at end of file diff --git a/crates/lib/password/src/bcrypt_hash_password.rs b/crates/lib/password/src/bcrypt_hash_password.rs new file mode 100644 index 0000000000..9e0c11a051 --- /dev/null +++ b/crates/lib/password/src/bcrypt_hash_password.rs @@ -0,0 +1,19 @@ +use errors::AnyhowResult; + +pub fn bcrypt_hash_password(password: String) -> AnyhowResult { + let hash = bcrypt::hash(&password, bcrypt::DEFAULT_COST)?; + Ok(hash) +} + +#[cfg(test)] +mod tests { + use crate::bcrypt_hash_password::bcrypt_hash_password; + + #[test] + fn test_password_hash_round_trip() { + let password = "password"; + let hash = bcrypt_hash_password(password.to_string()).unwrap(); + let valid = bcrypt::verify(password, &hash).unwrap(); + assert!(valid); + } +} diff --git a/crates/lib/password/src/lib.rs b/crates/lib/password/src/lib.rs new file mode 100644 index 0000000000..045836a245 --- /dev/null +++ b/crates/lib/password/src/lib.rs @@ -0,0 +1,24 @@ +//! password +//! +//! Password-related functions +//! + +// Never allow these +#![forbid(private_in_public)] +#![forbid(unused_must_use)] // NB: It's unsafe to not close/check some things + +// Okay to toggle +#![forbid(unreachable_patterns)] +#![forbid(unused_imports)] +#![forbid(unused_mut)] +#![forbid(unused_variables)] + +// Always allow +#![allow(dead_code)] +#![allow(non_snake_case)] + +// TODO(bt,2023-11-11): Start using a more modern / safer password hashing algorithm such as Argon2 or scrypt +// https://medium.com/@mpreziuso/password-hashing-pbkdf2-scrypt-bcrypt-and-argon2-e25aaf41598e +// https://stytch.com/blog/argon2-vs-bcrypt-vs-scrypt/ +pub mod bcrypt_hash_password; +pub mod bcrypt_confirm_password; diff --git a/crates/service/job/download_job/src/job_types/voice_conversion/rvc_v2/process_rvc_v2_model.rs b/crates/service/job/download_job/src/job_types/voice_conversion/rvc_v2/process_rvc_v2_model.rs index d09be8287b..fc54d31f58 100644 --- a/crates/service/job/download_job/src/job_types/voice_conversion/rvc_v2/process_rvc_v2_model.rs +++ b/crates/service/job/download_job/src/job_types/voice_conversion/rvc_v2/process_rvc_v2_model.rs @@ -183,7 +183,7 @@ pub async fn process_rvc_v2_model<'a, 'b>( //#[cfg(test)] //mod tests { -// use crockford::crockford_entropy_lower; +// use crockford_deprecated::crockford_entropy_lower; // // #[test] // fn temp() { diff --git a/crates/service/plugins/users/Cargo.toml b/crates/service/plugins/users/Cargo.toml index 8e5d603ea3..840148a663 100644 --- a/crates/service/plugins/users/Cargo.toml +++ b/crates/service/plugins/users/Cargo.toml @@ -17,10 +17,13 @@ path = "src/lib.rs" # Internal container_common = { path = "../../../lib/deprecated/container_common" } cookies = { path = "../../../lib/cookies" } +crockford = { path = "../../../lib/crockford" } +email_sender = { path = "../../../lib/email_sender" } enums = { path = "../../../_types/enums" } errors = { path = "../../../lib/errors" } http_server_common = { path = "../../../lib/deprecated/http_server_common" } mysql_queries = { path = "../../../lib/mysql_queries" } +password = { path = "../../../lib/password" } redis_caching = { path = "../../../lib/caching/redis_caching" } redis_common = { path = "../../../lib/redis_common" } reusable_types = { path = "../../../_types/reusable_types" } @@ -45,7 +48,6 @@ actix-web = "4.0.0-beta.10" jwt = "0.13.0" # Crypto -bcrypt = "0.8" # password hashes hmac = "0.10.0" # cookies md-5 = "0.9.1" # gravatar sha2 = "0.9.5" # file hashes, inference text hashes, etc. @@ -65,3 +67,6 @@ 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" +byteorder = "1.5.0" +strum = "0.25.0" +strum_macros = "0.25.3" diff --git a/crates/service/plugins/users/src/default_routes.rs b/crates/service/plugins/users/src/default_routes.rs index 10adedc13d..fe05cf9d69 100644 --- a/crates/service/plugins/users/src/default_routes.rs +++ b/crates/service/plugins/users/src/default_routes.rs @@ -7,6 +7,8 @@ use actix_web::error::Error; use crate::endpoints::create_account_handler::create_account_handler; use crate::endpoints::login_handler::login_handler; use crate::endpoints::logout_handler::logout_handler; +use crate::endpoints::password_reset_redeem_handler::password_reset_redeem_handler; +use crate::endpoints::password_reset_request_handler::password_reset_request_handler; use crate::endpoints::session_info_handler::session_info_handler; // NB: This does not include the user edit endpoints since FakeYou's API mounts them alongside other things. @@ -25,6 +27,7 @@ pub fn add_suggested_api_v1_account_creation_and_session_routes (app: App< { app .service( + // TODO(bt,2022-11-16): non-/v1/ endpoints are deprecated and subject for future removal web::resource("/create_account") .route(web::post().to(create_account_handler)) .route(web::head().to(|| HttpResponse::Ok())) @@ -35,6 +38,7 @@ pub fn add_suggested_api_v1_account_creation_and_session_routes (app: App< .route(web::head().to(|| HttpResponse::Ok())) ) .service( + // TODO(bt,2022-11-16): non-/v1/ endpoints are deprecated and subject for future removal web::resource("/login") .route(web::post().to(login_handler)) .route(web::head().to(|| HttpResponse::Ok())) @@ -45,6 +49,7 @@ pub fn add_suggested_api_v1_account_creation_and_session_routes (app: App< .route(web::head().to(|| HttpResponse::Ok())) ) .service( + // TODO(bt,2022-11-16): non-/v1/ endpoints are deprecated and subject for future removal web::resource("/logout") .route(web::post().to(logout_handler)) .route(web::head().to(|| HttpResponse::Ok())) @@ -55,6 +60,7 @@ pub fn add_suggested_api_v1_account_creation_and_session_routes (app: App< .route(web::head().to(|| HttpResponse::Ok())) ) .service( + // TODO(bt,2022-11-16): non-/v1/ endpoints are deprecated and subject for future removal web::resource("/session") .route(web::get().to(session_info_handler)) .route(web::head().to(|| HttpResponse::Ok())) @@ -64,4 +70,14 @@ pub fn add_suggested_api_v1_account_creation_and_session_routes (app: App< .route(web::get().to(session_info_handler)) .route(web::head().to(|| HttpResponse::Ok())) ) + .service( + web::resource("/v1/password_reset/request") + .route(web::post().to(password_reset_request_handler)) + .route(web::head().to(|| HttpResponse::Ok())) + ) + .service( + web::resource("/v1/password_reset/redeem") + .route(web::post().to(password_reset_redeem_handler)) + .route(web::head().to(|| HttpResponse::Ok())) + ) } diff --git a/crates/service/plugins/users/src/endpoints/create_account_handler.rs b/crates/service/plugins/users/src/endpoints/create_account_handler.rs index bf9f1d9a24..a0ea2a2ccf 100644 --- a/crates/service/plugins/users/src/endpoints/create_account_handler.rs +++ b/crates/service/plugins/users/src/endpoints/create_account_handler.rs @@ -18,6 +18,7 @@ use http_server_common::response::serialize_as_json_error::serialize_as_json_err use mysql_queries::mediators::firehose_publisher::FirehosePublisher; use mysql_queries::queries::users::user::create_account::{create_account, CreateAccountArgs, CreateAccountError}; use mysql_queries::queries::users::user_sessions::create_user_session::create_user_session; +use password::bcrypt_hash_password::bcrypt_hash_password; use user_input_common::check_for_slurs::contains_slurs; use crate::cookies::session::session_cookie_manager::SessionCookieManager; @@ -132,7 +133,7 @@ pub async fn create_account_handler( }); } - let password_hash = match bcrypt::hash(&request.password, bcrypt::DEFAULT_COST) { + let password_hash = match bcrypt_hash_password(request.password.clone()) { Ok(hash) => hash, Err(err) => { warn!("Bcrypt error: {:?}", err); diff --git a/crates/service/plugins/users/src/endpoints/login_handler.rs b/crates/service/plugins/users/src/endpoints/login_handler.rs index 5953d61db5..b353769e5a 100644 --- a/crates/service/plugins/users/src/endpoints/login_handler.rs +++ b/crates/service/plugins/users/src/endpoints/login_handler.rs @@ -18,6 +18,7 @@ use mysql_queries::helpers::boolean_converters::i8_to_bool; 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 mysql_queries::queries::users::user_sessions::create_user_session::create_user_session; +use password::bcrypt_confirm_password::bcrypt_confirm_password; use crate::cookies::session::session_cookie_manager::SessionCookieManager; @@ -123,7 +124,7 @@ pub async fn login_handler( } }; - match bcrypt::verify(&request.password, &actual_hash) { + match bcrypt_confirm_password(request.password.clone(), &actual_hash) { Err(e) => { warn!("Login hash comparison error: {:?}", e); return Err(LoginErrorResponse::server_error()); @@ -135,7 +136,7 @@ pub async fn login_handler( } // Good to go...! }, - }; + } let ip_address = get_request_ip(&http_request); diff --git a/crates/service/plugins/users/src/endpoints/mod.rs b/crates/service/plugins/users/src/endpoints/mod.rs index ddbd3ba54a..e92d7130d5 100644 --- a/crates/service/plugins/users/src/endpoints/mod.rs +++ b/crates/service/plugins/users/src/endpoints/mod.rs @@ -3,4 +3,6 @@ pub mod edit_profile_handler; pub mod get_profile_handler; pub mod login_handler; pub mod logout_handler; +pub mod password_reset_redeem_handler; +pub mod password_reset_request_handler; pub mod session_info_handler; \ No newline at end of file diff --git a/crates/service/plugins/users/src/endpoints/password_reset_redeem_handler.rs b/crates/service/plugins/users/src/endpoints/password_reset_redeem_handler.rs new file mode 100644 index 0000000000..9afd3daa73 --- /dev/null +++ b/crates/service/plugins/users/src/endpoints/password_reset_redeem_handler.rs @@ -0,0 +1,150 @@ +use std::fmt::Display; + +use actix_web::{HttpRequest, HttpResponse, ResponseError, web}; +use actix_web::http::StatusCode; +use log::{error, warn}; +use serde::Deserialize; +use sqlx::MySqlPool; +use strum_macros::Display; + +use http_server_common::request::get_request_ip::get_request_ip; +use http_server_common::response::serialize_as_json_error::serialize_as_json_error; +use mysql_queries::queries::users::user_password_resets::change_password_from_password_reset::{change_password_from_password_reset, ChangePasswordFromPasswordResetArgs}; +use mysql_queries::queries::users::user_password_resets::lookup_password_reset_request::lookup_password_reset_request; +use password::bcrypt_hash_password::bcrypt_hash_password; + +#[derive(Deserialize)] +pub struct PasswordResetRedemptionRequest { + username_or_email: String, + reset_token: String, + new_password: String, + new_password_validation: String, +} + +#[derive(Serialize)] +pub struct PasswordResetRedemptionResponse { + success: bool, +} + +#[derive(Serialize, Debug, Display)] +pub enum PasswordResetRedemptionError { + /// Account does not exist or reset token is wrong. + InvalidRedemption, + PasswordsDoNotMatch, + Internal, +} + +#[derive(Serialize, Debug)] +pub struct PasswordResetRedemptionErrorResponse { + kind: PasswordResetRedemptionError, +} +impl Display for PasswordResetRedemptionErrorResponse { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{:?}", self) + } +} + +impl From for PasswordResetRedemptionErrorResponse { + fn from(value: PasswordResetRedemptionError) -> Self { + Self { kind: value } + } +} +impl From for PasswordResetRedemptionErrorResponse { + fn from(value: errors::AnyhowError) -> Self { + log::error!("Internal error: {value}"); + Self { kind: PasswordResetRedemptionError::Internal } + } +} + +impl ResponseError for PasswordResetRedemptionErrorResponse { + //TODO: Yknow, clean this up and stuff + fn status_code(&self) -> StatusCode { + StatusCode::INTERNAL_SERVER_ERROR + } + + fn error_response(&self) -> HttpResponse { + serialize_as_json_error(self) + } + } + +pub async fn password_reset_redeem_handler( + http_request: HttpRequest, + request: web::Json, + mysql_pool: web::Data, +) -> Result { + + let new_password = request.new_password.trim(); + + if new_password != request.new_password_validation.trim() { + return Err(PasswordResetRedemptionErrorResponse { + kind: PasswordResetRedemptionError::PasswordsDoNotMatch, + }); + } + + //TODO: Handle banned users, they shouldn't be able to do this + + let result = lookup_password_reset_request(&request.reset_token, &mysql_pool).await + .map_err(|err| { + log::error!("Password reset error {:?}", err); + err + }); + + let transaction_and_state = match result { + Ok(Some(reset_state)) => reset_state, + Ok(None) => { + warn!("No such reset request."); + return Err(PasswordResetRedemptionErrorResponse { + kind: PasswordResetRedemptionError::InvalidRedemption, + }); + } + Err(err) => { + error!("lookup error: {err}"); + return Err(PasswordResetRedemptionErrorResponse { + kind: PasswordResetRedemptionError::InvalidRedemption, + }); + } + }; + + println!("Reset state: {}", transaction_and_state.reset_state.user_token); + + let ip_address = get_request_ip(&http_request); + + let password_hash = match bcrypt_hash_password(new_password.to_string()) { + Ok(hash) => hash, + Err(err) => { + error!("password hash error: {err}"); + return Err(PasswordResetRedemptionErrorResponse { + kind: PasswordResetRedemptionError::Internal, + }); + } + }; + + let result = change_password_from_password_reset(ChangePasswordFromPasswordResetArgs { + password_reset_token: &transaction_and_state.reset_state.password_reset_token, + user_token: &transaction_and_state.reset_state.user_token, + new_password_hash: &password_hash, + ip_address: &ip_address, + mysql_transaction: transaction_and_state.transaction, + }).await; + + if let Err(err) = result { + error!("password reset error: {err}"); + return Err(PasswordResetRedemptionErrorResponse { + kind: PasswordResetRedemptionError::Internal, + }); + } + + let response = PasswordResetRedemptionResponse { + success: true, + }; + + let body = serde_json::to_string(&response) + .map_err(|e| { + error!("error returning response: {:?}", e); + PasswordResetRedemptionError::Internal + })?; + + Ok(HttpResponse::Ok() + .content_type("application/json") + .body(body)) +} diff --git a/crates/service/plugins/users/src/endpoints/password_reset_request_handler.rs b/crates/service/plugins/users/src/endpoints/password_reset_request_handler.rs new file mode 100644 index 0000000000..7560c127f5 --- /dev/null +++ b/crates/service/plugins/users/src/endpoints/password_reset_request_handler.rs @@ -0,0 +1,149 @@ +use std::fmt::Display; + +use actix_web::{HttpRequest, HttpResponse, ResponseError, web}; +use actix_web::http::StatusCode; +use log::{error, warn}; +use serde::Deserialize; +use sqlx::MySqlPool; +use strum_macros::Display; + +use crockford::crockford_entropy_lower; +use email_sender::letter_exports; +use email_sender::letter_exports::Message; +use email_sender::smtp_email_sender::SmtpEmailSender; +use http_server_common::request::get_request_ip::get_request_ip; +use http_server_common::response::serialize_as_json_error::serialize_as_json_error; +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 mysql_queries::queries::users::user_password_resets::create_password_reset_request::create_password_reset; + +#[derive(Deserialize)] +pub struct PasswordResetRequestedRequest { + username_or_email: String, +} + +#[derive(Serialize)] +pub struct PasswordResetRequestedResponse { + success: bool, +} + +#[derive(Serialize, Debug, Display)] +pub enum PasswordResetRequestedRequestError { + NoSuchUser, + Internal, +} + +#[derive(Serialize, Debug)] +pub struct PasswordResetRequestedErrorResponse { + kind: PasswordResetRequestedRequestError, +} +impl Display for PasswordResetRequestedErrorResponse { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{:?}", self) + } +} + +impl From for PasswordResetRequestedErrorResponse { + fn from(value: PasswordResetRequestedRequestError) -> Self { + Self { kind: value } + } +} +impl From for PasswordResetRequestedErrorResponse { + fn from(value: errors::AnyhowError) -> Self { + log::error!("Internal error: {value}"); + Self { kind: PasswordResetRequestedRequestError::Internal } + } +} + +impl ResponseError for PasswordResetRequestedErrorResponse { + //TODO: Yknow, clean this up and stuff + fn status_code(&self) -> StatusCode { + StatusCode::INTERNAL_SERVER_ERROR + } + + fn error_response(&self) -> HttpResponse { + serialize_as_json_error(self) + } + } + +/// +/// 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, + mysql_pool: web::Data, + sender: web::Data, +) -> Result { + + let username_or_email = request.username_or_email.trim(); + + let user = if username_or_email.contains("@") { + lookup_user_for_login_by_email(&username_or_email, &mysql_pool).await + } else { + lookup_user_for_login_by_username(&username_or_email, &mysql_pool).await + }.map_err(|e| { + warn!("Password reset user lookup error: {:?}", e); + //TODO: This could be anything, not necessarily a lookup. The name is misleading 🤷🏻 + PasswordResetRequestedRequestError::NoSuchUser + })?; + + let secret_key = crockford_entropy_lower(32); + + //TODO: Handle banned users, they shouldn't be able to do this + + let ip_address = get_request_ip(&http_request); + + create_password_reset(&mysql_pool, &user, ip_address, secret_key.clone()).await + .map_err(|err| { + log::error!("Error creating password reset: {err}"); + PasswordResetRequestedRequestError::Internal + })?; + + let from_address = "Support " + .parse() + .map_err(|err| { + log::error!("Error parsing from address: {err}"); + PasswordResetRequestedRequestError::Internal + })?; + + let to_address = user.email_address + .parse() + .map_err(|err| { + log::error!("Error parsing to address: {err}"); + PasswordResetRequestedRequestError::Internal + })?; + + let email = Message::builder() + .from(from_address) + .to(to_address) + .subject("FakeYou Password Reset") + .header(letter_exports::ContentType::TEXT_PLAIN) + .body(format!("Here's the secret key: {secret_key}")) + .map_err(|err| { + log::error!("Error constructing email: {err}"); + PasswordResetRequestedRequestError::Internal + })?; + + sender.send_message(&email).map_err(|err| { + log::error!("Error sending email: {err}"); + PasswordResetRequestedRequestError::Internal + })?; + + let response = PasswordResetRequestedResponse { + success: true, + }; + + let body = serde_json::to_string(&response) + .map_err(|e| { + error!("error returning response: {:?}", e); + PasswordResetRequestedRequestError::Internal + })?; + + Ok(HttpResponse::Ok() + .content_type("application/json") + .body(body)) +} diff --git a/crates/service/web/storyteller_web/Cargo.toml b/crates/service/web/storyteller_web/Cargo.toml index daa92978ad..0346164aed 100644 --- a/crates/service/web/storyteller_web/Cargo.toml +++ b/crates/service/web/storyteller_web/Cargo.toml @@ -26,6 +26,7 @@ cookies = { path = "../../../lib/cookies" } datetimes = { path = "../../../lib/datetimes" } easyenv = { path = "../../../lib/easyenv" } elasticsearch_schema = { path = "../../../lib/elasticsearch_schema" } +email_sender= { path = "../../../lib/email_sender" } enums = { path = "../../../_types/enums" } errors = { path = "../../../lib/errors" } google_drive_common = { path = "../../../lib/api_clients/google_drive_common" } diff --git a/crates/service/web/storyteller_web/src/main.rs b/crates/service/web/storyteller_web/src/main.rs index 9fa4c09ff3..19456ebbc7 100644 --- a/crates/service/web/storyteller_web/src/main.rs +++ b/crates/service/web/storyteller_web/src/main.rs @@ -57,6 +57,7 @@ use config::common_env::CommonEnv; use config::shared_constants::DEFAULT_MYSQL_CONNECTION_STRING; use config::shared_constants::DEFAULT_RUST_LOG; use container_common::files::read_toml_file_to_struct::read_toml_file_to_struct; +use email_sender::smtp_email_sender::SmtpEmailSender; use errors::AnyhowResult; use http_server_common::cors::{build_cors_config, build_production_cors_config}; use memory_caching::single_item_ttl_cache::SingleItemTtlCache; @@ -434,6 +435,12 @@ async fn main() -> AnyhowResult<()> { .trim() .to_string(); + // TODO(bt,2023-11-11): Password and account details should be a secret, but gotta go fast. + let email_sender = SmtpEmailSender::new( + "smtp.gmail.com", + "noreply@storyteller.ai".to_string(), + "FakeYouHanashi1".to_string())?; + let server_state = ServerState { env_config: EnvConfig { num_workers, @@ -475,6 +482,7 @@ async fn main() -> AnyhowResult<()> { audio_uploads_bucket_root, sort_key_crypto, static_api_token_set, + email_sender, caches: InMemoryCaches { durable: DurableInMemoryCaches { model_token_info: model_token_info_cache, @@ -651,6 +659,7 @@ pub async fn serve(server_state: ServerState) -> AnyhowResult<()> .app_data(web::Data::new(server_state_arc.stripe.clone().config.clone())) .app_data(web::Data::new(server_state_arc.stripe.clone().client.clone())) .app_data(web::Data::new(server_state_arc.third_party_url_redirector.clone())) + .app_data(web::Data::new(server_state_arc.email_sender.clone())) .app_data(web::Data::new(server_environment)) .app_data(web::Data::from(product_lookup)) // NB: Data::from(Arc) for dynamic dispatch .app_data(web::Data::from(stripe_lookup)) // NB: Data::from(Arc) for dynamic dispatch diff --git a/crates/service/web/storyteller_web/src/server_state.rs b/crates/service/web/storyteller_web/src/server_state.rs index b61c443524..41ddadf1d3 100644 --- a/crates/service/web/storyteller_web/src/server_state.rs +++ b/crates/service/web/storyteller_web/src/server_state.rs @@ -6,6 +6,7 @@ use actix_helpers::middleware::banned_cidr_filter::banned_cidr_set::BannedCidrSe use actix_helpers::middleware::banned_ip_filter::ip_ban_list::ip_ban_list::IpBanList; use billing_component::stripe::stripe_config::StripeConfig; use cloud_storage::bucket_client::BucketClient; +use email_sender::smtp_email_sender::SmtpEmailSender; use memory_caching::single_item_ttl_cache::SingleItemTtlCache; use mysql_queries::mediators::badge_granter::BadgeGranter; use mysql_queries::mediators::firehose_publisher::FirehosePublisher; @@ -78,6 +79,8 @@ pub struct ServerState { pub sort_key_crypto: SortKeyCrypto, + pub email_sender: SmtpEmailSender, + pub ip_ban_list: IpBanList, pub cidr_ban_set: BannedCidrSet,