This commit is contained in:
eraden 2024-01-27 14:44:24 +01:00
parent 0923427551
commit 70cb64a2e0
12 changed files with 781 additions and 260 deletions

217
Cargo.lock generated
View File

@ -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"

View File

@ -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"] }

View File

@ -0,0 +1,3 @@
pub mod require_instance;
pub use require_instance::*;

View File

@ -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<Self, Self::Error>>;
fn from_request(
req: &actix_web::HttpRequest,
_payload: &mut actix_web::dev::Payload,
) -> Self::Future {
let db = req.app_data::<Data<DatabaseConnection>>().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<Self, Self::Error>>;
fn from_request(
req: &actix_web::HttpRequest,
_payload: &mut actix_web::dev::Payload,
) -> Self::Future {
let db = req.app_data::<Data<DatabaseConnection>>().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()
}
}

View File

@ -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<DatabaseConnection>) -> HttpResponse {
HttpResponse::NotImplemented().finish()
}
#[get("/sign-in")]
async fn sign_in(_db: Data<DatabaseConnection>) -> HttpResponse {
HttpResponse::NotImplemented().finish()
async fn create_user(
req: &HttpRequest,
email: &str,
password: &str,
db: &mut DatabaseTransaction,
) -> Result<entities::users::Model, Error> {
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<bool, Error> {
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<DatabaseConnection>) -> HttpResponse {
HttpResponse::NotImplemented().finish()
}
async fn auth_http_response(
user: User,
session: Data<SessionStorage>,
status: StatusCode,
) -> Result<HttpResponse, Error> {
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,
}))
}

View File

@ -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<EmailCheckPayload>,
db: Data<DatabaseConnection>,
@ -30,13 +32,6 @@ pub async fn email_check(
event_bus: Data<EventBusClient>,
redis: Data<RedisClient>,
) -> Result<HttpResponse, Error> {
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()),

View File

@ -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<SignInPayload>,
db: Data<DatabaseConnection>,
config: Data<ApplicationConfig>,
event_bus: Data<crate::EventBusClient>,
session: Data<SessionStorage>,
) -> Result<HttpResponse, JsonError> {
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<SignInPayload>,
db: &mut DatabaseTransaction,
config: Data<ApplicationConfig>,
event_bus: Data<crate::EventBusClient>,
session: Data<SessionStorage>,
) -> Result<HttpResponse, JsonError> {
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<String>,
pub comment_after_local_part: Option<String>,
pub comment_before_domain_part: Option<String>,
pub comment_after_domain_part: Option<String>,
}
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,
}

View File

@ -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<crate::EventBusClient>,
session: Data<actix_jwt_session::SessionStorage>,
) -> std::result::Result<HttpResponse, AuthError> {
) -> std::result::Result<HttpResponse, Error> {
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::<Vec<_>>(),
),
)
.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::<Vec<_>>(),
),
)
.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(

View File

@ -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,12 +41,7 @@ async fn main() {
)
.await
.expect("Failed to connect to database");
let keys = JwtSigningKeys::load_or_create();
let (storage, factory) = SessionMiddlewareFactory::<session::AppClaims>::build(
Arc::new(keys.encoding_key),
Arc::new(keys.decoding_key),
Algorithm::EdDSA,
)
let (storage, factory) = SessionMiddlewareFactory::<session::AppClaims>::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)
@ -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())

View File

@ -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<actix_web::body::BoxBody> {
HttpResponse::BadRequest().json(self)
}
}
impl From<Error> 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<AuthError> for Error {

View File

@ -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::<Vec<_>>(),
),
)
.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::<Vec<_>>(),
),
)
.exec(&mut *db)
.await
{
error!(
"Failed clean up project invites for user {user_id:?} with provider {provider:?}: {e}"
);
return Err(Error::DatabaseError);
};
Ok(())
}

View File

@ -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",
}
}
}