diff --git a/crates/jet-api/src/http/api/authentication.rs b/crates/jet-api/src/http/api/authentication.rs index d7a1f4d..1d171ff 100644 --- a/crates/jet-api/src/http/api/authentication.rs +++ b/crates/jet-api/src/http/api/authentication.rs @@ -1,7 +1,8 @@ -use crate::models::Error; +use crate::models::{Error, JsonError}; use crate::session::AppClaims; use actix_jwt_session::{ - Duration, Hashing, JwtTtl, RefreshTtl, SessionStorage, JWT_HEADER_NAME, REFRESH_HEADER_NAME, + Duration, Hashing, JwtTtl, Pair, RefreshTtl, SessionStorage, JWT_HEADER_NAME, + REFRESH_HEADER_NAME, }; use actix_web::web::scope; use actix_web::web::{Data, ServiceConfig}; @@ -31,6 +32,23 @@ pub struct AuthResponseBody { refresh_token: String, } +impl AuthResponseBody { + pub fn build(pair: Pair) -> Result { + let access_token = pair.jwt.encode().map_err(|e| { + tracing::error!("Failed encode JWT: {e}"); + JsonError::new(AuthError::EncryptPair) + })?; + let refresh_token = pair.refresh.encode().map_err(|e| { + tracing::error!("Failed encode JWT: {e}"); + JsonError::new(AuthError::EncryptPair) + })?; + Ok(Self { + access_token, + refresh_token, + }) + } +} + pub fn configure(http_client: reqwest::Client, config: &mut ServiceConfig) { config.service( scope("") @@ -89,6 +107,8 @@ pub enum AuthError { Oauth(OAuthError), #[display(fmt = "Encrypt password failed")] EncryptPass, + #[display(fmt = "Encrypt JWT pair failed")] + EncryptPair, } async fn create_user( @@ -210,9 +230,30 @@ pub fn random_password() -> String { .collect() } +pub mod password { + const HAS_NUM: u8 = 1; + const HAS_UPPER: u8 = 2; + const HAS_LOWER: u8 = 4; + const HAS_SPECIAL: u8 = 8; + + pub fn is_valid(pass: &str) -> bool { + pass.len() >= 8 + || pass.chars().fold(0, |memo, c| match c { + _ if c.is_numeric() => memo | HAS_NUM, + _ if c.is_uppercase() => memo | HAS_UPPER, + _ if c.is_lowercase() => memo | HAS_UPPER, + _ => memo | HAS_SPECIAL, + }) & HAS_NUM + & HAS_SPECIAL + & HAS_UPPER + & HAS_LOWER + != 0 + } +} + pub mod magic_link { use crate::models::Error; - use crate::{http::AuthError, RedisClient}; + use crate::{http::AuthError, redis_c, RedisClient}; use actix_web::web::Data; use jet_contract::*; use rand::prelude::*; @@ -234,12 +275,24 @@ pub mod magic_link { format!("magic_{email}") } + pub async fn clear_magic_link(email: &str, redis: Data) -> Result<(), Error> { + let Ok(mut redis) = redis.get().await else { + return Err(AuthError::SerializeMsg.into()); + }; + + let key = magic_link_key(email); + let _: () = redis.del(key).await.map_err(|_| Error::RedisConnection)?; + Ok(()) + } + pub async fn create_magic_link( email: &str, redis: Data, ) -> Result<(MagicLinkKey, MagicLinkToken, AttemptValidity), Error> { use rand::distributions::Alphanumeric; + let mut redis = redis_c!(redis)?; + let key = magic_link_key(email); let mut rng = rand::thread_rng(); let token = format!( @@ -261,10 +314,6 @@ pub mod magic_link { .collect::(), ); - let Ok(mut redis) = redis.get().await else { - return Err(AuthError::SerializeMsg.into()); - }; - if redis .exists(&key) .await diff --git a/crates/jet-api/src/http/api/authentication/email_check.rs b/crates/jet-api/src/http/api/authentication/email_check.rs index d6fe842..3cade35 100644 --- a/crates/jet-api/src/http/api/authentication/email_check.rs +++ b/crates/jet-api/src/http/api/authentication/email_check.rs @@ -4,7 +4,8 @@ use actix_web::web::{Data, Json}; use actix_web::{post, HttpRequest, HttpResponse}; use entities::prelude::{Users, WorkspaceMemberInvites}; use entities::users::Model as User; -use sea_orm::prelude::*; +use jet_contract::event_bus::SignInMedium; +use sea_orm::{prelude::*, DatabaseTransaction}; use sea_orm::{DatabaseConnection, EntityTrait, QueryFilter}; use serde::{Deserialize, Serialize}; use serde_email::Email; @@ -12,7 +13,8 @@ use serde_email::Email; use crate::config::ApplicationConfig; use crate::extractors::RequireInstanceConfigured; use crate::http::magic_link::create_magic_link; -use crate::models::*; +use crate::utils::user_by_email; +use crate::{db_commit, db_rollback, db_t, models::*}; use crate::{EventBusClient, RedisClient}; #[derive(Debug, Deserialize)] @@ -33,16 +35,24 @@ pub async fn email_check( if !serde_email::is_valid_email(&payload.email) { return Ok(HttpResponse::BadRequest().json(JsonError::new("Email is not valid"))); } + let mut t = db_t!(db)?; - let user = Users::find() - .filter(entities::users::Column::Email.eq(payload.email.as_str())) - .one(&**db) - .await - .map_err(|_| Error::DatabaseError)?; + let user = user_by_email(payload.email.as_str(), &mut t).await?; - match user { + let res = match user { Some(user) => handle_existing_user(req, payload, user, app_config, event_bus, redis).await, - None => register(req, payload, app_config, event_bus, db, redis).await, + None => register(req, payload, app_config, event_bus, &mut t, redis).await, + }; + + match res { + Ok(r) => { + db_commit!(t)?; + Ok(r) + } + Err(e) => { + db_rollback!(t)?; + Err(e) + } } } @@ -134,7 +144,7 @@ async fn register( payload: Json, app_config: Data, event_bus: Data, - db: Data, + db: &mut DatabaseTransaction, redis: Data, ) -> Result { use sea_orm::Set; @@ -142,7 +152,7 @@ async fn register( if !app_config.enable_signup && WorkspaceMemberInvites::find() .filter(entities::workspace_member_invites::Column::Email.eq(payload.email.as_str())) - .count(&**db) + .count(&mut *db) .await .map_err(|_| Error::DatabaseError)? == 0 @@ -167,14 +177,17 @@ async fn register( user_timezone: Set("UTC".to_string()), last_login_ip: Set(ip.to_string()), last_logout_ip: Set(ip.to_string()), - last_login_medium: Set("MAGIC_LINK".to_string()), + last_login_medium: Set(SignInMedium::MagicLink.as_str().to_owned()), last_login_uagent: Set(user_agent.clone()), ..Default::default() }; if !app_config.enable_magic_link_login { return Err(AuthError::MagicLinkOff.into()); } - let user = user.save(&**db).await.map_err(|_| Error::DatabaseError)?; + let user = user + .save(&mut *db) + .await + .map_err(|_| Error::DatabaseError)?; { use jet_contract::event_bus::*; let user_id = user.id.clone().unwrap(); diff --git a/crates/jet-api/src/http/api/authentication/magic_generate.rs b/crates/jet-api/src/http/api/authentication/magic_generate.rs index 07677c5..0b6e750 100644 --- a/crates/jet-api/src/http/api/authentication/magic_generate.rs +++ b/crates/jet-api/src/http/api/authentication/magic_generate.rs @@ -4,14 +4,16 @@ use actix_web::{ HttpRequest, HttpResponse, }; use jet_contract::event_bus::{EmailMsg, Topic}; -use sea_orm::prelude::*; use sea_orm::DatabaseConnection; +use sea_orm::{prelude::*, DatabaseTransaction}; use serde::Deserialize; +use serde_json::json; +use tracing::error; use super::{create_user, random_password}; -use crate::{db_commit, models::*, utils::extract_req_current_site}; use crate::{ - db_t, extractors::RequireInstanceConfigured, models::JsonError, EventBusClient, RedisClient, + db_commit, db_rollback, db_t, extractors::RequireInstanceConfigured, models::JsonError, + models::*, utils::extract_req_current_site, EventBusClient, RedisClient, }; #[derive(Debug, Deserialize)] @@ -29,31 +31,56 @@ pub async fn magic_generate( event_bus: Data, ) -> Result { let mut t = db_t!(db)?; + match try_create_magic_link(req, payload, &mut t, redis, event_bus).await { + Ok(r) => { + db_commit!(t)?; + Ok(r) + } + Err(e) => { + db_rollback!(t)?; + Err(e.into()) + } + } +} + +async fn try_create_magic_link( + req: HttpRequest, + payload: Json, + t: &mut DatabaseTransaction, + redis: Data, + event_bus: Data, +) -> Result { let email = payload.into_inner().email; - let user = match entities::prelude::Users::find() + let _user = match entities::prelude::Users::find() .filter(entities::users::Column::Email.eq(&email)) - .one(&mut t) + .one(&mut *t) .await { Ok(Some(user)) => user, - Ok(None) => create_user(&req, &email, &random_password(), &mut t).await?, - Err(e) => return Err(Error::DatabaseError.into()), + Ok(None) => create_user(&req, &email, &random_password(), &mut *t).await?, + Err(e) => { + error!("Failed to connect to database while creating magic link: {e}"); + return Err(Error::DatabaseError.into()); + } }; - db_commit!(t)?; - - let (key, token, validity) = super::magic_link::create_magic_link(&email, redis).await?; + let (key, token, _validity) = super::magic_link::create_magic_link(&email, redis).await?; let current_site = extract_req_current_site(&req)?; event_bus - .publish(Topic::Email, jet_contract::event_bus::Msg::Email(EmailMsg::MagicLink { - email, - key, - token, - current_site, - }), rumqttc::QoS::AtLeastOnce, true) + .publish( + Topic::Email, + jet_contract::event_bus::Msg::Email(EmailMsg::MagicLink { + email, + key: jet_contract::MagicLinkKey::new(key.clone()), + token, + current_site, + }), + rumqttc::QoS::AtLeastOnce, + true, + ) .await; - Ok(HttpResponse::NotImplemented().finish()) + Ok(HttpResponse::Ok().json(json!({ "key": key }))) } diff --git a/crates/jet-api/src/http/api/authentication/magic_sign_in.rs b/crates/jet-api/src/http/api/authentication/magic_sign_in.rs index 073a371..29eb23e 100644 --- a/crates/jet-api/src/http/api/authentication/magic_sign_in.rs +++ b/crates/jet-api/src/http/api/authentication/magic_sign_in.rs @@ -1,16 +1,33 @@ +use actix_jwt_session::{Duration, JwtTtl, RefreshTtl, SessionStorage}; use actix_web::{ post, web::{Data, Json}, HttpRequest, HttpResponse, }; -/* + +use jet_contract::{ + event_bus::{Msg, SignInMedium, Topic, UserMsg}, + redis::AsyncCommands, +}; +use reqwest::StatusCode; +use rumqttc::QoS; use sea_orm::prelude::*; use sea_orm::*; -use tracing::error; use serde::Deserialize; +use serde_json::json; +use tracing::error; -use crate::{models::{Error, JsonError}, extractors::RequireInstanceConfigured}; -use crate::{RedisClient, EventBusClient}; +use crate::{ + db_commit, db_rollback, db_t, + extractors::RequireInstanceConfigured, + models::{Error, JsonError}, + redis_c, + session::AppClaims, + utils::{extract_req_ip, extract_req_uagent, user_by_email}, +}; +use crate::{EventBusClient, RedisClient}; + +use super::{AuthResponseBody, auth_http_response}; #[post("/magic-sign-in")] async fn magic_sign_in( @@ -20,32 +37,102 @@ async fn magic_sign_in( db: Data, redis: Data, event_bus: Data, + session: Data, ) -> Result { - let mut t = db.begin().await.map_err(|e| { - error!("Failed to get database connection: {e}"); - Error::DatabaseError - })?; + let mut t = db_t!(db)?; - match try_magic_sign_in(&mut t).await { + match try_magic_sign_in(req, payload, &mut t, redis, event_bus, session).await { Ok(r) => { - t.commit().await.map_err(|e| { - error!("Failed to commit database changes"); - JsonError::new("Internal server error") - })?; + db_commit!(t)?; Ok(r) } Err(e) => { - t.rollback().await.ok(); + db_rollback!(t).ok(); Err(e) } } } -async fn try_magic_sign_in() -> Result {} +async fn try_magic_sign_in( + req: HttpRequest, + payload: Json, + t: &mut DatabaseTransaction, + redis: Data, + event_bus: Data, + session: Data, +) -> Result { + let payload = payload.into_inner(); + let (key, user_token) = (payload.key.trim(), payload.token.trim()); + if key.is_empty() || user_token.is_empty() { + return Err(JsonError::new("User token and key are required")); + } + let mut redis = redis_c!(redis)?; + + let token: String = redis.hget(key, "token").await.map_err(|e| { + tracing::error!("Failed to read token from redis: {e}"); + Error::RedisConnection + })?; + let email: String = redis.hget(key, "email").await.map_err(|e| { + tracing::error!("Failed to read token from redis: {e}"); + Error::RedisConnection + })?; + if user_token != token { + return Err(JsonError::new( + "The magic code/link has expired please try again", + )); + } + let user = user_by_email(&email, &mut *t) + .await? + .ok_or(Error::UserRequired)?; + let user_agent = extract_req_uagent(&req)?; + let ip = extract_req_ip(&req)?; + event_bus + .publish( + Topic::User, + Msg::User(UserMsg::SignIn { + user_id: jet_contract::UserId::new(user.id), + email, + user_agent, + ip, + medium: SignInMedium::Email, + first_time: false, + }), + QoS::AtLeastOnce, + true, + ) + .await + .map_err(JsonError::new)?; + + let user_clone = user.clone(); + + let mut user: entities::users::ActiveModel = user.into(); + user.is_active = Set(true); + user.is_email_verified = Set(true); + user.last_active = Set(Some(chrono::Utc::now().fixed_offset())); + user.last_login_time = Set(Some(chrono::Utc::now().fixed_offset())); + user.last_login_ip = Set(extract_req_ip(&req)?); + user.last_login_uagent = Set(extract_req_uagent(&req)?); + user.token_updated_at = Set(Some(chrono::Utc::now().fixed_offset())); + user.save(&mut *t).await.map_err(|e| { + tracing::error!("Failed to update user: {e}"); + Error::DatabaseError + })?; + + crate::utils::invites_to_membership( + user_clone.id, + &user_clone.email.as_deref().unwrap_or_default(), + None, + &mut *t, + ) + .await?; + + auth_http_response(user_clone, session, StatusCode::OK) + .await + .map_err(JsonError::new) +} #[derive(Debug, Deserialize)] struct Input { key: String, token: String, } -*/ diff --git a/crates/jet-api/src/http/api/authentication/sign_in.rs b/crates/jet-api/src/http/api/authentication/sign_in.rs index 2948e74..b29d49a 100644 --- a/crates/jet-api/src/http/api/authentication/sign_in.rs +++ b/crates/jet-api/src/http/api/authentication/sign_in.rs @@ -15,6 +15,7 @@ use crate::config::ApplicationConfig; use crate::extractors::RequireInstanceConfigured; use crate::http::api::authentication::{auth_http_response, create_user, has_workspace_invites}; use crate::models::{Error, JsonError}; +use crate::utils::user_by_email; use super::EmailAllowComment; @@ -65,17 +66,8 @@ async fn try_sign_in( )); } - match Users::find() - .filter(entities::users::Column::Email.eq(&email)) - .one(&mut *db) - .await - { - Ok(None) => {} - Ok(Some(_user)) => return Err(JsonError::new("User with this email already exists")), - Err(e) => { - tracing::error!("Failed to load user for sign-in: {e}"); - return Ok(Error::DatabaseError.error_response()); - } + let None = user_by_email(&email, &mut *db).await? else { + return Err(JsonError::new("User with this email already exists")); }; let user = create_user(&req, &email, &password, db).await?; diff --git a/crates/jet-api/src/http/api/authentication/sign_up.rs b/crates/jet-api/src/http/api/authentication/sign_up.rs index 2ce64d1..5cb7647 100644 --- a/crates/jet-api/src/http/api/authentication/sign_up.rs +++ b/crates/jet-api/src/http/api/authentication/sign_up.rs @@ -10,14 +10,13 @@ use reqwest::StatusCode; use rumqttc::QoS; use sea_orm::DatabaseConnection; use sea_orm::*; -use validators::models::Host; use validators::prelude::*; use crate::config::ApplicationConfig; use crate::extractors::RequireInstanceConfigured; use crate::http::api::authentication::{auth_http_response, create_user, has_workspace_invites}; use crate::models::{Error, JsonError}; -use crate::utils::{extract_req_info, extract_req_ip, extract_req_uagent}; +use crate::utils::{extract_req_ip, extract_req_uagent}; use super::EmailAllowComment; @@ -56,8 +55,12 @@ async fn try_sign_in( return Err(JsonError::new("Both email and password are required")); } let email = payload.email.trim().to_lowercase(); - let password = payload.password.clone(); + + if !super::password::is_valid(&password) { + return Err(JsonError::new("Password is too weak. Password must have at least 8 characters and contains small letter, big letter, number and special character")); + } + if let Err(e) = EmailAllowComment::validate_str(&email) { tracing::error!("Invalid email address: {e}"); return Err(JsonError::new("Please provide a valid email address.")); diff --git a/crates/jet-api/src/http/api/authentication/social_auth.rs b/crates/jet-api/src/http/api/authentication/social_auth.rs index cf7f2bc..de500d3 100644 --- a/crates/jet-api/src/http/api/authentication/social_auth.rs +++ b/crates/jet-api/src/http/api/authentication/social_auth.rs @@ -39,6 +39,7 @@ use crate::{ extractors::RequireInstanceConfigured, http::OAuthError, models::{Error, JsonError}, + utils::user_by_email, }; macro_rules! oauth_envs { @@ -173,7 +174,7 @@ async fn handle_callback( v } Err(e) => { - db_rollback!(tx, "Failed to rollback social_auth changes to postgres"); + db_rollback!(tx, "Failed to rollback social_auth changes to postgres").ok(); return Err(e.into()); } } @@ -224,34 +225,14 @@ async fn handle_user_info( raw: _, } = user_info; - let Some(email) = email else { - return Ok(HttpResponse::BadRequest().json(JsonError::new( - "Something went wrong. Please try again later or contact the support team.", - ))); - }; + let email = email.ok_or(Error::ContactSupport)?; if !email.contains('@') { - return Ok(HttpResponse::BadRequest().json(JsonError::new( - "Something went wrong. Please try again later or contact the support team.", - ))); + return Err(Error::ContactSupport); } - let user = Users::find() - .filter(UserColumn::Email.eq(&email)) - .one(&mut *db) - .await; + let user = user_by_email(&email, &mut *db).await?; - let mut user: UserModel = match user { - Ok(Some(user)) => user.into(), - Ok(None) => { - return Ok(HttpResponse::BadRequest().json(JsonError::new( - "Something went wrong. Please try again later or contact the support team.", - ))); - } - Err(e) => { - error!("Failed to find user for oauth {provider} sign-in: {e}"); - return Ok(HttpResponse::InternalServerError().finish()); - } - }; + let mut user: UserModel = user.ok_or(Error::ContactSupport)?.into(); let (ip, user_agent, _current_site) = crate::utils::extract_req_info(&req)?; diff --git a/crates/jet-api/src/models.rs b/crates/jet-api/src/models.rs index 7276220..b4974af 100644 --- a/crates/jet-api/src/models.rs +++ b/crates/jet-api/src/models.rs @@ -48,6 +48,10 @@ pub enum Error { AddToWorkspace, #[display(fmt = "Failed to add user to projects")] AddToProject, + #[display(fmt = "Something went wrong. Please try again later or contact the support team.")] + ContactSupport, + #[display(fmt = "User not found")] + UserRequired, } impl From for Error { diff --git a/crates/jet-api/src/utils/mod.rs b/crates/jet-api/src/utils/mod.rs index 3665191..cf637c9 100644 --- a/crates/jet-api/src/utils/mod.rs +++ b/crates/jet-api/src/utils/mod.rs @@ -1,3 +1,4 @@ +use actix_web::web::Data; use actix_web::HttpRequest; use chrono::Utc; use entities::project_member_invites::{ @@ -64,6 +65,29 @@ macro_rules! db_rollback { }) }}; } +#[macro_export] +macro_rules! redis_c { + ($redis: expr) => { + $redis.get().await.map_err(|e| { + tracing::error!("Failed to obtain redis connection: {e}"); + Error::RedisConnection + }) + }; +} + +pub async fn user_by_email( + email: &str, + db: &mut DatabaseTransaction, +) -> Result, Error> { + entities::prelude::Users::find() + .filter(entities::users::Column::Email.eq(email)) + .one(db) + .await + .map_err(|e| { + tracing::error!("Failed to load users: {e}"); + Error::DatabaseError + }) +} pub fn extract_req_ip(req: &HttpRequest) -> Result { Ok(req