Skip to content

Commit

Permalink
magic link
Browse files Browse the repository at this point in the history
  • Loading branch information
kaplanelad committed Dec 15, 2024
1 parent 3bc8c07 commit 5bffba3
Show file tree
Hide file tree
Showing 23 changed files with 382 additions and 18 deletions.
1 change: 1 addition & 0 deletions loco-new/base_template/config/test.yaml.t
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ queue:

# Mailer Configuration.
mailer:
stub: true
# SMTP mailer configuration.
smtp:
# Enable/Disable smtp mailer.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ impl MigrationTrait for Migration {
Users::EmailVerificationSentAt,
))
.col(timestamp_with_time_zone_null(Users::EmailVerifiedAt))
.col(string_null(Users::MagicLinkToken))
.col(timestamp_with_time_zone_null(Users::MagicLinkExpiration))
.to_owned();
manager.create_table(table).await?;
Ok(())
Expand All @@ -47,4 +49,6 @@ pub enum Users {
EmailVerificationToken,
EmailVerificationSentAt,
EmailVerifiedAt,
MagicLinkToken,
MagicLinkExpiration,
}
61 changes: 61 additions & 0 deletions loco-new/base_template/src/controllers/auth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,11 @@ pub struct ResetParams {
pub password: String,
}

#[derive(Debug, Deserialize, Serialize)]
pub struct MagicLinkParams {
pub email: String,
}

/// Register function creates a new user with the given parameters and sends a
/// welcome email to the user
#[debug_handler]
Expand Down Expand Up @@ -145,6 +150,60 @@ async fn current(auth: auth::JWT, State(ctx): State<AppContext>) -> Result<Respo
format::json(CurrentResponse::new(&user))
}

/// 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 Ok(user) = users::Model::find_by_email(&ctx.db, &params.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>,
// Json(params): Json<MagicLinkParams>,
) -> 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::json(LoginResponse::new(&user, &token))
}

pub fn routes() -> Routes {
Routes::new()
.prefix("/api/auth")
Expand All @@ -154,4 +213,6 @@ pub fn routes() -> Routes {
.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))
}
27 changes: 27 additions & 0 deletions loco-new/base_template/src/mailers/auth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ use crate::models::users;

static welcome: Dir<'_> = include_dir!("src/mailers/auth/welcome");
static forgot: Dir<'_> = include_dir!("src/mailers/auth/forgot");
static magic_link: Dir<'_> = include_dir!("src/mailers/auth/magic_link");
// #[derive(Mailer)] // -- disabled for faster build speed. it works. but lets
// move on for now.

Expand Down Expand Up @@ -62,4 +63,30 @@ impl AuthMailer {

Ok(())
}

/// Sends a magic link authentication email to the user.
///
/// # Errors
///
/// When email sending is failed
pub async fn send_magic_link(ctx: &AppContext, user: &users::Model) -> Result<()> {
Self::mail_template(
ctx,
&magic_link,
mailer::Args {
to: user.email.to_string(),
locals: json!({
"name": user.name,
"token": user.magic_link_token.clone().ok_or_else(|| Error::string(
"the user model not contains magic link token",
))?,
"host": ctx.config.server.full_url()
}),
..Default::default()
},
)
.await?;

Ok(())
}
}
8 changes: 8 additions & 0 deletions loco-new/base_template/src/mailers/auth/magic_link/html.t
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
;<html>
<body>
<p>Magic link example:</p>
<a href="{{host}}/api/auth/magic-link/{{token}}">
Verify Your Account
</a>
</body>
</html>
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Magic link example
2 changes: 2 additions & 0 deletions loco-new/base_template/src/mailers/auth/magic_link/text.t
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
Magic link with this link:
{{host}}/api/auth/magic-link/{{token}}
2 changes: 2 additions & 0 deletions loco-new/base_template/src/models/_entities/users.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ pub struct Model {
pub email_verification_token: Option<String>,
pub email_verification_sent_at: Option<DateTimeWithTimeZone>,
pub email_verified_at: Option<DateTimeWithTimeZone>,
pub magic_link_token: Option<String>,
pub magic_link_expiration: Option<DateTimeWithTimeZone>,
}

#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
Expand Down
70 changes: 69 additions & 1 deletion loco-new/base_template/src/models/users.rs
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
use async_trait::async_trait;
use chrono::offset::Local;
use chrono::{offset::Local, Duration};
use loco_rs::{auth::jwt, hash, prelude::*};
use serde::{Deserialize, Serialize};
use uuid::Uuid;

pub use super::_entities::users::{self, ActiveModel, Entity, Model};

pub const MAGIC_LINK_LENGTH: i8 = 32;
pub const MAGIC_LINK_EXPIRATION_MIN: i8 = 5;

#[derive(Debug, Deserialize, Serialize)]
pub struct LoginParams {
pub email: String,
Expand Down Expand Up @@ -111,6 +114,42 @@ impl super::_entities::users::Model {
user.ok_or_else(|| ModelError::EntityNotFound)
}

/// finds a user by the magic token and verify and token expiration
///
/// # Errors
///
/// When could not find user by the given token or DB query error ot token expired
pub async fn find_by_magic_token(db: &DatabaseConnection, token: &str) -> ModelResult<Self> {
let user = users::Entity::find()
.filter(
query::condition()
.eq(users::Column::MagicLinkToken, token)
.build(),
)
.one(db)
.await?;

let user = user.ok_or_else(|| ModelError::EntityNotFound)?;
if let Some(expired_at) = user.magic_link_expiration {
if expired_at >= Local::now() {
Ok(user)
} else {
tracing::debug!(
user_pid = user.pid.to_string(),
token_expiration = expired_at.to_string(),
"magic token expired for the user."
);
Err(ModelError::msg("magic token expired"))
}
} else {
tracing::error!(
user_pid = user.pid.to_string(),
"magic link expiration time not exists"
);
Err(ModelError::msg("expiration token not exists"))
}
}

/// finds a user by the provided reset token
///
/// # Errors
Expand Down Expand Up @@ -295,4 +334,33 @@ impl super::_entities::users::ActiveModel {
self.reset_sent_at = ActiveValue::Set(None);
Ok(self.update(db).await?)
}

/// Creates a magic link token for passwordless authentication.
///
/// Generates a random token with a specified length and sets an expiration time
/// for the magic link. This method is used to initiate the magic link authentication flow.
///
/// # Errors
/// - Returns an error if database update fails
pub async fn create_magic_link(mut self, db: &DatabaseConnection) -> ModelResult<Model> {
let random_str = hash::random_string(MAGIC_LINK_LENGTH as usize);
let expired = Local::now() + Duration::minutes(MAGIC_LINK_EXPIRATION_MIN.into());

self.magic_link_token = ActiveValue::set(Some(random_str));
self.magic_link_expiration = ActiveValue::set(Some(expired.into()));
Ok(self.update(db).await?)
}

/// Verifies and invalidates the magic link after successful authentication.
///
/// Clears the magic link token and expiration time after the user has
/// successfully authenticated using the magic link.
///
/// # Errors
/// - Returns an error if database update fails
pub async fn clear_magic_link(mut self, db: &DatabaseConnection) -> ModelResult<Model> {
self.magic_link_token = ActiveValue::set(None);
self.magic_link_expiration = ActiveValue::set(None);
Ok(self.update(db).await?)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -17,5 +17,7 @@ Ok(
email_verification_token: None,
email_verification_sent_at: None,
email_verified_at: None,
magic_link_token: None,
magic_link_expiration: None,
},
)
Original file line number Diff line number Diff line change
Expand Up @@ -17,5 +17,7 @@ Ok(
email_verification_token: None,
email_verification_sent_at: None,
email_verified_at: None,
magic_link_token: None,
magic_link_expiration: None,
},
)
Original file line number Diff line number Diff line change
Expand Up @@ -17,5 +17,7 @@ Ok(
email_verification_token: None,
email_verification_sent_at: None,
email_verified_at: None,
magic_link_token: None,
magic_link_expiration: None,
},
)
68 changes: 68 additions & 0 deletions loco-new/base_template/tests/models/users.rs.t
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
use chrono::{offset::Local, Duration};
use insta::assert_debug_snapshot;
use loco_rs::{model::ModelError, testing::prelude::*};
use {{settings.module_name}}::{
Expand Down Expand Up @@ -221,3 +222,70 @@ async fn can_reset_password() {
.verify_password("new-password")
);
}

#[tokio::test]
#[serial]
async fn magic_link() {
let boot = boot_test::<App>().await.unwrap();
seed::<App>(&boot.app_context.db).await.unwrap();

let user = Model::find_by_pid(&boot.app_context.db, "11111111-1111-1111-1111-111111111111")
.await
.unwrap();

assert!(
user.magic_link_token.is_none(),
"Magic link token should be initially unset"
);
assert!(
user.magic_link_expiration.is_none(),
"Magic link expiration should be initially unset"
);

let create_result = user
.into_active_model()
.create_magic_link(&boot.app_context.db)
.await;

assert!(
create_result.is_ok(),
"Failed to create magic link: {:?}",
create_result.unwrap_err()
);

let updated_user =
Model::find_by_pid(&boot.app_context.db, "11111111-1111-1111-1111-111111111111")
.await
.expect("Failed to refetch user after magic link creation");

assert!(
updated_user.magic_link_token.is_some(),
"Magic link token should be set after creation"
);

let magic_link_token = updated_user.magic_link_token.unwrap();
assert_eq!(
magic_link_token.len(),
users::MAGIC_LINK_LENGTH as usize,
"Magic link token length does not match expected length"
);

assert!(
updated_user.magic_link_expiration.is_some(),
"Magic link expiration should be set after creation"
);

let now = Local::now();
let should_expired_at = now + Duration::minutes(users::MAGIC_LINK_EXPIRATION_MIN.into());
let actual_expiration = updated_user.magic_link_expiration.unwrap();

assert!(
actual_expiration >= now,
"Magic link expiration should be in the future or now"
);

assert!(
actual_expiration <= should_expired_at,
"Magic link expiration exceeds expected maximum expiration time"
);
}
Loading

0 comments on commit 5bffba3

Please sign in to comment.