magic sign in

This commit is contained in:
eraden 2024-01-29 22:27:28 +01:00
parent 6b6fd54292
commit ba4e6a377f
9 changed files with 272 additions and 92 deletions

View File

@ -1,7 +1,8 @@
use crate::models::Error; use crate::models::{Error, JsonError};
use crate::session::AppClaims; use crate::session::AppClaims;
use actix_jwt_session::{ use actix_jwt_session::{
Duration, Hashing, JwtTtl, RefreshTtl, SessionStorage, JWT_HEADER_NAME, REFRESH_HEADER_NAME, Duration, Hashing, JwtTtl, Pair, RefreshTtl, SessionStorage, JWT_HEADER_NAME,
REFRESH_HEADER_NAME,
}; };
use actix_web::web::scope; use actix_web::web::scope;
use actix_web::web::{Data, ServiceConfig}; use actix_web::web::{Data, ServiceConfig};
@ -31,6 +32,23 @@ pub struct AuthResponseBody {
refresh_token: String, refresh_token: String,
} }
impl AuthResponseBody {
pub fn build(pair: Pair<AppClaims>) -> Result<Self, JsonError> {
let access_token = pair.jwt.encode().map_err(|e| {
tracing::error!("Failed encode JWT: {e}");
JsonError::new(AuthError::EncryptPair)
})?;
let refresh_token = pair.refresh.encode().map_err(|e| {
tracing::error!("Failed encode JWT: {e}");
JsonError::new(AuthError::EncryptPair)
})?;
Ok(Self {
access_token,
refresh_token,
})
}
}
pub fn configure(http_client: reqwest::Client, config: &mut ServiceConfig) { pub fn configure(http_client: reqwest::Client, config: &mut ServiceConfig) {
config.service( config.service(
scope("") scope("")
@ -89,6 +107,8 @@ pub enum AuthError {
Oauth(OAuthError), Oauth(OAuthError),
#[display(fmt = "Encrypt password failed")] #[display(fmt = "Encrypt password failed")]
EncryptPass, EncryptPass,
#[display(fmt = "Encrypt JWT pair failed")]
EncryptPair,
} }
async fn create_user( async fn create_user(
@ -210,9 +230,30 @@ pub fn random_password() -> String {
.collect() .collect()
} }
pub mod password {
const HAS_NUM: u8 = 1;
const HAS_UPPER: u8 = 2;
const HAS_LOWER: u8 = 4;
const HAS_SPECIAL: u8 = 8;
pub fn is_valid(pass: &str) -> bool {
pass.len() >= 8
|| pass.chars().fold(0, |memo, c| match c {
_ if c.is_numeric() => memo | HAS_NUM,
_ if c.is_uppercase() => memo | HAS_UPPER,
_ if c.is_lowercase() => memo | HAS_UPPER,
_ => memo | HAS_SPECIAL,
}) & HAS_NUM
& HAS_SPECIAL
& HAS_UPPER
& HAS_LOWER
!= 0
}
}
pub mod magic_link { pub mod magic_link {
use crate::models::Error; use crate::models::Error;
use crate::{http::AuthError, RedisClient}; use crate::{http::AuthError, redis_c, RedisClient};
use actix_web::web::Data; use actix_web::web::Data;
use jet_contract::*; use jet_contract::*;
use rand::prelude::*; use rand::prelude::*;
@ -234,12 +275,24 @@ pub mod magic_link {
format!("magic_{email}") format!("magic_{email}")
} }
pub async fn clear_magic_link(email: &str, redis: Data<RedisClient>) -> Result<(), Error> {
let Ok(mut redis) = redis.get().await else {
return Err(AuthError::SerializeMsg.into());
};
let key = magic_link_key(email);
let _: () = redis.del(key).await.map_err(|_| Error::RedisConnection)?;
Ok(())
}
pub async fn create_magic_link( pub async fn create_magic_link(
email: &str, email: &str,
redis: Data<RedisClient>, redis: Data<RedisClient>,
) -> Result<(MagicLinkKey, MagicLinkToken, AttemptValidity), Error> { ) -> Result<(MagicLinkKey, MagicLinkToken, AttemptValidity), Error> {
use rand::distributions::Alphanumeric; use rand::distributions::Alphanumeric;
let mut redis = redis_c!(redis)?;
let key = magic_link_key(email); let key = magic_link_key(email);
let mut rng = rand::thread_rng(); let mut rng = rand::thread_rng();
let token = format!( let token = format!(
@ -261,10 +314,6 @@ pub mod magic_link {
.collect::<String>(), .collect::<String>(),
); );
let Ok(mut redis) = redis.get().await else {
return Err(AuthError::SerializeMsg.into());
};
if redis if redis
.exists(&key) .exists(&key)
.await .await

View File

@ -4,7 +4,8 @@ use actix_web::web::{Data, Json};
use actix_web::{post, HttpRequest, HttpResponse}; use actix_web::{post, HttpRequest, HttpResponse};
use entities::prelude::{Users, WorkspaceMemberInvites}; use entities::prelude::{Users, WorkspaceMemberInvites};
use entities::users::Model as User; use entities::users::Model as User;
use sea_orm::prelude::*; use jet_contract::event_bus::SignInMedium;
use sea_orm::{prelude::*, DatabaseTransaction};
use sea_orm::{DatabaseConnection, EntityTrait, QueryFilter}; use sea_orm::{DatabaseConnection, EntityTrait, QueryFilter};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use serde_email::Email; use serde_email::Email;
@ -12,7 +13,8 @@ use serde_email::Email;
use crate::config::ApplicationConfig; use crate::config::ApplicationConfig;
use crate::extractors::RequireInstanceConfigured; use crate::extractors::RequireInstanceConfigured;
use crate::http::magic_link::create_magic_link; use crate::http::magic_link::create_magic_link;
use crate::models::*; use crate::utils::user_by_email;
use crate::{db_commit, db_rollback, db_t, models::*};
use crate::{EventBusClient, RedisClient}; use crate::{EventBusClient, RedisClient};
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
@ -33,16 +35,24 @@ pub async fn email_check(
if !serde_email::is_valid_email(&payload.email) { if !serde_email::is_valid_email(&payload.email) {
return Ok(HttpResponse::BadRequest().json(JsonError::new("Email is not valid"))); return Ok(HttpResponse::BadRequest().json(JsonError::new("Email is not valid")));
} }
let mut t = db_t!(db)?;
let user = Users::find() let user = user_by_email(payload.email.as_str(), &mut t).await?;
.filter(entities::users::Column::Email.eq(payload.email.as_str()))
.one(&**db)
.await
.map_err(|_| Error::DatabaseError)?;
match user { let res = match user {
Some(user) => handle_existing_user(req, payload, user, app_config, event_bus, redis).await, Some(user) => handle_existing_user(req, payload, user, app_config, event_bus, redis).await,
None => register(req, payload, app_config, event_bus, db, redis).await, None => register(req, payload, app_config, event_bus, &mut t, redis).await,
};
match res {
Ok(r) => {
db_commit!(t)?;
Ok(r)
}
Err(e) => {
db_rollback!(t)?;
Err(e)
}
} }
} }
@ -134,7 +144,7 @@ async fn register(
payload: Json<EmailCheckPayload>, payload: Json<EmailCheckPayload>,
app_config: Data<ApplicationConfig>, app_config: Data<ApplicationConfig>,
event_bus: Data<EventBusClient>, event_bus: Data<EventBusClient>,
db: Data<DatabaseConnection>, db: &mut DatabaseTransaction,
redis: Data<RedisClient>, redis: Data<RedisClient>,
) -> Result<HttpResponse, Error> { ) -> Result<HttpResponse, Error> {
use sea_orm::Set; use sea_orm::Set;
@ -142,7 +152,7 @@ async fn register(
if !app_config.enable_signup if !app_config.enable_signup
&& WorkspaceMemberInvites::find() && WorkspaceMemberInvites::find()
.filter(entities::workspace_member_invites::Column::Email.eq(payload.email.as_str())) .filter(entities::workspace_member_invites::Column::Email.eq(payload.email.as_str()))
.count(&**db) .count(&mut *db)
.await .await
.map_err(|_| Error::DatabaseError)? .map_err(|_| Error::DatabaseError)?
== 0 == 0
@ -167,14 +177,17 @@ async fn register(
user_timezone: Set("UTC".to_string()), user_timezone: Set("UTC".to_string()),
last_login_ip: Set(ip.to_string()), last_login_ip: Set(ip.to_string()),
last_logout_ip: Set(ip.to_string()), last_logout_ip: Set(ip.to_string()),
last_login_medium: Set("MAGIC_LINK".to_string()), last_login_medium: Set(SignInMedium::MagicLink.as_str().to_owned()),
last_login_uagent: Set(user_agent.clone()), last_login_uagent: Set(user_agent.clone()),
..Default::default() ..Default::default()
}; };
if !app_config.enable_magic_link_login { if !app_config.enable_magic_link_login {
return Err(AuthError::MagicLinkOff.into()); return Err(AuthError::MagicLinkOff.into());
} }
let user = user.save(&**db).await.map_err(|_| Error::DatabaseError)?; let user = user
.save(&mut *db)
.await
.map_err(|_| Error::DatabaseError)?;
{ {
use jet_contract::event_bus::*; use jet_contract::event_bus::*;
let user_id = user.id.clone().unwrap(); let user_id = user.id.clone().unwrap();

View File

@ -4,14 +4,16 @@ use actix_web::{
HttpRequest, HttpResponse, HttpRequest, HttpResponse,
}; };
use jet_contract::event_bus::{EmailMsg, Topic}; use jet_contract::event_bus::{EmailMsg, Topic};
use sea_orm::prelude::*;
use sea_orm::DatabaseConnection; use sea_orm::DatabaseConnection;
use sea_orm::{prelude::*, DatabaseTransaction};
use serde::Deserialize; use serde::Deserialize;
use serde_json::json;
use tracing::error;
use super::{create_user, random_password}; use super::{create_user, random_password};
use crate::{db_commit, models::*, utils::extract_req_current_site};
use crate::{ use crate::{
db_t, extractors::RequireInstanceConfigured, models::JsonError, EventBusClient, RedisClient, db_commit, db_rollback, db_t, extractors::RequireInstanceConfigured, models::JsonError,
models::*, utils::extract_req_current_site, EventBusClient, RedisClient,
}; };
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
@ -29,31 +31,56 @@ pub async fn magic_generate(
event_bus: Data<EventBusClient>, event_bus: Data<EventBusClient>,
) -> Result<HttpResponse, JsonError> { ) -> Result<HttpResponse, JsonError> {
let mut t = db_t!(db)?; let mut t = db_t!(db)?;
match try_create_magic_link(req, payload, &mut t, redis, event_bus).await {
Ok(r) => {
db_commit!(t)?;
Ok(r)
}
Err(e) => {
db_rollback!(t)?;
Err(e.into())
}
}
}
async fn try_create_magic_link(
req: HttpRequest,
payload: Json<Input>,
t: &mut DatabaseTransaction,
redis: Data<RedisClient>,
event_bus: Data<EventBusClient>,
) -> Result<HttpResponse, Error> {
let email = payload.into_inner().email; let email = payload.into_inner().email;
let user = match entities::prelude::Users::find() let _user = match entities::prelude::Users::find()
.filter(entities::users::Column::Email.eq(&email)) .filter(entities::users::Column::Email.eq(&email))
.one(&mut t) .one(&mut *t)
.await .await
{ {
Ok(Some(user)) => user, Ok(Some(user)) => user,
Ok(None) => create_user(&req, &email, &random_password(), &mut t).await?, Ok(None) => create_user(&req, &email, &random_password(), &mut *t).await?,
Err(e) => return Err(Error::DatabaseError.into()), Err(e) => {
error!("Failed to connect to database while creating magic link: {e}");
return Err(Error::DatabaseError.into());
}
}; };
db_commit!(t)?; let (key, token, _validity) = super::magic_link::create_magic_link(&email, redis).await?;
let (key, token, validity) = super::magic_link::create_magic_link(&email, redis).await?;
let current_site = extract_req_current_site(&req)?; let current_site = extract_req_current_site(&req)?;
event_bus event_bus
.publish(Topic::Email, jet_contract::event_bus::Msg::Email(EmailMsg::MagicLink { .publish(
Topic::Email,
jet_contract::event_bus::Msg::Email(EmailMsg::MagicLink {
email, email,
key, key: jet_contract::MagicLinkKey::new(key.clone()),
token, token,
current_site, current_site,
}), rumqttc::QoS::AtLeastOnce, true) }),
rumqttc::QoS::AtLeastOnce,
true,
)
.await; .await;
Ok(HttpResponse::NotImplemented().finish()) Ok(HttpResponse::Ok().json(json!({ "key": key })))
} }

View File

@ -1,16 +1,33 @@
use actix_jwt_session::{Duration, JwtTtl, RefreshTtl, SessionStorage};
use actix_web::{ use actix_web::{
post, post,
web::{Data, Json}, web::{Data, Json},
HttpRequest, HttpResponse, HttpRequest, HttpResponse,
}; };
/*
use jet_contract::{
event_bus::{Msg, SignInMedium, Topic, UserMsg},
redis::AsyncCommands,
};
use reqwest::StatusCode;
use rumqttc::QoS;
use sea_orm::prelude::*; use sea_orm::prelude::*;
use sea_orm::*; use sea_orm::*;
use tracing::error;
use serde::Deserialize; use serde::Deserialize;
use serde_json::json;
use tracing::error;
use crate::{models::{Error, JsonError}, extractors::RequireInstanceConfigured}; use crate::{
use crate::{RedisClient, EventBusClient}; db_commit, db_rollback, db_t,
extractors::RequireInstanceConfigured,
models::{Error, JsonError},
redis_c,
session::AppClaims,
utils::{extract_req_ip, extract_req_uagent, user_by_email},
};
use crate::{EventBusClient, RedisClient};
use super::{AuthResponseBody, auth_http_response};
#[post("/magic-sign-in")] #[post("/magic-sign-in")]
async fn magic_sign_in( async fn magic_sign_in(
@ -20,32 +37,102 @@ async fn magic_sign_in(
db: Data<DatabaseConnection>, db: Data<DatabaseConnection>,
redis: Data<RedisClient>, redis: Data<RedisClient>,
event_bus: Data<EventBusClient>, event_bus: Data<EventBusClient>,
session: Data<SessionStorage>,
) -> Result<HttpResponse, JsonError> { ) -> Result<HttpResponse, JsonError> {
let mut t = db.begin().await.map_err(|e| { let mut t = db_t!(db)?;
error!("Failed to get database connection: {e}");
Error::DatabaseError
})?;
match try_magic_sign_in(&mut t).await { match try_magic_sign_in(req, payload, &mut t, redis, event_bus, session).await {
Ok(r) => { Ok(r) => {
t.commit().await.map_err(|e| { db_commit!(t)?;
error!("Failed to commit database changes");
JsonError::new("Internal server error")
})?;
Ok(r) Ok(r)
} }
Err(e) => { Err(e) => {
t.rollback().await.ok(); db_rollback!(t).ok();
Err(e) Err(e)
} }
} }
} }
async fn try_magic_sign_in() -> Result<HttpResponse, JsonError> {} async fn try_magic_sign_in(
req: HttpRequest,
payload: Json<Input>,
t: &mut DatabaseTransaction,
redis: Data<RedisClient>,
event_bus: Data<EventBusClient>,
session: Data<SessionStorage>,
) -> Result<HttpResponse, JsonError> {
let payload = payload.into_inner();
let (key, user_token) = (payload.key.trim(), payload.token.trim());
if key.is_empty() || user_token.is_empty() {
return Err(JsonError::new("User token and key are required"));
}
let mut redis = redis_c!(redis)?;
let token: String = redis.hget(key, "token").await.map_err(|e| {
tracing::error!("Failed to read token from redis: {e}");
Error::RedisConnection
})?;
let email: String = redis.hget(key, "email").await.map_err(|e| {
tracing::error!("Failed to read token from redis: {e}");
Error::RedisConnection
})?;
if user_token != token {
return Err(JsonError::new(
"The magic code/link has expired please try again",
));
}
let user = user_by_email(&email, &mut *t)
.await?
.ok_or(Error::UserRequired)?;
let user_agent = extract_req_uagent(&req)?;
let ip = extract_req_ip(&req)?;
event_bus
.publish(
Topic::User,
Msg::User(UserMsg::SignIn {
user_id: jet_contract::UserId::new(user.id),
email,
user_agent,
ip,
medium: SignInMedium::Email,
first_time: false,
}),
QoS::AtLeastOnce,
true,
)
.await
.map_err(JsonError::new)?;
let user_clone = user.clone();
let mut user: entities::users::ActiveModel = user.into();
user.is_active = Set(true);
user.is_email_verified = Set(true);
user.last_active = Set(Some(chrono::Utc::now().fixed_offset()));
user.last_login_time = Set(Some(chrono::Utc::now().fixed_offset()));
user.last_login_ip = Set(extract_req_ip(&req)?);
user.last_login_uagent = Set(extract_req_uagent(&req)?);
user.token_updated_at = Set(Some(chrono::Utc::now().fixed_offset()));
user.save(&mut *t).await.map_err(|e| {
tracing::error!("Failed to update user: {e}");
Error::DatabaseError
})?;
crate::utils::invites_to_membership(
user_clone.id,
&user_clone.email.as_deref().unwrap_or_default(),
None,
&mut *t,
)
.await?;
auth_http_response(user_clone, session, StatusCode::OK)
.await
.map_err(JsonError::new)
}
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
struct Input { struct Input {
key: String, key: String,
token: String, token: String,
} }
*/

View File

@ -15,6 +15,7 @@ use crate::config::ApplicationConfig;
use crate::extractors::RequireInstanceConfigured; use crate::extractors::RequireInstanceConfigured;
use crate::http::api::authentication::{auth_http_response, create_user, has_workspace_invites}; use crate::http::api::authentication::{auth_http_response, create_user, has_workspace_invites};
use crate::models::{Error, JsonError}; use crate::models::{Error, JsonError};
use crate::utils::user_by_email;
use super::EmailAllowComment; use super::EmailAllowComment;
@ -65,17 +66,8 @@ async fn try_sign_in(
)); ));
} }
match Users::find() let None = user_by_email(&email, &mut *db).await? else {
.filter(entities::users::Column::Email.eq(&email)) return Err(JsonError::new("User with this email already exists"));
.one(&mut *db)
.await
{
Ok(None) => {}
Ok(Some(_user)) => return Err(JsonError::new("User with this email already exists")),
Err(e) => {
tracing::error!("Failed to load user for sign-in: {e}");
return Ok(Error::DatabaseError.error_response());
}
}; };
let user = create_user(&req, &email, &password, db).await?; let user = create_user(&req, &email, &password, db).await?;

View File

@ -10,14 +10,13 @@ use reqwest::StatusCode;
use rumqttc::QoS; use rumqttc::QoS;
use sea_orm::DatabaseConnection; use sea_orm::DatabaseConnection;
use sea_orm::*; use sea_orm::*;
use validators::models::Host;
use validators::prelude::*; use validators::prelude::*;
use crate::config::ApplicationConfig; use crate::config::ApplicationConfig;
use crate::extractors::RequireInstanceConfigured; use crate::extractors::RequireInstanceConfigured;
use crate::http::api::authentication::{auth_http_response, create_user, has_workspace_invites}; use crate::http::api::authentication::{auth_http_response, create_user, has_workspace_invites};
use crate::models::{Error, JsonError}; use crate::models::{Error, JsonError};
use crate::utils::{extract_req_info, extract_req_ip, extract_req_uagent}; use crate::utils::{extract_req_ip, extract_req_uagent};
use super::EmailAllowComment; use super::EmailAllowComment;
@ -56,8 +55,12 @@ async fn try_sign_in(
return Err(JsonError::new("Both email and password are required")); return Err(JsonError::new("Both email and password are required"));
} }
let email = payload.email.trim().to_lowercase(); let email = payload.email.trim().to_lowercase();
let password = payload.password.clone(); let password = payload.password.clone();
if !super::password::is_valid(&password) {
return Err(JsonError::new("Password is too weak. Password must have at least 8 characters and contains small letter, big letter, number and special character"));
}
if let Err(e) = EmailAllowComment::validate_str(&email) { if let Err(e) = EmailAllowComment::validate_str(&email) {
tracing::error!("Invalid email address: {e}"); tracing::error!("Invalid email address: {e}");
return Err(JsonError::new("Please provide a valid email address.")); return Err(JsonError::new("Please provide a valid email address."));

View File

@ -39,6 +39,7 @@ use crate::{
extractors::RequireInstanceConfigured, extractors::RequireInstanceConfigured,
http::OAuthError, http::OAuthError,
models::{Error, JsonError}, models::{Error, JsonError},
utils::user_by_email,
}; };
macro_rules! oauth_envs { macro_rules! oauth_envs {
@ -173,7 +174,7 @@ async fn handle_callback(
v v
} }
Err(e) => { Err(e) => {
db_rollback!(tx, "Failed to rollback social_auth changes to postgres"); db_rollback!(tx, "Failed to rollback social_auth changes to postgres").ok();
return Err(e.into()); return Err(e.into());
} }
} }
@ -224,34 +225,14 @@ async fn handle_user_info(
raw: _, raw: _,
} = user_info; } = user_info;
let Some(email) = email else { let email = email.ok_or(Error::ContactSupport)?;
return Ok(HttpResponse::BadRequest().json(JsonError::new(
"Something went wrong. Please try again later or contact the support team.",
)));
};
if !email.contains('@') { if !email.contains('@') {
return Ok(HttpResponse::BadRequest().json(JsonError::new( return Err(Error::ContactSupport);
"Something went wrong. Please try again later or contact the support team.",
)));
} }
let user = Users::find() let user = user_by_email(&email, &mut *db).await?;
.filter(UserColumn::Email.eq(&email))
.one(&mut *db)
.await;
let mut user: UserModel = match user { let mut user: UserModel = user.ok_or(Error::ContactSupport)?.into();
Ok(Some(user)) => user.into(),
Ok(None) => {
return Ok(HttpResponse::BadRequest().json(JsonError::new(
"Something went wrong. Please try again later or contact the support team.",
)));
}
Err(e) => {
error!("Failed to find user for oauth {provider} sign-in: {e}");
return Ok(HttpResponse::InternalServerError().finish());
}
};
let (ip, user_agent, _current_site) = crate::utils::extract_req_info(&req)?; let (ip, user_agent, _current_site) = crate::utils::extract_req_info(&req)?;

View File

@ -48,6 +48,10 @@ pub enum Error {
AddToWorkspace, AddToWorkspace,
#[display(fmt = "Failed to add user to projects")] #[display(fmt = "Failed to add user to projects")]
AddToProject, AddToProject,
#[display(fmt = "Something went wrong. Please try again later or contact the support team.")]
ContactSupport,
#[display(fmt = "User not found")]
UserRequired,
} }
impl From<AuthError> for Error { impl From<AuthError> for Error {

View File

@ -1,3 +1,4 @@
use actix_web::web::Data;
use actix_web::HttpRequest; use actix_web::HttpRequest;
use chrono::Utc; use chrono::Utc;
use entities::project_member_invites::{ use entities::project_member_invites::{
@ -64,6 +65,29 @@ macro_rules! db_rollback {
}) })
}}; }};
} }
#[macro_export]
macro_rules! redis_c {
($redis: expr) => {
$redis.get().await.map_err(|e| {
tracing::error!("Failed to obtain redis connection: {e}");
Error::RedisConnection
})
};
}
pub async fn user_by_email(
email: &str,
db: &mut DatabaseTransaction,
) -> Result<Option<entities::users::Model>, Error> {
entities::prelude::Users::find()
.filter(entities::users::Column::Email.eq(email))
.one(db)
.await
.map_err(|e| {
tracing::error!("Failed to load users: {e}");
Error::DatabaseError
})
}
pub fn extract_req_ip(req: &HttpRequest) -> Result<String, Error> { pub fn extract_req_ip(req: &HttpRequest) -> Result<String, Error> {
Ok(req Ok(req