diff --git a/Cargo.lock b/Cargo.lock index 7fccd42..867ca9b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -332,7 +332,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f7ed3ac4252be4d3e36fb589ed5659356842587b21a31596a9dc89d3b2f5e2a" dependencies = [ "cssparser", - "html5ever", + "html5ever 0.40.1", "maplit", "url", ] @@ -422,6 +422,18 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "argon2" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "134c52ddac6d63c576bef8168db10c83c49c26444ecbc68060fef078925a901c" +dependencies = [ + "base64ct", + "blake2", + "cpufeatures 0.3.1", + "password-hash", +] + [[package]] name = "arrayvec" version = "0.7.8" @@ -612,6 +624,12 @@ version = "0.23.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + [[package]] name = "bit_field" version = "0.10.3" @@ -636,6 +654,15 @@ dependencies = [ "no_std_io2", ] +[[package]] +name = "blake2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b5d4d889834ee8ecfc0f8426ad30faf7cdcb10f741a8e6d7224d95325479f6f" +dependencies = [ + "digest 0.11.3", +] + [[package]] name = "block-buffer" version = "0.10.4" @@ -850,12 +877,6 @@ version = "0.5.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" -[[package]] -name = "color_quant" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" - [[package]] name = "colorchoice" version = "1.0.5" @@ -924,6 +945,15 @@ version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" +[[package]] +name = "constant_time_eq" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6187e670c346a04bcbf5def0393d57ec0e5ab56565cde6be4a7cbdb6cdc2f3f6" +dependencies = [ + "rustversion", +] + [[package]] name = "convert_case" version = "0.10.0" @@ -1309,6 +1339,22 @@ dependencies = [ "serde", ] +[[package]] +name = "email-encoding" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "420b9da095f052ea597503e39073b5b3c522f7db933fbac202d91d24492693fd" +dependencies = [ + "base64 0.23.1", + "memchr", +] + +[[package]] +name = "email_address" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e079f19b08ca6239f47f8ba8509c11cf3ea30095831f7fed61441475edd8c449" + [[package]] name = "encoding_rs" version = "0.8.41" @@ -1413,12 +1459,6 @@ version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" -[[package]] -name = "fax" -version = "0.2.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "caf1079563223d5d59d83c85886a56e586cfd5c1a26292e971a0fa266531ac5a" - [[package]] name = "fdeflate" version = "0.3.7" @@ -1602,14 +1642,22 @@ dependencies = [ "actix-web", "ammonia", "anyhow", + "argon2", "bytes", "chrono", "clap", + "constant_time_eq 0.6.0", "dotenvy", "futures", + "html2text", + "idna", "image", + "lettre", + "maud", "mimalloc", "moka", + "parking_lot", + "phc", "regex", "reqwest 0.13.5", "rust-s3", @@ -1671,16 +1719,6 @@ dependencies = [ "wasm-bindgen", ] -[[package]] -name = "gif" -version = "0.14.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee8cfcc411d9adbbaba82fb72661cc1bcca13e8bba98b364e62b2dba8f960159" -dependencies = [ - "color_quant", - "weezl", -] - [[package]] name = "h2" version = "0.3.27" @@ -1810,6 +1848,39 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "hostname" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "617aaa3557aef3810a6369d0a99fac8a080891b68bd9f9812a1eeda0c0730cbd" +dependencies = [ + "cfg-if", + "libc", + "windows-link 0.2.1", +] + +[[package]] +name = "html2text" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1c97eea9d3e6524d8d2a4643ce0e3135500c4c7ca1d02393a485f871bef69d8" +dependencies = [ + "html5ever 0.39.0", + "tendril", + "thiserror", + "unicode-width", +] + +[[package]] +name = "html5ever" +version = "0.39.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46a1761807faccc9a19e86944bbf40610014066306f96edcdedc2fb714bcb7b8" +dependencies = [ + "log", + "markup5ever 0.39.0", +] + [[package]] name = "html5ever" version = "0.40.1" @@ -1817,7 +1888,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "456a1a377e608e555d22ddab27ac0114bc7a7b4199078108e34c2aeae6c9b130" dependencies = [ "log", - "markup5ever", + "markup5ever 0.40.0", "memchr", ] @@ -2096,18 +2167,13 @@ checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" dependencies = [ "bytemuck", "byteorder-lite", - "color_quant", "exr", - "gif", "image-webp", "moxcms", "num-traits", "png", - "qoi", "ravif", "rayon", - "rgb", - "tiff", "zune-core", "zune-jpeg", ] @@ -2287,6 +2353,36 @@ version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7a79a3332a6609480d7d0c9eab957bca6b455b91bb84e66d19f5ff66294b85b8" +[[package]] +name = "lettre" +version = "0.11.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2c646bd5cc763b1087b15493e29a64be6147ba8f19342004fa52048ee596eae" +dependencies = [ + "async-trait", + "base64 0.23.1", + "email-encoding", + "email_address", + "fastrand", + "futures-io", + "futures-util", + "hostname", + "httpdate", + "idna", + "mime", + "nom", + "percent-encoding", + "quoted_printable", + "rustls", + "rustls-platform-verifier", + "socket2", + "tokio", + "tokio-rustls", + "tracing", + "url", + "webpki-roots", +] + [[package]] name = "libbz2-rs-sys" version = "0.2.5" @@ -2408,6 +2504,17 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3e2e65a1a2e43cfcb47a895c4c8b10d1f4a61097f9f254f183aee60cad9c651d" +[[package]] +name = "markup5ever" +version = "0.39.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7122d987ec5f704ee56f6e5b41a7d93722e9aae27ae07cafa4036c4d3f9757de" +dependencies = [ + "log", + "tendril", + "web_atoms 0.2.6", +] + [[package]] name = "markup5ever" version = "0.40.0" @@ -2416,7 +2523,7 @@ checksum = "0ab3dc68ac4a0f5719e560136778c1ee716e296030d75dbd4484e37e39e3a842" dependencies = [ "log", "tendril", - "web_atoms", + "web_atoms 0.3.0", ] [[package]] @@ -2428,6 +2535,28 @@ dependencies = [ "regex-automata", ] +[[package]] +name = "maud" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8156733e27020ea5c684db5beac5d1d611e1272ab17901a49466294b84fc217e" +dependencies = [ + "itoa", + "maud_macros", +] + +[[package]] +name = "maud_macros" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7261b00f3952f617899bc012e3dbd56e4f0110a038175929fa5d18e5a19913ca" +dependencies = [ + "proc-macro2", + "proc-macro2-diagnostics", + "quote", + "syn 2.0.119", +] + [[package]] name = "maybe-async" version = "0.2.11" @@ -2781,6 +2910,16 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "password-hash" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aab41826031698d6ffcd9cff78ef56ef998e39dc7e5067cdfebe373842d4723b" +dependencies = [ + "getrandom 0.4.3", + "phc", +] + [[package]] name = "paste" version = "1.0.15" @@ -2809,24 +2948,66 @@ version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +[[package]] +name = "phc" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44dc769b75f93afdddd8c7fa12d685292ddeff1e66f7f0f3a234cf1818afe892" +dependencies = [ + "base64ct", + "ctutils", + "getrandom 0.4.3", + "rand_core 0.10.1", +] + +[[package]] +name = "phf" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" +dependencies = [ + "phf_shared 0.13.1", + "serde", +] + [[package]] name = "phf" version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "010378780309880b08997fae13be7834dba947d36393bd372f2b1556deb2a2f6" dependencies = [ - "phf_shared", + "phf_shared 0.14.0", "serde", ] +[[package]] +name = "phf_codegen" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49aa7f9d80421bca176ca8dbfebe668cc7a2684708594ec9f3c0db0805d5d6e1" +dependencies = [ + "phf_generator 0.13.1", + "phf_shared 0.13.1", +] + [[package]] name = "phf_codegen" version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "41b585a510fb76fdebead6897982ef2a03a21d8e6cbcca904999742a4afc6ffe" dependencies = [ - "phf_generator", - "phf_shared", + "phf_generator 0.14.0", + "phf_shared 0.14.0", +] + +[[package]] +name = "phf_generator" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737" +dependencies = [ + "fastrand", + "phf_shared 0.13.1", ] [[package]] @@ -2836,7 +3017,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aeb62e0959d5a1bebc965f4d15d9e2b7cea002b6b0f5ba8cde6cc26738467100" dependencies = [ "fastrand", - "phf_shared", + "phf_shared 0.14.0", +] + +[[package]] +name = "phf_shared" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266" +dependencies = [ + "siphasher", ] [[package]] @@ -2946,6 +3136,18 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "proc-macro2-diagnostics" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af066a9c399a26e020ada66a034357a868728e72cd426f3adcd35f80d88d88c8" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "version_check", +] + [[package]] name = "profiling" version = "1.0.18" @@ -2994,15 +3196,6 @@ version = "0.1.30" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea" -[[package]] -name = "qoi" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f6d64c71eb498fe9eae14ce4ec935c555749aef511cca85b5568910d6e48001" -dependencies = [ - "bytemuck", -] - [[package]] name = "quick-error" version = "2.0.1" @@ -3085,6 +3278,12 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "quoted_printable" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "478e0585659a122aa407eb7e3c0e1fa51b1d8a870038bd29f0cf4a8551eea972" + [[package]] name = "r-efi" version = "5.3.0" @@ -4024,6 +4223,18 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" +[[package]] +name = "string_cache" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a18596f8c785a729f2819c0f6a7eae6ebeebdfffbfe4214ae6b087f690e31901" +dependencies = [ + "new_debug_unreachable", + "parking_lot", + "phf_shared 0.13.1", + "precomputed-hash", +] + [[package]] name = "string_cache" version = "0.11.0" @@ -4032,18 +4243,30 @@ checksum = "ffa8a5dbe8b3f0bbe29d4c3225daafaeead63afdc1b65fc4c01a1384166038e6" dependencies = [ "new_debug_unreachable", "parking_lot", - "phf_shared", + "phf_shared 0.14.0", "precomputed-hash", ] +[[package]] +name = "string_cache_codegen" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "585635e46db231059f76c5849798146164652513eb9e8ab2685939dd90f29b69" +dependencies = [ + "phf_generator 0.13.1", + "phf_shared 0.13.1", + "proc-macro2", + "quote", +] + [[package]] name = "string_cache_codegen" version = "0.11.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "928dcdf75e47626b3617a976ec205d9f057584c371c1f23b782129268d0e6edc" dependencies = [ - "phf_generator", - "phf_shared", + "phf_generator 0.14.0", + "phf_shared 0.14.0", "proc-macro2", "quote", ] @@ -4205,20 +4428,6 @@ dependencies = [ "cfg-if", ] -[[package]] -name = "tiff" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b63feaf3343d35b6ca4d50483f94843803b0f51634937cc2ec519fc32232bc52" -dependencies = [ - "fax", - "flate2", - "half", - "quick-error", - "weezl", - "zune-jpeg", -] - [[package]] name = "time" version = "0.3.55" @@ -4533,6 +4742,12 @@ version = "1.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + [[package]] name = "unicode-xid" version = "0.2.6" @@ -4825,16 +5040,28 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "web_atoms" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba8b815c1b593dc0baf78dd0f4fc8fdb2de53198fb1163738093e9a311c33fb3" +dependencies = [ + "phf 0.13.1", + "phf_codegen 0.13.1", + "string_cache 0.9.0", + "string_cache_codegen 0.6.1", +] + [[package]] name = "web_atoms" version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7572660c8890448ba236b7376f27e389c6a7e1c70195622faced601f855c0ada" dependencies = [ - "phf", - "phf_codegen", - "string_cache", - "string_cache_codegen", + "phf 0.14.0", + "phf_codegen 0.14.0", + "string_cache 0.11.0", + "string_cache_codegen 0.11.2", ] [[package]] @@ -4855,12 +5082,6 @@ dependencies = [ "rustls-pki-types", ] -[[package]] -name = "weezl" -version = "0.1.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" - [[package]] name = "whoami" version = "2.1.3" @@ -5282,7 +5503,7 @@ checksum = "2d04a6b5381502aa6087c94c669499eb1602eb9c5e8198e534de571f7154809b" dependencies = [ "aes", "bzip2", - "constant_time_eq", + "constant_time_eq 0.4.2", "crc32fast", "deflate64", "flate2", diff --git a/Cargo.toml b/Cargo.toml index 6a38d7b..e1e6ead 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,6 +7,7 @@ edition = "2024" serve-static = ["dep:actix-files"] [dependencies] +argon2 = "0.6.0" actix-cors = "0.7" actix-files = { version = "0.7", optional = true } actix-multipart = "0.8" @@ -16,10 +17,27 @@ anyhow = "1.0" bytes = "1.11" chrono = { version = "0.4", features = ["serde"] } clap = { version = "4.5", features = ["derive", "env"] } +constant_time_eq = "0.6.0" dotenvy = "0.15" futures = "0.3" -image = "0.25" +html2text = "0.17.1" +lettre = { version = "0.11.23", default-features = false, features = [ + "builder", + "hostname", + "smtp-transport", + "pool", + "tokio1-rustls-tls", + "rustls-platform-verifier", + "tracing", +] } +image = { version = "0.25", default-features = false, features = [ + "png", + "jpeg", + "webp", + "rayon", +] } moka = { version = "0.12.13", features = ["future"] } +parking_lot = "0.12.5" regex = "1.10" reqwest = { version = "0.13", features = [ "gzip", @@ -61,9 +79,11 @@ rust-s3 = { git = "https://github.com/w4nderlust/rust-s3", rev = "2936eb7", defa "tokio-rustls-tls", "fail-on-err", ] } +maud = "0.27.0" +phc = "0.6.1" +idna = "1.1.0" [profile.release] -opt-level = 's' lto = true [target.'cfg(target_env = "musl")'.dependencies] diff --git a/migrations/20260714181725_create_developer_login_info_table.down.sql b/migrations/20260714181725_create_developer_login_info_table.down.sql new file mode 100644 index 0000000..49339f5 --- /dev/null +++ b/migrations/20260714181725_create_developer_login_info_table.down.sql @@ -0,0 +1,3 @@ +-- Add down migration script here + +DROP TABLE IF EXISTS developer_login_info; diff --git a/migrations/20260714181725_create_developer_login_info_table.up.sql b/migrations/20260714181725_create_developer_login_info_table.up.sql new file mode 100644 index 0000000..f6a95ce --- /dev/null +++ b/migrations/20260714181725_create_developer_login_info_table.up.sql @@ -0,0 +1,11 @@ +-- Add up migration script here + +CREATE TABLE IF NOT EXISTS developer_login_info ( + developer_id INTEGER NOT NULL PRIMARY KEY, + email TEXT NOT NULL, + email_verified_at TIMESTAMPTZ, + password TEXT NOT NULL, + + FOREIGN KEY (developer_id) REFERENCES developers(id) ON DELETE CASCADE +); + diff --git a/migrations/20260810163519_add_unique_to_developer_login_infos.down.sql b/migrations/20260810163519_add_unique_to_developer_login_infos.down.sql new file mode 100644 index 0000000..8ebb274 --- /dev/null +++ b/migrations/20260810163519_add_unique_to_developer_login_infos.down.sql @@ -0,0 +1,3 @@ +-- Add down migration script here + +DROP INDEX developer_login_info_email_lower_idx; diff --git a/migrations/20260810163519_add_unique_to_developer_login_infos.up.sql b/migrations/20260810163519_add_unique_to_developer_login_infos.up.sql new file mode 100644 index 0000000..3da97c9 --- /dev/null +++ b/migrations/20260810163519_add_unique_to_developer_login_infos.up.sql @@ -0,0 +1,4 @@ +-- Add up migration script here + +CREATE UNIQUE INDEX developer_login_info_email_lower_idx +ON developer_login_info(LOWER(email)); diff --git a/migrations/20260810163645_add_email_setup_requests_table.down.sql b/migrations/20260810163645_add_email_setup_requests_table.down.sql new file mode 100644 index 0000000..26d61c4 --- /dev/null +++ b/migrations/20260810163645_add_email_setup_requests_table.down.sql @@ -0,0 +1,3 @@ +-- Add down migration script here + +DROP TABLE email_setup_requests; diff --git a/migrations/20260810163645_add_email_setup_requests_table.up.sql b/migrations/20260810163645_add_email_setup_requests_table.up.sql new file mode 100644 index 0000000..e6eea62 --- /dev/null +++ b/migrations/20260810163645_add_email_setup_requests_table.up.sql @@ -0,0 +1,12 @@ +-- Add up migration script here + +CREATE TABLE email_setup_requests ( + developer_id INTEGER NOT NULL PRIMARY KEY, + token UUID NOT NULL UNIQUE DEFAULT gen_random_uuid(), + email TEXT NOT NULL, + password TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + expires_at TIMESTAMPTZ NOT NULL, + + FOREIGN KEY (developer_id) REFERENCES developers(id) ON DELETE CASCADE +); diff --git a/migrations/20260810163652_add_email_change_requests_table.down.sql b/migrations/20260810163652_add_email_change_requests_table.down.sql new file mode 100644 index 0000000..0bd32c3 --- /dev/null +++ b/migrations/20260810163652_add_email_change_requests_table.down.sql @@ -0,0 +1,4 @@ +-- Add down migration script here + +DROP TABLE email_change_history; +DROP TABLE email_change_requests; diff --git a/migrations/20260810163652_add_email_change_requests_table.up.sql b/migrations/20260810163652_add_email_change_requests_table.up.sql new file mode 100644 index 0000000..5654b35 --- /dev/null +++ b/migrations/20260810163652_add_email_change_requests_table.up.sql @@ -0,0 +1,21 @@ +-- Add up migration script here + +CREATE TABLE email_change_requests ( + developer_id INTEGER NOT NULL PRIMARY KEY, + token UUID NOT NULL UNIQUE DEFAULT gen_random_uuid(), + new_email TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + expires_at TIMESTAMPTZ NOT NULL, + + FOREIGN KEY (developer_id) REFERENCES developers(id) ON DELETE CASCADE +); + +CREATE TABLE email_change_history ( + id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + developer_id INTEGER NOT NULL, + old_email TEXT NOT NULL, + new_email TEXT NOT NULL, + changed_at TIMESTAMPTZ NOT NULL, + + FOREIGN KEY (developer_id) REFERENCES developers(id) ON DELETE CASCADE +); diff --git a/src/auth/mod.rs b/src/auth/mod.rs index 9d56c68..13f0d52 100644 --- a/src/auth/mod.rs +++ b/src/auth/mod.rs @@ -1,4 +1,5 @@ pub mod github; +pub mod password; #[derive(thiserror::Error, Debug)] pub enum AuthenticationError { diff --git a/src/auth/password.rs b/src/auth/password.rs new file mode 100644 index 0000000..f794ddc --- /dev/null +++ b/src/auth/password.rs @@ -0,0 +1,71 @@ +use std::sync::OnceLock; + +use argon2::{Algorithm, Argon2, Params, PasswordHasher, PasswordVerifier, Version}; +use phc::PasswordHash; + + +#[derive(Debug, thiserror::Error)] +pub enum PasswordError { + #[error("stored password hash is malformed")] + MalformedHash(String), + #[error("password verification failed")] + Verify(String), + #[error("password task panicked")] + Join(#[from] tokio::task::JoinError) +} + +pub async fn verify(hash: String, password: String, pepper: Option) -> Result { + tokio::task::spawn_blocking(move || verify_blocking(&hash, &password, pepper.as_ref())) + .await + .inspect_err(|e| tracing::error!("tokio join error: {e}"))? +} + +/// Used to prevent timing attacks: still hash-check something, +/// but discard the result after +pub async fn verify_or_dummy(hash: Option, password: String, pepper: Option) -> Result { + tokio::task::spawn_blocking(move || { + let exists = hash.is_some(); + let hash = hash.as_deref().unwrap_or_else(|| dummy_hash(pepper.as_ref())); + + Ok(verify_blocking(hash, &password, pepper.as_ref())? && exists) + }) + .await + .inspect_err(|e| tracing::error!("tokio join error: {e}"))? +} + +pub fn argon2(pepper: Option<&String>) -> Argon2<'_> { + match pepper { + Some(pepper) => Argon2::new_with_secret( + pepper.as_bytes(), + Algorithm::default(), + Version::default(), + Params::default(), + ) + .unwrap(), // This only fails if the secret is too long, it'll be fine! + None => Argon2::default(), + } +} + +fn verify_blocking(hash: &str, password: &str, pepper: Option<&String>) -> Result { + let parsed_hash = PasswordHash::new(hash) + .inspect_err(|e| tracing::error!("failed to parse password hash - pretty bad: {e}")) + .map_err(|_| PasswordError::MalformedHash(hash.into()))?; + + match argon2(pepper).verify_password(password.as_bytes(), &parsed_hash) { + Ok(()) => Ok(true), + Err(argon2::password_hash::Error::PasswordInvalid) => Ok(false), + Err(e) => Err(PasswordError::Verify(e.to_string())) + } +} + +fn dummy_hash(pepper: Option<&String>) -> &'static str { + static HASH: OnceLock = OnceLock::new(); + + HASH.get_or_init(|| { + argon2(pepper) + .hash_password(b"soggymod") + .expect("hashing a static str can't fail") + .to_string() + }) +} + diff --git a/src/config.rs b/src/config.rs index 1c67fb7..c920b29 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,4 +1,6 @@ use std::{ + path::PathBuf, + str::FromStr, sync::{Arc, OnceLock}, time::Duration, }; @@ -9,6 +11,9 @@ use tokio::sync::mpsc::Sender; use crate::{ auth::github::GithubClient, dns::ValidateDnsResolver, + email::{ + SmtpConfig, SmtpSecurity, blocklist::Blocklist, lettre::LettreBackend, mailer::Mailer, + }, http::{self, response::ApiResponse}, s3_worker::{QueuedS3Task, S3WorkerTask}, storage::{LocalBackend, PrivateDisk, PublicDisk, S3Backend, S3Configuration}, @@ -34,10 +39,13 @@ pub struct AppData { max_download_mb: u32, port: u16, debug: bool, + password_hash_pepper: Option, mods_cache: Cache>>, http_client: reqwest::Client, check_dns_http_client: reqwest::Client, + mailer: Option, + email_blocklist: Option, s3_sender: OnceLock>, } @@ -60,6 +68,14 @@ pub async fn build_config() -> anyhow::Result { let github_client = dotenvy::var("GITHUB_CLIENT_ID").unwrap_or("".to_string()); let github_secret = dotenvy::var("GITHUB_CLIENT_SECRET").unwrap_or("".to_string()); let webhook_url = dotenvy::var("DISCORD_WEBHOOK_URL").unwrap_or("".to_string()); + let password_hash_pepper = dotenvy::var("PASSWORD_HASH_PEPPER").ok(); + + if password_hash_pepper.is_none() { + tracing::warn!( + "IMPORTANT: no password hash pepper found, password storage will be LESS secure. Check https://en.wikipedia.org/wiki/Pepper_(cryptography) for more information." + ); + } + let index_admin_webhook_url = dotenvy::var("INDEX_ADMIN_DISCORD_WEBHOOK_URL").unwrap_or("".to_string()); let disable_downloads = @@ -89,6 +105,26 @@ pub async fn build_config() -> anyhow::Result { .timeout(Duration::from_secs(30)) .build()?; + let smtp_config = SmtpConfig::from_env()?; + + if !debug + && smtp_config + .as_ref() + .is_some_and(|x| x.security == SmtpSecurity::None) + { + tracing::warn!( + "You are using no SMTP security for sending emails with APP_DEBUG=0. You have been warned." + ); + } + + let blocklist = if smtp_config.is_some() { + let env_var = dotenvy::var("EMAIL_BLOCKLIST_PATH").unwrap_or("./blocklist.conf".into()); + let path = PathBuf::from_str(&env_var).ok(); + + path.map(Blocklist::load) + } else { + None + }; let http_client = reqwest::Client::builder() .pool_max_idle_per_host(4) .connect_timeout(Duration::from_secs(10)) @@ -116,9 +152,20 @@ pub async fn build_config() -> anyhow::Result { max_download_mb, port, debug, + password_hash_pepper, mods_cache, http_client, check_dns_http_client, + mailer: smtp_config + .and_then(|config| match LettreBackend::new(&config) { + Ok(backend) => Some(Arc::new(backend)), + Err(e) => { + tracing::warn!("failed to create SMTP backend: {e}"); + None + } + }) + .map(|backend| Mailer::new(backend)), + email_blocklist: blocklist, s3_sender: OnceLock::new(), }) } @@ -164,6 +211,10 @@ impl AppData { self.debug } + pub fn password_hash_pepper(&self) -> Option<&String> { + self.password_hash_pepper.as_ref() + } + pub fn static_storage(&self) -> &PublicDisk { &self.static_storage } @@ -188,6 +239,14 @@ impl AppData { &self.http_client } + pub fn mailer(&self) -> Option<&Mailer> { + self.mailer.as_ref() + } + + pub fn email_blocklist(&self) -> Option<&Blocklist> { + self.email_blocklist.as_ref() + } + /// Client that validates passed host, denies all private resolved IP addresses. /// Useful for preventing Server Side Request Forgery. /// diff --git a/src/database/repository/developers.rs b/src/database/repository/developers.rs index 7d044dc..85e1461 100644 --- a/src/database/repository/developers.rs +++ b/src/database/repository/developers.rs @@ -1,8 +1,10 @@ use crate::database::DatabaseError; +use crate::email::blocklist::ApprovedEmailAddress; use crate::types::api::PaginatedData; -use crate::types::models::developer::{Developer, DeveloperBan, ModDeveloper}; +use crate::types::models::developer::{Developer, DeveloperBan, DeveloperEmailLogin, ModDeveloper}; +use chrono::Utc; use sqlx::PgConnection; -use sqlx::types::chrono::{DateTime, Utc}; +use sqlx::types::chrono::DateTime; use std::collections::HashMap; use uuid::Uuid; @@ -495,6 +497,31 @@ pub async fn has_accepted_mod(id: i32, conn: &mut PgConnection) -> Result Result, DatabaseError> { + sqlx::query_as!( + Developer, + "SELECT + d.id, + d.username, + d.display_name, + d.verified, + d.admin, + d.github_user_id as github_id + FROM developers d + INNER JOIN developer_login_info login ON login.developer_id = d.id + WHERE LOWER(login.email) = LOWER($1)", + email + ) + .fetch_optional(&mut *conn) + .await + .inspect_err(|e| tracing::error!("{:?}", e)) + .map_err(|e| e.into()) +} + pub async fn create_ban( dev_id: i32, admin_id: i32, @@ -502,12 +529,16 @@ pub async fn create_ban( revoked_at: Option>, conn: &mut PgConnection, ) -> Result { - sqlx::query_as!(DeveloperBan, + sqlx::query_as!( + DeveloperBan, "INSERT INTO bans (developer_id, reason, admin_id, revoked_at) VALUES ($1, $2, $3, $4) RETURNING id, developer_id, reason, admin_id, created_at, revoked_at", - dev_id, reason, admin_id, revoked_at + dev_id, + reason, + admin_id, + revoked_at ) .fetch_one(&mut *conn) .await @@ -519,8 +550,62 @@ pub async fn check_ban( dev_id: i32, conn: &mut PgConnection, ) -> Result, DatabaseError> { - sqlx::query_as!(DeveloperBan, - "SELECT developer_id, reason, admin_id, created_at, id, revoked_at FROM bans WHERE developer_id=$1 AND (revoked_at > NOW() or revoked_at IS NULL) ORDER BY revoked_at DESC NULLS FIRST, id DESC LIMIT 1", dev_id + sqlx::query_as!( + DeveloperBan, + "SELECT + developer_id, reason, admin_id, created_at, + id, revoked_at + FROM bans + WHERE developer_id=$1 + AND revoked_at > NOW() + OR revoked_at IS NULL + ORDER BY revoked_at DESC NULLS FIRST, id DESC + LIMIT 1", + dev_id + ) + .fetch_optional(&mut *conn) + .await + .inspect_err(|e| tracing::error!("{:?}", e)) + .map_err(|e| e.into()) +} + +#[tracing::instrument(skip_all, fields(id = %id))] +pub async fn find_login_data_for_developer( + id: i32, + conn: &mut PgConnection, +) -> Result, DatabaseError> { + sqlx::query_as!( + DeveloperEmailLogin, + "SELECT + d.id, + login.email, + login.password as password_hash + FROM developers d + INNER JOIN developer_login_info login ON d.id = login.developer_id + WHERE d.id = $1", + id + ) + .fetch_optional(&mut *conn) + .await + .inspect_err(|e| tracing::error!("{:?}", e)) + .map_err(|e| e.into()) +} + +#[tracing::instrument(skip_all, fields(email = %email))] +pub async fn find_login_data_by_email( + email: &str, + conn: &mut PgConnection, +) -> Result, DatabaseError> { + sqlx::query_as!( + DeveloperEmailLogin, + "SELECT + d.id, + login.email, + login.password as password_hash + FROM developers d + INNER JOIN developer_login_info login ON d.id = login.developer_id + WHERE LOWER(email) = LOWER($1)", + email ) .fetch_optional(&mut *conn) .await @@ -541,19 +626,123 @@ pub async fn get_bans( .map_err(|e| e.into()) } +#[tracing::instrument(skip_all, fields(developer_id = %id))] +pub async fn finalize_email_setup( + id: i32, + email: &ApprovedEmailAddress, + password: &str, + conn: &mut PgConnection, +) -> Result<(), DatabaseError> { + let verified_at = Utc::now(); + let email_str = email.email().to_string().to_lowercase(); + + sqlx::query!( + "INSERT INTO developer_login_info + (developer_id, email, email_verified_at, password) + VALUES ($1, $2, $3, $4) + ON CONFLICT (developer_id) + DO UPDATE + SET email = EXCLUDED.email, + email_verified_at = EXCLUDED.email_verified_at, + password = EXCLUDED.password", + id, + &email_str, + verified_at, + password + ) + .execute(&mut *conn) + .await + .inspect_err(|e| tracing::error!("{:?}", e))?; + + sqlx::query!( + "DELETE FROM email_setup_requests WHERE developer_id = $1", + id + ) + .execute(&mut *conn) + .await + .inspect_err(|e| tracing::error!("{:?}", e))?; + + Ok(()) +} + +pub async fn get_email(id: i32, conn: &mut PgConnection) -> Result, DatabaseError> { + sqlx::query!( + "SELECT email FROM developer_login_info + WHERE developer_id = $1", + id + ) + .fetch_optional(&mut *conn) + .await + .inspect_err(|e| tracing::error!("{:?}", e)) + .map(|r| r.map(|r| r.email)) + .map_err(|e| e.into()) +} + +/// Assumes developer_login_info exists for given ID. +/// Also adds a history entry. +#[tracing::instrument(skip_all, fields(developer_id = %id))] +pub async fn change_email( + id: i32, + old_email: &str, + email: &ApprovedEmailAddress, + conn: &mut PgConnection, +) -> Result<(), DatabaseError> { + let now = Utc::now(); + let email_str = email.email().to_string().to_lowercase(); + + sqlx::query!( + "UPDATE developer_login_info + SET email = $1 + WHERE developer_id = $2", + email_str, + id + ) + .execute(&mut *conn) + .await + .inspect_err(|e| tracing::error!("{:?}", e))?; + + sqlx::query!( + "DELETE FROM email_change_requests WHERE developer_id = $1", + id + ) + .execute(&mut *conn) + .await + .inspect_err(|e| tracing::error!("{:?}", e))?; + + sqlx::query!( + "INSERT INTO email_change_history + (developer_id, old_email, new_email, changed_at) + VALUES ($1, $2, $3, $4)", + id, + old_email, + email_str, + now + ) + .execute(&mut *conn) + .await + .inspect_err(|e| tracing::error!("{:?}", e))?; + + Ok(()) +} + pub async fn update_ban( ban_id: i32, revoked_at: Option>, reason: Option<&str>, developer_id: i32, - conn: &mut PgConnection + conn: &mut PgConnection, ) -> Result { - sqlx::query_as!(DeveloperBan, + sqlx::query_as!( + DeveloperBan, "UPDATE bans SET revoked_at=$2, reason=$3, admin_id=$4 WHERE id=$1 RETURNING id, developer_id, reason, admin_id, created_at, revoked_at", - ban_id, revoked_at, reason, developer_id) + ban_id, + revoked_at, + reason, + developer_id + ) .fetch_one(&mut *conn) .await .inspect_err(|e| tracing::error!("{:?}", e)) @@ -562,9 +751,12 @@ pub async fn update_ban( pub async fn delete_ban(dev_id: i32, conn: &mut PgConnection) -> Result<(), DatabaseError> { if let Some(current_ban) = check_ban(dev_id, conn).await? { - sqlx::query!("UPDATE bans SET revoked_at=NOW() WHERE id=$1", current_ban.id) - .execute(conn) - .await + sqlx::query!( + "UPDATE bans SET revoked_at=NOW() WHERE id=$1", + current_ban.id + ) + .execute(conn) + .await .inspect_err(|e| tracing::error!("{:?}", e))?; } diff --git a/src/database/repository/email_change_requests.rs b/src/database/repository/email_change_requests.rs new file mode 100644 index 0000000..b6e5051 --- /dev/null +++ b/src/database/repository/email_change_requests.rs @@ -0,0 +1,91 @@ +use chrono::{DateTime, Utc}; +use sqlx::PgConnection; +use uuid::Uuid; + +use crate::{database::DatabaseError, email::blocklist::ApprovedEmailAddress}; + +pub struct EmailChangeRequestRow { + pub developer_id: i32, + pub token: Uuid, + pub new_email: String, + pub created_at: DateTime, + pub expires_at: DateTime, +} + +pub async fn find_one( + id: i32, + conn: &mut PgConnection, +) -> Result, DatabaseError> { + sqlx::query_as!( + EmailChangeRequestRow, + "SELECT + developer_id, token, new_email, + created_at, expires_at + FROM email_change_requests + WHERE developer_id = $1", + id + ) + .fetch_optional(&mut *conn) + .await + .inspect_err(|e| tracing::error!("{}", e)) + .map_err(|e| e.into()) +} + +pub async fn find_one_by_token( + token: &Uuid, + conn: &mut PgConnection, +) -> Result, DatabaseError> { + sqlx::query_as!( + EmailChangeRequestRow, + "SELECT + developer_id, token, new_email, + created_at, expires_at + FROM email_change_requests + WHERE token = $1", + token + ) + .fetch_optional(&mut *conn) + .await + .inspect_err(|e| tracing::error!("{}", e)) + .map_err(|e| e.into()) +} + +pub async fn create( + id: i32, + token: &Uuid, + new_email: &ApprovedEmailAddress, + expires_at: &DateTime, + conn: &mut PgConnection, +) -> Result { + sqlx::query_as!( + EmailChangeRequestRow, + "INSERT INTO email_change_requests + (developer_id, token, new_email, expires_at) + VALUES ($1, $2, $3, $4) + RETURNING + developer_id, token, new_email, + created_at, expires_at", + id, + token, + new_email.email().to_string(), + expires_at + ) + .fetch_one(&mut *conn) + .await + .inspect_err(|e| tracing::error!("{}", e)) + .map_err(|e| e.into()) +} + +pub async fn delete(id: i32, conn: &mut PgConnection) -> Result<(), DatabaseError> { + sqlx::query_as!( + EmailChangeRequestRow, + "DELETE FROM email_change_requests + WHERE developer_id = $1", + id + ) + .execute(&mut *conn) + .await + .inspect_err(|e| tracing::error!("{}", e)) + .map(|_| ()) + .map_err(|e| e.into()) +} diff --git a/src/database/repository/email_setup_requests.rs b/src/database/repository/email_setup_requests.rs new file mode 100644 index 0000000..db35733 --- /dev/null +++ b/src/database/repository/email_setup_requests.rs @@ -0,0 +1,119 @@ +use chrono::Utc; +use sqlx::PgConnection; +use uuid::Uuid; + +use crate::{database::DatabaseError, email::blocklist::ApprovedEmailAddress}; + +pub struct EmailSetupRequestRow { + pub token: Uuid, + pub developer_id: i32, + pub email: String, + pub password: String, +} + +#[tracing::instrument(skip_all)] +pub async fn find_for_developer( + developer_id: i32, + conn: &mut PgConnection, +) -> Result, DatabaseError> { + sqlx::query!( + "SELECT developer_id, email, password, token + FROM email_setup_requests + WHERE developer_id = $1 + AND expires_at > NOW()", + developer_id + ) + .fetch_optional(&mut *conn) + .await + .inspect_err(|e| tracing::error!("{:?}", e)) + .map_err(|e| e.into()) + .map(|result| { + result.map(|e| EmailSetupRequestRow { + developer_id: e.developer_id, + token: e.token, + email: e.email, + password: e.password, + }) + }) +} + +/// Finds a valid (not expired) EmailSetupRequest for the given token +#[tracing::instrument(skip_all)] +pub async fn find_for_token( + token: Uuid, + conn: &mut PgConnection, +) -> Result, DatabaseError> { + sqlx::query!( + "SELECT developer_id, email, password, token + FROM email_setup_requests + WHERE token = $1 + AND expires_at > NOW()", + token + ) + .fetch_optional(&mut *conn) + .await + .inspect_err(|e| tracing::error!("{:?}", e)) + .map_err(|e| e.into()) + .map(|result| { + result.map(|e| EmailSetupRequestRow { + developer_id: e.developer_id, + token: e.token, + email: e.email, + password: e.password, + }) + }) +} + +#[tracing::instrument(skip_all, fields(developer_id = %developer_id))] +pub async fn create( + uuid: Uuid, + developer_id: i32, + email: &ApprovedEmailAddress, + password: String, + conn: &mut PgConnection, +) -> Result<(), DatabaseError> { + let expires_at = Utc::now() + chrono::Duration::minutes(30); + + sqlx::query!( + "INSERT INTO email_setup_requests + (developer_id, email, password, expires_at, token) + VALUES + ($1, $2, $3, $4, $5)", + developer_id, + email.email().to_string(), + password.as_str(), + expires_at, + uuid + ) + .execute(&mut *conn) + .await + .inspect_err(|e| tracing::error!("{:?}", e)) + .map(|_| ()) + .map_err(|e| e.into()) +} + +#[tracing::instrument(skip_all)] +pub async fn delete(token: &Uuid, conn: &mut PgConnection) -> Result<(), DatabaseError> { + sqlx::query!("DELETE FROM email_setup_requests WHERE token = $1", token) + .execute(&mut *conn) + .await + .inspect_err(|e| tracing::error!("{:?}", e)) + .map(|_| ()) + .map_err(|e| e.into()) +} + +#[tracing::instrument(skip_all, fields(developer_id = %developer_id))] +pub async fn delete_for_developer( + developer_id: i32, + conn: &mut PgConnection, +) -> Result<(), DatabaseError> { + sqlx::query!( + "DELETE FROM email_setup_requests WHERE developer_id = $1", + developer_id + ) + .execute(&mut *conn) + .await + .inspect_err(|e| tracing::error!("{:?}", e)) + .map(|_| ()) + .map_err(|e| e.into()) +} diff --git a/src/database/repository/mod.rs b/src/database/repository/mod.rs index e15fe17..135c6ff 100644 --- a/src/database/repository/mod.rs +++ b/src/database/repository/mod.rs @@ -2,6 +2,8 @@ pub mod auth_tokens; pub mod dependencies; pub mod deprecations; pub mod developers; +pub mod email_change_requests; +pub mod email_setup_requests; pub mod geode_versions; pub mod github_login_attempts; pub mod github_web_logins; diff --git a/src/email/blocklist.rs b/src/email/blocklist.rs new file mode 100644 index 0000000..27e30c6 --- /dev/null +++ b/src/email/blocklist.rs @@ -0,0 +1,192 @@ +use std::{ + collections::HashSet, + path::{Path, PathBuf}, + sync::Arc, +}; + +use idna::{AsciiDenyList, domain_to_ascii_cow}; +use parking_lot::RwLock; + +use crate::email::{EmailAddress, EmailError}; + +const MAX_BLOCKLIST_SIZE: u64 = 10 * 1024 * 1024; // 10 MB + +#[derive(Debug, thiserror::Error)] +pub enum BlocklistError { + #[error("blocked domain")] + BlockedDomain, +} + +/// This struct just holds an address that has been checked +/// with the blocklist. +#[derive(Clone, Debug)] +pub struct ApprovedEmailAddress(EmailAddress); + +impl ApprovedEmailAddress { + pub fn parse(email: EmailAddress, blocklist: Option<&Blocklist>) -> Result { + match blocklist { + Some(blocklist) => { + if blocklist.is_blocked(&email) { + Err(EmailError::BlocklistError(BlocklistError::BlockedDomain)) + } else { + Ok(Self(email)) + } + } + None => Ok(Self(email)), + } + } + + pub fn email(&self) -> &EmailAddress { + &self.0 + } +} + +#[derive(Clone)] +pub struct Blocklist { + entries: Arc>>, + path: Arc, +} + +impl Blocklist { + pub fn load(path: PathBuf) -> Self { + let entries = Self::read_file(&path) + .inspect_err(|e| { + tracing::warn!( + "failed to parse blocklist {}, using empty blocklist: {e}", + path.display() + ) + }) + .unwrap_or_default(); + + Self { + entries: Arc::new(RwLock::new(entries)), + path: Arc::new(path), + } + } + + pub fn is_blocked(&self, email: &EmailAddress) -> bool { + let domain = email.domain.as_str(); + let mut rest = domain; + + let entries = self.entries.read(); + + loop { + if entries.contains(rest) { + return true; + } + + // Yeah this also checks the TLD against the blocklist, whatever + match rest.split_once('.') { + Some((_, parent)) => rest = parent, + None => return false, + } + } + } + + pub fn refresh(&self) { + let entries = Self::read_file(&self.path).inspect_err(|e| { + tracing::warn!( + "failed to parse blocklist {}, skipping blocklist refresh: {e}", + &self.path.display() + ) + }); + + if let Ok(entries) = entries { + let mut guard = self.entries.write(); + *guard = entries; + } + } + + fn read_file(path: &Path) -> anyhow::Result> { + let size = std::fs::metadata(path)?.len(); + + if size > MAX_BLOCKLIST_SIZE { + anyhow::bail!( + "blocklist file {} is {size} bytes, exceeds {MAX_BLOCKLIST_SIZE} byte limit", + path.display() + ) + } + + let content = std::fs::read_to_string(path)?; + + Ok(Blocklist::parse_entries(&content)) + } + + fn parse_entries(content: &str) -> HashSet { + content + .lines() + .map(str::trim) + .filter(|&line| !line.is_empty() && !line.starts_with('#')) + .filter_map( + |line| match domain_to_ascii_cow(line.as_bytes(), AsciiDenyList::STD3) { + Ok(d) => Some(d.trim_end_matches('.').to_owned()), + Err(_) => { + tracing::warn!("skipping invalid blocklist entry: {line}"); + None + } + }, + ) + .filter(|d| !d.is_empty()) + .collect() + } +} + +#[cfg(test)] +mod tests { + use parking_lot::lock_api::RwLock; + use std::str::FromStr; + + use super::*; + + impl Blocklist { + fn from_str_entries(content: &str) -> Self { + Self { + entries: Arc::new(RwLock::new(Self::parse_entries(content))), + path: Arc::new(PathBuf::new()), + } + } + } + + #[test] + fn test_unicode_domain_parsed_as_ascii() { + let entries = Blocklist::parse_entries("よう.jp"); + + assert!(entries.contains("xn--p8j3g.jp")); + } + + #[test] + fn test_domains_are_lowercased() { + let entries = Blocklist::parse_entries("ExAmPLE.cOm"); + + assert!(entries.contains("example.com")); + } + + #[test] + fn test_subdomain_blocked() { + let list = Blocklist::from_str_entries("example.com"); + let email = EmailAddress::from_str("test@a.example.com").unwrap(); + assert!(list.is_blocked(&email)); + } + + #[test] + fn test_multiple_line_blocklist() { + let entries = Blocklist::parse_entries("example.com\ntest.com"); + + assert!(entries.contains("example.com")); + assert!(entries.contains("test.com")); + } + + #[test] + fn test_dont_block_substring() { + let list = Blocklist::from_str_entries("example.com"); + let email = EmailAddress::from_str("test@anexample.com").unwrap(); + assert!(!list.is_blocked(&email)); + } + + #[test] + fn test_dont_read_trailing_dot() { + let entries = Blocklist::parse_entries("example.com."); + + assert!(entries.contains("example.com")); + } +} diff --git a/src/email/lettre.rs b/src/email/lettre.rs new file mode 100644 index 0000000..71a03f1 --- /dev/null +++ b/src/email/lettre.rs @@ -0,0 +1,83 @@ +use lettre::{ + Address, AsyncSmtpTransport, AsyncTransport, Message, Tokio1Executor, + address::AddressError, + message::{Mailbox, MultiPart, SinglePart, header::ContentType}, + transport::smtp::authentication::Credentials, +}; + +use crate::email::{ + EmailAddress, SmtpConfig, SmtpSecurity, + mailer::{BoxFuture, EmailBody, MailerBackend, MailerError, MailerResult, OutgoingEmail}, +}; + +pub struct LettreBackend { + transport: AsyncSmtpTransport, + from: Mailbox, +} + +impl LettreBackend { + pub fn new(config: &SmtpConfig) -> anyhow::Result { + let mut builder = match config.security { + SmtpSecurity::None => { + AsyncSmtpTransport::::builder_dangerous(&config.host) + } + SmtpSecurity::StartTls => { + AsyncSmtpTransport::::starttls_relay(&config.host)? + } + SmtpSecurity::Wrapper => AsyncSmtpTransport::::relay(&config.host)?, + } + .port(config.port); + + if let (Some(user), Some(pass)) = (&config.username, &config.password) { + builder = builder.credentials(Credentials::new(user.clone(), pass.clone())); + } + + Ok(Self { + transport: builder.build(), + from: Mailbox::new(config.from_name.clone(), config.from_address.parse()?), + }) + } +} + +impl MailerBackend for LettreBackend { + fn send<'a>(&'a self, email: &'a OutgoingEmail) -> BoxFuture<'a, MailerResult<()>> { + Box::pin(async move { + let address = Address::try_from(email.to.email().clone()) + .map_err(|e| MailerError::InvalidMessage(e.to_string()))?; + + let builder = Message::builder() + .from(self.from.clone()) + .to(Mailbox::new(None, address)) + .subject(&email.subject); + + let message = match &email.body { + EmailBody::Plaintext(text) => { + builder.header(ContentType::TEXT_PLAIN).body(text.clone()) + } + EmailBody::Html(html) => builder.header(ContentType::TEXT_HTML).body(html.clone()), + EmailBody::Multipart { text, html } => builder.multipart( + MultiPart::alternative() + .singlepart(SinglePart::plain(text.clone())) + .singlepart(SinglePart::html(html.clone())), + ), + } + .map_err(|e| MailerError::InvalidMessage(e.to_string()))?; + + self.transport + .send(message) + .await + .inspect_err(|e| tracing::error!("{:?}", e)) + .map_err(|e| MailerError::SendError(e.to_string()))?; + + Ok(()) + }) + } +} + +impl TryFrom for Address { + type Error = AddressError; + + fn try_from(value: EmailAddress) -> Result { + Address::new(value.local_part(), value.domain()) + } +} diff --git a/src/email/mailer.rs b/src/email/mailer.rs new file mode 100644 index 0000000..7a3c662 --- /dev/null +++ b/src/email/mailer.rs @@ -0,0 +1,60 @@ +use std::{pin::Pin, sync::Arc}; + +use crate::email::blocklist::ApprovedEmailAddress; + +pub type BoxFuture<'a, T> = Pin + Send + 'a>>; + +#[derive(Clone, Debug, thiserror::Error)] +pub enum MailerError { + #[error("invalid message: {0}")] + InvalidMessage(String), + #[error("failed to send: {0}")] + SendError(String), +} + +pub type MailerResult = Result; + +#[derive(Debug)] +pub enum EmailBody { + Plaintext(String), + Html(String), + Multipart { text: String, html: String }, +} + +impl EmailBody { + pub fn html_with_autogenerated_text(html: impl Into) -> MailerResult { + let html = html.into(); + let text = html2text::from_read(html.as_bytes(), 80) + .inspect_err(|e| tracing::error!("{:?}", e)) + .map_err(|e| MailerError::InvalidMessage(e.to_string()))?; + + Ok(EmailBody::Multipart { text, html }) + } +} + +#[derive(Debug)] +pub struct OutgoingEmail { + pub to: ApprovedEmailAddress, + pub subject: String, + pub body: EmailBody, +} + +pub trait MailerBackend: Send + Sync { + fn send<'a>(&'a self, email: &'a OutgoingEmail) -> BoxFuture<'a, MailerResult<()>>; +} + +#[derive(Clone)] +pub struct Mailer { + backend: Arc, +} + +impl Mailer { + pub fn new(backend: Arc) -> Self { + Mailer { backend } + } + + pub async fn send(&self, email: &OutgoingEmail) -> MailerResult<()> { + self.backend.send(email).await?; + Ok(()) + } +} diff --git a/src/email/mod.rs b/src/email/mod.rs new file mode 100644 index 0000000..a126464 --- /dev/null +++ b/src/email/mod.rs @@ -0,0 +1,184 @@ +use std::fmt::{Display, Write}; +use std::str::FromStr; + +use anyhow::anyhow; +use idna::AsciiDenyList; +use idna::uts46::Uts46; +use validator::ValidateEmail; + +use crate::email::blocklist::BlocklistError; + +pub mod blocklist; +pub mod lettre; +pub mod mailer; +pub mod partials; +pub mod templates; + +#[derive(Debug, thiserror::Error)] +pub enum EmailError { + #[error("email address parse error: {0}")] + ParseError(#[from] EmailAddressParseError), + #[error("email is not allowed: {0}")] + BlocklistError(#[from] BlocklistError), +} + +#[derive(Debug, thiserror::Error)] +pub enum EmailAddressParseError { + #[error("invalid email address")] + InvalidEmail, +} + +#[derive(Clone, Debug)] +pub struct EmailAddress { + local_part: String, + domain: String, +} + +#[derive(PartialEq)] +pub enum SmtpSecurity { + None, + StartTls, + Wrapper, +} + +impl std::str::FromStr for SmtpSecurity { + type Err = String; + + fn from_str(s: &str) -> Result { + let lowercase = s.to_lowercase(); + + match lowercase.as_str() { + "none" => Ok(SmtpSecurity::None), + "starttls" => Ok(SmtpSecurity::StartTls), + "wrapper" => Ok(SmtpSecurity::Wrapper), + other => Err(format!("invalid SMTP_TLS value: {other}")), + } + } +} + +pub struct SmtpConfig { + pub host: String, + pub port: u16, + pub security: SmtpSecurity, + pub username: Option, + pub password: Option, + pub from_address: String, + pub from_name: Option, +} + +impl SmtpConfig { + pub fn from_env() -> anyhow::Result> { + const DEFAULT_SMTP_PORT: u16 = 587; + + let enabled = dotenvy::var("SMTP_ENABLED").unwrap_or_default(); + if enabled != "1" { + return Ok(None); + } + + Ok(Some(SmtpConfig { + host: dotenvy::var("SMTP_HOST")?, + port: dotenvy::var("SMTP_PORT") + .ok() + .and_then(|p| p.parse().ok()) + .unwrap_or(DEFAULT_SMTP_PORT), + security: dotenvy::var("SMTP_SECURITY") + .unwrap_or_else(|_| "starttls".into()) + .parse() + .map_err(|e| anyhow!("{}", e))?, + username: dotenvy::var("SMTP_USERNAME").ok(), + password: dotenvy::var("SMTP_PASSWORD").ok(), + from_address: dotenvy::var("SMTP_FROM_ADDRESS")?, + from_name: dotenvy::var("SMTP_FROM_NAME").ok(), + })) + } +} + +impl FromStr for EmailAddress { + type Err = EmailAddressParseError; + + fn from_str(s: &str) -> Result { + if !s.validate_email() { + return Err(EmailAddressParseError::InvalidEmail); + } + + let (local_part, domain) = s + .split_once('@') + .expect("validate_email already confirmed an '@' is present"); + + let domain = idna::domain_to_ascii_cow(domain.as_bytes(), AsciiDenyList::STD3) + .map_err(|_| EmailAddressParseError::InvalidEmail)? + .trim_end_matches('.') + .to_owned(); + + Ok(EmailAddress { + local_part: local_part.into(), + domain, + }) + } +} + +impl EmailAddress { + pub fn local_part(&self) -> &str { + &self.local_part + } + + pub fn local_part_masked(&self) -> &str { + let shown = if self.local_part.chars().count() <= 2 { + 1 + } else { + 2 + }; + + let end = self + .local_part + .char_indices() + .nth(shown) + .map_or(self.local_part.len(), |(i, _)| i); + + &self.local_part[..end] + } + + pub fn domain(&self) -> &str { + &self.domain + } + + pub fn to_masked_string(&self) -> String { + let (display, result) = Uts46::new().to_user_interface( + self.domain.as_bytes(), + AsciiDenyList::STD3, + idna::uts46::Hyphens::Allow, + |_label, _tld, _is_bidi| true, + ); + + let domain: &str = if result.is_ok() { + display.as_ref() + } else { + &self.domain + }; + let local_part_masked = self.local_part_masked(); + + let mut ret = format!("{}***@{}", local_part_masked, domain); + + if self.domain != domain { + write!(ret, " ({}***@{})", local_part_masked, self.domain).ok(); + } + + ret + } +} + +impl Display for EmailAddress { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}@{}", self.local_part, self.domain) + } +} + +impl From for String { + fn from(value: EmailAddress) -> Self { + let mut s = value.local_part; + s.push('@'); + s.push_str(&value.domain); + + s + } +} diff --git a/src/email/partials.rs b/src/email/partials.rs new file mode 100644 index 0000000..57ce29b --- /dev/null +++ b/src/email/partials.rs @@ -0,0 +1,18 @@ +use maud::{Markup, html}; + +pub fn salute() -> Markup { + html!( + p { + "Hi!" + } + ) +} + +pub fn footer() -> Markup { + html!( + p { + "Thanks," br; + "The Geode Team" + } + ) +} diff --git a/src/email/templates/change_email.rs b/src/email/templates/change_email.rs new file mode 100644 index 0000000..2154b20 --- /dev/null +++ b/src/email/templates/change_email.rs @@ -0,0 +1,58 @@ +use maud::{Markup, html}; + +use crate::email::partials; + +pub fn setup_email(endpoint: &str) -> Markup { + html! { + (partials::salute()) + p { + "Someone (hopefully you) requested to setup email sign-in for your Geode SDK developer account using this address." + } + p { + "If you didn't request this, you can safely ignore this email. No changes will be made to your account." + } + p { + "Visit" + a href=(endpoint) { (endpoint) } + "to confirm this email address" + } + p { + "This link expires in 30 minutes." + } + (partials::footer()) + } +} + +pub fn start_request(username: &str, endpoint: &str) -> Markup { + html! { + (partials::salute()) + p { + "Someone (hopefully you) requested to change the email address for the Geode SDK developer account " (username) " to this one." + } + p { + "If you didn't request this, you can safely ignore this email." + } + p { + "Visit" + a href=(endpoint) { (endpoint) } + "to confirm this email address." + } + p { + "This link expires in 30 minutes." + } + (partials::footer()) + } +} + +pub fn confirmation(username: &str, new_email: &str) -> Markup { + html!( + (partials::salute()) + p { + "You have changed your email for your Geode SDK developer account " (username) " to " (new_email) "." + } + p { + "If you didn't request this, please contact an index admin immediately." + } + (partials::footer()) + ) +} diff --git a/src/email/templates/mod.rs b/src/email/templates/mod.rs new file mode 100644 index 0000000..0a1033d --- /dev/null +++ b/src/email/templates/mod.rs @@ -0,0 +1 @@ +pub mod change_email; diff --git a/src/http/endpoints/auth/database.rs b/src/http/endpoints/auth/database.rs new file mode 100644 index 0000000..02fac68 --- /dev/null +++ b/src/http/endpoints/auth/database.rs @@ -0,0 +1,124 @@ +use std::str::FromStr; + +use actix_web::{Responder, post, web}; +use serde::Deserialize; +use sqlx::Connection; +use utoipa::ToSchema; +use validator::Validate; + +use crate::{ + auth, + config::AppData, + database::repository::{auth_tokens, developers, refresh_tokens}, + email::EmailAddress, + http::{endpoints::auth::TokensResponse, error::ApiError, response::ApiResponse}, +}; + +pub fn router_config(cfg: &mut web::ServiceConfig) { + cfg.service(login).service(forgot_password); +} + +#[derive(ToSchema, Validate, Deserialize)] +struct EmailLogin { + #[validate(email)] + email: String, + password: String, +} + +/// Login using email +#[utoipa::path( + post, + path = "/v1/login", + tag = "auth", + responses( + (status = 200, description = "Authenticated", body = inline(ApiResponse)), + (status = 401, description = "Invalid login"), + (status = 500, description = "Server error") + ) +)] +#[post("/login")] +#[tracing::instrument(skip_all)] +pub async fn login( + data: web::Data, + json: web::Json, +) -> Result { + data.mailer().ok_or(ApiError::NotImplemented)?; + + let login_data = { + let mut pool = data.db().acquire().await?; + + let email = EmailAddress::from_str(&json.email) + .map_err(|_| ApiError::BadRequest("invalid email address".into()))? + .to_string(); + + developers::find_login_data_by_email(&email, &mut pool).await? + }; + + let password_hash = login_data.as_ref().map(|data| &data.password_hash).cloned(); + let pepper = data.password_hash_pepper().cloned(); + + if !auth::password::verify_or_dummy(password_hash, json.password.clone(), pepper).await? { + return Err(ApiError::BadRequest( + "No user found with these credentials".into(), + )); + } + + let login_data = login_data.ok_or(ApiError::BadRequest( + "No user found with these credentials".into(), + ))?; + + let mut pool = data.db().acquire().await?; + let mut tx = pool.begin().await?; + + let token = auth_tokens::generate_token(login_data.id, true, &mut tx).await?; + let refresh = refresh_tokens::generate_token(login_data.id, &mut tx).await?; + + tx.commit().await?; + + Ok(web::Json(ApiResponse { + error: "".to_string(), + payload: TokensResponse { + access_token: token.to_string(), + refresh_token: refresh.to_string(), + }, + })) +} + +#[derive(ToSchema, Deserialize, Validate)] +struct ForgotPassword { + #[validate(email)] + email: String, +} + +/// Start forgot password flow +#[utoipa::path( + tag = "auth", + responses( + (status = 204, description = "Maybe sent password reset notification"), + (status = 500, description = "Server error") + ) +)] +#[tracing::instrument(skip_all)] +#[post("/forgot-password")] +pub async fn forgot_password( + data: web::Data, + json: web::Json, +) -> Result { + data.mailer().ok_or(ApiError::NotImplemented)?; + let front_url = data.front_url(); + if front_url.is_empty() { + return Err(ApiError::NotImplemented); + } + + let developer = { + let mut conn = data.db().acquire().await?; + + let email = EmailAddress::from_str(&json.email.to_lowercase()) + .map_err(|_| ApiError::BadRequest("invalid email address".into()))? + .to_string(); + + developers::find_by_email(&email, &mut conn).await? + }; + + Ok(web::Json({})) +} diff --git a/src/http/endpoints/auth/mod.rs b/src/http/endpoints/auth/mod.rs index 688693d..b6e30db 100644 --- a/src/http/endpoints/auth/mod.rs +++ b/src/http/endpoints/auth/mod.rs @@ -9,6 +9,7 @@ use sqlx::Acquire; use utoipa::ToSchema; use uuid::Uuid; +pub mod database; pub mod github; #[derive(Serialize, ToSchema)] @@ -25,6 +26,7 @@ struct RefreshBody { pub fn router_config(cfg: &mut web::ServiceConfig) { cfg .service(refresh_token) + .configure(database::router_config) .configure(github::router_config); } diff --git a/src/http/endpoints/developers.rs b/src/http/endpoints/developers.rs index 360be6e..190eb76 100644 --- a/src/http/endpoints/developers.rs +++ b/src/http/endpoints/developers.rs @@ -1,12 +1,23 @@ use actix_web::{HttpResponse, Responder, delete, get, post, put, web}; +use argon2::PasswordHasher; +use chrono::{DateTime, Utc}; +use phc::PasswordHash; use serde::Deserialize; use sqlx::Acquire; +use std::str::FromStr; use utoipa::{IntoParams, ToSchema}; -use chrono::{DateTime, Utc}; +use uuid::Uuid; +use validator::Validate; -use crate::http::error::ApiError; +use crate::auth; use crate::config::AppData; -use crate::database::repository::{auth_tokens, developers, mods, refresh_tokens}; +use crate::database::repository::{ + auth_tokens, developers, email_setup_requests, mods, refresh_tokens, +}; +use crate::email::blocklist::ApprovedEmailAddress; +use crate::email::mailer::{EmailBody, OutgoingEmail}; +use crate::email::{EmailAddress, templates}; +use crate::http::error::ApiError; use crate::http::response::ApiResponse; use crate::types::api::PaginatedData; use crate::types::models::developer::SelfDeveloper; @@ -60,8 +71,7 @@ struct DeveloperBanPayload { } pub fn router_config(cfg: &mut web::ServiceConfig) { - cfg - .service(developer_index) + cfg.service(developer_index) .service(get_developer) .service(add_developer_to_mod) .service(remove_dev_from_mod) @@ -73,7 +83,9 @@ pub fn router_config(cfg: &mut web::ServiceConfig) { .service(update_developer) .service(ban_developer) .service(unban_developer) - .service(get_developer_ban); + .service(get_developer_ban) + .service(setup_email) + .service(verify_email_setup); } /// List all developers with optional search and pagination @@ -149,7 +161,9 @@ pub async fn add_developer_to_mod( )))?; if let Some(_) = developers::check_ban(target.id, &mut pool).await? { - return Err(ApiError::Banned(Some("The developer being added is banned".into()))); + return Err(ApiError::Banned(Some( + "The developer being added is banned".into(), + ))); } mods::assign_developer(&path.id, target.id, false, &mut pool).await?; @@ -383,6 +397,225 @@ pub async fn get_me(auth: Auth, data: web::Data) -> Result, +} + +/// Start email configuration +#[utoipa::path( + post, + path = "/v1/me/email/setup", + request_body = StartEmailConfigurationPayload, + tag = "developers", + responses( + (status = 201, description = "Email setup request started", body = inline(ApiResponse)), + (status = 401, description = "Unauthorized"), + (status = 409, description = "Email already configured") + ), + security( + ("bearer_token" = []) + ) +)] +#[post("v1/me/email/setup")] +#[tracing::instrument(skip_all)] +pub async fn setup_email( + auth: Auth, + data: web::Data, + json: web::Json, +) -> Result { + json.validate()?; + + let mailer = data.mailer().ok_or(ApiError::NotImplemented)?; + let front_url = data.front_url(); + + // We need to redirect the user to a frontend URL. + // If by chance we don't have a base URL, let's just lock the implementation + if front_url.is_empty() { + return Err(ApiError::NotImplemented); + } + + let developer = auth.developer()?; + + let email = ApprovedEmailAddress::parse( + EmailAddress::from_str(&json.email.to_lowercase()) + .map_err(|_| ApiError::BadRequest("invalid email address".into()))?, + data.email_blocklist(), + )?; + + // We're trying to keep the DB connection occupied as little as possible. + // So do this chunk, send the email with no DB connection hogged, + // then get another connection to finish the insert + { + let mut conn = data.db().acquire().await?; + + let existing_email_login = + developers::find_login_data_for_developer(developer.id, &mut conn).await?; + + if existing_email_login.is_some() { + return Err(ApiError::Conflict( + "You already have a configured email address. Use /v1/me/email/change for changing your email or /v1/me/password/change for changing your password".into(), + )); + } + + // Prevent someone stealing an address + let existing = developers::find_by_email(&email.email().to_string(), &mut conn).await?; + + if existing.is_some_and(|e| e.id != developer.id) { + return Err(ApiError::Conflict( + "Developer already exists with this email address".into(), + )); + } + + let force = json.force.unwrap_or(false); + let existing = email_setup_requests::find_for_developer(developer.id, &mut conn).await?; + + if existing.is_some() && !force { + return Err(ApiError::Conflict( + "User already has an active email setup request".into(), + )); + } + } + + let password = json.password.clone(); + let pepper = data.password_hash_pepper().cloned(); + + let hash = tokio::task::spawn_blocking(move || { + auth::password::argon2(pepper.as_ref()) + .hash_password(password.as_bytes()) + .inspect_err(|e| tracing::error!("{:?}", e)) + .map_err(|_| ApiError::InternalError("failed to hash password".into())) + .map(|hash| hash.to_string()) + }) + .await + .inspect_err(|e| tracing::error!("spawn_blocking failed: {e}")) + .map_err(|_| ApiError::InternalError("hashing task panicked".into()))??; + + let uuid = Uuid::new_v4(); + + let endpoint = format!("{}/email/setup/verify?token={}", data.front_url(), uuid); + + let html = templates::change_email::setup_email(&endpoint).into_string(); + + let outgoing = OutgoingEmail { + to: email.clone(), + subject: "Email setup | Geode SDK".into(), + body: EmailBody::html_with_autogenerated_text(html)?, + }; + + mailer + .send(&outgoing) + .await + .inspect_err(|e| tracing::error!("{:?}", e))?; + + let mut pool = data.db().acquire().await?; + let mut tx = pool.begin().await?; + email_setup_requests::delete_for_developer(developer.id, &mut tx).await?; + email_setup_requests::create(uuid, developer.id, &email, hash.to_string(), &mut tx).await?; + tx.commit().await?; + + Ok(HttpResponse::Created().json(ApiResponse { + payload: "".to_string(), + error: "".into(), + })) +} + +#[derive(Deserialize, ToSchema, Validate)] +struct VerifyEmailConfigurationPayload { + request_id: String, +} + +/// Finalize email configuration +#[utoipa::path( + post, + path = "/v1/me/email/setup/verify", + request_body = VerifyEmailConfigurationPayload, + tag = "developers", + responses( + (status = 204, description = "Email settings applied"), + (status = 401, description = "Unauthorized"), + (status = 404, description = "Setup request not found"), + (status = 409, description = "Email already configured") + ), + security( + ("bearer_token" = []) + ) +)] +#[post("v1/me/email/setup/verify")] +#[tracing::instrument(skip_all)] +pub async fn verify_email_setup( + data: web::Data, + json: web::Json, + auth: Auth, +) -> Result { + json.validate()?; + + let developer = auth.developer()?; + + let notfound_msg = "The request associated with this ID either does not exist or has expired"; + + let request_id = + Uuid::try_parse(&json.request_id).map_err(|_| ApiError::NotFound(notfound_msg.into()))?; + + let mut conn = data.db().acquire().await?; + let mut tx = conn.begin().await?; + + let request = email_setup_requests::find_for_token(request_id, &mut tx) + .await? + .ok_or(ApiError::NotFound(notfound_msg.into()))?; + + if request.developer_id != developer.id { + // Let's be a little sneaky here... >:) + return Err(ApiError::NotFound(notfound_msg.into())); + } + + let existing = developers::find_by_email(&request.email, &mut tx).await?; + + if existing.is_some_and(|e| e.id != developer.id) { + return Err(ApiError::Conflict( + "Developer already exists with this email address".into(), + )); + } + + // Who knows, maybe the blocklist gets updated while the request is running + let email = ApprovedEmailAddress::parse( + EmailAddress::from_str(&request.email.to_lowercase()) + .inspect_err(|e| tracing::error!("failed to parse email from EmailSetupRequest: {e}")) + .map_err(|e| { + ApiError::InternalError(format!( + "Failed to read email from email setup request: {e}" + )) + })?, + data.email_blocklist(), + )?; + + let password = request.password; + + // This should basically never fail, but just check we have a valid + // phc string in the buffer + if let Err(e) = PasswordHash::new(&password) { + tracing::error!("Invalid hashed password in EmailSetupRequest: {}", e); + email_setup_requests::delete(&request.token, &mut tx).await?; + tx.commit().await?; + + return Err(ApiError::InternalError("Failed to read password from email setup request. Please try setting up your email address again.".into())); + } + + developers::finalize_email_setup(developer.id, &email, &password, &mut tx).await?; + + tx.commit().await?; + + Ok(HttpResponse::NoContent()) +} + #[derive(Deserialize, IntoParams)] struct GetDeveloperPath { id: i32, @@ -479,7 +712,6 @@ pub async fn update_developer( })) } - #[derive(Deserialize, IntoParams)] struct CreateDeveloperBanPath { id: i32, @@ -529,15 +761,16 @@ pub async fn ban_developer( payload.revoked_at, payload.reason.as_deref(), dev.id, - &mut tx - ).await?; + &mut tx, + ) + .await?; tx.commit().await?; return Ok(web::Json(ApiResponse { error: "".to_string(), payload: result, - })) + })); } let result = developers::create_ban( @@ -586,11 +819,7 @@ pub async fn unban_developer( let mut pool = data.db().acquire().await?; - developers::delete_ban( - path.id, - &mut pool, - ) - .await?; + developers::delete_ban(path.id, &mut pool).await?; Ok(HttpResponse::NoContent()) } @@ -625,10 +854,7 @@ pub async fn get_developer_ban( let mut pool = data.db().acquire().await?; - let result = developers::get_bans( - path.id, - &mut pool, - ).await?; + let result = developers::get_bans(path.id, &mut pool).await?; Ok(web::Json(ApiResponse { error: "".to_string(), diff --git a/src/http/endpoints/email_change_requests.rs b/src/http/endpoints/email_change_requests.rs new file mode 100644 index 0000000..2a9cc5b --- /dev/null +++ b/src/http/endpoints/email_change_requests.rs @@ -0,0 +1,258 @@ +use std::{ops::Add, str::FromStr}; + +use actix_web::{ + HttpResponse, Responder, post, + web::{self, ServiceConfig}, +}; +use chrono::{Duration, Utc}; +use serde::Deserialize; +use sqlx::Connection; +use utoipa::ToSchema; +use uuid::Uuid; +use validator::Validate; + +use crate::{ + auth, + config::AppData, + database::repository, + email::{ + EmailAddress, + blocklist::ApprovedEmailAddress, + mailer::{EmailBody, OutgoingEmail}, + templates::change_email, + }, + http::{error::ApiError, extractors::auth::Auth, response::ApiResponse}, +}; + +#[derive(Deserialize, Validate, ToSchema)] +struct StartEmailChangePayload { + #[validate(email, length(max = 512))] + email: String, + password: String, + force: Option, +} + +pub fn router_config(cfg: &mut ServiceConfig) { + cfg.service(start_email_change) + .service(finalize_email_change); +} + +/// Finalize email configuration +#[utoipa::path( + request_body = StartEmailChangePayload, + tag = "developers", + responses( + (status = 201, description = "Request created", body = inline(ApiResponse)), + (status = 400, description = "New email is same as old email / Invalid password provided"), + (status = 401, description = "Unauthorized"), + (status = 404, description = "Setup request not found"), + (status = 409, description = "You already have a pending request, retry with \"force\" in JSON to confirm / Email address is not setup") + ), + security( + ("bearer_token" = []) + ) +)] +#[tracing::instrument(skip_all)] +#[post("v1/me/email/change")] +pub async fn start_email_change( + auth: Auth, + json: web::Json, + data: web::Data, +) -> Result { + json.validate()?; + + let mailer = data.mailer().ok_or(ApiError::NotImplemented)?; + let front_url = data.front_url(); + + if front_url.is_empty() { + return Err(ApiError::NotImplemented); + } + + let developer = auth.developer()?; + let now = Utc::now(); + + let email = ApprovedEmailAddress::parse( + EmailAddress::from_str(&json.email.to_lowercase()) + .map_err(|_| ApiError::BadRequest("Invalid email address".into()))?, + data.email_blocklist(), + )?; + + let existing_email_login = { + let mut conn = data.db().acquire().await?; + + let existing_email_login = + repository::developers::find_login_data_for_developer(developer.id, &mut conn) + .await? + .ok_or_else(|| { + ApiError::Conflict( + "You don't have your email address setup. Use /v1/me/email/setup".into(), + ) + })?; + + let exists = repository::email_change_requests::find_one(developer.id, &mut conn).await?; + + let force = json.force.unwrap_or(false); + let exists_and_is_valid = exists + .as_ref() + .is_some_and(|exists| exists.expires_at.gt(&now)); + + if !force && exists_and_is_valid { + return Err(ApiError::Conflict( + "You already have a pending email change request. Please confirm you want to invalidate it.".into(), + )); + } + + if existing_email_login.email.to_lowercase() == email.email().to_string().to_lowercase() { + return Err(ApiError::BadRequest( + "Your new email cannot be the same as your current email".into(), + )); + } + + existing_email_login + }; + + let password_hash = existing_email_login.password_hash.clone(); + let pepper = data.password_hash_pepper().cloned(); + + if !auth::password::verify(password_hash, json.password.clone(), pepper).await? { + return Err(ApiError::BadRequest("Invalid password".into())); + } + + let expiry_date = now.add(Duration::minutes(30)); + let uuid = Uuid::new_v4(); + let endpoint = format!("{}/email/change/verify?token={}", data.front_url(), uuid); + + let html = change_email::start_request(&developer.username, &endpoint).into_string(); + + let outgoing = OutgoingEmail { + to: email.clone(), + subject: "Email change request | Geode SDK".into(), + body: EmailBody::html_with_autogenerated_text(html)?, + }; + + mailer + .send(&outgoing) + .await + .inspect_err(|e| tracing::error!("{:?}", e))?; + + let mut conn = data.db().acquire().await?; + let mut tx = conn.begin().await?; + repository::email_change_requests::delete(developer.id, &mut tx).await?; + repository::email_change_requests::create(developer.id, &uuid, &email, &expiry_date, &mut tx) + .await?; + tx.commit().await?; + + Ok(HttpResponse::Created().json(ApiResponse { + payload: "".to_string(), + error: "".into(), + })) +} + +#[derive(Deserialize, ToSchema)] +struct FinalizeEmailChange { + request_id: String, +} + +/// Finalize email change +#[utoipa::path( + request_body = FinalizeEmailChange, + tag = "developers", + responses( + (status = 204, description = "Email changed"), + (status = 401, description = "Unauthorized"), + (status = 404, description = "Change request not found"), + (status = 409, description = "You don't actually an email setup or you already have the requested address (should never happen)") + ), + security( + ("bearer_token" = []) + ) +)] +#[tracing::instrument(skip_all)] +#[post("v1/me/email/change/verify")] +pub async fn finalize_email_change( + data: web::Data, + json: web::Json, + auth: Auth, +) -> Result { + let mailer = data.mailer().ok_or(ApiError::NotImplemented)?; + let developer = auth.developer()?; + + let request_id = Uuid::try_parse(&json.request_id).map_err(|_| { + ApiError::NotFound( + "The request associated with this ID either does not exist or has expired".into(), + ) + })?; + + let now = Utc::now(); + + let mut conn = data.db().acquire().await?; + let mut tx = conn.begin().await?; + + let not_found_msg = "The request associated with this ID either does not exist or has expired"; + + let request = repository::email_change_requests::find_one_by_token(&request_id, &mut tx) + .await? + .ok_or(ApiError::NotFound(not_found_msg.into()))?; + + if request.developer_id != developer.id { + return Err(ApiError::NotFound(not_found_msg.into())); + } + + if request.expires_at.lt(&now) { + return Err(ApiError::NotFound(not_found_msg.into())); + } + + let existing = repository::developers::find_by_email(&request.new_email, &mut tx).await?; + + if existing.is_some_and(|e| e.id != developer.id) { + return Err(ApiError::Conflict( + "Someone else already has this email address".into(), + )); + } + + let current_email = repository::developers::get_email(developer.id, &mut tx).await? + .ok_or(ApiError::Conflict("Somehow, you don't have an email address, but manged to get this far. Congratulations! You broke the index!".into()))?; + + // Who knows, maybe the blocklist gets updated while the request is running + let email = ApprovedEmailAddress::parse( + EmailAddress::from_str(&request.new_email.to_lowercase()) + .inspect_err(|e| tracing::error!("failed to parse email from EmailChangeRequest: {e}")) + .map_err(|e| { + ApiError::InternalError(format!("Failed to read email from request: {e}")) + })?, + data.email_blocklist(), + )?; + + repository::developers::change_email(developer.id, ¤t_email, &email, &mut tx).await?; + + tx.commit().await?; + + let masked_email = email.email().to_masked_string(); + + let confirmation_email = + change_email::confirmation(&developer.username, &masked_email).into_string(); + + match EmailAddress::from_str(¤t_email) { + Ok(old) => { + let outgoing = OutgoingEmail { + // No need for a blocklist, this is an already approved email, also doesn't return Err + // without a blacklist + to: ApprovedEmailAddress::parse(old, None).unwrap(), + subject: "Email change successful | Geode SDK".into(), + body: EmailBody::html_with_autogenerated_text(&confirmation_email) + .inspect_err(|e| tracing::error!("Failed to autogenerate text from html: {e}")) + .unwrap_or(EmailBody::Html(confirmation_email)), + }; + + let _ = mailer + .send(&outgoing) + .await + .inspect_err(|e| tracing::error!("{:?}", e)); + } + Err(e) => { + tracing::error!("stored email failed to parse, skipping change notification: {e}") + } + }; + + Ok(HttpResponse::NoContent()) +} diff --git a/src/http/endpoints/mod.rs b/src/http/endpoints/mod.rs index df58b82..934e178 100644 --- a/src/http/endpoints/mod.rs +++ b/src/http/endpoints/mod.rs @@ -1,6 +1,7 @@ pub mod auth; pub mod deprecations; pub mod developers; +pub mod email_change_requests; pub mod health; pub mod loader; pub mod mod_status_badge; diff --git a/src/http/error.rs b/src/http/error.rs index 3413cb2..0a0efb3 100644 --- a/src/http/error.rs +++ b/src/http/error.rs @@ -1,6 +1,6 @@ use crate::{ - http::response::ApiResponse, mod_zip::ModZipError, - types::models::mod_gd_version::PlatformParseError, + auth::password::PasswordError, email::mailer::MailerError, http::response::ApiResponse, + mod_zip::ModZipError, types::models::mod_gd_version::PlatformParseError, }; use actix_web::{HttpRequest, HttpResponse, error::QueryPayloadError, http::StatusCode}; use image::ImageError; @@ -13,11 +13,15 @@ pub enum ApiError { Authentication(#[from] crate::auth::AuthenticationError), #[error("You do not have access to this resource")] Authorization, + #[error("Conflict: {0}")] + Conflict(String), #[error("{0}")] Database(#[from] crate::database::DatabaseError), #[error("{0}")] Storage(#[from] crate::storage::StorageError), #[error("{0}")] + Email(#[from] crate::email::EmailError), + #[error("{0}")] ModZip(#[from] ModZipError), #[error("Database error")] SqlxError(#[from] sqlx::Error), @@ -32,6 +36,8 @@ pub enum ApiError { InternalError(String), #[error("{0}")] NotFound(String), + #[error("this functionality is not available on this index instance")] + NotImplemented, #[error("Error: {0}")] PlatformParseError(#[from] PlatformParseError), #[error("Unable to unzip archive")] @@ -40,8 +46,12 @@ pub enum ApiError { Reqwest(#[from] reqwest::Error), #[error("I/O error: {0}")] IO(#[from] std::io::Error), + #[error("failed to send email")] + Mailer(#[from] MailerError), #[error("You are banned from accessing this resource: {}", .0.as_deref().unwrap_or("No reason provided"))] Banned(Option), + #[error("{0}")] + Password(#[from] PasswordError), } impl ApiError { @@ -62,6 +72,9 @@ impl actix_web::ResponseError for ApiError { ApiError::TooManyRequests(..) => StatusCode::TOO_MANY_REQUESTS, ApiError::NotFound(_) => StatusCode::NOT_FOUND, ApiError::BadRequest(..) => StatusCode::BAD_REQUEST, + ApiError::Conflict(..) => StatusCode::CONFLICT, + ApiError::NotImplemented => StatusCode::NOT_IMPLEMENTED, + ApiError::Email(..) => StatusCode::BAD_REQUEST, ApiError::ModZip(err) => mod_zip_status_code(err), ApiError::PlatformParseError(_) => StatusCode::BAD_REQUEST, _ => StatusCode::INTERNAL_SERVER_ERROR, diff --git a/src/http/mod.rs b/src/http/mod.rs index 0383d4d..1281a99 100644 --- a/src/http/mod.rs +++ b/src/http/mod.rs @@ -14,7 +14,7 @@ use uuid::Uuid; use crate::{ config::AppData, http::endpoints::{ - auth, deprecations, developers, health, loader, mod_status_badge, mod_version_submissions, + auth, deprecations, developers, email_change_requests, health, loader, mod_status_badge, mod_version_submissions, mod_versions, mods, stats, tags, }, http::{error::query_error_handler, openapi::ApiDoc}, @@ -43,6 +43,7 @@ fn router_config(cfg: &mut web::ServiceConfig) { .configure(developers::router_config) .configure(tags::router_config) .configure(stats::router_config) + .configure(email_change_requests::router_config) .configure(loader::router_config), ) .configure(health::router_config); diff --git a/src/http/openapi.rs b/src/http/openapi.rs index 6420fb3..a54480a 100644 --- a/src/http/openapi.rs +++ b/src/http/openapi.rs @@ -41,9 +41,13 @@ use crate::{http, http::endpoints, types}; endpoints::developers::get_own_mods, endpoints::developers::get_me, endpoints::developers::update_developer, + endpoints::developers::setup_email, + endpoints::developers::verify_email_setup, endpoints::developers::ban_developer, endpoints::developers::unban_developer, endpoints::developers::get_developer_ban, + endpoints::email_change_requests::start_email_change, + endpoints::email_change_requests::finalize_email_change, endpoints::tags::index, endpoints::tags::detailed_index, endpoints::stats::get_stats, @@ -51,6 +55,7 @@ use crate::{http, http::endpoints, types}; endpoints::loader::create_version, endpoints::loader::get_many, endpoints::auth::refresh_token, + endpoints::auth::database::login, endpoints::auth::github::start_github_login, endpoints::auth::github::start_github_web_login, endpoints::auth::github::github_web_callback, diff --git a/src/lib.rs b/src/lib.rs index 9814341..65b0448 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -6,6 +6,7 @@ mod abbreviate; mod auth; mod database; mod dns; +mod email; mod events; mod http; mod jobs; diff --git a/src/types/models/developer.rs b/src/types/models/developer.rs index 2b34593..60641ee 100644 --- a/src/types/models/developer.rs +++ b/src/types/models/developer.rs @@ -20,6 +20,12 @@ pub struct Developer { pub github_id: i64, } +pub struct DeveloperEmailLogin { + pub id: i32, + pub email: String, + pub password_hash: String, +} + #[derive(sqlx::FromRow, Serialize, Clone, Debug, ToSchema)] pub struct SelfDeveloper { pub id: i32,