/ src / controllers / auth.rs
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, &params).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 = &params.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, &params.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, &params.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, &params.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, &params.email).await?;
134  
135      let valid = user.verify_password(&params.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(&params.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, &params.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  }