From 5ade00b0c946b2ec78fba6308fab9ef57eb6e132 Mon Sep 17 00:00:00 2001 From: Matthew Date: Sun, 9 Apr 2023 04:14:54 -0700 Subject: [PATCH 01/11] WIP: adds verification handler pre-email --- Cargo.lock | 271 +++++++++++++++++++++++++++++++++++++--- Cargo.toml | 3 + src/main.rs | 10 ++ src/services/auth.rs | 1 + src/services/email.rs | 129 +++++++++++++++++++ src/services/mod.rs | 1 + src/services/profile.rs | 3 + src/types.rs | 5 + 8 files changed, 406 insertions(+), 17 deletions(-) create mode 100644 src/services/email.rs diff --git a/Cargo.lock b/Cargo.lock index ed43ee9..7c944cc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -46,7 +46,7 @@ dependencies = [ "actix-tls", "actix-utils", "ahash", - "base64", + "base64 0.13.0", "bitflags", "bytes", "bytestring", @@ -243,6 +243,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + [[package]] name = "async-trait" version = "0.1.56" @@ -277,6 +286,12 @@ version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "904dfeac50f3cdaba28fc6f57fdcddb75f49ed61346676a78c4ffe55877802fd" +[[package]] +name = "base64" +version = "0.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4a4ddaa51a5bc52a6948f74c06d20aaaddb71924eab79b8c97a8c556e942d6a" + [[package]] name = "bitflags" version = "1.3.2" @@ -308,7 +323,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a24ecf39f5a314493ede1bb015984735d41aa6aedb59cafb95492d40cd893330" dependencies = [ "ahash", - "base64", + "base64 0.13.0", "hex", "indexmap", "lazy_static", @@ -355,14 +370,16 @@ checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" [[package]] name = "chrono" -version = "0.4.19" +version = "0.4.24" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "670ad68c9088c2a963aaa298cb369688cf3f9465ce5e2d4ca10e6e0098a1ce73" +checksum = "4e3c5919066adf22df73762e50cffcde3a758f2a848b113b586d1f86728b673b" dependencies = [ - "libc", + "iana-time-zone", + "js-sys", "num-integer", "num-traits", "time 0.1.44", + "wasm-bindgen", "winapi", ] @@ -376,6 +393,16 @@ dependencies = [ "inout", ] +[[package]] +name = "codespan-reporting" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3538270d33cc669650c4b093848450d380def10c331d38c768e34cac80576e6e" +dependencies = [ + "termcolor", + "unicode-width", +] + [[package]] name = "confesi-server" version = "0.1.0" @@ -383,16 +410,19 @@ dependencies = [ "actix-cors", "actix-web", "aes", - "base64", + "base64 0.13.0", "blake2", + "chrono", "env_logger", "futures", "hex", + "jsonwebtoken", "log", "maxminddb", "memmap", "mongodb", "rand", + "regex", "ring", "serde", "serde_json", @@ -405,6 +435,12 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6245d59a3e82a7fc217c5828a6692dbc6dfb63a0c8c90495621f7b9d79704a0e" +[[package]] +name = "core-foundation-sys" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e496a50fda8aacccc86d7529e2c1e0892dbd0f898a6b5645b5561b89c3210efa" + [[package]] name = "cpufeatures" version = "0.2.2" @@ -424,6 +460,50 @@ dependencies = [ "typenum", ] +[[package]] +name = "cxx" +version = "1.0.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a140f260e6f3f79013b8bfc65e7ce630c9ab4388c6a89c71e07226f49487b72" +dependencies = [ + "cc", + "cxxbridge-flags", + "cxxbridge-macro", + "link-cplusplus", +] + +[[package]] +name = "cxx-build" +version = "1.0.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da6383f459341ea689374bf0a42979739dc421874f112ff26f829b8040b8e613" +dependencies = [ + "cc", + "codespan-reporting", + "once_cell", + "proc-macro2", + "quote", + "scratch", + "syn", +] + +[[package]] +name = "cxxbridge-flags" +version = "1.0.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90201c1a650e95ccff1c8c0bb5a343213bdd317c6e600a93075bca2eff54ec97" + +[[package]] +name = "cxxbridge-macro" +version = "1.0.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b75aed41bb2e6367cae39e6326ef817a851db13c13e4f3263714ca3cfb8de56" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "darling" version = "0.13.4" @@ -761,6 +841,30 @@ version = "2.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9a3a5bfb195931eeb336b2a7b4d761daec841b97f947d34394601737a7bba5e4" +[[package]] +name = "iana-time-zone" +version = "0.1.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0722cd7114b7de04316e7ea5456a0bbb20e4adb46fd27a3697adb812cff0f37c" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "wasm-bindgen", + "windows", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0703ae284fc167426161c2e3f1da3ea71d94b21bedbcc9494e92b28e334e3dca" +dependencies = [ + "cxx", + "cxx-build", +] + [[package]] name = "ident_case" version = "1.0.1" @@ -839,6 +943,20 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "jsonwebtoken" +version = "8.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6971da4d9c3aa03c3d8f3ff0f4155b534aad021292003895a469716b2a230378" +dependencies = [ + "base64 0.21.0", + "pem", + "ring", + "serde", + "serde_json", + "simple_asn1", +] + [[package]] name = "language-tags" version = "0.3.2" @@ -857,6 +975,15 @@ version = "0.2.126" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "349d5a591cd28b49e1d1037471617a32ddcda5731b99419008085f72d5a53836" +[[package]] +name = "link-cplusplus" +version = "1.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecd207c9c713c34f95a097a5b029ac2ce6010530c7b49d7fea24d977dede04f5" +dependencies = [ + "cc", +] + [[package]] name = "linked-hash-map" version = "0.5.6" @@ -983,7 +1110,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "28f3943e379e9dcaaab9dc319c308a8caaf9e7ff083c6838dff740afbba59df7" dependencies = [ "async-trait", - "base64", + "base64 0.13.0", "bitflags", "bson", "chrono", @@ -1022,6 +1149,17 @@ dependencies = [ "webpki-roots", ] +[[package]] +name = "num-bigint" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f93ab6289c7b344a8a9f60f88d80aa20032336fe78da341afc91c8a2341fc75f" +dependencies = [ + "autocfg", + "num-integer", + "num-traits", +] + [[package]] name = "num-integer" version = "0.1.45" @@ -1114,6 +1252,15 @@ dependencies = [ "digest", ] +[[package]] +name = "pem" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8835c273a76a90455d7344889b0964598e3316e2a79ede8e36f16bdcf2228b8" +dependencies = [ + "base64 0.13.0", +] + [[package]] name = "percent-encoding" version = "2.1.0" @@ -1204,9 +1351,9 @@ dependencies = [ [[package]] name = "regex" -version = "1.6.0" +version = "1.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c4eb3267174b8c6c2f654116623910a0fef09c4753f8dd83db29c48a0df988b" +checksum = "8b1f693b24f6ac912f4893ef08244d70b6067480d2f1a46e950c9691e6749d1d" dependencies = [ "aho-corasick", "memchr", @@ -1215,9 +1362,9 @@ dependencies = [ [[package]] name = "regex-syntax" -version = "0.6.27" +version = "0.6.29" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a3f87b73ce11b1619a3c6332f45341e0047173771e8b8b73f87bfeefb7b56244" +checksum = "f162c6dd7b008981e4d40210aca20b4bd0f9b60ca9271061b07f78537722f2e1" [[package]] name = "resolv-conf" @@ -1290,7 +1437,7 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ee86d63972a7c661d1536fefe8c3c8407321c3df668891286de28abcd087360" dependencies = [ - "base64", + "base64 0.13.0", ] [[package]] @@ -1305,6 +1452,12 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d29ab0c6d3fc0ee92fe66e2d99f700eab17a8d57d1c1d3b748380fb20baa78cd" +[[package]] +name = "scratch" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1792db035ce95be60c3f8853017b3999209281c24e2ba5bc8e59bf97a0c590c1" + [[package]] name = "sct" version = "0.7.0" @@ -1453,6 +1606,18 @@ dependencies = [ "libc", ] +[[package]] +name = "simple_asn1" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adc4e5204eb1910f40f9cfa375f6f05b68c3abac4b6fd879c8ff5e7ae8a0a085" +dependencies = [ + "num-bigint", + "num-traits", + "thiserror", + "time 0.3.11", +] + [[package]] name = "slab" version = "0.4.6" @@ -1754,6 +1919,12 @@ dependencies = [ "tinyvec", ] +[[package]] +name = "unicode-width" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0edd1e5b14653f783770bce4a4dabb4a5108a5370a5f5d8cfe8710c361f6c8b" + [[package]] name = "untrusted" version = "0.7.1" @@ -1920,49 +2091,115 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" +[[package]] +name = "windows" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e686886bc078bc1b0b600cac0147aadb815089b6e4da64016cbd754b6342700f" +dependencies = [ + "windows-targets", +] + [[package]] name = "windows-sys" version = "0.36.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ea04155a16a59f9eab786fe12a4a450e75cdb175f9e0d80da1e17db09f55b8d2" dependencies = [ - "windows_aarch64_msvc", - "windows_i686_gnu", - "windows_i686_msvc", - "windows_x86_64_gnu", - "windows_x86_64_msvc", + "windows_aarch64_msvc 0.36.1", + "windows_i686_gnu 0.36.1", + "windows_i686_msvc 0.36.1", + "windows_x86_64_gnu 0.36.1", + "windows_x86_64_msvc 0.36.1", +] + +[[package]] +name = "windows-targets" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b1eb6f0cd7c80c79759c929114ef071b87354ce476d9d94271031c0497adfd5" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc 0.48.0", + "windows_i686_gnu 0.48.0", + "windows_i686_msvc 0.48.0", + "windows_x86_64_gnu 0.48.0", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc 0.48.0", ] +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91ae572e1b79dba883e0d315474df7305d12f569b400fcf90581b06062f7e1bc" + [[package]] name = "windows_aarch64_msvc" version = "0.36.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9bb8c3fd39ade2d67e9874ac4f3db21f0d710bee00fe7cab16949ec184eeaa47" +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2ef27e0d7bdfcfc7b868b317c1d32c641a6fe4629c171b8928c7b08d98d7cf3" + [[package]] name = "windows_i686_gnu" version = "0.36.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "180e6ccf01daf4c426b846dfc66db1fc518f074baa793aa7d9b9aaeffad6a3b6" +[[package]] +name = "windows_i686_gnu" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "622a1962a7db830d6fd0a69683c80a18fda201879f0f447f065a3b7467daa241" + [[package]] name = "windows_i686_msvc" version = "0.36.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e2e7917148b2812d1eeafaeb22a97e4813dfa60a3f8f78ebe204bcc88f12f024" +[[package]] +name = "windows_i686_msvc" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4542c6e364ce21bf45d69fdd2a8e455fa38d316158cfd43b3ac1c5b1b19f8e00" + [[package]] name = "windows_x86_64_gnu" version = "0.36.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4dcd171b8776c41b97521e5da127a2d86ad280114807d0b2ab1e462bc764d9e1" +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2b8a661f7628cbd23440e50b05d705db3686f894fc9580820623656af974b1" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7896dbc1f41e08872e9d5e8f8baa8fdd2677f29468c4e156210174edc7f7b953" + [[package]] name = "windows_x86_64_msvc" version = "0.36.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c811ca4a8c853ef420abd8592ba53ddbbac90410fab6903b3e79972a631f7680" +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a515f5799fe4961cb532f983ce2b23082366b898e52ffbce459c86f67c8378a" + [[package]] name = "winreg" version = "0.7.0" diff --git a/Cargo.toml b/Cargo.toml index d6d4686..9aa32e5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,14 +10,17 @@ actix-web = { version = '^4.1.0', default-features = false, features = ['macros' aes = { version = '^0.8.1' } base64 = '^0.13.0' blake2 = '^0.10.4' +chrono = "0.4.24" env_logger = '^0.9.0' futures = '^0.3.21' hex = '^0.4.3' +jsonwebtoken = "8.3.0" log = '^0.4.17' maxminddb = '^0.23.0' memmap = '^0.7.0' mongodb = '^2.2.2' rand = { version = '^0.8.5', features = ['log'] } +regex = "1.7.3" ring = { version = '^0.16.0', default-features = false, features = ['std'] } serde = { version = '^1.0.139', features = ['derive'] } serde_json = '^1.0.82' diff --git a/src/main.rs b/src/main.rs index 77385fa..3445109 100644 --- a/src/main.rs +++ b/src/main.rs @@ -109,6 +109,7 @@ async fn initialize_database(db: &Database) -> mongodb::error::Result<()> { "type": "Point", "coordinates": [-123.3117, 48.4633], }, + "email_domains": vec!["uvic.ca"], }, }, UpdateOptions::builder().upsert(true).build(), @@ -127,6 +128,7 @@ async fn initialize_database(db: &Database) -> mongodb::error::Result<()> { "type": "Point", "coordinates": [-123.2460, 49.2606], }, + "email_domains": vec!["student.ubc.ca", "allumni.ubc.ca"], }, }, UpdateOptions::builder().upsert(true).build(), @@ -135,6 +137,12 @@ async fn initialize_database(db: &Database) -> mongodb::error::Result<()> { Ok(()) }, + schools.create_index( + IndexModel::builder() + .keys(doc! {"email_domains": 1}) + .build(), + None, + ), votes.create_index( IndexModel::builder() .keys(doc! {"post": 1, "user": 1}) @@ -223,6 +231,8 @@ async fn main() -> Result<(), Box> { .service(services::profile::get_watched) .service(services::profile::add_watched) .service(services::profile::delete_watched) + .service(services::email::verify_link) + .service(services::email::send_verification_email) }) .bind(("0.0.0.0", 3000))? .run() diff --git a/src/services/auth.rs b/src/services/auth.rs index 4b20d63..b72129c 100644 --- a/src/services/auth.rs +++ b/src/services/auth.rs @@ -154,6 +154,7 @@ pub async fn register( "username": to_bson(&new_user.username).map_err(to_unexpected!("Converting username to bson failed"))?, "watched_school_ids": to_bson::>(&vec![]).map_err(to_unexpected!("Converting empty vector to bson failed"))?, "school_id": &new_user.school_id, + "email_verified": false, }, None ); diff --git a/src/services/email.rs b/src/services/email.rs new file mode 100644 index 0000000..ab3cd7d --- /dev/null +++ b/src/services/email.rs @@ -0,0 +1,129 @@ +use chrono; +use log::error; +use mongodb::bson::doc; +use regex::Regex; + +use crate::{ + api_types::{success, ApiResult, Failure}, + auth::AuthenticatedUser, + masked_oid::{self, MaskedObjectId, MaskingKey}, + to_unexpected, + types::{School, User}, +}; +use actix_web::{delete, get, post, put, web}; +use jsonwebtoken::{decode, encode, errors::Error, DecodingKey, EncodingKey, Header, Validation}; +use mongodb::Database; +use serde::{de::Expected, Deserialize, Serialize}; + +#[derive(Serialize, Deserialize)] +struct Claims { + masked_user_id: MaskedObjectId, + email: String, + exp: usize, +} + +// todo: use .env for JWT secrets? + +trait JWT { + fn create_jwt(&self) -> Result; + fn decode_jwt(token: &str) -> Result + where + Self: Sized; +} + +impl JWT for Claims { + fn create_jwt(&self) -> Result { + let key = EncodingKey::from_secret("secret".as_ref()); + encode(&Header::default(), self, &key).map_err(|err| err.into()) + } + + fn decode_jwt(token: &str) -> Result { + let key = DecodingKey::from_secret("secret".as_ref()); + let decoded = decode::(token, &key, &Validation::default())?; + Ok(decoded.claims) + } +} + +#[post("/verify")] +pub async fn send_verification_email( + db: web::Data, + masking_key: web::Data<&'static MaskingKey>, + user: AuthenticatedUser, + email: web::Json, +) -> ApiResult { + // validate the email + let email_matcher = Regex::new( + r"^([a-z0-9_+]([a-z0-9_+.]*[a-z0-9_+])?)@([a-z0-9]+([\-\.]{1}[a-z0-9]+)*\.[a-z]{2,6})", + ) + .unwrap(); + if (!email_matcher.is_match(&email)) { + return Err(Failure::BadRequest("incorrectly formatted email")); + } else if email.contains("+") { + return Err(Failure::BadRequest("email can't be an alias")); + } + + let domain = email + .split('@') + .last() + .ok_or(Failure::BadRequest("incorrectly formatted email"))?; + + // is the domain a valid school domain? + db.collection::("schools") + .find_one( + doc! { + "email_domains": { + "$in": [domain] + } + }, + None, + ) + .await + .map_err(to_unexpected!("validating school's domain failed"))? + .ok_or(Failure::BadRequest("invalid school domain"))?; + + // is the email already in use? + let potential_user = db + .collection::("users") + .find_one( + doc! { + "email": email.to_string() + }, + None, + ) + .await + .map_err(to_unexpected!("finding a user with this email failed"))?; + + if let Some(_) = potential_user { + return Err(Failure::BadRequest("email already in use")); + } + + let claims = Claims { + masked_user_id: masking_key.mask(&user.id), + email: email.to_string(), + exp: (chrono::Utc::now() + chrono::Duration::seconds(60)).timestamp() as usize, + }; + + match claims.create_jwt() { + Ok(token) => { + println!("Token: {}", token); + let decoded = Claims::decode_jwt(&token).unwrap(); + println!( + "Decoded: {:?}", + masking_key.unmask(&decoded.masked_user_id).map_err( + |masked_oid::PaddingError| Failure::BadRequest("bad masked sequential id"), + )?, + ); + success(token) + } + Err(err) => { + error!("Error creating JWT: {}", err); + return Err(Failure::Unexpected); + } + } +} + +#[get("/verify/{token}/")] +pub async fn verify_link(db: web::Data, user: AuthenticatedUser) -> ApiResult<(), ()> { + // todo: when verifying, do one last check to ensure the email isn't already in use (potential race condition between 2 people verifying the same email) + success(()) +} diff --git a/src/services/mod.rs b/src/services/mod.rs index 6523ae5..0c190d8 100644 --- a/src/services/mod.rs +++ b/src/services/mod.rs @@ -1,6 +1,7 @@ pub mod auth; pub mod posts; pub mod profile; +pub mod email; use actix_web::web; use actix_web::{get, HttpRequest}; diff --git a/src/services/profile.rs b/src/services/profile.rs index b17cb9a..aa109f1 100644 --- a/src/services/profile.rs +++ b/src/services/profile.rs @@ -32,6 +32,8 @@ pub struct ProfileData { pub school_id: String, // Username of user pub username: String, + /// If the user is verified (via their school email) + pub verified: bool, } /// Fetches user profile information. @@ -50,6 +52,7 @@ pub async fn get_profile( Ok(possible_user) => match possible_user { Some(user) => { return success(ProfileData { + verified: user.email_verified, year_of_study: user.year_of_study, faculty: user.faculty, school_id: user.school_id, diff --git a/src/types.rs b/src/types.rs index 0a8137b..5e7da83 100644 --- a/src/types.rs +++ b/src/types.rs @@ -65,6 +65,10 @@ pub struct User { pub school_id: String, // Watched universities of the user pub watched_school_ids: Vec, + /// The user's email address. + pub email: Option, // TODO: should this be masked? + /// If a user has a verified email address + pub email_verified: bool, } #[derive(Deserialize, Serialize)] @@ -123,6 +127,7 @@ pub struct School { #[serde(rename = "_id")] pub id: String, pub name: String, + pub email_domains: Vec, } #[derive(Deserialize, Serialize)] From 2dac8749972966a1fb0694c20209a1217b36db8e Mon Sep 17 00:00:00 2001 From: Matthew Date: Sun, 9 Apr 2023 04:29:46 -0700 Subject: [PATCH 02/11] WIP: adds verification route by clicking on link sent to email, without the actual email working yet --- src/services/email.rs | 54 +++++++++++++++++++++++++++++++++++++++---- 1 file changed, 49 insertions(+), 5 deletions(-) diff --git a/src/services/email.rs b/src/services/email.rs index ab3cd7d..0157bef 100644 --- a/src/services/email.rs +++ b/src/services/email.rs @@ -6,14 +6,15 @@ use regex::Regex; use crate::{ api_types::{success, ApiResult, Failure}, auth::AuthenticatedUser, + conf::HOST, masked_oid::{self, MaskedObjectId, MaskingKey}, to_unexpected, types::{School, User}, }; -use actix_web::{delete, get, post, put, web}; +use actix_web::{get, post, web}; use jsonwebtoken::{decode, encode, errors::Error, DecodingKey, EncodingKey, Header, Validation}; use mongodb::Database; -use serde::{de::Expected, Deserialize, Serialize}; +use serde::{Deserialize, Serialize}; #[derive(Serialize, Deserialize)] struct Claims { @@ -113,7 +114,7 @@ pub async fn send_verification_email( |masked_oid::PaddingError| Failure::BadRequest("bad masked sequential id"), )?, ); - success(token) + success(format!("http://{}/verify/{}/", HOST, token)) // todo: send email here } Err(err) => { error!("Error creating JWT: {}", err); @@ -122,8 +123,51 @@ pub async fn send_verification_email( } } +// todo: return simple HTML disclosing results? #[get("/verify/{token}/")] -pub async fn verify_link(db: web::Data, user: AuthenticatedUser) -> ApiResult<(), ()> { - // todo: when verifying, do one last check to ensure the email isn't already in use (potential race condition between 2 people verifying the same email) +pub async fn verify_link( + db: web::Data, + token: web::Path, + masking_key: web::Data<&'static MaskingKey>, +) -> ApiResult<(), ()> { + // todo: when verifying, do one last check to ensure the email isn't already in use (potential race condition between 2 people verifying the same email). does it need to be atomic? + let claims = Claims::decode_jwt(&token).map_err(|_| Failure::BadRequest("invalid token"))?; + let user_id = masking_key + .unmask(&claims.masked_user_id) + .map_err(|masked_oid::PaddingError| Failure::BadRequest("bad masked sequential id"))?; + + // is the email already in use? + let potential_user = db + .collection::("users") + .find_one( + doc! { + "email": &claims.email + }, + None, + ) + .await + .map_err(to_unexpected!("finding a user with this email failed"))?; + + if let Some(_) = potential_user { + return Err(Failure::BadRequest("email already in use")); + } + + // update user with verified and email_verified + db.collection::("users") + .update_one( + doc! { + "_id": user_id + }, + doc! { + "$set": { + "email": claims.email, + "email_verified": true, + } + }, + None, + ) + .await + .map_err(to_unexpected!("updating user's email failed"))?; + success(()) } From 7300a14229cf4aa167ef5465c2dca34a5f655c59 Mon Sep 17 00:00:00 2001 From: Matthew Date: Sun, 9 Apr 2023 06:04:34 -0700 Subject: [PATCH 03/11] adds email verifying without yet actually sending the email --- src/conf.rs | 3 ++ src/services/email.rs | 88 ++++++++++++++++++++++++++++++------------- 2 files changed, 64 insertions(+), 27 deletions(-) diff --git a/src/conf.rs b/src/conf.rs index 592787c..fe1701a 100644 --- a/src/conf.rs +++ b/src/conf.rs @@ -28,3 +28,6 @@ pub const PERMITTED_ORIGINS: &[&str] = &["https://app.invalid", "http://api-docs pub const TRENDING_EPOCH: i64 = 1640995200; // 2022-01-01T00:00:00Z pub const TRENDING_DECAY: f64 = 103616.32918473207; // 45000 ln 10 + +/// How long before the email verification link expires, in seconds. +pub const EMAIL_VERIFICATION_LINK_EXPIRATION: i64 = 60 * 10; // 10 minutes diff --git a/src/services/email.rs b/src/services/email.rs index 0157bef..f0e6125 100644 --- a/src/services/email.rs +++ b/src/services/email.rs @@ -6,13 +6,17 @@ use regex::Regex; use crate::{ api_types::{success, ApiResult, Failure}, auth::AuthenticatedUser, - conf::HOST, - masked_oid::{self, MaskedObjectId, MaskingKey}, + conf::{EMAIL_VERIFICATION_LINK_EXPIRATION, HOST}, + masked_oid::{MaskedObjectId, MaskingKey}, to_unexpected, types::{School, User}, }; -use actix_web::{get, post, web}; -use jsonwebtoken::{decode, encode, errors::Error, DecodingKey, EncodingKey, Header, Validation}; +use actix_web::{get, post, web, HttpResponse}; +use jsonwebtoken::{ + decode, encode, + errors::{Error, ErrorKind}, + DecodingKey, EncodingKey, Header, Validation, +}; use mongodb::Database; use serde::{Deserialize, Serialize}; @@ -54,7 +58,7 @@ pub async fn send_verification_email( ) -> ApiResult { // validate the email let email_matcher = Regex::new( - r"^([a-z0-9_+]([a-z0-9_+.]*[a-z0-9_+])?)@([a-z0-9]+([\-\.]{1}[a-z0-9]+)*\.[a-z]{2,6})", + r"(?i)^([a-z0-9_+]([a-z0-9_+.]*[a-z0-9_+])?)@([a-z0-9]+([\-\.]{1}[a-z0-9]+)*\.[a-z]{2,6})", ) .unwrap(); if (!email_matcher.is_match(&email)) { @@ -101,19 +105,13 @@ pub async fn send_verification_email( let claims = Claims { masked_user_id: masking_key.mask(&user.id), email: email.to_string(), - exp: (chrono::Utc::now() + chrono::Duration::seconds(60)).timestamp() as usize, + exp: (chrono::Utc::now() + chrono::Duration::seconds(EMAIL_VERIFICATION_LINK_EXPIRATION)) + .timestamp() as usize, }; match claims.create_jwt() { Ok(token) => { println!("Token: {}", token); - let decoded = Claims::decode_jwt(&token).unwrap(); - println!( - "Decoded: {:?}", - masking_key.unmask(&decoded.masked_user_id).map_err( - |masked_oid::PaddingError| Failure::BadRequest("bad masked sequential id"), - )?, - ); success(format!("http://{}/verify/{}/", HOST, token)) // todo: send email here } Err(err) => { @@ -123,21 +121,44 @@ pub async fn send_verification_email( } } -// todo: return simple HTML disclosing results? +fn gen_html(content: &str) -> HttpResponse { + let html = format!( + " + + Email verification + + +

{}

+ + ", + content + ); + HttpResponse::Ok() + .content_type("text/html; charset=utf-8") + .body(html) +} + #[get("/verify/{token}/")] pub async fn verify_link( db: web::Data, token: web::Path, masking_key: web::Data<&'static MaskingKey>, -) -> ApiResult<(), ()> { - // todo: when verifying, do one last check to ensure the email isn't already in use (potential race condition between 2 people verifying the same email). does it need to be atomic? - let claims = Claims::decode_jwt(&token).map_err(|_| Failure::BadRequest("invalid token"))?; - let user_id = masking_key - .unmask(&claims.masked_user_id) - .map_err(|masked_oid::PaddingError| Failure::BadRequest("bad masked sequential id"))?; +) -> HttpResponse { + // todo: will there be a race condition that could cause multiple users to be verified with the same email? + let claims = match Claims::decode_jwt(&token) { + Ok(claims) => claims, + Err(err) => match err.kind() { + ErrorKind::ExpiredSignature => return gen_html("Verification link expired ๐Ÿฅถ"), + _ => return gen_html("Malformed verification link ๐Ÿคจ"), + }, + }; + let user_id = match masking_key.unmask(&claims.masked_user_id) { + Ok(user_id) => user_id, + Err(_) => return gen_html("Malformed verification link ๐Ÿคจ"), + }; // is the email already in use? - let potential_user = db + let potential_user = match db .collection::("users") .find_one( doc! { @@ -146,14 +167,21 @@ pub async fn verify_link( None, ) .await - .map_err(to_unexpected!("finding a user with this email failed"))?; + { + Ok(potential_user) => potential_user, + Err(err) => { + error!("Error finding a user with this email: {}", err); + return gen_html("Internal server error validating email ๐Ÿฅฒ"); + } + }; if let Some(_) = potential_user { - return Err(Failure::BadRequest("email already in use")); + return gen_html("Email already verified ๐Ÿ˜…"); } // update user with verified and email_verified - db.collection::("users") + match db + .collection::("users") .update_one( doc! { "_id": user_id @@ -167,7 +195,13 @@ pub async fn verify_link( None, ) .await - .map_err(to_unexpected!("updating user's email failed"))?; - - success(()) + { + Ok(_) => gen_html("Email verified successfully โœ…"), + Err(err) => { + error!("Error updating user's email: {}", err); + return gen_html("Internal server error validating email ๐Ÿฅฒ"); + } + } } + +// todo: update docs for new routes and alterations to old routes From 5436cb39c5addaf586e8c224fdac9ced64f201d7 Mon Sep 17 00:00:00 2001 From: Matthew Date: Sun, 9 Apr 2023 20:25:11 -0700 Subject: [PATCH 04/11] WIP: adds ability to have personal and school emails --- src/main.rs | 22 ++++++ src/services/auth.rs | 4 +- src/services/email.rs | 149 +++++++++++++++++++++++----------------- src/services/profile.rs | 5 +- src/types.rs | 18 +++-- 5 files changed, 130 insertions(+), 68 deletions(-) diff --git a/src/main.rs b/src/main.rs index 3445109..decb9f7 100644 --- a/src/main.rs +++ b/src/main.rs @@ -57,6 +57,28 @@ async fn initialize_database(db: &Database) -> mongodb::error::Result<()> { .build(), None, ), + users.create_index( + IndexModel::builder() + .keys(doc! {"personal_email": 1}) + .options( + IndexOptions::builder() + .unique(true) + .build() + ) + .build(), + None, + ), + users.create_index( + IndexModel::builder() + .keys(doc! {"school_email": 1}) + .options( + IndexOptions::builder() + .unique(true) + .build() + ) + .build(), + None, + ), sessions.create_index(IndexModel::builder().keys(doc! {"user": 1}).build(), None,), sessions.create_index( IndexModel::builder() diff --git a/src/services/auth.rs b/src/services/auth.rs index b72129c..1876319 100644 --- a/src/services/auth.rs +++ b/src/services/auth.rs @@ -11,6 +11,7 @@ use serde_with::{serde_as, DisplayFromStr}; use crate::api_types::{failure, success, ApiError, ApiResult, Failure}; use crate::auth::{AuthenticatedUser, AuthenticationError, Authorization, Guest}; use crate::to_unexpected; +use crate::types::PrimaryEmail; use crate::types::{ PosterFaculty, PosterYearOfStudy, School, Session, SessionToken, User, Username, }; @@ -154,11 +155,12 @@ pub async fn register( "username": to_bson(&new_user.username).map_err(to_unexpected!("Converting username to bson failed"))?, "watched_school_ids": to_bson::>(&vec![]).map_err(to_unexpected!("Converting empty vector to bson failed"))?, "school_id": &new_user.school_id, - "email_verified": false, + "primary_email": to_bson(&PrimaryEmail::NoEmail).map_err(to_unexpected!("Converting primary email to bson failed"))?, }, None ); + match op.await { Ok(result) => { debug!("Inserted user: {:?}", result); diff --git a/src/services/email.rs b/src/services/email.rs index f0e6125..943baa1 100644 --- a/src/services/email.rs +++ b/src/services/email.rs @@ -1,6 +1,6 @@ use chrono; use log::error; -use mongodb::bson::doc; +use mongodb::bson::{doc, to_bson}; use regex::Regex; use crate::{ @@ -9,7 +9,7 @@ use crate::{ conf::{EMAIL_VERIFICATION_LINK_EXPIRATION, HOST}, masked_oid::{MaskedObjectId, MaskingKey}, to_unexpected, - types::{School, User}, + types::{PrimaryEmail, School, User}, }; use actix_web::{get, post, web, HttpResponse}; use jsonwebtoken::{ @@ -24,6 +24,7 @@ use serde::{Deserialize, Serialize}; struct Claims { masked_user_id: MaskedObjectId, email: String, + email_type: EmailType, exp: usize, } @@ -49,62 +50,88 @@ impl JWT for Claims { } } +#[derive(Deserialize, Serialize, Clone)] +#[serde(rename_all = "kebab-case")] +pub enum EmailType { + Personal, + School, +} + +#[derive(Deserialize)] +pub struct VerificationRequest { + email: String, + email_type: EmailType, +} + #[post("/verify")] pub async fn send_verification_email( db: web::Data, masking_key: web::Data<&'static MaskingKey>, user: AuthenticatedUser, - email: web::Json, + verification: web::Json, ) -> ApiResult { + // todo: gmail ignores dots? outlook doesn't? what about other providers? should I force remove dots from the email (or just ignore them)? + // validate the email let email_matcher = Regex::new( r"(?i)^([a-z0-9_+]([a-z0-9_+.]*[a-z0-9_+])?)@([a-z0-9]+([\-\.]{1}[a-z0-9]+)*\.[a-z]{2,6})", ) .unwrap(); - if (!email_matcher.is_match(&email)) { + if (!email_matcher.is_match(&verification.email)) { return Err(Failure::BadRequest("incorrectly formatted email")); - } else if email.contains("+") { + } else if verification.email.contains("+") { return Err(Failure::BadRequest("email can't be an alias")); } - let domain = email - .split('@') - .last() - .ok_or(Failure::BadRequest("incorrectly formatted email"))?; + // if we're trying to verify a school email, make sure the domain is valid + if matches!(verification.email_type, EmailType::School) { + let domain = &verification + .email + .split('@') + .last() + .ok_or(Failure::BadRequest("incorrectly formatted email"))?; + + // is the domain a valid school domain? + db.collection::("schools") + .find_one( + doc! { + "email_domains": { + "$in": [domain] + } + }, + None, + ) + .await + .map_err(to_unexpected!("validating school's domain failed"))? + .ok_or(Failure::BadRequest("invalid school domain"))?; + } - // is the domain a valid school domain? - db.collection::("schools") - .find_one( - doc! { - "email_domains": { - "$in": [domain] - } - }, - None, - ) - .await - .map_err(to_unexpected!("validating school's domain failed"))? - .ok_or(Failure::BadRequest("invalid school domain"))?; + let matching_user = match verification.email_type { + EmailType::Personal => doc! { + "personal_email": &verification.email + }, + EmailType::School => doc! { + "school_email": &verification.email + }, + }; // is the email already in use? let potential_user = db .collection::("users") - .find_one( - doc! { - "email": email.to_string() - }, - None, - ) + .find_one(matching_user, None) .await .map_err(to_unexpected!("finding a user with this email failed"))?; if let Some(_) = potential_user { - return Err(Failure::BadRequest("email already in use")); + return Err(Failure::BadRequest("email already in use for this email type")); } + // todo: check if the user already has a primary/school email, if so, we need to update it + let claims = Claims { masked_user_id: masking_key.mask(&user.id), - email: email.to_string(), + email: verification.email.to_string(), + email_type: verification.email_type.clone(), exp: (chrono::Utc::now() + chrono::Duration::seconds(EMAIL_VERIFICATION_LINK_EXPIRATION)) .timestamp() as usize, }; @@ -144,11 +171,12 @@ pub async fn verify_link( token: web::Path, masking_key: web::Data<&'static MaskingKey>, ) -> HttpResponse { - // todo: will there be a race condition that could cause multiple users to be verified with the same email? let claims = match Claims::decode_jwt(&token) { Ok(claims) => claims, Err(err) => match err.kind() { - ErrorKind::ExpiredSignature => return gen_html("Verification link expired ๐Ÿฅถ"), + ErrorKind::ExpiredSignature => { + return gen_html("Verification link expired, please send another email ๐Ÿฅถ") + } _ => return gen_html("Malformed verification link ๐Ÿคจ"), }, }; @@ -157,50 +185,47 @@ pub async fn verify_link( Err(_) => return gen_html("Malformed verification link ๐Ÿคจ"), }; - // is the email already in use? - let potential_user = match db - .collection::("users") - .find_one( - doc! { - "email": &claims.email - }, - None, - ) - .await - { - Ok(potential_user) => potential_user, - Err(err) => { - error!("Error finding a user with this email: {}", err); - return gen_html("Internal server error validating email ๐Ÿฅฒ"); - } + // every time you verify a new email, it'll become your "primary" email by default + let email_type = match to_bson(&claims.email_type) { + Ok(bson) => bson, + Err(_) => return gen_html("Error verifying email, please try again later ๐Ÿ˜ณ"), }; - if let Some(_) = potential_user { - return gen_html("Email already verified ๐Ÿ˜…"); - } + let update_doc = match claims.email_type { + EmailType::Personal => doc! { + "$set": { + "primary_email": email_type, + "personal_email": &claims.email, + } + }, + EmailType::School => doc! { + "$set": { + "primary_email": email_type, + "school_email": &claims.email, + } + }, + }; - // update user with verified and email_verified + // update user with newly verified email match db .collection::("users") .update_one( doc! { "_id": user_id }, - doc! { - "$set": { - "email": claims.email, - "email_verified": true, - } - }, + update_doc, None, ) .await { - Ok(_) => gen_html("Email verified successfully โœ…"), - Err(err) => { - error!("Error updating user's email: {}", err); - return gen_html("Internal server error validating email ๐Ÿฅฒ"); + Ok(result) => { + if result.modified_count == 1 { + gen_html("Email verified โœ…") + } else { + gen_html("Email already verified ๐Ÿ˜…") + } } + Err(_) => gen_html("Error verifying email, please try again later ๐Ÿ˜ณ") } } diff --git a/src/services/profile.rs b/src/services/profile.rs index aa109f1..ebfc8bf 100644 --- a/src/services/profile.rs +++ b/src/services/profile.rs @@ -52,7 +52,10 @@ pub async fn get_profile( Ok(possible_user) => match possible_user { Some(user) => { return success(ProfileData { - verified: user.email_verified, + verified: match user.school_email { + Some(_) => true, + None => false, + }, year_of_study: user.year_of_study, faculty: user.faculty, school_id: user.school_id, diff --git a/src/types.rs b/src/types.rs index 5e7da83..4f017fb 100644 --- a/src/types.rs +++ b/src/types.rs @@ -52,6 +52,14 @@ impl fmt::Display for UsernameInvalid { } } +#[derive(Deserialize, Serialize)] +#[serde(rename_all = "kebab-case")] +pub enum PrimaryEmail { + Personal, + School, + NoEmail +} + #[derive(Deserialize)] pub struct User { #[serde(rename = "_id")] @@ -65,10 +73,12 @@ pub struct User { pub school_id: String, // Watched universities of the user pub watched_school_ids: Vec, - /// The user's email address. - pub email: Option, // TODO: should this be masked? - /// If a user has a verified email address - pub email_verified: bool, + /// The user's personal email address. + pub personal_email: Option, // TODO: should this be masked? + /// The user's personal email address. + pub school_email: Option, // TODO: should this be masked? + /// Ther user's preferred email address. + pub primary_email: PrimaryEmail, } #[derive(Deserialize, Serialize)] From b8dcf927201d3e5b714eaa2ea76399b231413e6d Mon Sep 17 00:00:00 2001 From: Matthew Date: Sun, 9 Apr 2023 21:03:20 -0700 Subject: [PATCH 05/11] WIP: adds ability to change primary email addresses --- src/main.rs | 1 + src/services/email.rs | 47 ++++++++++++++++++++++++++++++++++++++----- 2 files changed, 43 insertions(+), 5 deletions(-) diff --git a/src/main.rs b/src/main.rs index decb9f7..33a0edf 100644 --- a/src/main.rs +++ b/src/main.rs @@ -255,6 +255,7 @@ async fn main() -> Result<(), Box> { .service(services::profile::delete_watched) .service(services::email::verify_link) .service(services::email::send_verification_email) + .service(services::email::change_primary_email) }) .bind(("0.0.0.0", 3000))? .run() diff --git a/src/services/email.rs b/src/services/email.rs index 943baa1..52a9953 100644 --- a/src/services/email.rs +++ b/src/services/email.rs @@ -9,9 +9,9 @@ use crate::{ conf::{EMAIL_VERIFICATION_LINK_EXPIRATION, HOST}, masked_oid::{MaskedObjectId, MaskingKey}, to_unexpected, - types::{PrimaryEmail, School, User}, + types::{School, User}, }; -use actix_web::{get, post, web, HttpResponse}; +use actix_web::{get, post, put, web, HttpResponse}; use jsonwebtoken::{ decode, encode, errors::{Error, ErrorKind}, @@ -29,6 +29,7 @@ struct Claims { } // todo: use .env for JWT secrets? +// todo: update docs for new routes and alterations to old routes trait JWT { fn create_jwt(&self) -> Result; @@ -123,7 +124,9 @@ pub async fn send_verification_email( .map_err(to_unexpected!("finding a user with this email failed"))?; if let Some(_) = potential_user { - return Err(Failure::BadRequest("email already in use for this email type")); + return Err(Failure::BadRequest( + "email already in use for this email type", + )); } // todo: check if the user already has a primary/school email, if so, we need to update it @@ -225,8 +228,42 @@ pub async fn verify_link( gen_html("Email already verified ๐Ÿ˜…") } } - Err(_) => gen_html("Error verifying email, please try again later ๐Ÿ˜ณ") + Err(_) => gen_html("Error verifying email, please try again later ๐Ÿ˜ณ"), } } -// todo: update docs for new routes and alterations to old routes +// todo: add what type of primary email a user has to their account and what it is so they can fetch it + +#[put("/email")] +pub async fn change_primary_email( + db: web::Data, + user: AuthenticatedUser, + email_type: web::Json, +) -> ApiResult<(), ()> { + let (not_null_field, update_name) = match &email_type.into_inner() { + EmailType::Personal => ("personal_email", "personal"), + EmailType::School => ("school_email", "school"), + }; + + match db + .collection::("users") + .update_one( + doc! {"_id": user.id, not_null_field: { "$ne": null }}, // query + doc! {"$set": {"primary_email": update_name}}, // update + None, + ) + .await + { + Ok(result) => { + if result.matched_count == 0 { + Err(Failure::BadRequest("this email type doesn't exist for this user")) + } else if result.modified_count == 1 { + success(()) + } else { + println!("already changed"); + success(()) + } + } + Err(_) => Err(Failure::Unexpected), + } +} From 69af8699c2530d39774b398479b5ade49b37b11a Mon Sep 17 00:00:00 2001 From: Matthew Date: Mon, 10 Apr 2023 04:01:18 -0700 Subject: [PATCH 06/11] WIP: adds route for email deletion --- src/main.rs | 4 +- src/services/email.rs | 154 ++++++++++++++++++++++++++++++++++------ src/services/profile.rs | 11 ++- 3 files changed, 146 insertions(+), 23 deletions(-) diff --git a/src/main.rs b/src/main.rs index 33a0edf..d4c6f3c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -253,9 +253,11 @@ async fn main() -> Result<(), Box> { .service(services::profile::get_watched) .service(services::profile::add_watched) .service(services::profile::delete_watched) - .service(services::email::verify_link) + .service(services::email::verify_email) .service(services::email::send_verification_email) .service(services::email::change_primary_email) + .service(services::email::delete_email) + .service(services::email::verify_deleting_email) }) .bind(("0.0.0.0", 3000))? .run() diff --git a/src/services/email.rs b/src/services/email.rs index 52a9953..a0dea5e 100644 --- a/src/services/email.rs +++ b/src/services/email.rs @@ -1,6 +1,8 @@ use chrono; use log::error; use mongodb::bson::{doc, to_bson}; +use mongodb::options::{FindOneAndUpdateOptions, UpdateOptions}; +use mongodb::Client as MongoClient; use regex::Regex; use crate::{ @@ -11,23 +13,30 @@ use crate::{ to_unexpected, types::{School, User}, }; -use actix_web::{get, post, put, web, HttpResponse}; +use actix_web::{delete, get, post, put, web, HttpResponse}; use jsonwebtoken::{ decode, encode, errors::{Error, ErrorKind}, DecodingKey, EncodingKey, Header, Validation, }; use mongodb::Database; -use serde::{Deserialize, Serialize}; +use serde::{de::DeserializeOwned, Deserialize, Serialize}; #[derive(Serialize, Deserialize)] -struct Claims { +struct VerificationClaims { masked_user_id: MaskedObjectId, email: String, email_type: EmailType, exp: usize, } +#[derive(Serialize, Deserialize)] +struct DeletionClaims { + masked_user_id: MaskedObjectId, + email_type: EmailType, + exp: usize, +} + // todo: use .env for JWT secrets? // todo: update docs for new routes and alterations to old routes @@ -38,17 +47,28 @@ trait JWT { Self: Sized; } -impl JWT for Claims { - fn create_jwt(&self) -> Result { - let key = EncodingKey::from_secret("secret".as_ref()); - encode(&Header::default(), self, &key).map_err(|err| err.into()) - } +// impl JWT for Claims { +// fn create_jwt(&self) -> Result { +// let key = EncodingKey::from_secret("secret".as_ref()); +// encode(&Header::default(), self, &key).map_err(|err| err.into()) +// } - fn decode_jwt(token: &str) -> Result { - let key = DecodingKey::from_secret("secret".as_ref()); - let decoded = decode::(token, &key, &Validation::default())?; - Ok(decoded.claims) - } +// fn decode_jwt(token: &str) -> Result { +// let key = DecodingKey::from_secret("secret".as_ref()); +// let decoded = decode::(token, &key, &Validation::default())?; +// Ok(decoded.claims) +// } +// } + +fn decode_jwt(token: &str, secret: &[u8]) -> Result { + let key = DecodingKey::from_secret(secret); + let decoded = decode::(token, &key, &Validation::default())?; + Ok(decoded.claims) +} + +fn create_jwt(claims: &T, secret: &[u8]) -> Result { + let key = EncodingKey::from_secret(secret); + encode(&Header::default(), claims, &key).map_err(|err| err.into()) } #[derive(Deserialize, Serialize, Clone)] @@ -64,6 +84,7 @@ pub struct VerificationRequest { email_type: EmailType, } +// todo: if the user has already verified the address they're putting into another field, skip email verification? #[post("/verify")] pub async fn send_verification_email( db: web::Data, @@ -131,7 +152,7 @@ pub async fn send_verification_email( // todo: check if the user already has a primary/school email, if so, we need to update it - let claims = Claims { + let claims = VerificationClaims { masked_user_id: masking_key.mask(&user.id), email: verification.email.to_string(), email_type: verification.email_type.clone(), @@ -139,10 +160,9 @@ pub async fn send_verification_email( .timestamp() as usize, }; - match claims.create_jwt() { + match create_jwt(&claims, "secret".as_ref()) { Ok(token) => { - println!("Token: {}", token); - success(format!("http://{}/verify/{}/", HOST, token)) // todo: send email here + success(format!("http://{}/verify_creation/{}/", HOST, token)) // todo: send email here } Err(err) => { error!("Error creating JWT: {}", err); @@ -168,13 +188,13 @@ fn gen_html(content: &str) -> HttpResponse { .body(html) } -#[get("/verify/{token}/")] -pub async fn verify_link( +#[get("/verify_creation/{token}/")] +pub async fn verify_email( db: web::Data, token: web::Path, masking_key: web::Data<&'static MaskingKey>, ) -> HttpResponse { - let claims = match Claims::decode_jwt(&token) { + let claims = match decode_jwt::(&token, "secret".as_ref()) { Ok(claims) => claims, Err(err) => match err.kind() { ErrorKind::ExpiredSignature => { @@ -256,7 +276,9 @@ pub async fn change_primary_email( { Ok(result) => { if result.matched_count == 0 { - Err(Failure::BadRequest("this email type doesn't exist for this user")) + Err(Failure::BadRequest( + "this email type doesn't exist for this user", + )) } else if result.modified_count == 1 { success(()) } else { @@ -267,3 +289,93 @@ pub async fn change_primary_email( Err(_) => Err(Failure::Unexpected), } } + +/// Sends a verification email to the address that is to be deleted +#[delete("/email")] +pub async fn delete_email( + user: AuthenticatedUser, + email_type: web::Json, + masking_key: web::Data<&'static MaskingKey>, +) -> ApiResult { + let claims = DeletionClaims { + masked_user_id: masking_key.mask(&user.id), + email_type: email_type.into_inner(), + exp: (chrono::Utc::now() + chrono::Duration::seconds(EMAIL_VERIFICATION_LINK_EXPIRATION)) + .timestamp() as usize, + }; + match create_jwt(&claims, "secret".as_ref()) { + Ok(token) => { + success(format!("http://{}/verify_deletion/{}/", HOST, token)) // todo: send email here + } + Err(err) => { + error!("Error creating JWT: {}", err); + return Err(Failure::Unexpected); + } + } +} + +#[get("/verify_deletion/{token}/")] +pub async fn verify_deleting_email( + db: web::Data, + mongo_client: web::Data, + token: web::Path, + masking_key: web::Data<&'static MaskingKey>, +) -> HttpResponse { + let claims = match decode_jwt::(&token, "secret".as_ref()) { + Ok(claims) => claims, + Err(err) => match err.kind() { + ErrorKind::ExpiredSignature => { + return gen_html("Verification link expired, please send another email ๐Ÿฅถ") + } + _ => return gen_html("Malformed verification link ๐Ÿคจ"), + }, + }; + let user_id = match masking_key.unmask(&claims.masked_user_id) { + Ok(user_id) => user_id, + Err(_) => return gen_html("Malformed verification link ๐Ÿคจ"), + }; + + let (email_type, opposite_type_label) = match claims.email_type { + EmailType::Personal => ("personal_email", "school"), + EmailType::School => ("school_email", "personal"), + }; + + let update_options = FindOneAndUpdateOptions::builder() + .return_document(mongodb::options::ReturnDocument::After) + .build(); + + let updated_doc = db + .collection::("users") + .find_one_and_update( + doc! { "_id": user_id }, + doc! { "$set": { email_type: null , "primary_email": opposite_type_label} }, + Some(update_options), + ) + .await; + + match updated_doc { + Ok(doc) => { + if let Some(user) = doc { + if user.personal_email.is_none() && user.school_email.is_none() { + let second_update = db + .collection::("users") + .find_one_and_update( + doc! { "_id": user_id }, + doc! { "$set": { "primary_email": "no-email" } }, + None, + ) + .await; + match second_update { + Ok(_) => return gen_html("Email deleted ๐Ÿ—‘"), + Err(_) => return gen_html("Error deleting email, please try again later ๐Ÿ˜ณ"), + } + } else { + return gen_html("do nothing all good"); + } + } else { + return gen_html("error"); + } + } + Err(_) => return gen_html("error"), + } +} diff --git a/src/services/profile.rs b/src/services/profile.rs index ebfc8bf..61d9daf 100644 --- a/src/services/profile.rs +++ b/src/services/profile.rs @@ -6,7 +6,7 @@ use crate::{ api_types::{success, ApiResult, Failure}, auth::AuthenticatedUser, to_unexpected, - types::{PosterFaculty, PosterYearOfStudy, School, User}, + types::{PosterFaculty, PosterYearOfStudy, School, User, PrimaryEmail}, }; use actix_web::{delete, get, post, put, web}; use mongodb::Database; @@ -34,6 +34,12 @@ pub struct ProfileData { pub username: String, /// If the user is verified (via their school email) pub verified: bool, + /// School email of user + pub school_email: Option, + /// Personal email of user + pub personal_email: Option, + /// Primary email + pub primary_email: PrimaryEmail, } /// Fetches user profile information. @@ -52,6 +58,9 @@ pub async fn get_profile( Ok(possible_user) => match possible_user { Some(user) => { return success(ProfileData { + personal_email: user.personal_email, + school_email: user.school_email.to_owned(), + primary_email: user.primary_email, verified: match user.school_email { Some(_) => true, None => false, From ddd30983025ed82fdbcd3270bc70f1ea1c23d72d Mon Sep 17 00:00:00 2001 From: Matthew Date: Mon, 10 Apr 2023 05:48:45 -0700 Subject: [PATCH 07/11] completes email deletion without it actually emailing out the token --- src/services/email.rs | 122 ++++++++++++++++++++++++++---------------- 1 file changed, 77 insertions(+), 45 deletions(-) diff --git a/src/services/email.rs b/src/services/email.rs index a0dea5e..43cc940 100644 --- a/src/services/email.rs +++ b/src/services/email.rs @@ -39,26 +39,7 @@ struct DeletionClaims { // todo: use .env for JWT secrets? // todo: update docs for new routes and alterations to old routes - -trait JWT { - fn create_jwt(&self) -> Result; - fn decode_jwt(token: &str) -> Result - where - Self: Sized; -} - -// impl JWT for Claims { -// fn create_jwt(&self) -> Result { -// let key = EncodingKey::from_secret("secret".as_ref()); -// encode(&Header::default(), self, &key).map_err(|err| err.into()) -// } - -// fn decode_jwt(token: &str) -> Result { -// let key = DecodingKey::from_secret("secret".as_ref()); -// let decoded = decode::(token, &key, &Validation::default())?; -// Ok(decoded.claims) -// } -// } +// todo: will the jwts work multiple times if you use them fast enough? how do I "expire" them after one use? fn decode_jwt(token: &str, secret: &[u8]) -> Result { let key = DecodingKey::from_secret(secret); @@ -128,25 +109,48 @@ pub async fn send_verification_email( .ok_or(Failure::BadRequest("invalid school domain"))?; } - let matching_user = match verification.email_type { - EmailType::Personal => doc! { - "personal_email": &verification.email - }, - EmailType::School => doc! { - "school_email": &verification.email - }, - }; - // is the email already in use? - let potential_user = db - .collection::("users") - .find_one(matching_user, None) - .await - .map_err(to_unexpected!("finding a user with this email failed"))?; + if matches!( + db.collection::("users") + .find_one( + doc! { + "$or": [ + { "personal_email": &verification.email }, + { "school_email": &verification.email } + ] + }, + None + ) + .await + .map_err(to_unexpected!("finding a user with these emails failed"))?, + Some(_) + ) { + return Err(Failure::BadRequest("email already in use")); + } - if let Some(_) = potential_user { + let empty_field_name = match &verification.email_type { + EmailType::Personal => "personal_email", + EmailType::School => "school_email", + }; + + // does the user already have an email of this type? + if matches!( + db.collection::("users") + .find_one( + doc! { + "$and": [ + {"_id": user.id}, + {empty_field_name: {"$eq": null}} + ] + }, + None + ) + .await + .map_err(to_unexpected!("finding a user with THIS TODO"))?, + None + ) { return Err(Failure::BadRequest( - "email already in use for this email type", + "either your account doesn't exist, or you already have an email of this type", )); } @@ -229,12 +233,20 @@ pub async fn verify_email( }, }; + let empty_field_name = match claims.email_type { + EmailType::Personal => "personal_email", + EmailType::School => "school_email", + }; + // update user with newly verified email match db .collection::("users") .update_one( doc! { - "_id": user_id + "$and": [ + {"_id": user_id}, + {empty_field_name: {"$eq": null}} + ] }, update_doc, None, @@ -242,7 +254,7 @@ pub async fn verify_email( .await { Ok(result) => { - if result.modified_count == 1 { + if result.modified_count == 1 { gen_html("Email verified โœ…") } else { gen_html("Email already verified ๐Ÿ˜…") @@ -344,12 +356,25 @@ pub async fn verify_deleting_email( .return_document(mongodb::options::ReturnDocument::After) .build(); + // start session + let mut session = match mongo_client.start_session(None).await { + Ok(session) => session, + Err(_) => return gen_html("Error verifying email, please try again later ๐Ÿ˜ณ"), + }; + + // start transaction + match session.start_transaction(None).await { + Ok(_) => {} + Err(_) => return gen_html("Error verifying email, please try again later ๐Ÿ˜ณ"), + } + let updated_doc = db .collection::("users") - .find_one_and_update( + .find_one_and_update_with_session( doc! { "_id": user_id }, doc! { "$set": { email_type: null , "primary_email": opposite_type_label} }, Some(update_options), + &mut session, ) .await; @@ -359,23 +384,30 @@ pub async fn verify_deleting_email( if user.personal_email.is_none() && user.school_email.is_none() { let second_update = db .collection::("users") - .find_one_and_update( + .find_one_and_update_with_session( doc! { "_id": user_id }, doc! { "$set": { "primary_email": "no-email" } }, None, + &mut session, ) .await; match second_update { - Ok(_) => return gen_html("Email deleted ๐Ÿ—‘"), - Err(_) => return gen_html("Error deleting email, please try again later ๐Ÿ˜ณ"), + Ok(_) => {} + Err(_) => { + return gen_html("Error deleting email, please try again later ๐Ÿ˜ณ") + } } - } else { - return gen_html("do nothing all good"); } } else { - return gen_html("error"); + return gen_html("Error deleting email, please try again later ๐Ÿ˜ณ"); } } Err(_) => return gen_html("error"), } + match session.commit_transaction().await { + Ok(_) => return gen_html("Email deleted successfully โœ…"), + Err(_) => { + return gen_html("Error deleting email, please try again later ๐Ÿ˜ณ"); + } + } } From 4b8197a8814224a821098766ccad02b0ebba0432 Mon Sep 17 00:00:00 2001 From: Matthew Date: Mon, 10 Apr 2023 06:38:12 -0700 Subject: [PATCH 08/11] updates docs for profile route; adds fetching jwt secrets from env --- .env | 1 + compose.yaml | 1 + docs/openapi.yaml | 8 ++++++++ src/main.rs | 6 ++++++ src/services/email.rs | 12 ++++++++---- 5 files changed, 24 insertions(+), 4 deletions(-) diff --git a/.env b/.env index d036512..0605bda 100644 --- a/.env +++ b/.env @@ -1,2 +1,3 @@ RUST_LOG=debug OID_SECRET=fd3a6709f65e9e8f502bb456f15fa909 +JWT_SECRET=temporaryjwtsecretfortesting diff --git a/compose.yaml b/compose.yaml index f5c3c88..52f4c8a 100644 --- a/compose.yaml +++ b/compose.yaml @@ -10,6 +10,7 @@ services: environment: DB_CONNECT: mongodb://mongo/db?directConnection=true&readConcernLevel=majority OID_SECRET: + JWT_SECRET: RUST_LOG: read_only: true init: true diff --git a/docs/openapi.yaml b/docs/openapi.yaml index 9773264..9eb2245 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -214,6 +214,14 @@ paths: type: string username: type: string + verified: + type: boolean + school_email: + type: string + personal_email: + type: string + primary_email: + type: string '400': content: application/json: diff --git a/src/main.rs b/src/main.rs index d4c6f3c..78ff735 100644 --- a/src/main.rs +++ b/src/main.rs @@ -188,6 +188,11 @@ fn open_geoip_database() -> Result> { async fn main() -> Result<(), Box> { env_logger::init_from_env(env_logger::Env::default()); + let jwt_secret = env::var("JWT_SECRET") + .expect("JWT_SECRET environment variable not set") + .into_bytes(); + + let masking_key: &'static MaskingKey = { let mut key_bytes = [0; 16]; // TODO: read this from `/run/secrets` instead @@ -239,6 +244,7 @@ async fn main() -> Result<(), Box> { .app_data(web::Data::new(db.clone())) .app_data(web::Data::new(geoip_reader)) .app_data(web::Data::new(masking_key)) + .app_data(web::Data::new(jwt_secret.clone())) .service(services::schools_list) .service(services::auth::login) .service(services::auth::logout) diff --git a/src/services/email.rs b/src/services/email.rs index 43cc940..cfa0bf2 100644 --- a/src/services/email.rs +++ b/src/services/email.rs @@ -68,6 +68,7 @@ pub struct VerificationRequest { // todo: if the user has already verified the address they're putting into another field, skip email verification? #[post("/verify")] pub async fn send_verification_email( + jwt_secret: web::Data>, db: web::Data, masking_key: web::Data<&'static MaskingKey>, user: AuthenticatedUser, @@ -164,7 +165,7 @@ pub async fn send_verification_email( .timestamp() as usize, }; - match create_jwt(&claims, "secret".as_ref()) { + match create_jwt(&claims, jwt_secret.as_ref()) { Ok(token) => { success(format!("http://{}/verify_creation/{}/", HOST, token)) // todo: send email here } @@ -194,11 +195,12 @@ fn gen_html(content: &str) -> HttpResponse { #[get("/verify_creation/{token}/")] pub async fn verify_email( + jwt_secret: web::Data>, db: web::Data, token: web::Path, masking_key: web::Data<&'static MaskingKey>, ) -> HttpResponse { - let claims = match decode_jwt::(&token, "secret".as_ref()) { + let claims = match decode_jwt::(&token, jwt_secret.as_ref()) { Ok(claims) => claims, Err(err) => match err.kind() { ErrorKind::ExpiredSignature => { @@ -305,6 +307,7 @@ pub async fn change_primary_email( /// Sends a verification email to the address that is to be deleted #[delete("/email")] pub async fn delete_email( + jwt_secret: web::Data>, user: AuthenticatedUser, email_type: web::Json, masking_key: web::Data<&'static MaskingKey>, @@ -315,7 +318,7 @@ pub async fn delete_email( exp: (chrono::Utc::now() + chrono::Duration::seconds(EMAIL_VERIFICATION_LINK_EXPIRATION)) .timestamp() as usize, }; - match create_jwt(&claims, "secret".as_ref()) { + match create_jwt(&claims, jwt_secret.get_ref()) { Ok(token) => { success(format!("http://{}/verify_deletion/{}/", HOST, token)) // todo: send email here } @@ -329,11 +332,12 @@ pub async fn delete_email( #[get("/verify_deletion/{token}/")] pub async fn verify_deleting_email( db: web::Data, + jwt_secret: web::Data>, mongo_client: web::Data, token: web::Path, masking_key: web::Data<&'static MaskingKey>, ) -> HttpResponse { - let claims = match decode_jwt::(&token, "secret".as_ref()) { + let claims = match decode_jwt::(&token, jwt_secret.as_ref()) { Ok(claims) => claims, Err(err) => match err.kind() { ErrorKind::ExpiredSignature => { From a817335bd1f4b6aeef8b0619d9737fe0135d2052 Mon Sep 17 00:00:00 2001 From: Matthew Date: Tue, 11 Apr 2023 03:54:41 -0700 Subject: [PATCH 09/11] adds docs for /verify_link and /verify_unlink routes --- docs/openapi.yaml | 82 +++++++++++++++++++++++++++++++++++++++++++ src/main.rs | 4 +-- src/services/email.rs | 8 ++--- 3 files changed, 88 insertions(+), 6 deletions(-) diff --git a/docs/openapi.yaml b/docs/openapi.yaml index 9eb2245..56dfaaa 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -549,3 +549,85 @@ paths: - type: 'null' - type: number minimum: 0 + /verify_link: + get: + summary: Fetch a verification link for adding a new email address to a user's account + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - email + - email_type + properties: + email: + type: string + email_type: + type: string + enum: + - personal + - school + responses: + '400': + content: + application/json: + schema: + type: 'object' + properties: + BadRequest: + type: string + '401': + $ref: '#/components/responses/unauthenticated' + '500': + $ref: '#/components/responses/unexpected' + '200': + content: + application/json: + schema: + type: object + properties: + value: + type: string + example: http://localhost:3000/verify_creation// + error: + type: string + example: null + /verify_unlink: + get: + summary: Fetch a verification link for removing an email address from a user's account + requestBody: + required: true + content: + application/json: + schema: + type: string + enum: + - school + - personal + responses: + '400': + content: + application/json: + schema: + type: 'object' + properties: + BadRequest: + type: string + '401': + $ref: '#/components/responses/unauthenticated' + '500': + $ref: '#/components/responses/unexpected' + '200': + content: + application/json: + schema: + type: object + properties: + value: + type: string + example: http://localhost:3000/verify_deletion// + error: + type: string + example: null diff --git a/src/main.rs b/src/main.rs index 78ff735..ac2adc1 100644 --- a/src/main.rs +++ b/src/main.rs @@ -260,9 +260,9 @@ async fn main() -> Result<(), Box> { .service(services::profile::add_watched) .service(services::profile::delete_watched) .service(services::email::verify_email) - .service(services::email::send_verification_email) + .service(services::email::send_verification_email_to_link_email) .service(services::email::change_primary_email) - .service(services::email::delete_email) + .service(services::email::send_verification_email_to_unlink_email) .service(services::email::verify_deleting_email) }) .bind(("0.0.0.0", 3000))? diff --git a/src/services/email.rs b/src/services/email.rs index cfa0bf2..c714dc9 100644 --- a/src/services/email.rs +++ b/src/services/email.rs @@ -66,8 +66,8 @@ pub struct VerificationRequest { } // todo: if the user has already verified the address they're putting into another field, skip email verification? -#[post("/verify")] -pub async fn send_verification_email( +#[get("/verify_link")] +pub async fn send_verification_email_to_link_email( jwt_secret: web::Data>, db: web::Data, masking_key: web::Data<&'static MaskingKey>, @@ -305,8 +305,8 @@ pub async fn change_primary_email( } /// Sends a verification email to the address that is to be deleted -#[delete("/email")] -pub async fn delete_email( +#[get("/verify_unlink")] +pub async fn send_verification_email_to_unlink_email( jwt_secret: web::Data>, user: AuthenticatedUser, email_type: web::Json, From a618f0a8deb9e589e0cb028545d1f1314fbe3909 Mon Sep 17 00:00:00 2001 From: Matthew Date: Tue, 11 Apr 2023 16:55:55 -0700 Subject: [PATCH 10/11] adds docs for routes that delete/create an email with your account after opening link in email --- docs/openapi.yaml | 89 +++++++++++++++++++++++++++++++++++++++++++ src/services/email.rs | 13 +++++-- 2 files changed, 98 insertions(+), 4 deletions(-) diff --git a/docs/openapi.yaml b/docs/openapi.yaml index 56dfaaa..3b74ecc 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -631,3 +631,92 @@ paths: error: type: string example: null + /email: + put: + summary: Change the user's primary email address + requestBody: + required: true + content: + application/json: + schema: + type: string + example: personal + enum: + - school + - personal + responses: + '400': + content: + application/json: + schema: + type: 'object' + properties: + BadRequest: + type: string + '401': + $ref: '#/components/responses/unauthenticated' + '500': + $ref: '#/components/responses/unexpected' + '200': + content: + application/json: + schema: + type: object + properties: + value: + type: string + error: + type: string + /verify_creation/{token}/: + get: + summary: Verifies the addition of an email to a user's account via opening email link + parameters: + - name: token + in: path + required: true + schema: + type: string + responses: + '200': + description: OK + content: + text/html: + schema: + type: string + example: | + + + + Email verification + + +

SOME MESSAGE

+ + + /verify_deletion/{token}/: + get: + summary: Verifies the deletion of an email to a user's account via opening email link + parameters: + - name: token + in: path + required: true + schema: + type: string + responses: + '200': + description: OK + content: + text/html: + schema: + type: string + example: | + + + + Email verification + + +

SOME MESSAGE

+ + + diff --git a/src/services/email.rs b/src/services/email.rs index c714dc9..b4964a3 100644 --- a/src/services/email.rs +++ b/src/services/email.rs @@ -13,7 +13,7 @@ use crate::{ to_unexpected, types::{School, User}, }; -use actix_web::{delete, get, post, put, web, HttpResponse}; +use actix_web::{get, post, put, web, HttpResponse}; use jsonwebtoken::{ decode, encode, errors::{Error, ErrorKind}, @@ -178,7 +178,8 @@ pub async fn send_verification_email_to_link_email( fn gen_html(content: &str) -> HttpResponse { let html = format!( - " + " + Email verification @@ -193,6 +194,8 @@ fn gen_html(content: &str) -> HttpResponse { .body(html) } +// todo: should this be a POST request? It's creating a resource, but it needs to be called directly when +// todo: the link is opened in the browswer, which initiates a GET request. #[get("/verify_creation/{token}/")] pub async fn verify_email( jwt_secret: web::Data>, @@ -318,7 +321,7 @@ pub async fn send_verification_email_to_unlink_email( exp: (chrono::Utc::now() + chrono::Duration::seconds(EMAIL_VERIFICATION_LINK_EXPIRATION)) .timestamp() as usize, }; - match create_jwt(&claims, jwt_secret.get_ref()) { + match create_jwt(&claims, jwt_secret.as_ref()) { Ok(token) => { success(format!("http://{}/verify_deletion/{}/", HOST, token)) // todo: send email here } @@ -329,6 +332,8 @@ pub async fn send_verification_email_to_unlink_email( } } +// todo: should this be a POST request? It's creating a resource, but it needs to be called directly when +// todo: the link is opened in the browswer, which initiates a GET request. #[get("/verify_deletion/{token}/")] pub async fn verify_deleting_email( db: web::Data, @@ -406,7 +411,7 @@ pub async fn verify_deleting_email( return gen_html("Error deleting email, please try again later ๐Ÿ˜ณ"); } } - Err(_) => return gen_html("error"), + Err(_) => return gen_html("Error deleting email, please try again later ๐Ÿ˜ณ"), } match session.commit_transaction().await { Ok(_) => return gen_html("Email deleted successfully โœ…"), From e10fd81e44e694f73f7762272da4302681d039b0 Mon Sep 17 00:00:00 2001 From: Matthew Date: Tue, 11 Apr 2023 18:29:43 -0700 Subject: [PATCH 11/11] minor cleanup; fixes doc inconsistencies --- .env | 2 +- compose.yaml | 2 +- docs/openapi.yaml | 12 +- src/main.rs | 6 +- src/services/email.rs | 254 ++++++++++++++++++++++++++---------------- 5 files changed, 171 insertions(+), 105 deletions(-) diff --git a/.env b/.env index 0605bda..b84850d 100644 --- a/.env +++ b/.env @@ -1,3 +1,3 @@ RUST_LOG=debug OID_SECRET=fd3a6709f65e9e8f502bb456f15fa909 -JWT_SECRET=temporaryjwtsecretfortesting +EMAIL_VERIFICTION_SECRET=temporaryjwtsecretfortesting diff --git a/compose.yaml b/compose.yaml index 52f4c8a..c8e2abf 100644 --- a/compose.yaml +++ b/compose.yaml @@ -10,7 +10,7 @@ services: environment: DB_CONNECT: mongodb://mongo/db?directConnection=true&readConcernLevel=majority OID_SECRET: - JWT_SECRET: + EMAIL_VERIFICTION_SECRET: RUST_LOG: read_only: true init: true diff --git a/docs/openapi.yaml b/docs/openapi.yaml index 3b74ecc..e62feda 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -549,7 +549,7 @@ paths: - type: 'null' - type: number minimum: 0 - /verify_link: + /verify-link: get: summary: Fetch a verification link for adding a new email address to a user's account requestBody: @@ -590,11 +590,11 @@ paths: properties: value: type: string - example: http://localhost:3000/verify_creation// + example: http://localhost:3000/verify-link// error: type: string example: null - /verify_unlink: + /verify-unlink: get: summary: Fetch a verification link for removing an email address from a user's account requestBody: @@ -627,7 +627,7 @@ paths: properties: value: type: string - example: http://localhost:3000/verify_deletion// + example: http://localhost:3000/verify-unlink// error: type: string example: null @@ -667,7 +667,7 @@ paths: type: string error: type: string - /verify_creation/{token}/: + /verify-link/{token}/: get: summary: Verifies the addition of an email to a user's account via opening email link parameters: @@ -693,7 +693,7 @@ paths:

SOME MESSAGE

- /verify_deletion/{token}/: + /verify-unlink/{token}/: get: summary: Verifies the deletion of an email to a user's account via opening email link parameters: diff --git a/src/main.rs b/src/main.rs index ac2adc1..ab9af2d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -188,8 +188,8 @@ fn open_geoip_database() -> Result> { async fn main() -> Result<(), Box> { env_logger::init_from_env(env_logger::Env::default()); - let jwt_secret = env::var("JWT_SECRET") - .expect("JWT_SECRET environment variable not set") + let email_verifiction_secret = env::var("EMAIL_VERIFICTION_SECRET") + .expect("EMAIL_VERIFICTION_SECRET environment variable not set") .into_bytes(); @@ -244,7 +244,7 @@ async fn main() -> Result<(), Box> { .app_data(web::Data::new(db.clone())) .app_data(web::Data::new(geoip_reader)) .app_data(web::Data::new(masking_key)) - .app_data(web::Data::new(jwt_secret.clone())) + .app_data(web::Data::new(email_verifiction_secret.clone())) .service(services::schools_list) .service(services::auth::login) .service(services::auth::logout) diff --git a/src/services/email.rs b/src/services/email.rs index b4964a3..ff50889 100644 --- a/src/services/email.rs +++ b/src/services/email.rs @@ -1,7 +1,7 @@ use chrono; use log::error; use mongodb::bson::{doc, to_bson}; -use mongodb::options::{FindOneAndUpdateOptions, UpdateOptions}; +use mongodb::options::FindOneAndUpdateOptions; use mongodb::Client as MongoClient; use regex::Regex; @@ -13,7 +13,7 @@ use crate::{ to_unexpected, types::{School, User}, }; -use actix_web::{get, post, put, web, HttpResponse}; +use actix_web::{get, put, web, HttpResponse}; use jsonwebtoken::{ decode, encode, errors::{Error, ErrorKind}, @@ -37,10 +37,6 @@ struct DeletionClaims { exp: usize, } -// todo: use .env for JWT secrets? -// todo: update docs for new routes and alterations to old routes -// todo: will the jwts work multiple times if you use them fast enough? how do I "expire" them after one use? - fn decode_jwt(token: &str, secret: &[u8]) -> Result { let key = DecodingKey::from_secret(secret); let decoded = decode::(token, &key, &Validation::default())?; @@ -52,6 +48,25 @@ fn create_jwt(claims: &T, secret: &[u8]) -> Result encode(&Header::default(), claims, &key).map_err(|err| err.into()) } +/// Generates a basic HTML webpage with the given text content +fn gen_html(content: &str) -> HttpResponse { + let html = format!( + " + + + Email verification + + +

{}

+ + ", + content + ); + HttpResponse::Ok() + .content_type("text/html; charset=utf-8") + .body(html) +} + #[derive(Deserialize, Serialize, Clone)] #[serde(rename_all = "kebab-case")] pub enum EmailType { @@ -65,29 +80,29 @@ pub struct VerificationRequest { email_type: EmailType, } -// todo: if the user has already verified the address they're putting into another field, skip email verification? -#[get("/verify_link")] +#[get("/verify-link")] pub async fn send_verification_email_to_link_email( - jwt_secret: web::Data>, + email_verifiction_secret: web::Data>, db: web::Data, masking_key: web::Data<&'static MaskingKey>, user: AuthenticatedUser, verification: web::Json, ) -> ApiResult { - // todo: gmail ignores dots? outlook doesn't? what about other providers? should I force remove dots from the email (or just ignore them)? + // todo: some providers ignore the "." in emails, should we do the same? (e.g. "john.doe" and "johndoe" are the same) // validate the email let email_matcher = Regex::new( r"(?i)^([a-z0-9_+]([a-z0-9_+.]*[a-z0-9_+])?)@([a-z0-9]+([\-\.]{1}[a-z0-9]+)*\.[a-z]{2,6})", ) .unwrap(); + if (!email_matcher.is_match(&verification.email)) { return Err(Failure::BadRequest("incorrectly formatted email")); } else if verification.email.contains("+") { return Err(Failure::BadRequest("email can't be an alias")); } - // if we're trying to verify a school email, make sure the domain is valid + // if we're trying to verify a school email, ensure the domain is valid if matches!(verification.email_type, EmailType::School) { let domain = &verification .email @@ -147,7 +162,9 @@ pub async fn send_verification_email_to_link_email( None ) .await - .map_err(to_unexpected!("finding a user with THIS TODO"))?, + .map_err(to_unexpected!( + "finding a user without email for this email type" + ))?, None ) { return Err(Failure::BadRequest( @@ -155,8 +172,6 @@ pub async fn send_verification_email_to_link_email( )); } - // todo: check if the user already has a primary/school email, if so, we need to update it - let claims = VerificationClaims { masked_user_id: masking_key.mask(&user.id), email: verification.email.to_string(), @@ -165,9 +180,9 @@ pub async fn send_verification_email_to_link_email( .timestamp() as usize, }; - match create_jwt(&claims, jwt_secret.as_ref()) { + match create_jwt(&claims, email_verifiction_secret.as_ref()) { Ok(token) => { - success(format!("http://{}/verify_creation/{}/", HOST, token)) // todo: send email here + success(format!("http://{}/verify-link/{}/", HOST, token)) // todo: send email here } Err(err) => { error!("Error creating JWT: {}", err); @@ -176,34 +191,17 @@ pub async fn send_verification_email_to_link_email( } } -fn gen_html(content: &str) -> HttpResponse { - let html = format!( - " - - - Email verification - - -

{}

- - ", - content - ); - HttpResponse::Ok() - .content_type("text/html; charset=utf-8") - .body(html) -} - // todo: should this be a POST request? It's creating a resource, but it needs to be called directly when // todo: the link is opened in the browswer, which initiates a GET request. -#[get("/verify_creation/{token}/")] +#[get("/verify-link/{token}/")] pub async fn verify_email( - jwt_secret: web::Data>, + mongo_client: web::Data, + email_verifiction_secret: web::Data>, db: web::Data, token: web::Path, masking_key: web::Data<&'static MaskingKey>, ) -> HttpResponse { - let claims = match decode_jwt::(&token, jwt_secret.as_ref()) { + let claims = match decode_jwt::(&token, email_verifiction_secret.as_ref()) { Ok(claims) => claims, Err(err) => match err.kind() { ErrorKind::ExpiredSignature => { @@ -223,6 +221,82 @@ pub async fn verify_email( Err(_) => return gen_html("Error verifying email, please try again later ๐Ÿ˜ณ"), }; + // start session + let mut session = match mongo_client.start_session(None).await { + Ok(session) => session, + Err(_) => return gen_html("Error verifying email, please try again later ๐Ÿ˜ณ"), + }; + + // start transaction + match session.start_transaction(None).await { + Ok(_) => {} + Err(_) => return gen_html("Error verifying email, please try again later ๐Ÿ˜ณ"), + } + + // is the email already in use? + if matches!( + match db + .collection::("users") + .find_one_with_session( + doc! { + "$or": [ + { "personal_email": &claims.email }, + { "school_email": &claims.email } + ] + }, + None, + &mut session + ) + .await + { + Ok(user) => user, + Err(err) => { + error!("Error finding user with email: {}", err); + return gen_html("Error verifying email, please try again later ๐Ÿ˜ณ"); + } + }, + Some(_) + ) { + return gen_html("Email already in use ๐Ÿคจ"); + } + + let empty_field_name = match &claims.email_type { + EmailType::Personal => "personal_email", + EmailType::School => "school_email", + }; + + let unmasked_user_id = match masking_key.unmask(&claims.masked_user_id) { + Ok(user_id) => user_id, + Err(_) => return gen_html("Malformed verification link ๐Ÿคจ"), + }; + + // does the user already have an email of this type? + if matches!( + match db + .collection::("users") + .find_one_with_session( + doc! { + "$and": [ + {"_id": unmasked_user_id}, + {empty_field_name: {"$eq": null}} + ] + }, + None, + &mut session + ) + .await + { + Ok(user) => user, + Err(err) => { + error!("Error finding user with email: {}", err); + return gen_html("Error verifying email, please try again later ๐Ÿ˜ณ"); + } + }, + None + ) { + return gen_html("You already have an email of this type ๐Ÿคจ"); + } + let update_doc = match claims.email_type { EmailType::Personal => doc! { "$set": { @@ -238,15 +312,10 @@ pub async fn verify_email( }, }; - let empty_field_name = match claims.email_type { - EmailType::Personal => "personal_email", - EmailType::School => "school_email", - }; - // update user with newly verified email match db .collection::("users") - .update_one( + .update_one_with_session( doc! { "$and": [ {"_id": user_id}, @@ -255,62 +324,26 @@ pub async fn verify_email( }, update_doc, None, + &mut session ) .await { - Ok(result) => { - if result.modified_count == 1 { - gen_html("Email verified โœ…") - } else { - gen_html("Email already verified ๐Ÿ˜…") + Ok(_) => { + match session.commit_transaction().await { + Ok(_) => return gen_html("Email verified โœ…"), + Err(_) => { + return gen_html("Error deleting email, please try again later ๐Ÿ˜ณ"); + } } - } + }, Err(_) => gen_html("Error verifying email, please try again later ๐Ÿ˜ณ"), } } -// todo: add what type of primary email a user has to their account and what it is so they can fetch it - -#[put("/email")] -pub async fn change_primary_email( - db: web::Data, - user: AuthenticatedUser, - email_type: web::Json, -) -> ApiResult<(), ()> { - let (not_null_field, update_name) = match &email_type.into_inner() { - EmailType::Personal => ("personal_email", "personal"), - EmailType::School => ("school_email", "school"), - }; - - match db - .collection::("users") - .update_one( - doc! {"_id": user.id, not_null_field: { "$ne": null }}, // query - doc! {"$set": {"primary_email": update_name}}, // update - None, - ) - .await - { - Ok(result) => { - if result.matched_count == 0 { - Err(Failure::BadRequest( - "this email type doesn't exist for this user", - )) - } else if result.modified_count == 1 { - success(()) - } else { - println!("already changed"); - success(()) - } - } - Err(_) => Err(Failure::Unexpected), - } -} - /// Sends a verification email to the address that is to be deleted -#[get("/verify_unlink")] +#[get("/verify-unlink")] pub async fn send_verification_email_to_unlink_email( - jwt_secret: web::Data>, + email_verifiction_secret: web::Data>, user: AuthenticatedUser, email_type: web::Json, masking_key: web::Data<&'static MaskingKey>, @@ -321,9 +354,9 @@ pub async fn send_verification_email_to_unlink_email( exp: (chrono::Utc::now() + chrono::Duration::seconds(EMAIL_VERIFICATION_LINK_EXPIRATION)) .timestamp() as usize, }; - match create_jwt(&claims, jwt_secret.as_ref()) { + match create_jwt(&claims, email_verifiction_secret.as_ref()) { Ok(token) => { - success(format!("http://{}/verify_deletion/{}/", HOST, token)) // todo: send email here + success(format!("http://{}/verify-unlink/{}/", HOST, token)) // todo: send email here } Err(err) => { error!("Error creating JWT: {}", err); @@ -334,15 +367,15 @@ pub async fn send_verification_email_to_unlink_email( // todo: should this be a POST request? It's creating a resource, but it needs to be called directly when // todo: the link is opened in the browswer, which initiates a GET request. -#[get("/verify_deletion/{token}/")] +#[get("/verify-unlink/{token}/")] pub async fn verify_deleting_email( db: web::Data, - jwt_secret: web::Data>, + email_verifiction_secret: web::Data>, mongo_client: web::Data, token: web::Path, masking_key: web::Data<&'static MaskingKey>, ) -> HttpResponse { - let claims = match decode_jwt::(&token, jwt_secret.as_ref()) { + let claims = match decode_jwt::(&token, email_verifiction_secret.as_ref()) { Ok(claims) => claims, Err(err) => match err.kind() { ErrorKind::ExpiredSignature => { @@ -420,3 +453,36 @@ pub async fn verify_deleting_email( } } } + +#[put("/email")] +pub async fn change_primary_email( + db: web::Data, + user: AuthenticatedUser, + email_type: web::Json, +) -> ApiResult<(), ()> { + let (not_null_field, update_name) = match &email_type.into_inner() { + EmailType::Personal => ("personal_email", "personal"), + EmailType::School => ("school_email", "school"), + }; + + // does the user have an email of this type? If so, update their primary email to it + match db + .collection::("users") + .update_one( + doc! {"_id": user.id, not_null_field: { "$ne": null }}, // query + doc! {"$set": {"primary_email": update_name}}, // update + None, + ) + .await + { + Ok(result) => { + if result.matched_count == 0 { + return Err(Failure::BadRequest( + "user doesn't have this type of email set", + )); + } + success(()) + } + Err(_) => Err(Failure::Unexpected), + } +}