317 lines
10 KiB
Rust
317 lines
10 KiB
Rust
use crate::{
|
|
mailers::auth::AuthMailer,
|
|
models::{
|
|
_entities::users,
|
|
users::{LoginParams, RegisterParams},
|
|
},
|
|
views::auth::{CurrentResponse, LoginResponse},
|
|
};
|
|
use axum_extra::extract::cookie::{Cookie, SameSite};
|
|
use loco_rs::prelude::*;
|
|
use regex::Regex;
|
|
use serde::{Deserialize, Serialize};
|
|
use std::sync::OnceLock;
|
|
use time::Duration as TimeDuration;
|
|
|
|
pub static EMAIL_DOMAIN_RE: OnceLock<Regex> = OnceLock::new();
|
|
const AUTH_COOKIE: &str = "auth_token";
|
|
|
|
fn get_allow_email_domain_re() -> &'static Regex {
|
|
EMAIL_DOMAIN_RE.get_or_init(|| {
|
|
Regex::new(r"@example\.com$|@gmail\.com$").expect("Failed to compile regex")
|
|
})
|
|
}
|
|
|
|
fn admin_email(ctx: &AppContext) -> Option<&str> {
|
|
ctx.config
|
|
.settings
|
|
.as_ref()
|
|
.and_then(|settings| settings.get("admin_email"))
|
|
.and_then(|email| email.as_str())
|
|
}
|
|
|
|
fn is_admin(ctx: &AppContext, user: &users::Model) -> bool {
|
|
admin_email(ctx).is_some_and(|email| user.email.eq_ignore_ascii_case(email))
|
|
}
|
|
|
|
fn auth_cookie(token: &str, max_age_seconds: u64) -> Cookie<'static> {
|
|
Cookie::build((AUTH_COOKIE, token.to_string()))
|
|
.path("/")
|
|
.http_only(true)
|
|
.same_site(SameSite::Lax)
|
|
.max_age(TimeDuration::seconds(max_age_seconds as i64))
|
|
.build()
|
|
}
|
|
|
|
fn clear_auth_cookie() -> Cookie<'static> {
|
|
Cookie::build((AUTH_COOKIE, ""))
|
|
.path("/")
|
|
.http_only(true)
|
|
.same_site(SameSite::Lax)
|
|
.max_age(TimeDuration::seconds(0))
|
|
.build()
|
|
}
|
|
|
|
#[derive(Debug, Deserialize, Serialize)]
|
|
pub struct ForgotParams {
|
|
pub email: String,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize, Serialize)]
|
|
pub struct ResetParams {
|
|
pub token: String,
|
|
pub password: String,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize, Serialize)]
|
|
pub struct MagicLinkParams {
|
|
pub email: String,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize, Serialize)]
|
|
pub struct ResendVerificationParams {
|
|
pub email: String,
|
|
}
|
|
|
|
/// Register function creates a new user with the given parameters and sends a
|
|
/// welcome email to the user
|
|
#[debug_handler]
|
|
async fn register(
|
|
State(ctx): State<AppContext>,
|
|
Json(params): Json<RegisterParams>,
|
|
) -> Result<Response> {
|
|
let res = users::Model::create_with_password(&ctx.db, ¶ms).await;
|
|
|
|
let user = match res {
|
|
Ok(user) => user,
|
|
Err(err) => {
|
|
tracing::info!(
|
|
message = err.to_string(),
|
|
user_email = ¶ms.email,
|
|
"could not register user",
|
|
);
|
|
return format::json(());
|
|
}
|
|
};
|
|
|
|
let user = user
|
|
.into_active_model()
|
|
.set_email_verification_sent(&ctx.db)
|
|
.await?;
|
|
|
|
AuthMailer::send_welcome(&ctx, &user).await?;
|
|
|
|
format::json(())
|
|
}
|
|
|
|
/// Verify register user. if the user not verified his email, he can't login to
|
|
/// the system.
|
|
#[debug_handler]
|
|
async fn verify(State(ctx): State<AppContext>, Path(token): Path<String>) -> Result<Response> {
|
|
let Ok(user) = users::Model::find_by_verification_token(&ctx.db, &token).await else {
|
|
return unauthorized("invalid token");
|
|
};
|
|
|
|
if user.email_verified_at.is_some() {
|
|
tracing::info!(pid = user.pid.to_string(), "user already verified");
|
|
} else {
|
|
let active_model = user.into_active_model();
|
|
let user = active_model.verified(&ctx.db).await?;
|
|
tracing::info!(pid = user.pid.to_string(), "user verified");
|
|
}
|
|
|
|
format::json(())
|
|
}
|
|
|
|
/// In case the user forgot his password this endpoints generate a forgot token
|
|
/// and send email to the user. In case the email not found in our DB, we are
|
|
/// returning a valid request for for security reasons (not exposing users DB
|
|
/// list).
|
|
#[debug_handler]
|
|
async fn forgot(
|
|
State(ctx): State<AppContext>,
|
|
Json(params): Json<ForgotParams>,
|
|
) -> Result<Response> {
|
|
let Ok(user) = users::Model::find_by_email(&ctx.db, ¶ms.email).await else {
|
|
// we don't want to expose our users email. if the email is invalid we still
|
|
// returning success to the caller
|
|
return format::json(());
|
|
};
|
|
|
|
let user = user
|
|
.into_active_model()
|
|
.set_forgot_password_sent(&ctx.db)
|
|
.await?;
|
|
|
|
AuthMailer::forgot_password(&ctx, &user).await?;
|
|
|
|
format::json(())
|
|
}
|
|
|
|
/// reset user password by the given parameters
|
|
#[debug_handler]
|
|
async fn reset(State(ctx): State<AppContext>, Json(params): Json<ResetParams>) -> Result<Response> {
|
|
let Ok(user) = users::Model::find_by_reset_token(&ctx.db, ¶ms.token).await else {
|
|
// we don't want to expose our users email. if the email is invalid we still
|
|
// returning success to the caller
|
|
tracing::info!("reset token not found");
|
|
|
|
return format::json(());
|
|
};
|
|
user.into_active_model()
|
|
.reset_password(&ctx.db, ¶ms.password)
|
|
.await?;
|
|
|
|
format::json(())
|
|
}
|
|
|
|
/// Creates a user login and returns a token
|
|
#[debug_handler]
|
|
async fn login(State(ctx): State<AppContext>, Json(params): Json<LoginParams>) -> Result<Response> {
|
|
let Ok(user) = users::Model::find_by_email(&ctx.db, ¶ms.email).await else {
|
|
tracing::debug!(
|
|
email = params.email,
|
|
"login attempt with non-existent email"
|
|
);
|
|
return unauthorized("Invalid credentials!");
|
|
};
|
|
|
|
let valid = user.verify_password(¶ms.password);
|
|
|
|
if !valid {
|
|
return unauthorized("unauthorized!");
|
|
}
|
|
|
|
let jwt_secret = ctx.config.get_jwt_config()?;
|
|
|
|
let token = user
|
|
.generate_jwt(&jwt_secret.secret, jwt_secret.expiration)
|
|
.or_else(|_| unauthorized("unauthorized!"))?;
|
|
|
|
format::render()
|
|
.cookies(&[auth_cookie(&token, jwt_secret.expiration)])?
|
|
.json(LoginResponse::new(&user, &token, is_admin(&ctx, &user)))
|
|
}
|
|
|
|
#[debug_handler]
|
|
async fn current(auth: auth::JWT, State(ctx): State<AppContext>) -> Result<Response> {
|
|
let user = users::Model::find_by_pid(&ctx.db, &auth.claims.pid).await?;
|
|
format::json(CurrentResponse::new(&user, is_admin(&ctx, &user)))
|
|
}
|
|
|
|
#[debug_handler]
|
|
async fn logout() -> Result<Response> {
|
|
format::render().cookies(&[clear_auth_cookie()])?.json(())
|
|
}
|
|
|
|
/// Magic link authentication provides a secure and passwordless way to log in to the application.
|
|
///
|
|
/// # Flow
|
|
/// 1. **Request a Magic Link**:
|
|
/// A registered user sends a POST request to `/magic-link` with their email.
|
|
/// If the email exists, a short-lived, one-time-use token is generated and sent to the user's email.
|
|
/// For security and to avoid exposing whether an email exists, the response always returns 200, even if the email is invalid.
|
|
///
|
|
/// 2. **Click the Magic Link**:
|
|
/// The user clicks the link (/magic-link/{token}), which validates the token and its expiration.
|
|
/// If valid, the server generates a JWT and responds with a [`LoginResponse`].
|
|
/// If invalid or expired, an unauthorized response is returned.
|
|
///
|
|
/// This flow enhances security by avoiding traditional passwords and providing a seamless login experience.
|
|
async fn magic_link(
|
|
State(ctx): State<AppContext>,
|
|
Json(params): Json<MagicLinkParams>,
|
|
) -> Result<Response> {
|
|
let email_regex = get_allow_email_domain_re();
|
|
if !email_regex.is_match(¶ms.email) {
|
|
tracing::debug!(
|
|
email = params.email,
|
|
"The provided email is invalid or does not match the allowed domains"
|
|
);
|
|
return bad_request("invalid request");
|
|
}
|
|
|
|
let Ok(user) = users::Model::find_by_email(&ctx.db, ¶ms.email).await else {
|
|
// we don't want to expose our users email. if the email is invalid we still
|
|
// returning success to the caller
|
|
tracing::debug!(email = params.email, "user not found by email");
|
|
return format::empty_json();
|
|
};
|
|
|
|
let user = user.into_active_model().create_magic_link(&ctx.db).await?;
|
|
AuthMailer::send_magic_link(&ctx, &user).await?;
|
|
|
|
format::empty_json()
|
|
}
|
|
|
|
/// Verifies a magic link token and authenticates the user.
|
|
async fn magic_link_verify(
|
|
Path(token): Path<String>,
|
|
State(ctx): State<AppContext>,
|
|
) -> Result<Response> {
|
|
let Ok(user) = users::Model::find_by_magic_token(&ctx.db, &token).await else {
|
|
// we don't want to expose our users email. if the email is invalid we still
|
|
// returning success to the caller
|
|
return unauthorized("unauthorized!");
|
|
};
|
|
|
|
let user = user.into_active_model().clear_magic_link(&ctx.db).await?;
|
|
|
|
let jwt_secret = ctx.config.get_jwt_config()?;
|
|
|
|
let token = user
|
|
.generate_jwt(&jwt_secret.secret, jwt_secret.expiration)
|
|
.or_else(|_| unauthorized("unauthorized!"))?;
|
|
|
|
format::render()
|
|
.cookies(&[auth_cookie(&token, jwt_secret.expiration)])?
|
|
.json(LoginResponse::new(&user, &token, is_admin(&ctx, &user)))
|
|
}
|
|
|
|
#[debug_handler]
|
|
async fn resend_verification_email(
|
|
State(ctx): State<AppContext>,
|
|
Json(params): Json<ResendVerificationParams>,
|
|
) -> Result<Response> {
|
|
let Ok(user) = users::Model::find_by_email(&ctx.db, ¶ms.email).await else {
|
|
tracing::info!(
|
|
email = params.email,
|
|
"User not found for resend verification"
|
|
);
|
|
return format::json(());
|
|
};
|
|
|
|
if user.email_verified_at.is_some() {
|
|
tracing::info!(
|
|
pid = user.pid.to_string(),
|
|
"User already verified, skipping resend"
|
|
);
|
|
return format::json(());
|
|
}
|
|
|
|
let user = user
|
|
.into_active_model()
|
|
.set_email_verification_sent(&ctx.db)
|
|
.await?;
|
|
|
|
AuthMailer::send_welcome(&ctx, &user).await?;
|
|
tracing::info!(pid = user.pid.to_string(), "Verification email re-sent");
|
|
|
|
format::json(())
|
|
}
|
|
|
|
pub fn routes() -> Routes {
|
|
Routes::new()
|
|
.prefix("/api/auth")
|
|
.add("/register", post(register))
|
|
.add("/verify/{token}", get(verify))
|
|
.add("/login", post(login))
|
|
.add("/logout", post(logout))
|
|
.add("/forgot", post(forgot))
|
|
.add("/reset", post(reset))
|
|
.add("/current", get(current))
|
|
.add("/magic-link", post(magic_link))
|
|
.add("/magic-link/{token}", get(magic_link_verify))
|
|
.add("/resend-verification-mail", post(resend_verification_email))
|
|
}
|