auth.rs
1 use crate::{ 2 mailers::auth::AuthMailer, 3 models::{ 4 _entities::users, 5 users::{LoginParams, RegisterParams}, 6 }, 7 views::auth::{CurrentResponse, LoginResponse}, 8 }; 9 use axum::debug_handler; 10 use loco_rs::prelude::*; 11 use loco_openapi::prelude::*; 12 use regex::Regex; 13 use serde::{Deserialize, Serialize}; 14 use std::sync::OnceLock; 15 16 pub static EMAIL_DOMAIN_RE: OnceLock<Regex> = OnceLock::new(); 17 18 fn get_allow_email_domain_re() -> &'static Regex { 19 EMAIL_DOMAIN_RE.get_or_init(|| { 20 Regex::new(r"@example\.com$|@gmail\.com$").expect("Failed to compile regex") 21 }) 22 } 23 24 #[derive(Debug, Deserialize, Serialize)] 25 pub struct ForgotParams { 26 pub email: String, 27 } 28 29 #[derive(Debug, Deserialize, Serialize)] 30 pub struct ResetParams { 31 pub token: String, 32 pub password: String, 33 } 34 35 #[derive(Debug, Deserialize, Serialize, ToSchema)] 36 pub struct MagicLinkParams { 37 pub email: String, 38 } 39 40 /// Register function creates a new user with the given parameters and sends a 41 /// welcome email to the user 42 #[debug_handler] 43 async fn register( 44 State(ctx): State<AppContext>, 45 Json(params): Json<RegisterParams>, 46 ) -> Result<Response> { 47 let res = users::Model::create_with_password(&ctx.db, ¶ms).await; 48 49 let user = match res { 50 Ok(user) => user, 51 Err(err) => { 52 tracing::info!( 53 message = err.to_string(), 54 user_email = ¶ms.email, 55 "could not register user", 56 ); 57 return format::json(()); 58 } 59 }; 60 61 let user = user 62 .into_active_model() 63 .set_email_verification_sent(&ctx.db) 64 .await?; 65 66 AuthMailer::send_welcome(&ctx, &user).await?; 67 68 format::json(()) 69 } 70 71 /// Verify register user. if the user not verified his email, he can't login to 72 /// the system. 73 #[debug_handler] 74 async fn verify(State(ctx): State<AppContext>, Path(token): Path<String>) -> Result<Response> { 75 let user = users::Model::find_by_verification_token(&ctx.db, &token).await?; 76 77 if user.email_verified_at.is_some() { 78 tracing::info!(pid = user.pid.to_string(), "user already verified"); 79 } else { 80 let active_model = user.into_active_model(); 81 let user = active_model.verified(&ctx.db).await?; 82 tracing::info!(pid = user.pid.to_string(), "user verified"); 83 } 84 85 format::json(()) 86 } 87 88 /// In case the user forgot his password this endpoints generate a forgot token 89 /// and send email to the user. In case the email not found in our DB, we are 90 /// returning a valid request for for security reasons (not exposing users DB 91 /// list). 92 #[debug_handler] 93 async fn forgot( 94 State(ctx): State<AppContext>, 95 Json(params): Json<ForgotParams>, 96 ) -> Result<Response> { 97 let Ok(user) = users::Model::find_by_email(&ctx.db, ¶ms.email).await else { 98 // we don't want to expose our users email. if the email is invalid we still 99 // returning success to the caller 100 return format::json(()); 101 }; 102 103 let user = user 104 .into_active_model() 105 .set_forgot_password_sent(&ctx.db) 106 .await?; 107 108 AuthMailer::forgot_password(&ctx, &user).await?; 109 110 format::json(()) 111 } 112 113 /// reset user password by the given parameters 114 #[debug_handler] 115 async fn reset(State(ctx): State<AppContext>, Json(params): Json<ResetParams>) -> Result<Response> { 116 let Ok(user) = users::Model::find_by_reset_token(&ctx.db, ¶ms.token).await else { 117 // we don't want to expose our users email. if the email is invalid we still 118 // returning success to the caller 119 tracing::info!("reset token not found"); 120 121 return format::json(()); 122 }; 123 user.into_active_model() 124 .reset_password(&ctx.db, ¶ms.password) 125 .await?; 126 127 format::json(()) 128 } 129 130 /// Creates a user login and returns a token 131 #[debug_handler] 132 async fn login(State(ctx): State<AppContext>, Json(params): Json<LoginParams>) -> Result<Response> { 133 let user = users::Model::find_by_email(&ctx.db, ¶ms.email).await?; 134 135 let valid = user.verify_password(¶ms.password); 136 137 if !valid { 138 return unauthorized("unauthorized!"); 139 } 140 141 let jwt_secret = ctx.config.get_jwt_config()?; 142 143 let token = user 144 .generate_jwt(&jwt_secret.secret, jwt_secret.expiration) 145 .or_else(|_| unauthorized("unauthorized!"))?; 146 147 format::json(LoginResponse::new(&user, &token)) 148 } 149 150 #[debug_handler] 151 async fn current(auth: auth::JWT, State(ctx): State<AppContext>) -> Result<Response> { 152 let user = users::Model::find_by_pid(&ctx.db, &auth.claims.pid).await?; 153 format::json(CurrentResponse::new(&user)) 154 } 155 156 /// Magic link authentication provides a secure and passwordless way to log in to the application. 157 /// 158 /// # Flow 159 /// 1. **Request a Magic Link**: 160 /// A registered user sends a POST request to `/magic-link` with their email. 161 /// If the email exists, a short-lived, one-time-use token is generated and sent to the user's email. 162 /// For security and to avoid exposing whether an email exists, the response always returns 200, even if the email is invalid. 163 /// 164 /// 2. **Click the Magic Link**: 165 /// The user clicks the link (/magic-link/{token}), which validates the token and its expiration. 166 /// If valid, the server generates a JWT and responds with a [`LoginResponse`]. 167 /// If invalid or expired, an unauthorized response is returned. 168 /// 169 /// This flow enhances security by avoiding traditional passwords and providing a seamless login experience. 170 #[utoipa::path( 171 get, 172 path = "/magic-link", 173 responses( 174 (status = 200, body = MagicLinkParams), 175 ), 176 )] 177 async fn magic_link( 178 State(ctx): State<AppContext>, 179 Json(params): Json<MagicLinkParams>, 180 ) -> Result<Response> { 181 let email_regex = get_allow_email_domain_re(); 182 if !email_regex.is_match(¶ms.email) { 183 tracing::debug!( 184 email = params.email, 185 "The provided email is invalid or does not match the allowed domains" 186 ); 187 return bad_request("invalid request"); 188 } 189 190 let Ok(user) = users::Model::find_by_email(&ctx.db, ¶ms.email).await else { 191 // we don't want to expose our users email. if the email is invalid we still 192 // returning success to the caller 193 tracing::debug!(email = params.email, "user not found by email"); 194 return format::empty_json(); 195 }; 196 197 let user = user.into_active_model().create_magic_link(&ctx.db).await?; 198 AuthMailer::send_magic_link(&ctx, &user).await?; 199 200 format::empty_json() 201 } 202 203 /// Verifies a magic link token and authenticates the user. 204 async fn magic_link_verify( 205 Path(token): Path<String>, 206 State(ctx): State<AppContext>, 207 ) -> Result<Response> { 208 let Ok(user) = users::Model::find_by_magic_token(&ctx.db, &token).await else { 209 // we don't want to expose our users email. if the email is invalid we still 210 // returning success to the caller 211 return unauthorized("unauthorized!"); 212 }; 213 214 let user = user.into_active_model().clear_magic_link(&ctx.db).await?; 215 216 let jwt_secret = ctx.config.get_jwt_config()?; 217 218 let token = user 219 .generate_jwt(&jwt_secret.secret, jwt_secret.expiration) 220 .or_else(|_| unauthorized("unauthorized!"))?; 221 222 format::json(LoginResponse::new(&user, &token)) 223 } 224 225 pub fn routes() -> Routes { 226 Routes::new() 227 .prefix("/api/auth") 228 .add("/register", post(register)) 229 .add("/verify/{token}", get(verify)) 230 .add("/login", post(login)) 231 .add("/forgot", post(forgot)) 232 .add("/reset", post(reset)) 233 .add("/current", get(current)) 234 .add("/magic-link", openapi(post(magic_link),routes!(magic_link))) 235 .add("/magic-link/{token}", get(magic_link_verify)) 236 }