Skip to content

Commit

Permalink
Merge branch 'master' into storage-refactor
Browse files Browse the repository at this point in the history
  • Loading branch information
kaplanelad authored Dec 17, 2024
2 parents 41ff261 + f5a9032 commit bf53f81
Show file tree
Hide file tree
Showing 63 changed files with 967 additions and 243 deletions.
19 changes: 10 additions & 9 deletions loco-new/base_template/Cargo.toml.t
Original file line number Diff line number Diff line change
Expand Up @@ -21,14 +21,15 @@ loco-rs = { {{settings.loco_version_text}} {%- if not settings.features.default_
[dependencies]
loco-rs = { workspace = true {% if feature_list | length > 0 %}, features = {{feature_list}}{% endif %} }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
serde_json = { version = "1" }
tokio = { version = "1.33.0", default-features = false, features = [
"rt-multi-thread",
] }
async-trait = "0.1.74"
axum = "0.7.5"
tracing = "0.1.40"
async-trait = { version = "0.1.74" }
axum = { version = "0.7.5" }
tracing = { version = "0.1.40" }
tracing-subscriber = { version = "0.3.17", features = ["env-filter", "json"] }
regex = { version = "1.11.1" }
{%- if settings.db %}
migration = { path = "migration" }
sea-orm = { version = "1.1.0", features = [
Expand All @@ -37,19 +38,19 @@ sea-orm = { version = "1.1.0", features = [
"runtime-tokio-rustls",
"macros",
] }
chrono = "0.4"
chrono = { version = "0.4" }
validator = { version = "0.19" }
uuid = { version = "1.6.0", features = ["v4"] }
{%- endif %}

{%- if settings.mailer %}
include_dir = "0.7"
include_dir = { version = "0.7" }
{%- endif %}

{%- if settings.asset %}
# view engine i18n
fluent-templates = { version = "0.8.0", features = ["tera"] }
unic-langid = "0.9.4"
unic-langid = { version = "0.9.4" }
# /view engine
{%- endif %}

Expand All @@ -67,6 +68,6 @@ required-features = []

[dev-dependencies]
loco-rs = { workspace = true, features = ["testing"] }
serial_test = "3.1.1"
rstest = "0.21.0"
serial_test = { version = "3.1.1" }
rstest = { version = "0.21.0" }
insta = { version = "1.34.0", features = ["redactions", "yaml", "filters"] }
3 changes: 2 additions & 1 deletion loco-new/base_template/config/test.yaml.t
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ workers:
# - BackgroundQueue - Workers operate asynchronously in the background, processing queued.
# - ForegroundBlocking - Workers operate in the foreground and block until tasks are completed.
# - BackgroundAsync - Workers operate asynchronously in the background, processing tasks with async capabilities.
mode: {{settings.background.kind}}
mode: ForegroundBlocking

{% if settings.background.kind == "BackgroundQueue"%}
# Queue Configuration
Expand All @@ -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 @@ -2,15 +2,19 @@
#![allow(clippy::wildcard_imports)]
pub use sea_orm_migration::prelude::*;

{%- if settings.auth %}
mod m20220101_000001_users;
{%- endif %}

pub struct Migrator;

#[async_trait::async_trait]
impl MigratorTrait for Migrator {
fn migrations() -> Vec<Box<dyn MigrationTrait>> {
vec![
{%- if settings.auth %}
Box::new(m20220101_000001_users::Migration),
{%- endif %}
// inject-above (do not remove this comment)
]
}
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,
}
22 changes: 17 additions & 5 deletions loco-new/base_template/src/app.rs.t
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ use loco_rs::{
Queue},
boot::{create_app, BootResult, StartMode},
controller::AppRoutes,
{%- if settings.db %}
{%- if settings.auth %}
db::{self, truncate_table},
{%- endif %}
environment::Environment,
Expand All @@ -25,17 +25,16 @@ use sea_orm::DatabaseConnection;

#[allow(unused_imports)]
use crate::{
controllers
controllers ,tasks
{%- if settings.initializers -%}
, initializers
{%- endif %}
{%- if settings.db %}
,tasks
{%- if settings.auth %}
, models::_entities::users
{%- endif %}
{%- if settings.background %}
, workers::downloader::DownloadWorker
{%- endif %},
{%- endif %}
};

pub struct App;
Expand Down Expand Up @@ -97,13 +96,26 @@ impl Hooks for App {
}
{%- if settings.db %}
{%- if settings.auth %}
async fn truncate(db: &DatabaseConnection) -> Result<()> {
{%- else %}
async fn truncate(_db: &DatabaseConnection) -> Result<()> {
{%- endif %}
{%- if settings.auth %}
truncate_table(db, users::Entity).await?;
{%- endif %}
Ok(())
}
{%- if settings.auth %}
async fn seed(db: &DatabaseConnection, base: &Path) -> Result<()> {
{%- else %}
async fn seed(_db: &DatabaseConnection, _base: &Path) -> Result<()> {
{%- endif %}
{%- if settings.auth %}
db::seed::<users::ActiveModel>(db, &base.join("users.yaml").display().to_string()).await?;
{%- endif %}
Ok(())
}
{%- endif %}
Expand Down
87 changes: 83 additions & 4 deletions loco-new/base_template/src/controllers/auth.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,3 @@
use axum::debug_handler;
use loco_rs::prelude::*;
use serde::{Deserialize, Serialize};

use crate::{
mailers::auth::AuthMailer,
models::{
Expand All @@ -10,6 +6,20 @@ use crate::{
},
views::auth::{CurrentResponse, LoginResponse},
};
use axum::debug_handler;
use loco_rs::prelude::*;
use regex::Regex;
use serde::{Deserialize, Serialize};
use std::sync::OnceLock;

pub static EMAIL_DOMAIN_RE: OnceLock<Regex> = OnceLock::new();

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")
})
}

#[derive(Debug, Deserialize, Serialize)]
pub struct VerifyParams {
pub token: String,
Expand All @@ -26,6 +36,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 +160,68 @@ 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 email_regex = get_allow_email_domain_re();
if !email_regex.is_match(&params.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, &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>,
) -> 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 +231,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}}
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
//! `SeaORM` Entity, @generated by sea-orm-codegen 1.0.0

pub mod prelude;

{%- if settings.auth %}
pub mod users;
{%- endif %}
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
//! `SeaORM` Entity, @generated by sea-orm-codegen 1.0.0

{%- if settings.auth %}
pub use super::users::Entity as Users;
{%- endif %}
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
2 changes: 0 additions & 2 deletions loco-new/base_template/src/models/mod.rs

This file was deleted.

4 changes: 4 additions & 0 deletions loco-new/base_template/src/models/mod.rs.t
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
pub mod _entities;
{%- if settings.auth %}
pub mod users;
{%- endif %}
Loading

0 comments on commit bf53f81

Please sign in to comment.