From 70cb64a2e0c17622e0e1f69869c421b6923b0b3b Mon Sep 17 00:00:00 2001 From: eraden Date: Sat, 27 Jan 2024 14:44:24 +0100 Subject: [PATCH] Sign in --- Cargo.lock | 217 ++++++++++++++++- crates/jet-api/Cargo.toml | 1 + crates/jet-api/src/extractors/mod.rs | 3 + .../src/extractors/require_instance.rs | 69 ++++++ crates/jet-api/src/http/api/authentication.rs | 120 +++++++++- .../http/api/authentication/email_check.rs | 21 +- .../src/http/api/authentication/sign_in.rs | 144 +++++++++++ .../http/api/authentication/social_auth.rs | 224 +----------------- crates/jet-api/src/main.rs | 34 ++- crates/jet-api/src/models.rs | 20 +- crates/jet-api/src/utils/mod.rs | 185 +++++++++++++++ crates/jet-contract/src/event_bus/messages.rs | 3 + 12 files changed, 781 insertions(+), 260 deletions(-) create mode 100644 crates/jet-api/src/extractors/mod.rs create mode 100644 crates/jet-api/src/extractors/require_instance.rs create mode 100644 crates/jet-api/src/http/api/authentication/sign_in.rs create mode 100644 crates/jet-api/src/utils/mod.rs diff --git a/Cargo.lock b/Cargo.lock index 35de6c5..cdbd65f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -498,7 +498,7 @@ dependencies = [ "attohttpc", "dirs", "log", - "quick-xml", + "quick-xml 0.26.0", "rust-ini", "serde", "thiserror", @@ -664,6 +664,17 @@ version = "3.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f30e7476521f6f8af1a1c4c0b8cc94f0bee37d91763d0ca2665f299b6cd8aec" +[[package]] +name = "byte-unit" +version = "5.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ac19bdf0b2665407c39d82dbc937e951e7e2001609f0fb32edd0af45a2d63e" +dependencies = [ + "rust_decimal", + "serde", + "utf8-width", +] + [[package]] name = "bytecheck" version = "0.6.11" @@ -829,6 +840,12 @@ version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06ea2b9bc92be3c2baa9334a323ebca2d6f074ff852cd1d7b11064035cd3868f" +[[package]] +name = "cow-utils" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "417bef24afe1460300965a25ff4a24b8b45ad011948302ec221e8a0a81eb2c79" + [[package]] name = "cpufeatures" version = "0.2.12" @@ -960,6 +977,12 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "data-encoding" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e962a19be5cfc3f3bf6dd8f61eb50107f356ad6270fbb3ed41476571db78be5" + [[package]] name = "deadpool" version = "0.10.0" @@ -1094,6 +1117,18 @@ version = "1.0.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "545b22097d44f8a9581187cdf93de7a71e4722bf51200cfaba810865b49a495d" +[[package]] +name = "educe" +version = "0.5.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4bd92664bf78c4d3dba9b7cdafce6fa15b13ed3ed16175218196942e99168a8" +dependencies = [ + "enum-ordinalize", + "proc-macro2", + "quote", + "syn 2.0.48", +] + [[package]] name = "either" version = "1.9.0" @@ -1129,6 +1164,26 @@ dependencies = [ "serde", ] +[[package]] +name = "enum-ordinalize" +version = "4.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fea0dcfa4e54eeb516fe454635a95753ddd39acda650ce703031c6973e315dd5" +dependencies = [ + "enum-ordinalize-derive", +] + +[[package]] +name = "enum-ordinalize-derive" +version = "4.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d28318a75d4aead5c4db25382e8ef717932d0346600cacae6357eb5941bc5ff" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.48", +] + [[package]] name = "equivalent" version = "1.0.1" @@ -1740,6 +1795,15 @@ dependencies = [ "waker-fn", ] +[[package]] +name = "itertools" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1c173a5686ce8bfa551b3563d0c2170bf24ca44da99c7ca4bfdab5418c3fe57" +dependencies = [ + "either", +] + [[package]] name = "itertools" version = "0.12.0" @@ -1796,6 +1860,7 @@ dependencies = [ "tracing", "tracing-subscriber", "uuid", + "validators", ] [[package]] @@ -1949,6 +2014,12 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "linked-hash-map" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0717cef1bc8b636c6e1c1bbdefc09e6322da8a9321966e8928ef80d20f7f770f" + [[package]] name = "linux-raw-sys" version = "0.4.13" @@ -1988,6 +2059,15 @@ version = "0.4.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b5e6163cb8c49088c2c36f57875e58ccd8c87c7427f7fbd50ea6710b2f3f2e8f" +[[package]] +name = "lru-cache" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31e24f1ad8321ca0e8a1e0ac13f23cb668e6f5466c2c57319f6a5cf1cc8e3b1c" +dependencies = [ + "linked-hash-map", +] + [[package]] name = "matchers" version = "0.1.0" @@ -2327,6 +2407,12 @@ version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3fdb12b2476b595f9358c5161aa467c2438859caa136dec86c26fdd2efe17b92" +[[package]] +name = "oncemutex" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44d11de466f4a3006fe8a5e7ec84e93b79c70cb992ae0aa0eb631ad2df8abfe2" + [[package]] name = "openssl" version = "0.10.63" @@ -2513,6 +2599,27 @@ version = "2.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e" +[[package]] +name = "phonenumber" +version = "0.3.3+8.13.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "635f3e6288e4f01c049d89332a031bd74f25d64b6fb94703ca966e819488cd06" +dependencies = [ + "bincode", + "either", + "fnv", + "itertools 0.11.0", + "lazy_static", + "nom", + "quick-xml 0.28.2", + "regex", + "regex-cache", + "serde", + "serde_derive", + "strum 0.24.1", + "thiserror", +] + [[package]] name = "pin-project" version = "1.1.3" @@ -2691,6 +2798,15 @@ dependencies = [ "serde", ] +[[package]] +name = "quick-xml" +version = "0.28.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ce5e73202a820a31f8a0ee32ada5e21029c81fd9e3ebf668a40832e4219d9d1" +dependencies = [ + "memchr", +] + [[package]] name = "quote" version = "1.0.35" @@ -2847,6 +2963,18 @@ dependencies = [ "regex-syntax 0.8.2", ] +[[package]] +name = "regex-cache" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f7b62d69743b8b94f353b6b7c3deb4c5582828328bcb8d5fedf214373808793" +dependencies = [ + "lru-cache", + "oncemutex", + "regex", + "regex-syntax 0.6.29", +] + [[package]] name = "regex-syntax" version = "0.6.29" @@ -3044,7 +3172,7 @@ dependencies = [ "md5", "minidom", "percent-encoding", - "quick-xml", + "quick-xml 0.26.0", "reqwest", "serde", "serde_derive", @@ -3155,6 +3283,12 @@ dependencies = [ "untrusted 0.9.0", ] +[[package]] +name = "rustversion" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ffc183a10b4478d04cbbbfc96d0873219d962dd5accaff2ffbd4ceb7df837f4" + [[package]] name = "rxml" version = "0.9.1" @@ -3251,7 +3385,7 @@ dependencies = [ "serde", "serde_json", "sqlx", - "strum", + "strum 0.25.0", "thiserror", "time", "tracing", @@ -3340,6 +3474,9 @@ name = "semver" version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b97ed7a9823b74f99c7742f5336af7be5ecd3eeafcb1507d1fa93347b1d589b0" +dependencies = [ + "serde", +] [[package]] name = "serde" @@ -3628,7 +3765,7 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ce81b7bd7c4493975347ef60d8c7e8b742d4694f4c49f93e0a12ea263938176c" dependencies = [ - "itertools", + "itertools 0.12.0", "nom", "unicode_categories", ] @@ -3855,6 +3992,16 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" +[[package]] +name = "str-utils" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60bcb3d541a8fd455189b9e022f27d255d103dafd5087a93cff4c0a156a8b597" +dependencies = [ + "cow-utils", + "unicase", +] + [[package]] name = "stringprep" version = "0.1.4" @@ -3866,12 +4013,34 @@ dependencies = [ "unicode-normalization", ] +[[package]] +name = "strum" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "063e6045c0e62079840579a7e47a355ae92f60eb74daaf156fb1e84ba164e63f" +dependencies = [ + "strum_macros", +] + [[package]] name = "strum" version = "0.25.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "290d54ea6f91c969195bdbcd7442c8c2a2ba87da8bf60a7ee86a235d4bc1e125" +[[package]] +name = "strum_macros" +version = "0.24.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e385be0d24f186b4ce2f9982191e7101bb737312ad61c1f2f984f34bcf85d59" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "rustversion", + "syn 1.0.109", +] + [[package]] name = "subtle" version = "2.5.0" @@ -4368,6 +4537,12 @@ version = "2.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" +[[package]] +name = "utf8-width" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86bd8d4e895da8537e5315b8254664e6b769c4ff3db18321b297a1e7004392e3" + [[package]] name = "uuid" version = "1.7.0" @@ -4378,6 +4553,40 @@ dependencies = [ "serde", ] +[[package]] +name = "validators" +version = "0.25.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57e4dd623e1c294e7d7850097c41863cda2703166c5f58c225c5ca969299fb7a" +dependencies = [ + "byte-unit", + "data-encoding", + "idna", + "phonenumber", + "regex", + "semver", + "serde", + "serde_json", + "str-utils", + "url", + "validators-derive", +] + +[[package]] +name = "validators-derive" +version = "0.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72377736834d42b3e4029d46058f7ee2dbd44340bed0e82aaffd5c395bb0e6b3" +dependencies = [ + "educe", + "enum-ordinalize", + "phonenumber", + "proc-macro2", + "quote", + "regex", + "syn 2.0.48", +] + [[package]] name = "valuable" version = "0.1.0" diff --git a/crates/jet-api/Cargo.toml b/crates/jet-api/Cargo.toml index dab7708..5fc204c 100644 --- a/crates/jet-api/Cargo.toml +++ b/crates/jet-api/Cargo.toml @@ -41,3 +41,4 @@ reqwest = { version = "0.11.23", default-features = false, features = ["rustls", http-api-isahc-client = { version = "0.2.2", features = ["with-sleep-via-tokio"] } dotenv = "0.15.0" chrono = { version = "0.4.32", default-features = false, features = ["clock", "serde"] } +validators = { version = "0.25.3", default-features = false, features = ["email", "derive", "all-validators"] } diff --git a/crates/jet-api/src/extractors/mod.rs b/crates/jet-api/src/extractors/mod.rs new file mode 100644 index 0000000..098b290 --- /dev/null +++ b/crates/jet-api/src/extractors/mod.rs @@ -0,0 +1,3 @@ +pub mod require_instance; + +pub use require_instance::*; diff --git a/crates/jet-api/src/extractors/require_instance.rs b/crates/jet-api/src/extractors/require_instance.rs new file mode 100644 index 0000000..aaea0ac --- /dev/null +++ b/crates/jet-api/src/extractors/require_instance.rs @@ -0,0 +1,69 @@ +use actix_web::{web::Data, FromRequest}; +use derive_more::*; +use futures_util::{future::LocalBoxFuture, FutureExt}; +use sea_orm::DatabaseConnection; + +#[derive(Debug, Display)] +#[display(fmt = "{{\"error\":\"Instance is not configured\"}}")] +pub struct NoInstance; + +impl actix_web::error::ResponseError for NoInstance { + fn status_code(&self) -> reqwest::StatusCode { + reqwest::StatusCode::BAD_REQUEST + } +} + +#[derive(Debug, Deref)] +pub struct RequireInstance(pub entities::instances::Model); + +impl FromRequest for RequireInstance { + type Error = NoInstance; + type Future = LocalBoxFuture<'static, Result>; + + fn from_request( + req: &actix_web::HttpRequest, + _payload: &mut actix_web::dev::Payload, + ) -> Self::Future { + let db = req.app_data::>().cloned(); + async move { + let Some(db) = db else { + return Err(NoInstance); + }; + use sea_orm::EntityTrait; + let Ok(Some(instance)) = entities::prelude::Instances::find().one(&**db).await else { + return Err(NoInstance); + }; + Ok(Self(instance)) + } + .boxed_local() + } +} + +#[derive(Debug, Deref)] +pub struct RequireInstanceConfigured(pub entities::instances::Model); + +impl FromRequest for RequireInstanceConfigured { + type Error = NoInstance; + type Future = LocalBoxFuture<'static, Result>; + + fn from_request( + req: &actix_web::HttpRequest, + _payload: &mut actix_web::dev::Payload, + ) -> Self::Future { + let db = req.app_data::>().cloned(); + async move { + let Some(db) = db else { + return Err(NoInstance); + }; + use sea_orm::EntityTrait; + let Ok(Some(instance)) = entities::prelude::Instances::find().one(&**db).await else { + return Err(NoInstance); + }; + if !instance.is_setup_done { + return Err(NoInstance); + } + return Ok(Self(instance)); + } + .boxed_local() + } +} diff --git a/crates/jet-api/src/http/api/authentication.rs b/crates/jet-api/src/http/api/authentication.rs index 0ce2421..e713ce1 100644 --- a/crates/jet-api/src/http/api/authentication.rs +++ b/crates/jet-api/src/http/api/authentication.rs @@ -1,13 +1,33 @@ +use crate::models::Error; +use crate::session::AppClaims; +use actix_jwt_session::{ + Duration, Hashing, JwtTtl, RefreshTtl, SessionStorage, JWT_HEADER_NAME, REFRESH_HEADER_NAME, +}; use actix_web::web::{Data, ServiceConfig}; -use actix_web::{delete, get, HttpResponse}; +use actix_web::{delete, get, HttpRequest, HttpResponse}; +use entities::prelude::Users; +use entities::users::Model as User; +use reqwest::StatusCode; +use sea_orm::prelude::*; use sea_orm::DatabaseConnection; +use sea_orm::*; use uuid::Uuid; mod email_check; +mod sign_in; mod social_auth; +#[derive(Debug, serde::Serialize)] +pub struct AuthResponseBody { + access_token: String, + refresh_token: String, +} + pub fn configure(http_client: reqwest::Client, config: &mut ServiceConfig) { - config.service(email_check::email_check).service(oauth); + config + .service(email_check::email_check) + .service(oauth) + .service(sign_in::sign_in); social_auth::configure(http_client, config); } @@ -53,6 +73,8 @@ pub enum AuthError { #[display(fmt = "{}", _0)] #[from] Oauth(OAuthError), + #[display(fmt = "Encrypt password failed")] + EncryptPass, } #[get("/social-auth")] @@ -60,12 +82,100 @@ async fn oauth(_db: Data) -> HttpResponse { HttpResponse::NotImplemented().finish() } -#[get("/sign-in")] -async fn sign_in(_db: Data) -> HttpResponse { - HttpResponse::NotImplemented().finish() +async fn create_user( + req: &HttpRequest, + email: &str, + password: &str, + db: &mut DatabaseTransaction, +) -> Result { + use entities::users::ActiveModel; + + let (ip, user_agent, _current_site) = crate::utils::extract_req_info(&req)?; + let password = Hashing::encrypt(password).map_err(|e| { + tracing::error!("Failed to encrypt password: {e}"); + AuthError::EncryptPass + })?; + Users::insert(ActiveModel { + password: Set(password), + email: Set(Some(email.to_string())), + display_name: Set(email.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(ip.to_string()), + last_logout_ip: Set(ip.to_string()), + last_login_uagent: Set(user_agent.clone()), + is_active: Set(true), + ..Default::default() + }) + .exec_with_returning(db) + .await + .map_err(|e| { + tracing::error!("Failed to create account for {email:?}: {e}"); + Error::DatabaseError + }) +} + +async fn has_workspace_invites(email: &str, db: &mut DatabaseTransaction) -> Result { + entities::prelude::WorkspaceMemberInvites::find() + .filter(entities::workspace_member_invites::Column::Email.eq(email)) + .count(db) + .await + .map_err(|e| { + tracing::error!("Failed to count workspace member invites for {email:?}: {e}"); + Error::DatabaseError + }) + .map(|n| n > 0) } #[delete("/sign-out")] async fn sign_out(_db: Data) -> HttpResponse { HttpResponse::NotImplemented().finish() } + +async fn auth_http_response( + user: User, + session: Data, + status: StatusCode, +) -> Result { + let claims = AppClaims::from_user(user, JwtTtl::new(Duration::days(99999))); + let pair = session + .store( + claims, + JwtTtl::new(Duration::days(99999)), + RefreshTtl::new(Duration::days(99999)), + ) + .await + .map_err(|e| { + tracing::error!("Failed to store session: {e}"); + Error::DatabaseError + })?; + let access_token = match pair.jwt.encode() { + Ok(s) => s, + Err(e) => { + tracing::error!("Failed to store session: {e}"); + return Ok(HttpResponse::InternalServerError().finish()); + } + }; + let refresh_token = match pair.refresh.encode() { + Ok(s) => s, + Err(e) => { + tracing::error!("Failed to store session: {e}"); + return Ok(HttpResponse::InternalServerError().finish()); + } + }; + + Ok(HttpResponse::build(status) + .append_header((JWT_HEADER_NAME, access_token.clone())) + .append_header((REFRESH_HEADER_NAME, refresh_token.clone())) + .json(AuthResponseBody { + access_token, + refresh_token, + })) +} 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 5fb4817..9e86d49 100644 --- a/crates/jet-api/src/http/api/authentication/email_check.rs +++ b/crates/jet-api/src/http/api/authentication/email_check.rs @@ -2,7 +2,7 @@ use super::{AuthError, PublishError}; use actix_web::http::header::USER_AGENT; use actix_web::web::{Data, Json}; use actix_web::{post, HttpRequest, HttpResponse}; -use entities::prelude::{Instances, Users, WorkspaceMemberInvites}; +use entities::prelude::{Users, WorkspaceMemberInvites}; use entities::users::Model as User; use jet_contract::redis::AsyncCommands; use jet_contract::{MagicLinkKey, MagicLinkToken}; @@ -13,6 +13,7 @@ use serde::{Deserialize, Serialize}; use serde_email::Email; use crate::config::ApplicationConfig; +use crate::extractors::RequireInstanceConfigured; use crate::models::*; use crate::{EventBusClient, RedisClient}; @@ -23,6 +24,7 @@ struct EmailCheckPayload { #[post("/email-check")] pub async fn email_check( + _: RequireInstanceConfigured, req: HttpRequest, payload: Json, db: Data, @@ -30,13 +32,6 @@ pub async fn email_check( event_bus: Data, redis: Data, ) -> Result { - let instance = Instances::find() - .one(&**db) - .await - .map_err(|_| Error::DatabaseError)?; - let _instance = instance - .filter(|i| !i.is_setup_done) - .ok_or(Error::NotConfigured)?; if !serde_email::is_valid_email(&payload.email) { return Ok(HttpResponse::BadRequest().json(JsonError::new("Email is not valid"))); } @@ -157,15 +152,7 @@ async fn register( return Err(Error::Auth(AuthError::RegisterOff)); } let payload = payload.into_inner(); - let ip = req.peer_addr().ok_or(AuthError::NoPeerAddr)?.ip(); - let user_agent = req - .headers() - .get(USER_AGENT) - .ok_or(AuthError::NoUserAgent)? - .to_str() - .map_err(|_| AuthError::InvalidUserAgent)? - .to_string(); - let current_site = req.uri().host().ok_or(Error::NoHost)?.to_owned(); + let (ip, user_agent, current_site) = crate::utils::extract_req_info(&req)?; let user = entities::users::ActiveModel { password: Set(Uuid::new_v4().to_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 new file mode 100644 index 0000000..14ab5de --- /dev/null +++ b/crates/jet-api/src/http/api/authentication/sign_in.rs @@ -0,0 +1,144 @@ +use actix_jwt_session::SessionStorage; +use actix_web::web::{Data, Json}; +use actix_web::ResponseError; +use actix_web::{post, HttpRequest, HttpResponse}; +use entities::prelude::Users; +use entities::users::ActiveModel as UserModel; +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; +use crate::extractors::RequireInstanceConfigured; +use crate::http::api::authentication::{auth_http_response, create_user, has_workspace_invites}; +use crate::models::{Error, JsonError}; + +#[post("/sign-in")] +pub async fn sign_in( + _: RequireInstanceConfigured, + req: HttpRequest, + payload: Json, + db: Data, + config: Data, + event_bus: Data, + session: Data, +) -> Result { + let mut t = db.begin().await.map_err(|e| { + tracing::error!("Failed to start transaction for sign-in: {e}"); + Error::DatabaseError + })?; + let res = try_sign_in(req, payload, &mut t, config, event_bus, session).await?; + t.commit().await.map_err(|e| { + tracing::error!("Failed to commit transaction for sign-in: {e}"); + Error::DatabaseError + })?; + + Ok(res) +} + +async fn try_sign_in( + req: HttpRequest, + payload: Json, + db: &mut DatabaseTransaction, + config: Data, + event_bus: Data, + session: Data, +) -> Result { + if payload.email.trim().is_empty() || payload.password.trim().is_empty() { + return Err(JsonError::new("Both email and password are required")); + } + 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}"); + return Err(JsonError::new("Please provide a valid email address.")); + } + + let (user, was_created) = match Users::find() + .filter(entities::users::Column::Email.eq(&email)) + .one(&mut *db) + .await + { + Ok(Some(user)) => (user, false), + Ok(None) if !config.enable_signup && !has_workspace_invites(&email, &mut *db).await? => { + return Err(JsonError::new( + "New account creation is disabled. Please contact your site administrator", + )); + } + Ok(None) => (create_user(&req, &email, &password, &mut *db).await?, true), + Err(e) => { + tracing::error!("Failed to load user for sign-in: {e}"); + return Ok(Error::DatabaseError.error_response()); + } + }; + + let user_id = user.id; + let mut user: UserModel = user.into(); + + let (ip, user_agent, _current_site) = crate::utils::extract_req_info(&req)?; + + user.is_active = 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(ip.clone()); + user.last_login_uagent = Set(user_agent.clone()); + user.token_updated_at = Set(Some(chrono::Utc::now().fixed_offset())); + let user = Users::update(user).exec(&mut *db).await.map_err(|e| { + tracing::error!("Failed to update account for {email:?}: {e}"); + Error::DatabaseError + })?; + crate::utils::invites_to_membership(user_id, &email, None, &mut *db).await?; + + if let Err(e) = event_bus + .publish( + Topic::User, + Msg::User(UserMsg::SignIn { + user_id: UserId::new(user_id), + email, + user_agent, + ip, + medium: SignInMedium::Email, + first_time: false, + }), + QoS::AtLeastOnce, + true, + ) + .await + { + tracing::warn!("Failed to publish sign-in msg after sign in: {e}"); + }; + + auth_http_response(user, session, StatusCode::OK) + .await + .map_err(JsonError::new) +} + +#[derive(serde::Deserialize)] +struct SignInPayload { + email: String, + password: String, +} 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 59930ed..0a7b49f 100644 --- a/crates/jet-api/src/http/api/authentication/social_auth.rs +++ b/crates/jet-api/src/http/api/authentication/social_auth.rs @@ -1,30 +1,14 @@ use std::env::var as env_var; -use super::AuthError; +use super::{auth_http_response, AuthError, AuthResponseBody}; use actix_jwt_session::{JwtTtl, RefreshTtl, JWT_HEADER_NAME, REFRESH_HEADER_NAME}; use actix_web::{ get, web::{self, Data, ServiceConfig}, HttpRequest, HttpResponse, }; -use chrono::Utc; -use entities::project_member_invites::{ - Column as ProjectMemberInviteColumn, Model as ProjectMemberInvite, -}; +use entities::prelude::Users; use entities::users::{ActiveModel as UserModel, Column as UserColumn}; -use entities::workspace_member_invites::{ - Column as WorkspaceMemberInviteColumn, Model as WorkspaceMemberInvite, -}; -use entities::workspace_members::ActiveModel as WorkspaceMemberModel; -use entities::{ - prelude::{ - ProjectMemberInvites, ProjectMembers, Users, WorkspaceMemberInvites, WorkspaceMembers, - }, - sea_orm_active_enums::Roles, -}; -use entities::{ - project_members::ActiveModel as ProjectMemberModel, sea_orm_active_enums::ProjectMemberRoles, -}; use http_api_isahc_client::IsahcClient; use jet_contract::{ event_bus::{Msg, SignInMedium, Topic, UserMsg}, @@ -43,10 +27,10 @@ use oauth2_google::{ GoogleProviderForWebServerAppsAccessType, GoogleScope, }; use oauth2_signin::web_app::{SigninFlow, SigninFlowHandleCallbackByQueryConfiguration}; -use reqwest::header::{LOCATION, USER_AGENT}; +use reqwest::{header::LOCATION, StatusCode}; use sea_orm::{ - sea_query::OnConflict, ActiveModelTrait, ActiveValue::NotSet, ColumnTrait, DatabaseConnection, - DatabaseTransaction, EntityTrait, QueryFilter, Set, TransactionTrait, + ActiveModelTrait, ActiveValue::NotSet, ColumnTrait, DatabaseConnection, DatabaseTransaction, + EntityTrait, QueryFilter, Set, TransactionTrait, }; use tracing::{debug, error, warn}; @@ -236,7 +220,7 @@ async fn handle_user_info( user_info: UserInfo, event_bus: Data, session: Data, -) -> std::result::Result { +) -> std::result::Result { let UserInfo { uid: _, name: _, @@ -273,18 +257,7 @@ async fn handle_user_info( } }; - let ip = req - .peer_addr() - .ok_or(AuthError::NoPeerAddr)? - .ip() - .to_string(); - let user_agent = req - .headers() - .get(USER_AGENT) - .ok_or(AuthError::NoUserAgent)? - .to_str() - .map_err(|_| AuthError::InvalidUserAgent)? - .to_string(); + let (ip, user_agent, _current_site) = crate::utils::extract_req_info(&req)?; user.is_active = Set(true); user.last_active = Set(Some(chrono::Utc::now().fixed_offset())); @@ -303,144 +276,7 @@ async fn handle_user_info( } }; - let workspace_invites = WorkspaceMemberInvites::find() - .filter( - WorkspaceMemberInviteColumn::Accepted - .eq(true) - .and(WorkspaceMemberInviteColumn::Email.eq(&email)), - ) - .all(&mut *db) - .await - .map_err(|e| { - error!("Failed to update user {user_id:?} on oauth {provider}: {e}"); - OAuthError::FetchWorkspaceInvites { - user_id, - provider: provider.to_owned(), - } - })?; - let project_invites = ProjectMemberInvites::find() - .filter( - ProjectMemberInviteColumn::Accepted - .eq(true) - .and(ProjectMemberInviteColumn::Email.eq(&email)), - ) - .all(&mut *db) - .await - .map_err(|e| { - error!("Failed to update user {user_id:?} on oauth {provider}: {e}"); - OAuthError::FetchProjectInvites { - user_id, - provider: provider.to_owned(), - } - })?; - - // Create workspace members - let insert = WorkspaceMembers::insert_many(workspace_invites.iter().map( - |WorkspaceMemberInvite { - role, - created_by_id, - workspace_id, - .. - }| WorkspaceMemberModel { - id: NotSet, - created_at: Set(Utc::now().fixed_offset()), - updated_at: Set(Utc::now().fixed_offset()), - role: Set(match role { - Roles::Admin => Roles::Member, - _ => role.clone(), - }), - created_by_id: Set(created_by_id.clone()), - member_id: Set(user_id), - updated_by_id: NotSet, - workspace_id: Set(workspace_id.clone()), - company_role: NotSet, - view_props: NotSet, - default_props: NotSet, - issue_props: NotSet, - is_active: Set(true), - }, - )); - if let Err(e) = insert - .on_conflict(OnConflict::new().do_nothing().to_owned()) - .exec(&mut *db) - .await - { - error!("Failed to add user {user_id:?} to workspace with provider {provider}: {e}"); - return Ok(HttpResponse::InternalServerError() - .json(JsonError::new("Failed to add user to workspaces"))); - } - - // Create project members - let insert = ProjectMembers::insert_many(project_invites.iter().map( - |ProjectMemberInvite { - role, - created_by_id, - workspace_id, - .. - }| ProjectMemberModel { - id: NotSet, - created_at: Set(Utc::now().fixed_offset()), - updated_at: Set(Utc::now().fixed_offset()), - role: Set(match role { - ProjectMemberRoles::Admin => ProjectMemberRoles::Member, - _ => role.clone(), - }), - created_by_id: Set(created_by_id.clone()), - member_id: Set(Some(user_id)), - updated_by_id: NotSet, - workspace_id: Set(workspace_id.clone()), - view_props: NotSet, - default_props: NotSet, - is_active: Set(true), - comment: Set(None), - project_id: NotSet, - sort_order: NotSet, - preferences: NotSet, - }, - )); - if let Err(e) = insert - .on_conflict(OnConflict::new().do_nothing().to_owned()) - .exec(&mut *db) - .await - { - error!("Failed to add user {user_id:?} to project with provider {provider}: {e}"); - return Ok(HttpResponse::InternalServerError() - .json(JsonError::new("Failed to add user to projects"))); - } - - // cleanups - if let Err(e) = WorkspaceMemberInvites::delete_many() - .filter( - WorkspaceMemberInviteColumn::Id.is_in( - workspace_invites - .into_iter() - .map(|w| uuid::Uuid::from_u128(w.id.as_u128())) - .collect::>(), - ), - ) - .exec(&mut *db) - .await - { - error!( - "Failed clean up workspace invites for user {user_id:?} with provider {provider}: {e}" - ); - }; - if let Err(e) = ProjectMemberInvites::delete_many() - .filter( - ProjectMemberInviteColumn::Id.is_in( - project_invites - .into_iter() - .map(|w| uuid::Uuid::from_u128(w.id.as_u128())) - .collect::>(), - ), - ) - .exec(&mut *db) - .await - { - error!( - "Failed clean up project invites for user {user_id:?} with provider {provider}: {e}" - ); - }; + crate::utils::invites_to_membership(user_id, &email, Some(provider), &mut *db).await?; entities::social_login_connections::ActiveModel { id: NotSet, @@ -459,10 +295,10 @@ async fn handle_user_info( .await .map_err(|e| { error!("Failed to create social media connection {provider:?}: {e}"); - OAuthError::ConnectSocialMedia { + AuthError::from(OAuthError::ConnectSocialMedia { user_id, provider: provider.to_owned(), - } + }) })?; if let Err(e) = event_bus @@ -484,45 +320,7 @@ async fn handle_user_info( warn!("Failed to publish sign-in msg after {provider} callback: {e}"); }; - let access_ttl = JwtTtl(actix_jwt_session::Duration::days(9_999)); - let refresh_ttl = RefreshTtl(actix_jwt_session::Duration::days(9_999)); - let claims = AppClaims::from_user(user, access_ttl); - let pair = match session.store(claims, access_ttl, refresh_ttl).await { - Ok(pair) => pair, - Err(e) => { - error!("Failed to store session: {e}"); - return Ok(HttpResponse::InternalServerError().finish()); - } - }; - - let access_token = match pair.jwt.encode() { - Ok(s) => s, - Err(e) => { - error!("Failed to store session: {e}"); - return Ok(HttpResponse::InternalServerError().finish()); - } - }; - let refresh_token = match pair.refresh.encode() { - Ok(s) => s, - Err(e) => { - error!("Failed to store session: {e}"); - return Ok(HttpResponse::InternalServerError().finish()); - } - }; - - #[derive(Debug, serde::Serialize)] - struct Response { - access_token: String, - refresh_token: String, - } - - Ok(HttpResponse::Created() - .append_header((JWT_HEADER_NAME, access_token.clone())) - .append_header((REFRESH_HEADER_NAME, refresh_token.clone())) - .json(Response { - access_token, - refresh_token, - })) + auth_http_response(user, session, StatusCode::CREATED).await } fn github_flow( diff --git a/crates/jet-api/src/main.rs b/crates/jet-api/src/main.rs index 56d3ab0..460b2ec 100644 --- a/crates/jet-api/src/main.rs +++ b/crates/jet-api/src/main.rs @@ -1,5 +1,4 @@ use std::env; -use std::sync::Arc; use actix_jwt_session::*; use actix_web::{web::Data, App, HttpServer}; @@ -9,9 +8,11 @@ pub use sea_orm::{Database, DatabaseConnection}; pub mod config; pub mod events; +pub mod extractors; pub mod http; pub mod models; pub mod session; +pub mod utils; pub const APPLICATION_NAME: &str = "jet-api"; @@ -40,21 +41,16 @@ async fn main() { ) .await .expect("Failed to connect to database"); - let keys = JwtSigningKeys::load_or_create(); - let (storage, factory) = SessionMiddlewareFactory::::build( - Arc::new(keys.encoding_key), - Arc::new(keys.decoding_key), - Algorithm::EdDSA, - ) - .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) - .finish(); + let (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) + .finish(); let jwt_ttl = JwtTtl(Duration::days(9999)); let refresh_ttl = RefreshTtl(Duration::days(3 * 31 * 999)); let (eb_client, eb_stream) = rumqttc::AsyncClient::new( @@ -76,9 +72,7 @@ async fn main() { crate::events::handle_events(eb_stream).await; let http_client = reqwest::Client::new(); - let redis = Data::new(redis); let application_config = Data::new(application_config); - let db = Data::new(db); let event_bus = Data::new(jet_contract::event_bus::Client::new(eb_client)); HttpServer::new(move || { @@ -86,9 +80,9 @@ async fn main() { .app_data(Data::new(storage.clone())) .app_data(Data::new(jwt_ttl)) .app_data(Data::new(refresh_ttl)) + .app_data(Data::new(redis.clone())) + .app_data(Data::new(db.clone())) .app_data(application_config.clone()) - .app_data(redis.clone()) - .app_data(db.clone()) .app_data(event_bus.clone()) .wrap(factory.clone()) .wrap(actix_web::middleware::Logger::default()) diff --git a/crates/jet-api/src/models.rs b/crates/jet-api/src/models.rs index 0ca7543..7276220 100644 --- a/crates/jet-api/src/models.rs +++ b/crates/jet-api/src/models.rs @@ -1,9 +1,11 @@ use actix_web::HttpResponse; +use derive_more::Display; use serde::{Deserialize, Serialize}; use crate::http::AuthError; -#[derive(Debug, Serialize, Deserialize)] +#[derive(Debug, Serialize, Deserialize, Display)] +#[display(fmt = "{{\"error\":\"{error}\"}}")] pub struct JsonError { pub error: String, } @@ -16,6 +18,18 @@ impl JsonError { } } +impl actix_web::ResponseError for JsonError { + fn error_response(&self) -> HttpResponse { + HttpResponse::BadRequest().json(self) + } +} + +impl From for JsonError { + fn from(e: Error) -> Self { + Self::new(e) + } +} + #[derive(Debug, Clone, derive_more::Display)] pub enum Error { #[display(fmt = "Database connection error")] @@ -30,6 +44,10 @@ pub enum Error { Auth(AuthError), #[display(fmt = "Internal server error, failed to connect to storage")] RedisConnection, + #[display(fmt = "Failed to add user to workspaces")] + AddToWorkspace, + #[display(fmt = "Failed to add user to projects")] + AddToProject, } impl From for Error { diff --git a/crates/jet-api/src/utils/mod.rs b/crates/jet-api/src/utils/mod.rs new file mode 100644 index 0000000..e67a3d1 --- /dev/null +++ b/crates/jet-api/src/utils/mod.rs @@ -0,0 +1,185 @@ +use actix_web::{HttpRequest, HttpResponse}; +use chrono::Utc; +use entities::project_member_invites::{ + Column as ProjectMemberInviteColumn, Model as ProjectMemberInvite, +}; +use entities::workspace_member_invites::{ + Column as WorkspaceMemberInviteColumn, Model as WorkspaceMemberInvite, +}; +use entities::workspace_members::ActiveModel as WorkspaceMemberModel; +use entities::{ + prelude::{ProjectMemberInvites, ProjectMembers, WorkspaceMemberInvites, WorkspaceMembers}, + sea_orm_active_enums::Roles, +}; +use entities::{ + project_members::ActiveModel as ProjectMemberModel, sea_orm_active_enums::ProjectMemberRoles, +}; +use reqwest::header::USER_AGENT; +use sea_orm::sea_query::OnConflict; +use sea_orm::*; +use tracing::error; +use uuid::Uuid; + +use crate::http::OAuthError; +use crate::models::JsonError; +use crate::{http::AuthError, models::Error}; + +pub fn extract_req_info(req: &HttpRequest) -> Result<(String, String, String), Error> { + let ip = req.peer_addr().ok_or(AuthError::NoPeerAddr)?.ip(); + let user_agent = req + .headers() + .get(USER_AGENT) + .ok_or(AuthError::NoUserAgent)? + .to_str() + .map_err(|_| AuthError::InvalidUserAgent)? + .to_string(); + let current_site = req.uri().host().ok_or(Error::NoHost)?.to_owned(); + Ok((ip.to_string(), user_agent, current_site)) +} + +pub async fn invites_to_membership( + user_id: Uuid, + email: &str, + provider: Option<&str>, + db: &mut DatabaseTransaction, +) -> Result<(), Error> { + let workspace_invites = WorkspaceMemberInvites::find() + .filter( + WorkspaceMemberInviteColumn::Accepted + .eq(true) + .and(WorkspaceMemberInviteColumn::Email.eq(email)), + ) + .all(&mut *db) + .await + .map_err(|e| { + error!("Failed to update user {user_id:?} on oauth {provider:?}: {e}"); + AuthError::from(OAuthError::FetchWorkspaceInvites { + user_id, + provider: provider.to_owned().unwrap_or("--NONE--").to_owned(), + }) + })?; + let project_invites = ProjectMemberInvites::find() + .filter( + ProjectMemberInviteColumn::Accepted + .eq(true) + .and(ProjectMemberInviteColumn::Email.eq(email)), + ) + .all(&mut *db) + .await + .map_err(|e| { + error!("Failed to update user {user_id:?} on oauth {provider:?}: {e}"); + AuthError::from(OAuthError::FetchProjectInvites { + user_id, + provider: provider.to_owned().unwrap_or("--NONE--").to_owned(), + }) + })?; + + // Create workspace members + let insert = WorkspaceMembers::insert_many(workspace_invites.iter().map( + |WorkspaceMemberInvite { + role, + created_by_id, + workspace_id, + .. + }| WorkspaceMemberModel { + id: NotSet, + created_at: Set(Utc::now().fixed_offset()), + updated_at: Set(Utc::now().fixed_offset()), + role: Set(match role { + Roles::Admin => Roles::Member, + _ => role.clone(), + }), + created_by_id: Set(created_by_id.clone()), + member_id: Set(user_id), + updated_by_id: NotSet, + workspace_id: Set(workspace_id.clone()), + company_role: NotSet, + view_props: NotSet, + default_props: NotSet, + issue_props: NotSet, + is_active: Set(true), + }, + )); + if let Err(e) = insert + .on_conflict(OnConflict::new().do_nothing().to_owned()) + .exec(&mut *db) + .await + { + error!("Failed to add user {user_id:?} to workspace with provider {provider:?}: {e}"); + return Err(Error::AddToWorkspace); + } + + // Create project members + let insert = ProjectMembers::insert_many(project_invites.iter().map( + |ProjectMemberInvite { + role, + created_by_id, + workspace_id, + .. + }| ProjectMemberModel { + id: NotSet, + created_at: Set(Utc::now().fixed_offset()), + updated_at: Set(Utc::now().fixed_offset()), + role: Set(match role { + ProjectMemberRoles::Admin => ProjectMemberRoles::Member, + _ => role.clone(), + }), + created_by_id: Set(created_by_id.clone()), + member_id: Set(Some(user_id)), + updated_by_id: NotSet, + workspace_id: Set(workspace_id.clone()), + view_props: NotSet, + default_props: NotSet, + is_active: Set(true), + comment: Set(None), + project_id: NotSet, + sort_order: NotSet, + preferences: NotSet, + }, + )); + if let Err(e) = insert + .on_conflict(OnConflict::new().do_nothing().to_owned()) + .exec(&mut *db) + .await + { + error!("Failed to add user {user_id:?} to project with provider {provider:?}: {e}"); + return Err(Error::AddToProject); + } + + // cleanups + if let Err(e) = WorkspaceMemberInvites::delete_many() + .filter( + WorkspaceMemberInviteColumn::Id.is_in( + workspace_invites + .into_iter() + .map(|w| uuid::Uuid::from_u128(w.id.as_u128())) + .collect::>(), + ), + ) + .exec(&mut *db) + .await + { + error!( + "Failed clean up workspace invites for user {user_id:?} with provider {provider:?}: {e}" + ); + return Err(Error::DatabaseError); + }; + if let Err(e) = ProjectMemberInvites::delete_many() + .filter( + ProjectMemberInviteColumn::Id.is_in( + project_invites + .into_iter() + .map(|w| uuid::Uuid::from_u128(w.id.as_u128())) + .collect::>(), + ), + ) + .exec(&mut *db) + .await + { + error!( + "Failed clean up project invites for user {user_id:?} with provider {provider:?}: {e}" + ); + return Err(Error::DatabaseError); + }; + Ok(()) +} diff --git a/crates/jet-contract/src/event_bus/messages.rs b/crates/jet-contract/src/event_bus/messages.rs index 486bbac..56bd3c6 100644 --- a/crates/jet-contract/src/event_bus/messages.rs +++ b/crates/jet-contract/src/event_bus/messages.rs @@ -58,6 +58,8 @@ pub enum SignInMedium { MagicLink, #[display(fmt = "oauth")] OAuth, + #[display(fmt = "EMAIL")] + Email, } impl SignInMedium { @@ -65,6 +67,7 @@ impl SignInMedium { match self { Self::MagicLink => "MAGIC_LINK", Self::OAuth => "oauth", + Self::Email => "EMAIL", } } }