From ae327c1c91e04619eabbc8394f70ce80871aeda9 Mon Sep 17 00:00:00 2001 From: eraden Date: Fri, 9 Feb 2024 21:06:04 +0100 Subject: [PATCH] Forgot pass endpoint --- .../api/authentication/forgot_password.rs | 297 ++++++++++++++++++ 1 file changed, 297 insertions(+) create mode 100644 crates/jet-api/src/http/api/authentication/forgot_password.rs diff --git a/crates/jet-api/src/http/api/authentication/forgot_password.rs b/crates/jet-api/src/http/api/authentication/forgot_password.rs new file mode 100644 index 0000000..b5253b9 --- /dev/null +++ b/crates/jet-api/src/http/api/authentication/forgot_password.rs @@ -0,0 +1,297 @@ +use actix_web::web::Json; +use actix_web::{post, HttpRequest, HttpResponse}; +use entities::users::Column; +use jet_contract::event_bus::{EmailMsg, Topic}; +use serde::{Deserialize, Serialize}; +use serde_json::json; +use tracing::*; + +use super::*; +use crate::extractors::RequireInstanceConfigured; +use crate::models::{JsonError, PasswordResetSecret}; +use crate::utils::{extract_req_current_site, pass_reset_token, uidb}; +use crate::{db_commit, db_rollback, db_t, DatabaseConnection, EventBusClient}; + +#[derive(Debug, Deserialize, Serialize)] +struct Input { + email: String, +} + +#[post("/forgot-password")] +pub async fn forgot_password( + _: RequireInstanceConfigured, + req: HttpRequest, + input: Json, + db: Data, + event_bus: Data, + secret: Data, +) -> Result { + let mut t = db_t!(db)?; + + match try_forgot_password(req, input, &mut t, event_bus, secret).await { + Ok(r) => { + db_commit!(t)?; + Ok(r) + } + Err(e) => { + db_rollback!(t).ok(); + Err(e) + } + } +} + +async fn try_forgot_password( + req: HttpRequest, + input: Json, + t: &mut DatabaseTransaction, + event_bus: Data, + secret: Data, +) -> Result { + let email = input.into_inner().email; + + if let Err(e) = EmailAllowComment::validate_str(&email) { + warn!("Received invalid email: {email:?} {e}"); + return Err(JsonError::new("Please enter a valid email")); + } + + let user = Users::find() + .filter(Column::Email.eq(&email)) + .one(&mut *t) + .await + .map_err(|e| { + error!("Failed to load user for forgot-password: {e}"); + Error::DatabaseError + })? + .ok_or_else(|| JsonError::new("Please check the email"))?; + + let current_site = extract_req_current_site(&req).map_err(|e| { + warn!("Request does not contains site information: {e}"); + Error::NoHost + })?; + + let uidb = uidb::encode(user.id); + let token = pass_reset_token::make_token(&user, &**secret); + + if let Err(e) = event_bus + .publish( + Topic::Email, + jet_contract::event_bus::Msg::Email(EmailMsg::ForgotPassword { + current_site, + token, + uidb, + first_name: user.first_name, + }), + rumqttc::QoS::AtLeastOnce, + true, + ) + .await + { + error!("Failed to send forgot pass email event: {e}"); + Err( + JsonError::new("Something went wrong. Please contact administrator.") + .with_status(StatusCode::INTERNAL_SERVER_ERROR), + ) + } else { + Ok( + HttpResponse::Ok() + .json(json!({ "message": "Check your email to reset your password" })), + ) + } +} + +#[cfg(test)] +mod tests { + use actix_jwt_session::{ + Hashing, SessionMiddlewareFactory, JWT_COOKIE_NAME, JWT_HEADER_NAME, REFRESH_COOKIE_NAME, + REFRESH_HEADER_NAME, + }; + use actix_web::body::to_bytes; + use actix_web::web::Data; + use actix_web::{test, App}; + use jet_contract::deadpool_redis; + use reqwest::{Method, StatusCode}; + use sea_orm::Database; + use serde_json::json; + use tracing_test::traced_test; + use uuid::Uuid; + + use super::*; + use crate::session; + + const OLD_PASS: &str = "qwertyQWERTY12345!@#$%"; + + macro_rules! create_app { + ($app: ident, $session_storage: ident, $db: ident) => { + std::env::set_var("DATABASE_URL", "postgres://postgres@0.0.0.0:5432/jet_test"); + let redis = deadpool_redis::Config::from_url("redis://0.0.0.0:6379") + .create_pool(Some(deadpool_redis::Runtime::Tokio1)) + .expect("Can't connect to redis"); + let $db: sea_orm::prelude::DatabaseConnection = + Database::connect("postgres://postgres@0.0.0.0:5432/jet_test") + .await + .expect("Failed to connect to database"); + let ($session_storage, factory) = + SessionMiddlewareFactory::::build_ed_dsa() + .with_redis_pool(redis.clone()) + // Check if header "Authorization" exists and contains Bearer with encoded JWT + .with_jwt_header(JWT_HEADER_NAME) + // Check if cookie JWT exists and contains encoded JWT + .with_jwt_cookie(JWT_COOKIE_NAME) + .with_refresh_header(REFRESH_HEADER_NAME) + // Check if cookie JWT exists and contains encoded JWT + .with_refresh_cookie(REFRESH_COOKIE_NAME) + .with_jwt_json(&["access_token"]) + .finish(); + let $db = Data::new($db.clone()); + ensure_instance($db.clone()).await; + let $app = test::init_service( + App::new() + .app_data(Data::new($session_storage.clone())) + .app_data($db.clone()) + .app_data(Data::new(redis)) + .wrap(actix_web::middleware::NormalizePath::trim()) + .wrap(actix_web::middleware::Logger::default()) + .wrap(factory) + .service(forgot_password), + ) + .await; + }; + } + + async fn ensure_instance(db: Data) { + use entities::instances::*; + use entities::prelude::Instances; + use sea_orm::*; + + if Instances::find().count(&**db).await.unwrap() > 0 { + return; + } + ActiveModel { + instance_name: Set("Plan Free".into()), + is_setup_done: Set(true), + ..Default::default() + } + .save(&**db) + .await + .unwrap(); + } + + async fn create_user( + db: Data, + user_name: &str, + pass: &str, + ) -> entities::users::Model { + use entities::users::*; + use sea_orm::*; + + if let Ok(Some(user)) = Users::find() + .filter(Column::Email.eq(format!("{user_name}@example.com"))) + .one(&**db) + .await + { + return user; + } + + let pass = Hashing::encrypt(pass).unwrap(); + + Users::insert(ActiveModel { + password: Set(pass), + email: Set(Some(format!("{user_name}@example.com"))), + display_name: Set(user_name.to_string()), + username: Set(Uuid::new_v4().to_string()), + first_name: Set("".to_string()), + last_name: Set("".to_string()), + last_location: Set("".to_string()), + created_location: Set("".to_string()), + is_password_autoset: Set(false), + token: Set(Uuid::new_v4().to_string()), + billing_address_country: Set("".to_string()), + user_timezone: Set("UTC".to_string()), + last_login_ip: Set("0.0.0.0".to_string()), + last_login_medium: Set("None".to_string()), + last_logout_ip: Set("0.0.0.0".to_string()), + last_login_uagent: Set("test".to_string()), + is_active: Set(true), + avatar: Set("".to_string()), + ..Default::default() + }) + .exec_with_returning(&**db) + .await + .unwrap() + } + + #[traced_test] + #[actix_web::test] + async fn no_user() { + create_app!(app, session, db); + + let _user = create_user(db.clone(), "no_user_forgot_pass", OLD_PASS).await; + let req = test::TestRequest::default() + .set_json(Input { + email: "a89hsd9hasd@dsa9hs8.com".to_string(), + }) + .uri("/forgot-password") + .method(Method::POST) + .to_request(); + + let resp = test::call_service(&app, req).await; + + assert_eq!(resp.status(), StatusCode::BAD_REQUEST); + + let body = resp.into_body(); + let json: serde_json::Value = + serde_json::from_slice(&to_bytes(body).await.unwrap()[..]).unwrap(); + let expected = json!({ "error": "Please check the email" }); + assert_eq!(json, expected); + } + + #[traced_test] + #[actix_web::test] + async fn invalid_email() { + create_app!(app, session, db); + + let _user = create_user(db.clone(), "no_user_forgot_pass", OLD_PASS).await; + let req = test::TestRequest::default() + .set_json(Input { + email: format!("no_user_forgot::??_pass@example.11"), + }) + .uri("/forgot-password") + .method(Method::POST) + .to_request(); + + let resp = test::call_service(&app, req).await; + + assert_eq!(resp.status(), StatusCode::BAD_REQUEST); + + let body = resp.into_body(); + let json: serde_json::Value = + serde_json::from_slice(&to_bytes(body).await.unwrap()[..]).unwrap(); + let expected = json!({ "error": "Please enter a valid email" }); + assert_eq!(json, expected); + } + + #[traced_test] + #[actix_web::test] + async fn valid() { + create_app!(app, session, db); + + let user = create_user(db.clone(), "valid_forgot_pass", OLD_PASS).await; + let req = test::TestRequest::default() + .set_json(Input { + email: user.email.unwrap(), + }) + .uri("/forgot-password") + .method(Method::POST) + .to_request(); + + let resp = test::call_service(&app, req).await; + + assert_eq!(resp.status(), StatusCode::BAD_REQUEST); + + let body = resp.into_body(); + let json: serde_json::Value = + serde_json::from_slice(&to_bytes(body).await.unwrap()[..]).unwrap(); + let expected = json!({ "message": "Check your email to reset your password" }); + assert_eq!(json, expected); + } +}