diff --git a/crates/jet-api/src/http/api/authentication.rs b/crates/jet-api/src/http/api/authentication.rs index f4eeeea..d7a1f4d 100644 --- a/crates/jet-api/src/http/api/authentication.rs +++ b/crates/jet-api/src/http/api/authentication.rs @@ -8,12 +8,18 @@ use actix_web::web::{Data, ServiceConfig}; use actix_web::{HttpRequest, HttpResponse}; use entities::prelude::Users; use entities::users::Model as User; +use rand::Rng; use reqwest::StatusCode; use sea_orm::prelude::*; use sea_orm::*; use uuid::Uuid; +use validators::prelude::*; +use validators::Validator; +use validators_prelude::Host; mod email_check; +mod magic_generate; +mod magic_sign_in; mod sign_in; mod sign_out; mod sign_up; @@ -32,6 +38,7 @@ pub fn configure(http_client: reqwest::Client, config: &mut ServiceConfig) { .service(sign_in::sign_in) .service(sign_up::sign_up) .service(sign_out::sign_out) + .service(magic_sign_in::magic_sign_in) .configure(|c| { social_auth::configure(http_client, c); }), @@ -176,3 +183,228 @@ async fn auth_http_response( refresh_token, })) } + +#[derive(Validator)] +#[validator(email( + comment(Allow), + ip(Allow), + local(Allow), + at_least_two_labels(Allow), + non_ascii(Allow) +))] +pub struct EmailAllowComment { + pub local_part: String, + pub need_quoted: bool, + pub domain_part: Host, + pub comment_before_local_part: Option, + pub comment_after_local_part: Option, + pub comment_before_domain_part: Option, + pub comment_after_domain_part: Option, +} + +pub fn random_password() -> String { + rand::thread_rng() + .sample_iter(rand::distributions::Alphanumeric) + .take(30) + .map(char::from) + .collect() +} + +pub mod magic_link { + use crate::models::Error; + use crate::{http::AuthError, RedisClient}; + use actix_web::web::Data; + use jet_contract::*; + use rand::prelude::*; + use redis::AsyncCommands; + + #[derive(Debug, Copy, Clone, PartialEq)] + pub enum AttemptValidity { + Allowed, + Exhausted, + } + impl AttemptValidity { + pub fn is_exhausted(self) -> bool { + matches!(self, Self::Exhausted) + } + } + + #[inline(always)] + pub fn magic_link_key(email: &str) -> String { + format!("magic_{email}") + } + + pub async fn create_magic_link( + email: &str, + redis: Data, + ) -> Result<(MagicLinkKey, MagicLinkToken, AttemptValidity), Error> { + use rand::distributions::Alphanumeric; + + let key = magic_link_key(email); + let mut rng = rand::thread_rng(); + let token = format!( + "{}-{}-{}", + (&mut rng) + .sample_iter(Alphanumeric) + .take(4) + .map(char::from) + .collect::(), + (&mut rng) + .sample_iter(Alphanumeric) + .take(4) + .map(char::from) + .collect::(), + (&mut rng) + .sample_iter(Alphanumeric) + .take(4) + .map(char::from) + .collect::(), + ); + + let Ok(mut redis) = redis.get().await else { + return Err(AuthError::SerializeMsg.into()); + }; + + if redis + .exists(&key) + .await + .map_err(|_| Error::RedisConnection)? + { + let attempt: u8 = redis + .hget(&key, "current_attempt") + .await + .map_err(|_| Error::RedisConnection)?; + if attempt + 1 > 2 { + let token: String = redis + .hget(&key, "token") + .await + .map_err(|_| Error::RedisConnection)?; + return Ok(( + MagicLinkKey::new(key), + MagicLinkToken::new(token), + AttemptValidity::Exhausted, + )); + } + let _: () = redis + .hincr(&key, "current_attempt", 1) + .await + .map_err(|_| Error::RedisConnection)?; + let _: () = redis + .hset(&key, "token", &token) + .await + .map_err(|_| Error::RedisConnection)?; + } else { + let _: () = redis + .hset(&key, "current_attempt", 0) + .await + .map_err(|_| Error::RedisConnection)?; + let _: () = redis + .hset_multiple(&key, &[("email", email), ("token", &token)]) + .await + .map_err(|_| Error::RedisConnection)?; + let _: () = redis + .expire(&key, 600) + .await + .map_err(|_| Error::RedisConnection)?; + } + + Ok(( + MagicLinkKey::new(key), + MagicLinkToken::new(token), + AttemptValidity::Allowed, + )) + } + + #[cfg(test)] + mod create_magic_link_tests { + use super::*; + use actix_web::web::Data; + use jet_contract::deadpool_redis; + + #[tokio::test] + async fn full() { + let email = "foo@bar.com".to_string(); + let redis = deadpool_redis::Config::default() + .create_pool(Some(deadpool_redis::Runtime::Tokio1)) + .unwrap(); + let mut conn = redis.get().await.unwrap(); + let _: () = conn.del(magic_link_key(&email)).await.unwrap(); + + let (key, token, validity) = create_magic_link(&email, Data::new(redis.clone())) + .await + .unwrap(); + let r: Vec = conn.hgetall(&*key).await.unwrap(); + assert_eq!( + (validity, r), + ( + AttemptValidity::Allowed, + vec![ + "current_attempt".into(), + "0".into(), + "email".into(), + email.clone(), + "token".into(), + (&*token).clone() + ] + ) + ); + + let (key, token, validity) = create_magic_link(&email, Data::new(redis.clone())) + .await + .unwrap(); + let r: Vec = conn.hgetall(&*key).await.unwrap(); + assert_eq!( + (validity, r), + ( + AttemptValidity::Allowed, + vec![ + "current_attempt".into(), + "1".into(), + "email".into(), + email.clone(), + "token".into(), + token.to_string() + ] + ) + ); + + let (key, token, validity) = create_magic_link(&email, Data::new(redis.clone())) + .await + .unwrap(); + let r: Vec = conn.hgetall(&*key).await.unwrap(); + assert_eq!( + (validity, r), + ( + AttemptValidity::Allowed, + vec![ + "current_attempt".into(), + "2".into(), + "email".into(), + email.clone(), + "token".into(), + token.to_string() + ] + ) + ); + + let (key, token, validity) = create_magic_link(&email, Data::new(redis.clone())) + .await + .unwrap(); + let r: Vec = conn.hgetall(&*key).await.unwrap(); + assert_eq!( + (validity, r), + ( + AttemptValidity::Exhausted, + vec![ + "current_attempt".into(), + "2".into(), + "email".into(), + email.clone(), + "token".into(), + token.to_string() + ] + ) + ); + } + } +} 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 9e86d49..d6fe842 100644 --- a/crates/jet-api/src/http/api/authentication/email_check.rs +++ b/crates/jet-api/src/http/api/authentication/email_check.rs @@ -4,9 +4,6 @@ use actix_web::web::{Data, Json}; use actix_web::{post, HttpRequest, HttpResponse}; use entities::prelude::{Users, WorkspaceMemberInvites}; use entities::users::Model as User; -use jet_contract::redis::AsyncCommands; -use jet_contract::{MagicLinkKey, MagicLinkToken}; -use rand::Rng; use sea_orm::prelude::*; use sea_orm::{DatabaseConnection, EntityTrait, QueryFilter}; use serde::{Deserialize, Serialize}; @@ -14,6 +11,7 @@ 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::{EventBusClient, RedisClient}; @@ -230,193 +228,3 @@ async fn register( is_existing: false, })) } - -#[derive(Debug, Copy, Clone, PartialEq)] -enum AttemptValidity { - Allowed, - Exhausted, -} -impl AttemptValidity { - fn is_exhausted(self) -> bool { - matches!(self, Self::Exhausted) - } -} - -#[inline(always)] -fn magic_link_key(email: &str) -> String { - format!("magic_{email}") -} - -async fn create_magic_link( - email: &str, - redis: Data, -) -> Result<(MagicLinkKey, MagicLinkToken, AttemptValidity), Error> { - use rand::distributions::Alphanumeric; - - let key = magic_link_key(email); - let mut rng = rand::thread_rng(); - let token = format!( - "{}-{}-{}", - (&mut rng) - .sample_iter(Alphanumeric) - .take(4) - .map(char::from) - .collect::(), - (&mut rng) - .sample_iter(Alphanumeric) - .take(4) - .map(char::from) - .collect::(), - (&mut rng) - .sample_iter(Alphanumeric) - .take(4) - .map(char::from) - .collect::(), - ); - - let Ok(mut redis) = redis.get().await else { - return Err(AuthError::SerializeMsg.into()); - }; - - if redis - .exists(&key) - .await - .map_err(|_| Error::RedisConnection)? - { - let attempt: u8 = redis - .hget(&key, "current_attempt") - .await - .map_err(|_| Error::RedisConnection)?; - if attempt + 1 > 2 { - let token: String = redis - .hget(&key, "token") - .await - .map_err(|_| Error::RedisConnection)?; - return Ok(( - MagicLinkKey::new(key), - MagicLinkToken::new(token), - AttemptValidity::Exhausted, - )); - } - let _: () = redis - .hincr(&key, "current_attempt", 1) - .await - .map_err(|_| Error::RedisConnection)?; - let _: () = redis - .hset(&key, "token", &token) - .await - .map_err(|_| Error::RedisConnection)?; - } else { - let _: () = redis - .hset(&key, "current_attempt", 0) - .await - .map_err(|_| Error::RedisConnection)?; - let _: () = redis - .hset_multiple(&key, &[("email", email), ("token", &token)]) - .await - .map_err(|_| Error::RedisConnection)?; - let _: () = redis - .expire(&key, 600) - .await - .map_err(|_| Error::RedisConnection)?; - } - - Ok(( - MagicLinkKey::new(key), - MagicLinkToken::new(token), - AttemptValidity::Allowed, - )) -} - -#[cfg(test)] -mod create_magic_link_tests { - use super::*; - use actix_web::web::Data; - use jet_contract::deadpool_redis; - - #[tokio::test] - async fn full() { - let email = "foo@bar.com".to_string(); - let redis = deadpool_redis::Config::default() - .create_pool(Some(deadpool_redis::Runtime::Tokio1)) - .unwrap(); - let mut conn = redis.get().await.unwrap(); - let _: () = conn.del(magic_link_key(&email)).await.unwrap(); - - let (key, token, validity) = create_magic_link(&email, Data::new(redis.clone())) - .await - .unwrap(); - let r: Vec = conn.hgetall(&*key).await.unwrap(); - assert_eq!( - (validity, r), - ( - AttemptValidity::Allowed, - vec![ - "current_attempt".into(), - "0".into(), - "email".into(), - email.clone(), - "token".into(), - (&*token).clone() - ] - ) - ); - - let (key, token, validity) = create_magic_link(&email, Data::new(redis.clone())) - .await - .unwrap(); - let r: Vec = conn.hgetall(&*key).await.unwrap(); - assert_eq!( - (validity, r), - ( - AttemptValidity::Allowed, - vec![ - "current_attempt".into(), - "1".into(), - "email".into(), - email.clone(), - "token".into(), - token.to_string() - ] - ) - ); - - let (key, token, validity) = create_magic_link(&email, Data::new(redis.clone())) - .await - .unwrap(); - let r: Vec = conn.hgetall(&*key).await.unwrap(); - assert_eq!( - (validity, r), - ( - AttemptValidity::Allowed, - vec![ - "current_attempt".into(), - "2".into(), - "email".into(), - email.clone(), - "token".into(), - token.to_string() - ] - ) - ); - - let (key, token, validity) = create_magic_link(&email, Data::new(redis.clone())) - .await - .unwrap(); - let r: Vec = conn.hgetall(&*key).await.unwrap(); - assert_eq!( - (validity, r), - ( - AttemptValidity::Exhausted, - vec![ - "current_attempt".into(), - "2".into(), - "email".into(), - email.clone(), - "token".into(), - token.to_string() - ] - ) - ); - } -} diff --git a/crates/jet-api/src/http/api/authentication/magic_generate.rs b/crates/jet-api/src/http/api/authentication/magic_generate.rs new file mode 100644 index 0000000..07677c5 --- /dev/null +++ b/crates/jet-api/src/http/api/authentication/magic_generate.rs @@ -0,0 +1,59 @@ +use actix_web::{ + post, + web::{Data, Json}, + HttpRequest, HttpResponse, +}; +use jet_contract::event_bus::{EmailMsg, Topic}; +use sea_orm::prelude::*; +use sea_orm::DatabaseConnection; +use serde::Deserialize; + +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, +}; + +#[derive(Debug, Deserialize)] +struct Input { + email: String, +} + +#[post("/magic-generate")] +pub async fn magic_generate( + req: HttpRequest, + _: RequireInstanceConfigured, + payload: Json, + db: Data, + redis: Data, + event_bus: Data, +) -> Result { + let mut t = db_t!(db)?; + let email = payload.into_inner().email; + + let user = match entities::prelude::Users::find() + .filter(entities::users::Column::Email.eq(&email)) + .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()), + }; + + db_commit!(t)?; + + 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) + .await; + + Ok(HttpResponse::NotImplemented().finish()) +} 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 new file mode 100644 index 0000000..073a371 --- /dev/null +++ b/crates/jet-api/src/http/api/authentication/magic_sign_in.rs @@ -0,0 +1,51 @@ +use actix_web::{ + post, + web::{Data, Json}, + HttpRequest, HttpResponse, +}; +/* +use sea_orm::prelude::*; +use sea_orm::*; +use tracing::error; +use serde::Deserialize; + +use crate::{models::{Error, JsonError}, extractors::RequireInstanceConfigured}; +use crate::{RedisClient, EventBusClient}; + +#[post("/magic-sign-in")] +async fn magic_sign_in( + _: RequireInstanceConfigured, + req: HttpRequest, + payload: Json, + db: Data, + redis: Data, + event_bus: Data, +) -> Result { + let mut t = db.begin().await.map_err(|e| { + error!("Failed to get database connection: {e}"); + Error::DatabaseError + })?; + + match try_magic_sign_in(&mut t).await { + Ok(r) => { + t.commit().await.map_err(|e| { + error!("Failed to commit database changes"); + JsonError::new("Internal server error") + })?; + Ok(r) + } + Err(e) => { + t.rollback().await.ok(); + Err(e) + } + } +} + +async fn try_magic_sign_in() -> Result {} + +#[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 f60f274..2948e74 100644 --- a/crates/jet-api/src/http/api/authentication/sign_in.rs +++ b/crates/jet-api/src/http/api/authentication/sign_in.rs @@ -8,9 +8,7 @@ use jet_contract::event_bus::{Msg, SignInMedium, Topic, UserMsg}; use jet_contract::UserId; use reqwest::StatusCode; use rumqttc::QoS; -use sea_orm::DatabaseConnection; use sea_orm::*; -use validators::models::Host; use validators::prelude::*; use crate::config::ApplicationConfig; @@ -18,6 +16,8 @@ use crate::extractors::RequireInstanceConfigured; use crate::http::api::authentication::{auth_http_response, create_user, has_workspace_invites}; use crate::models::{Error, JsonError}; +use super::EmailAllowComment; + #[post("/sign-in")] pub async fn sign_in( _: RequireInstanceConfigured, @@ -54,24 +54,6 @@ async fn try_sign_in( } let email = payload.email.trim().to_lowercase(); - #[derive(Validator)] - #[validator(email( - comment(Allow), - ip(Allow), - local(Allow), - at_least_two_labels(Allow), - non_ascii(Allow) - ))] - pub struct EmailAllowComment { - pub local_part: String, - pub need_quoted: bool, - pub domain_part: Host, - pub comment_before_local_part: Option, - pub comment_after_local_part: Option, - pub comment_before_domain_part: Option, - pub comment_after_domain_part: Option, - } - let password = payload.password.clone(); if let Err(e) = EmailAllowComment::validate_str(&email) { tracing::error!("Invalid email address: {e}"); 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 fa54ad2..2ce64d1 100644 --- a/crates/jet-api/src/http/api/authentication/sign_up.rs +++ b/crates/jet-api/src/http/api/authentication/sign_up.rs @@ -17,6 +17,9 @@ 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 super::EmailAllowComment; #[post("/sign-up")] pub async fn sign_up( @@ -54,24 +57,6 @@ async fn try_sign_in( } let email = payload.email.trim().to_lowercase(); - #[derive(Validator)] - #[validator(email( - comment(Allow), - ip(Allow), - local(Allow), - at_least_two_labels(Allow), - non_ascii(Allow) - ))] - pub struct EmailAllowComment { - pub local_part: String, - pub need_quoted: bool, - pub domain_part: Host, - pub comment_before_local_part: Option, - pub comment_after_local_part: Option, - pub comment_before_domain_part: Option, - pub comment_after_domain_part: Option, - } - let password = payload.password.clone(); if let Err(e) = EmailAllowComment::validate_str(&email) { tracing::error!("Invalid email address: {e}"); @@ -99,7 +84,8 @@ async fn try_sign_in( let user_id = user.id; let mut user: UserModel = user.into(); - let (ip, user_agent, _current_site) = crate::utils::extract_req_info(&req)?; + let ip = extract_req_ip(&req)?; + let user_agent = extract_req_uagent(&req)?; user.is_active = Set(true); user.last_active = Set(Some(chrono::Utc::now().fixed_offset())); 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 3921c65..cf7f2bc 100644 --- a/crates/jet-api/src/http/api/authentication/social_auth.rs +++ b/crates/jet-api/src/http/api/authentication/social_auth.rs @@ -35,6 +35,7 @@ use sea_orm::{ use tracing::{debug, error, warn}; use crate::{ + db_commit, db_rollback, db_t, extractors::RequireInstanceConfigured, http::OAuthError, models::{Error, JsonError}, @@ -155,7 +156,7 @@ async fn handle_callback( use oauth2_signin::web_app::SigninFlowHandleCallbackRet as R; let response = match ret { R::Ok((access_token_body, user_info)) => { - let mut tx = db.begin().await.map_err(|_| Error::DatabaseError)?; + let mut tx = db_t!(db)?; match handle_user_info( provider, req, @@ -168,17 +169,11 @@ async fn handle_callback( .await { Ok(v) => { - tx.commit().await.map_err(|e| { - error!("Failed to commit social_auth changes to postgres: {e}"); - Error::DatabaseError - })?; + db_commit!(tx, "Failed to commit social_auth changes to postgres")?; v } Err(e) => { - tx.rollback().await.map_err(|e| { - error!("Failed to rollback social_auth changes to postgres: {e}"); - Error::DatabaseError - })?; + db_rollback!(tx, "Failed to rollback social_auth changes to postgres"); return Err(e.into()); } } diff --git a/crates/jet-api/src/utils/mod.rs b/crates/jet-api/src/utils/mod.rs index 0b056e4..3665191 100644 --- a/crates/jet-api/src/utils/mod.rs +++ b/crates/jet-api/src/utils/mod.rs @@ -23,6 +23,48 @@ use uuid::Uuid; use crate::http::OAuthError; use crate::{http::AuthError, models::Error}; +#[macro_export] +macro_rules! db_t { + ($db: expr) => {{ + use sea_orm::*; + + $db.begin().await.map_err(|e| { + tracing::error!("Failed to start db tracation: {e}"); + crate::models::Error::DatabaseError + }) + }}; +} +#[macro_export] +macro_rules! db_commit { + ($db: expr) => {{ + $db.commit().await.map_err(|e| { + tracing::error!("Failed to commit db tracation: {e}"); + crate::models::Error::DatabaseError + }) + }}; + ($db: expr, $msg: expr) => {{ + $db.commit().await.map_err(|e| { + tracing::error!(std::concat!($msg, ": {}"), e); + crate::models::Error::DatabaseError + }) + }}; +} +#[macro_export] +macro_rules! db_rollback { + ($db: expr) => {{ + $db.rollback().await.map_err(|e| { + tracing::error!("Failed to rollback db tracation: {e}"); + crate::models::Error::DatabaseError + }) + }}; + ($db: expr, $msg: expr) => {{ + $db.rollback().await.map_err(|e| { + tracing::error!(std::concat!($msg, ": {}"), e); + crate::models::Error::DatabaseError + }) + }}; +} + pub fn extract_req_ip(req: &HttpRequest) -> Result { Ok(req .peer_addr()