diff --git a/crates/jet-api/src/http/api/authentication/change_password.rs b/crates/jet-api/src/http/api/authentication/change_password.rs index 13219ab..82826f3 100644 --- a/crates/jet-api/src/http/api/authentication/change_password.rs +++ b/crates/jet-api/src/http/api/authentication/change_password.rs @@ -220,6 +220,7 @@ mod tests { let req = test::TestRequest::default() .insert_header(ContentType::json()) .to_request(); + let resp = test::call_service(&app, req).await; assert!(resp.status().is_client_error()); @@ -257,6 +258,7 @@ mod tests { .uri("/users/me/change-password") .method(Method::POST) .to_request(); + let resp = test::call_service(&app, req).await; assert_eq!(resp.status(), StatusCode::BAD_REQUEST); @@ -320,6 +322,7 @@ mod tests { } })) .unwrap(); + assert_eq!(bytes, expected); } @@ -361,6 +364,7 @@ mod tests { .uri("/users/me/change-password") .method(Method::POST) .to_request(); + let resp = test::call_service(&app, req).await; assert_eq!(resp.status(), StatusCode::BAD_REQUEST); @@ -368,7 +372,10 @@ mod tests { let body = resp.into_body(); let json: serde_json::Value = serde_json::from_slice(&to_bytes(body).await.unwrap()[..]).unwrap(); - assert_eq!(json, serde_json::json!({ "error": "New password cannot be same as old password." })); + assert_eq!( + json, + serde_json::json!({ "error": "New password cannot be same as old password." }) + ); } #[traced_test] @@ -409,6 +416,7 @@ mod tests { .uri("/users/me/change-password") .method(Method::POST) .to_request(); + let resp = test::call_service(&app, req).await; assert_eq!(resp.status(), StatusCode::OK); @@ -416,6 +424,9 @@ mod tests { let body = resp.into_body(); let json: serde_json::Value = serde_json::from_slice(&to_bytes(body).await.unwrap()[..]).unwrap(); - assert_eq!(json, serde_json::json!({ "message": "Password updated successfully" })); + assert_eq!( + json, + serde_json::json!({ "message": "Password updated successfully" }) + ); } } diff --git a/crates/jet-api/src/http/api/authentication/reset_password.rs b/crates/jet-api/src/http/api/authentication/reset_password.rs index d21bffc..dd00150 100644 --- a/crates/jet-api/src/http/api/authentication/reset_password.rs +++ b/crates/jet-api/src/http/api/authentication/reset_password.rs @@ -21,7 +21,7 @@ struct Input { new_password: String, } -#[post("/reset-password/{uidb}/{token}/")] +#[post("/reset-password/{uidb}/{token}")] pub async fn reset_password( _: RequireInstanceConfigured, req: HttpRequest, @@ -88,7 +88,7 @@ async fn try_reset_password( match super::password::validate(&payload.new_password) { PassValidity::Valid => {} v => { - return Err(JsonError::new("Password is innvalid") + return Err(JsonError::new("Password is invalid") .with_status(StatusCode::BAD_REQUEST) .with_details(v)) } @@ -110,3 +110,282 @@ async fn try_reset_password( .await .map_err(Into::into) } + +#[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::http::header::ContentType; + use actix_web::web::Data; + use actix_web::{test, App}; + use jet_contract::deadpool_redis; + use reqwest::{Method, StatusCode}; + use sea_orm::Database; + use tracing_test::traced_test; + use uuid::Uuid; + + use super::*; + use crate::session; + use crate::utils::{pass_reset_token, uidb}; + + const PASS_RESET_SECRET: &str = "123ahsdih2he8dagw7"; + + 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)) + .app_data(Data::new(PasswordResetSecret::new( + PASS_RESET_SECRET.to_string(), + ))) + .app_data(Data::new(PasswordResetTimeout::new("6d"))) + .wrap(actix_web::middleware::NormalizePath::trim()) + .wrap(actix_web::middleware::Logger::default()) + .wrap(factory) + .service(reset_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] + #[tokio::test] + async fn path_exists() { + create_app!(app, session, db); + + let req = test::TestRequest::default() + .insert_header(ContentType::json()) + .uri("/reset-password/uidb/token") + .method(Method::POST) + .to_request(); + + let resp = test::call_service(&app, req).await; + + assert_ne!(resp.status(), StatusCode::NOT_FOUND); + } + + #[traced_test] + #[tokio::test] + async fn invalid_new_pass() { + create_app!(app, session, db); + + let user = create_user( + db.clone(), + "invalid_new_pass_reset_pass", + "ASDFasdf1234!@#$", + ) + .await; + let uidb = uidb::encode(user.id); + let token = pass_reset_token::make_token(&user, PASS_RESET_SECRET); + + let req = test::TestRequest::default() + .insert_header(ContentType::json()) + .uri(&format!("/reset-password/{uidb}/{token}")) + .method(Method::POST) + .set_json(serde_json::json!({ "new_password": "d)" })) + .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(); + assert_eq!( + json, + serde_json::json!({ + "error": "Password is invalid", + "errors": { + "has_lower": true, + "has_num": false, + "has_special": true, + "has_upper": false, + "too_long": false, + "too_short": true + } + }) + ); + } + + #[traced_test] + #[tokio::test] + async fn invalid_uidb() { + create_app!(app, session, db); + + let user = create_user(db.clone(), "invalid_uidb_reset_pass", "ASDFasdf1234!@#$").await; + let uidb = "aiodsjhoahsd9ays9d8"; + let token = pass_reset_token::make_token(&user, PASS_RESET_SECRET); + + let req = test::TestRequest::default() + .insert_header(ContentType::json()) + .uri(&format!("/reset-password/{uidb}/{token}")) + .method(Method::POST) + .set_json(serde_json::json!({ "new_password": "LKJpoi098)(*" })) + .to_request(); + + let resp = test::call_service(&app, req).await; + + assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); + + let body = resp.into_body(); + let json: serde_json::Value = + serde_json::from_slice(&to_bytes(body).await.unwrap()[..]).unwrap(); + assert_eq!( + json, + serde_json::json!({ + "error": "Token is invalid" + }) + ); + } + + #[traced_test] + #[tokio::test] + async fn invalid_token() { + create_app!(app, session, db); + + let user = create_user(db.clone(), "invalid_token_reset_pass", "ASDFasdf1234!@#$").await; + let uidb = uidb::encode(user.id); + let token = "aiosjd9ahsd98hasa"; + + let req = test::TestRequest::default() + .insert_header(ContentType::json()) + .uri(&format!("/reset-password/{uidb}/{token}")) + .method(Method::POST) + .set_json(serde_json::json!({ "new_password": "LKJpoi098)(*" })) + .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(); + assert_eq!( + json, + serde_json::json!({ + "error": "Token is invalid" + }) + ); + } + + #[traced_test] + #[tokio::test] + async fn valid() { + create_app!(app, session, db); + + let user = create_user(db.clone(), "valid_reset_pass", "ASDFasdf1234!@#$").await; + let uidb = uidb::encode(user.id); + let token = pass_reset_token::make_token(&user, PASS_RESET_SECRET); + + let req = test::TestRequest::default() + .insert_header(ContentType::json()) + .uri(&format!("/reset-password/{uidb}/{token}")) + .method(Method::POST) + .set_json(serde_json::json!({ "new_password": "LKJpoi098)(*" })) + .to_request(); + + let resp = test::call_service(&app, req).await; + + assert_eq!(resp.status(), StatusCode::OK); + + let body = resp.into_body(); + let json: serde_json::Value = + serde_json::from_slice(&to_bytes(body).await.unwrap()[..]).unwrap(); + let keys = json.as_object().unwrap().keys().collect::>(); + assert_eq!( + keys, + vec![&"access_token".to_string(), &"refresh_token".to_string()] + ); + } +} diff --git a/crates/jet-api/src/utils/mod.rs b/crates/jet-api/src/utils/mod.rs index d80276e..2f44be5 100644 --- a/crates/jet-api/src/utils/mod.rs +++ b/crates/jet-api/src/utils/mod.rs @@ -294,12 +294,39 @@ pub async fn invites_to_membership( } pub mod uidb { + use actix_web::{FromRequest, ResponseError}; use base64::prelude::*; + use derive_more::{Constructor, Deref}; + use futures_util::future::LocalBoxFuture; + use futures_util::FutureExt; use reqwest::StatusCode; use uuid::Uuid; use crate::models::JsonError; + #[derive(Debug, thiserror::Error)] + #[error("Unauthorized")] + pub struct Unauthorized; + + impl ResponseError for Unauthorized {} + + #[derive(Debug, PartialEq, Deref, Constructor)] + pub struct Uidb(String); + + impl FromRequest for Uidb { + type Error = Unauthorized; + + type Future = LocalBoxFuture<'static, Result>; + fn from_request( + req: &actix_web::HttpRequest, + _payload: &mut actix_web::dev::Payload, + ) -> Self::Future { + eprintln!("match info {:?}", req.match_info()); + eprintln!("as str {:?}", req.match_info().as_str()); + async move { Err(Unauthorized) }.boxed_local() + } + } + pub fn decode(uidb: &str) -> Result { let Ok(bytes) = BASE64_URL_SAFE.decode(uidb) else { return Err(JsonError::new("Token is invalid").with_status(StatusCode::UNAUTHORIZED)); @@ -350,7 +377,8 @@ pub mod pass_reset_token { Hmac::::new_from_slice(secret.as_bytes()).expect("Invalid hmac secret"); mac.update(hash_value.as_bytes()); let result = mac.finalize(); - let s = String::from_utf8(result.into_bytes()[..].to_vec()).unwrap(); + let bytes = &result.into_bytes()[..]; + let s = bytes.iter().map(|b| format!("{b:02x}")).collect::(); format!("{ts_b36}-{s}") }