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 = 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, Json(params): Json, ) -> Result { 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, Path(token): Path) -> Result { 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, Json(params): Json, ) -> Result { 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, Json(params): Json) -> Result { 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, Json(params): Json) -> Result { 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) -> Result { 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 { 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, Json(params): Json, ) -> Result { 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, State(ctx): State, ) -> Result { 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, Json(params): Json, ) -> Result { 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)) }