Generate magic link

This commit is contained in:
eraden 2024-01-29 11:40:58 +01:00
parent 9617bf08b0
commit 6b6fd54292
8 changed files with 396 additions and 241 deletions

View File

@ -8,12 +8,18 @@ use actix_web::web::{Data, ServiceConfig};
use actix_web::{HttpRequest, HttpResponse}; use actix_web::{HttpRequest, HttpResponse};
use entities::prelude::Users; use entities::prelude::Users;
use entities::users::Model as User; use entities::users::Model as User;
use rand::Rng;
use reqwest::StatusCode; use reqwest::StatusCode;
use sea_orm::prelude::*; use sea_orm::prelude::*;
use sea_orm::*; use sea_orm::*;
use uuid::Uuid; use uuid::Uuid;
use validators::prelude::*;
use validators::Validator;
use validators_prelude::Host;
mod email_check; mod email_check;
mod magic_generate;
mod magic_sign_in;
mod sign_in; mod sign_in;
mod sign_out; mod sign_out;
mod sign_up; mod sign_up;
@ -32,6 +38,7 @@ pub fn configure(http_client: reqwest::Client, config: &mut ServiceConfig) {
.service(sign_in::sign_in) .service(sign_in::sign_in)
.service(sign_up::sign_up) .service(sign_up::sign_up)
.service(sign_out::sign_out) .service(sign_out::sign_out)
.service(magic_sign_in::magic_sign_in)
.configure(|c| { .configure(|c| {
social_auth::configure(http_client, c); social_auth::configure(http_client, c);
}), }),
@ -176,3 +183,228 @@ async fn auth_http_response(
refresh_token, refresh_token,
})) }))
} }
#[derive(Validator)]
#[validator(email(
comment(Allow),
ip(Allow),
local(Allow),
at_least_two_labels(Allow),
non_ascii(Allow)
))]
pub struct EmailAllowComment {
pub local_part: String,
pub need_quoted: bool,
pub domain_part: Host,
pub comment_before_local_part: Option<String>,
pub comment_after_local_part: Option<String>,
pub comment_before_domain_part: Option<String>,
pub comment_after_domain_part: Option<String>,
}
pub fn random_password() -> String {
rand::thread_rng()
.sample_iter(rand::distributions::Alphanumeric)
.take(30)
.map(char::from)
.collect()
}
pub mod magic_link {
use crate::models::Error;
use crate::{http::AuthError, RedisClient};
use actix_web::web::Data;
use jet_contract::*;
use rand::prelude::*;
use redis::AsyncCommands;
#[derive(Debug, Copy, Clone, PartialEq)]
pub enum AttemptValidity {
Allowed,
Exhausted,
}
impl AttemptValidity {
pub fn is_exhausted(self) -> bool {
matches!(self, Self::Exhausted)
}
}
#[inline(always)]
pub fn magic_link_key(email: &str) -> String {
format!("magic_{email}")
}
pub async fn create_magic_link(
email: &str,
redis: Data<RedisClient>,
) -> Result<(MagicLinkKey, MagicLinkToken, AttemptValidity), Error> {
use rand::distributions::Alphanumeric;
let key = magic_link_key(email);
let mut rng = rand::thread_rng();
let token = format!(
"{}-{}-{}",
(&mut rng)
.sample_iter(Alphanumeric)
.take(4)
.map(char::from)
.collect::<String>(),
(&mut rng)
.sample_iter(Alphanumeric)
.take(4)
.map(char::from)
.collect::<String>(),
(&mut rng)
.sample_iter(Alphanumeric)
.take(4)
.map(char::from)
.collect::<String>(),
);
let Ok(mut redis) = redis.get().await else {
return Err(AuthError::SerializeMsg.into());
};
if redis
.exists(&key)
.await
.map_err(|_| Error::RedisConnection)?
{
let attempt: u8 = redis
.hget(&key, "current_attempt")
.await
.map_err(|_| Error::RedisConnection)?;
if attempt + 1 > 2 {
let token: String = redis
.hget(&key, "token")
.await
.map_err(|_| Error::RedisConnection)?;
return Ok((
MagicLinkKey::new(key),
MagicLinkToken::new(token),
AttemptValidity::Exhausted,
));
}
let _: () = redis
.hincr(&key, "current_attempt", 1)
.await
.map_err(|_| Error::RedisConnection)?;
let _: () = redis
.hset(&key, "token", &token)
.await
.map_err(|_| Error::RedisConnection)?;
} else {
let _: () = redis
.hset(&key, "current_attempt", 0)
.await
.map_err(|_| Error::RedisConnection)?;
let _: () = redis
.hset_multiple(&key, &[("email", email), ("token", &token)])
.await
.map_err(|_| Error::RedisConnection)?;
let _: () = redis
.expire(&key, 600)
.await
.map_err(|_| Error::RedisConnection)?;
}
Ok((
MagicLinkKey::new(key),
MagicLinkToken::new(token),
AttemptValidity::Allowed,
))
}
#[cfg(test)]
mod create_magic_link_tests {
use super::*;
use actix_web::web::Data;
use jet_contract::deadpool_redis;
#[tokio::test]
async fn full() {
let email = "foo@bar.com".to_string();
let redis = deadpool_redis::Config::default()
.create_pool(Some(deadpool_redis::Runtime::Tokio1))
.unwrap();
let mut conn = redis.get().await.unwrap();
let _: () = conn.del(magic_link_key(&email)).await.unwrap();
let (key, token, validity) = create_magic_link(&email, Data::new(redis.clone()))
.await
.unwrap();
let r: Vec<String> = conn.hgetall(&*key).await.unwrap();
assert_eq!(
(validity, r),
(
AttemptValidity::Allowed,
vec![
"current_attempt".into(),
"0".into(),
"email".into(),
email.clone(),
"token".into(),
(&*token).clone()
]
)
);
let (key, token, validity) = create_magic_link(&email, Data::new(redis.clone()))
.await
.unwrap();
let r: Vec<String> = conn.hgetall(&*key).await.unwrap();
assert_eq!(
(validity, r),
(
AttemptValidity::Allowed,
vec![
"current_attempt".into(),
"1".into(),
"email".into(),
email.clone(),
"token".into(),
token.to_string()
]
)
);
let (key, token, validity) = create_magic_link(&email, Data::new(redis.clone()))
.await
.unwrap();
let r: Vec<String> = conn.hgetall(&*key).await.unwrap();
assert_eq!(
(validity, r),
(
AttemptValidity::Allowed,
vec![
"current_attempt".into(),
"2".into(),
"email".into(),
email.clone(),
"token".into(),
token.to_string()
]
)
);
let (key, token, validity) = create_magic_link(&email, Data::new(redis.clone()))
.await
.unwrap();
let r: Vec<String> = conn.hgetall(&*key).await.unwrap();
assert_eq!(
(validity, r),
(
AttemptValidity::Exhausted,
vec![
"current_attempt".into(),
"2".into(),
"email".into(),
email.clone(),
"token".into(),
token.to_string()
]
)
);
}
}
}

View File

@ -4,9 +4,6 @@ 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 jet_contract::redis::AsyncCommands;
use jet_contract::{MagicLinkKey, MagicLinkToken};
use rand::Rng;
use sea_orm::prelude::*; use sea_orm::prelude::*;
use sea_orm::{DatabaseConnection, EntityTrait, QueryFilter}; use sea_orm::{DatabaseConnection, EntityTrait, QueryFilter};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
@ -14,6 +11,7 @@ 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::models::*; use crate::models::*;
use crate::{EventBusClient, RedisClient}; use crate::{EventBusClient, RedisClient};
@ -230,193 +228,3 @@ async fn register(
is_existing: false, is_existing: false,
})) }))
} }
#[derive(Debug, Copy, Clone, PartialEq)]
enum AttemptValidity {
Allowed,
Exhausted,
}
impl AttemptValidity {
fn is_exhausted(self) -> bool {
matches!(self, Self::Exhausted)
}
}
#[inline(always)]
fn magic_link_key(email: &str) -> String {
format!("magic_{email}")
}
async fn create_magic_link(
email: &str,
redis: Data<RedisClient>,
) -> Result<(MagicLinkKey, MagicLinkToken, AttemptValidity), Error> {
use rand::distributions::Alphanumeric;
let key = magic_link_key(email);
let mut rng = rand::thread_rng();
let token = format!(
"{}-{}-{}",
(&mut rng)
.sample_iter(Alphanumeric)
.take(4)
.map(char::from)
.collect::<String>(),
(&mut rng)
.sample_iter(Alphanumeric)
.take(4)
.map(char::from)
.collect::<String>(),
(&mut rng)
.sample_iter(Alphanumeric)
.take(4)
.map(char::from)
.collect::<String>(),
);
let Ok(mut redis) = redis.get().await else {
return Err(AuthError::SerializeMsg.into());
};
if redis
.exists(&key)
.await
.map_err(|_| Error::RedisConnection)?
{
let attempt: u8 = redis
.hget(&key, "current_attempt")
.await
.map_err(|_| Error::RedisConnection)?;
if attempt + 1 > 2 {
let token: String = redis
.hget(&key, "token")
.await
.map_err(|_| Error::RedisConnection)?;
return Ok((
MagicLinkKey::new(key),
MagicLinkToken::new(token),
AttemptValidity::Exhausted,
));
}
let _: () = redis
.hincr(&key, "current_attempt", 1)
.await
.map_err(|_| Error::RedisConnection)?;
let _: () = redis
.hset(&key, "token", &token)
.await
.map_err(|_| Error::RedisConnection)?;
} else {
let _: () = redis
.hset(&key, "current_attempt", 0)
.await
.map_err(|_| Error::RedisConnection)?;
let _: () = redis
.hset_multiple(&key, &[("email", email), ("token", &token)])
.await
.map_err(|_| Error::RedisConnection)?;
let _: () = redis
.expire(&key, 600)
.await
.map_err(|_| Error::RedisConnection)?;
}
Ok((
MagicLinkKey::new(key),
MagicLinkToken::new(token),
AttemptValidity::Allowed,
))
}
#[cfg(test)]
mod create_magic_link_tests {
use super::*;
use actix_web::web::Data;
use jet_contract::deadpool_redis;
#[tokio::test]
async fn full() {
let email = "foo@bar.com".to_string();
let redis = deadpool_redis::Config::default()
.create_pool(Some(deadpool_redis::Runtime::Tokio1))
.unwrap();
let mut conn = redis.get().await.unwrap();
let _: () = conn.del(magic_link_key(&email)).await.unwrap();
let (key, token, validity) = create_magic_link(&email, Data::new(redis.clone()))
.await
.unwrap();
let r: Vec<String> = conn.hgetall(&*key).await.unwrap();
assert_eq!(
(validity, r),
(
AttemptValidity::Allowed,
vec![
"current_attempt".into(),
"0".into(),
"email".into(),
email.clone(),
"token".into(),
(&*token).clone()
]
)
);
let (key, token, validity) = create_magic_link(&email, Data::new(redis.clone()))
.await
.unwrap();
let r: Vec<String> = conn.hgetall(&*key).await.unwrap();
assert_eq!(
(validity, r),
(
AttemptValidity::Allowed,
vec![
"current_attempt".into(),
"1".into(),
"email".into(),
email.clone(),
"token".into(),
token.to_string()
]
)
);
let (key, token, validity) = create_magic_link(&email, Data::new(redis.clone()))
.await
.unwrap();
let r: Vec<String> = conn.hgetall(&*key).await.unwrap();
assert_eq!(
(validity, r),
(
AttemptValidity::Allowed,
vec![
"current_attempt".into(),
"2".into(),
"email".into(),
email.clone(),
"token".into(),
token.to_string()
]
)
);
let (key, token, validity) = create_magic_link(&email, Data::new(redis.clone()))
.await
.unwrap();
let r: Vec<String> = conn.hgetall(&*key).await.unwrap();
assert_eq!(
(validity, r),
(
AttemptValidity::Exhausted,
vec![
"current_attempt".into(),
"2".into(),
"email".into(),
email.clone(),
"token".into(),
token.to_string()
]
)
);
}
}

View File

@ -0,0 +1,59 @@
use actix_web::{
post,
web::{Data, Json},
HttpRequest, HttpResponse,
};
use jet_contract::event_bus::{EmailMsg, Topic};
use sea_orm::prelude::*;
use sea_orm::DatabaseConnection;
use serde::Deserialize;
use super::{create_user, random_password};
use crate::{db_commit, models::*, utils::extract_req_current_site};
use crate::{
db_t, extractors::RequireInstanceConfigured, models::JsonError, EventBusClient, RedisClient,
};
#[derive(Debug, Deserialize)]
struct Input {
email: String,
}
#[post("/magic-generate")]
pub async fn magic_generate(
req: HttpRequest,
_: RequireInstanceConfigured,
payload: Json<Input>,
db: Data<DatabaseConnection>,
redis: Data<RedisClient>,
event_bus: Data<EventBusClient>,
) -> Result<HttpResponse, JsonError> {
let mut t = db_t!(db)?;
let email = payload.into_inner().email;
let user = match entities::prelude::Users::find()
.filter(entities::users::Column::Email.eq(&email))
.one(&mut t)
.await
{
Ok(Some(user)) => user,
Ok(None) => create_user(&req, &email, &random_password(), &mut t).await?,
Err(e) => return Err(Error::DatabaseError.into()),
};
db_commit!(t)?;
let (key, token, validity) = super::magic_link::create_magic_link(&email, redis).await?;
let current_site = extract_req_current_site(&req)?;
event_bus
.publish(Topic::Email, jet_contract::event_bus::Msg::Email(EmailMsg::MagicLink {
email,
key,
token,
current_site,
}), rumqttc::QoS::AtLeastOnce, true)
.await;
Ok(HttpResponse::NotImplemented().finish())
}

View File

@ -0,0 +1,51 @@
use actix_web::{
post,
web::{Data, Json},
HttpRequest, HttpResponse,
};
/*
use sea_orm::prelude::*;
use sea_orm::*;
use tracing::error;
use serde::Deserialize;
use crate::{models::{Error, JsonError}, extractors::RequireInstanceConfigured};
use crate::{RedisClient, EventBusClient};
#[post("/magic-sign-in")]
async fn magic_sign_in(
_: RequireInstanceConfigured,
req: HttpRequest,
payload: Json<Input>,
db: Data<DatabaseConnection>,
redis: Data<RedisClient>,
event_bus: Data<EventBusClient>,
) -> Result<HttpResponse, JsonError> {
let mut t = db.begin().await.map_err(|e| {
error!("Failed to get database connection: {e}");
Error::DatabaseError
})?;
match try_magic_sign_in(&mut t).await {
Ok(r) => {
t.commit().await.map_err(|e| {
error!("Failed to commit database changes");
JsonError::new("Internal server error")
})?;
Ok(r)
}
Err(e) => {
t.rollback().await.ok();
Err(e)
}
}
}
async fn try_magic_sign_in() -> Result<HttpResponse, JsonError> {}
#[derive(Debug, Deserialize)]
struct Input {
key: String,
token: String,
}
*/

View File

@ -8,9 +8,7 @@ use jet_contract::event_bus::{Msg, SignInMedium, Topic, UserMsg};
use jet_contract::UserId; use jet_contract::UserId;
use reqwest::StatusCode; use reqwest::StatusCode;
use rumqttc::QoS; use rumqttc::QoS;
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;
@ -18,6 +16,8 @@ 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 super::EmailAllowComment;
#[post("/sign-in")] #[post("/sign-in")]
pub async fn sign_in( pub async fn sign_in(
_: RequireInstanceConfigured, _: RequireInstanceConfigured,
@ -54,24 +54,6 @@ async fn try_sign_in(
} }
let email = payload.email.trim().to_lowercase(); 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(); let password = payload.password.clone();
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}");

View File

@ -17,6 +17,9 @@ 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 super::EmailAllowComment;
#[post("/sign-up")] #[post("/sign-up")]
pub async fn sign_up( pub async fn sign_up(
@ -54,24 +57,6 @@ async fn try_sign_in(
} }
let email = payload.email.trim().to_lowercase(); 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(); let password = payload.password.clone();
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}");
@ -99,7 +84,8 @@ async fn try_sign_in(
let user_id = user.id; let user_id = user.id;
let mut user: UserModel = user.into(); let mut user: UserModel = user.into();
let (ip, user_agent, _current_site) = crate::utils::extract_req_info(&req)?; let ip = extract_req_ip(&req)?;
let user_agent = extract_req_uagent(&req)?;
user.is_active = Set(true); user.is_active = Set(true);
user.last_active = Set(Some(chrono::Utc::now().fixed_offset())); user.last_active = Set(Some(chrono::Utc::now().fixed_offset()));

View File

@ -35,6 +35,7 @@ use sea_orm::{
use tracing::{debug, error, warn}; use tracing::{debug, error, warn};
use crate::{ use crate::{
db_commit, db_rollback, db_t,
extractors::RequireInstanceConfigured, extractors::RequireInstanceConfigured,
http::OAuthError, http::OAuthError,
models::{Error, JsonError}, models::{Error, JsonError},
@ -155,7 +156,7 @@ async fn handle_callback(
use oauth2_signin::web_app::SigninFlowHandleCallbackRet as R; use oauth2_signin::web_app::SigninFlowHandleCallbackRet as R;
let response = match ret { let response = match ret {
R::Ok((access_token_body, user_info)) => { R::Ok((access_token_body, user_info)) => {
let mut tx = db.begin().await.map_err(|_| Error::DatabaseError)?; let mut tx = db_t!(db)?;
match handle_user_info( match handle_user_info(
provider, provider,
req, req,
@ -168,17 +169,11 @@ async fn handle_callback(
.await .await
{ {
Ok(v) => { Ok(v) => {
tx.commit().await.map_err(|e| { db_commit!(tx, "Failed to commit social_auth changes to postgres")?;
error!("Failed to commit social_auth changes to postgres: {e}");
Error::DatabaseError
})?;
v v
} }
Err(e) => { Err(e) => {
tx.rollback().await.map_err(|e| { db_rollback!(tx, "Failed to rollback social_auth changes to postgres");
error!("Failed to rollback social_auth changes to postgres: {e}");
Error::DatabaseError
})?;
return Err(e.into()); return Err(e.into());
} }
} }

View File

@ -23,6 +23,48 @@ use uuid::Uuid;
use crate::http::OAuthError; use crate::http::OAuthError;
use crate::{http::AuthError, models::Error}; use crate::{http::AuthError, models::Error};
#[macro_export]
macro_rules! db_t {
($db: expr) => {{
use sea_orm::*;
$db.begin().await.map_err(|e| {
tracing::error!("Failed to start db tracation: {e}");
crate::models::Error::DatabaseError
})
}};
}
#[macro_export]
macro_rules! db_commit {
($db: expr) => {{
$db.commit().await.map_err(|e| {
tracing::error!("Failed to commit db tracation: {e}");
crate::models::Error::DatabaseError
})
}};
($db: expr, $msg: expr) => {{
$db.commit().await.map_err(|e| {
tracing::error!(std::concat!($msg, ": {}"), e);
crate::models::Error::DatabaseError
})
}};
}
#[macro_export]
macro_rules! db_rollback {
($db: expr) => {{
$db.rollback().await.map_err(|e| {
tracing::error!("Failed to rollback db tracation: {e}");
crate::models::Error::DatabaseError
})
}};
($db: expr, $msg: expr) => {{
$db.rollback().await.map_err(|e| {
tracing::error!(std::concat!($msg, ": {}"), e);
crate::models::Error::DatabaseError
})
}};
}
pub fn extract_req_ip(req: &HttpRequest) -> Result<String, Error> { pub fn extract_req_ip(req: &HttpRequest) -> Result<String, Error> {
Ok(req Ok(req
.peer_addr() .peer_addr()