From 5bffba3421afee7f80bd0d70bfc99897540a40d5 Mon Sep 17 00:00:00 2001 From: Elad Kaplan Date: Sun, 15 Dec 2024 10:55:14 +0200 Subject: [PATCH] magic link --- loco-new/base_template/config/test.yaml.t | 1 + .../migration/src/m20220101_000001_users.rs | 4 ++ .../base_template/src/controllers/auth.rs | 61 ++++++++++++++++ loco-new/base_template/src/mailers/auth.rs | 27 +++++++ .../src/mailers/auth/magic_link/html.t | 8 +++ .../src/mailers/auth/magic_link/subject.t | 1 + .../src/mailers/auth/magic_link/text.t | 2 + .../src/models/_entities/users.rs | 2 + loco-new/base_template/src/models/users.rs | 70 ++++++++++++++++++- .../can_create_with_password@users.snap | 2 + .../snapshots/can_find_by_email@users.snap | 2 + .../snapshots/can_find_by_pid@users.snap | 2 + .../base_template/tests/models/users.rs.t | 68 ++++++++++++++++++ .../base_template/tests/requests/auth.rs.t | 64 +++++++++++++++++ ...n_auth_with_magic_link@auth_request-2.snap | 5 ++ ...can_auth_with_magic_link@auth_request.snap | 5 ++ .../can_register@auth_request-2.snap | 6 +- .../snapshots/can_register@auth_request.snap | 2 + .../can_reset_password@auth_request-2.snap | 7 +- loco-new/tests/templates/mailer.rs | 8 ++- src/bgworker/mod.rs | 16 ++--- src/cli.rs | 3 +- src/hash.rs | 34 ++++++++- 23 files changed, 382 insertions(+), 18 deletions(-) create mode 100644 loco-new/base_template/src/mailers/auth/magic_link/html.t create mode 100644 loco-new/base_template/src/mailers/auth/magic_link/subject.t create mode 100644 loco-new/base_template/src/mailers/auth/magic_link/text.t create mode 100644 loco-new/base_template/tests/requests/snapshots/can_auth_with_magic_link@auth_request-2.snap create mode 100644 loco-new/base_template/tests/requests/snapshots/can_auth_with_magic_link@auth_request.snap diff --git a/loco-new/base_template/config/test.yaml.t b/loco-new/base_template/config/test.yaml.t index b1d1ed535..66c191b0c 100644 --- a/loco-new/base_template/config/test.yaml.t +++ b/loco-new/base_template/config/test.yaml.t @@ -70,6 +70,7 @@ queue: # Mailer Configuration. mailer: + stub: true # SMTP mailer configuration. smtp: # Enable/Disable smtp mailer. diff --git a/loco-new/base_template/migration/src/m20220101_000001_users.rs b/loco-new/base_template/migration/src/m20220101_000001_users.rs index 936ad3d0c..0b689d73e 100644 --- a/loco-new/base_template/migration/src/m20220101_000001_users.rs +++ b/loco-new/base_template/migration/src/m20220101_000001_users.rs @@ -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(()) @@ -47,4 +49,6 @@ pub enum Users { EmailVerificationToken, EmailVerificationSentAt, EmailVerifiedAt, + MagicLinkToken, + MagicLinkExpiration, } diff --git a/loco-new/base_template/src/controllers/auth.rs b/loco-new/base_template/src/controllers/auth.rs index 27e3de71e..97368bbcd 100644 --- a/loco-new/base_template/src/controllers/auth.rs +++ b/loco-new/base_template/src/controllers/auth.rs @@ -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] @@ -145,6 +150,60 @@ async fn current(auth: auth::JWT, State(ctx): State) -> Result, + 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 + 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, + // Json(params): Json, +) -> 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::json(LoginResponse::new(&user, &token)) +} + pub fn routes() -> Routes { Routes::new() .prefix("/api/auth") @@ -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)) } diff --git a/loco-new/base_template/src/mailers/auth.rs b/loco-new/base_template/src/mailers/auth.rs index 30bb1bf2f..f2d635232 100644 --- a/loco-new/base_template/src/mailers/auth.rs +++ b/loco-new/base_template/src/mailers/auth.rs @@ -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. @@ -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(()) + } } diff --git a/loco-new/base_template/src/mailers/auth/magic_link/html.t b/loco-new/base_template/src/mailers/auth/magic_link/html.t new file mode 100644 index 000000000..56eb2527c --- /dev/null +++ b/loco-new/base_template/src/mailers/auth/magic_link/html.t @@ -0,0 +1,8 @@ +; + +

Magic link example:

+ +Verify Your Account + + + diff --git a/loco-new/base_template/src/mailers/auth/magic_link/subject.t b/loco-new/base_template/src/mailers/auth/magic_link/subject.t new file mode 100644 index 000000000..93eaba77c --- /dev/null +++ b/loco-new/base_template/src/mailers/auth/magic_link/subject.t @@ -0,0 +1 @@ +Magic link example diff --git a/loco-new/base_template/src/mailers/auth/magic_link/text.t b/loco-new/base_template/src/mailers/auth/magic_link/text.t new file mode 100644 index 000000000..b33d33106 --- /dev/null +++ b/loco-new/base_template/src/mailers/auth/magic_link/text.t @@ -0,0 +1,2 @@ +Magic link with this link: +{{host}}/api/auth/magic-link/{{token}} \ No newline at end of file diff --git a/loco-new/base_template/src/models/_entities/users.rs b/loco-new/base_template/src/models/_entities/users.rs index 120b1a1b1..765e99272 100644 --- a/loco-new/base_template/src/models/_entities/users.rs +++ b/loco-new/base_template/src/models/_entities/users.rs @@ -22,6 +22,8 @@ pub struct Model { pub email_verification_token: Option, pub email_verification_sent_at: Option, pub email_verified_at: Option, + pub magic_link_token: Option, + pub magic_link_expiration: Option, } #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] diff --git a/loco-new/base_template/src/models/users.rs b/loco-new/base_template/src/models/users.rs index b4f3aaea0..23fc147eb 100644 --- a/loco-new/base_template/src/models/users.rs +++ b/loco-new/base_template/src/models/users.rs @@ -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, @@ -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 { + 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 @@ -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 { + 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 { + self.magic_link_token = ActiveValue::set(None); + self.magic_link_expiration = ActiveValue::set(None); + Ok(self.update(db).await?) + } } diff --git a/loco-new/base_template/tests/models/snapshots/can_create_with_password@users.snap b/loco-new/base_template/tests/models/snapshots/can_create_with_password@users.snap index 6e66fd35a..98113622d 100644 --- a/loco-new/base_template/tests/models/snapshots/can_create_with_password@users.snap +++ b/loco-new/base_template/tests/models/snapshots/can_create_with_password@users.snap @@ -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, }, ) diff --git a/loco-new/base_template/tests/models/snapshots/can_find_by_email@users.snap b/loco-new/base_template/tests/models/snapshots/can_find_by_email@users.snap index 067d0e752..518753a67 100644 --- a/loco-new/base_template/tests/models/snapshots/can_find_by_email@users.snap +++ b/loco-new/base_template/tests/models/snapshots/can_find_by_email@users.snap @@ -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, }, ) diff --git a/loco-new/base_template/tests/models/snapshots/can_find_by_pid@users.snap b/loco-new/base_template/tests/models/snapshots/can_find_by_pid@users.snap index 067d0e752..518753a67 100644 --- a/loco-new/base_template/tests/models/snapshots/can_find_by_pid@users.snap +++ b/loco-new/base_template/tests/models/snapshots/can_find_by_pid@users.snap @@ -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, }, ) diff --git a/loco-new/base_template/tests/models/users.rs.t b/loco-new/base_template/tests/models/users.rs.t index 7ace87eca..86a27f498 100644 --- a/loco-new/base_template/tests/models/users.rs.t +++ b/loco-new/base_template/tests/models/users.rs.t @@ -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}}::{ @@ -221,3 +222,70 @@ async fn can_reset_password() { .verify_password("new-password") ); } + +#[tokio::test] +#[serial] +async fn magic_link() { + let boot = boot_test::().await.unwrap(); + seed::(&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" + ); +} \ No newline at end of file diff --git a/loco-new/base_template/tests/requests/auth.rs.t b/loco-new/base_template/tests/requests/auth.rs.t index c7e37da7d..e6519a3ed 100644 --- a/loco-new/base_template/tests/requests/auth.rs.t +++ b/loco-new/base_template/tests/requests/auth.rs.t @@ -216,3 +216,67 @@ async fn can_get_current_user() { }) .await; } + +#[tokio::test] +#[serial] +async fn can_auth_with_magic_link() { + configure_insta!(); + request::(|request, ctx| async move { + seed::(&ctx.db).await.unwrap(); + + let payload = serde_json::json!({ + "email": "user1@example.com", + }); + let response = request.post("/api/auth/magic-link").json(&payload).await; + assert_eq!(response.status_code(), 200, "Magic link request should succeed"); + + let deliveries = ctx.mailer.unwrap().deliveries(); + assert_eq!(deliveries.count, 1, "Exactly one email should be sent"); + + let redact_token = format!("([a-zA-Z0-9]{% raw %}{{{}}}{% endraw %})", users::MAGIC_LINK_LENGTH); + with_settings!({ + filters => { + let mut combined_filters = cleanup_email().clone(); + combined_filters.extend(vec![(redact_token.as_str(), "[REDACT_TOKEN]")]); + combined_filters + } + }, { + assert_debug_snapshot!(deliveries.messages.first().expect("first message").replace("=\r\n", "")); + + }); + + let user = users::Model::find_by_email(&ctx.db, "user1@example.com") + .await + .expect("User should be found"); + + let magic_link_token = user.magic_link_token + .expect("Magic link token should be generated"); + let magic_link_response = request.get(&format!("/api/auth/magic-link/{magic_link_token}")).await; + assert_eq!(magic_link_response.status_code(), 200, "Magic link authentication should succeed"); + + with_settings!({ + filters => cleanup_user_model() + }, { + assert_debug_snapshot!(magic_link_response.text()); + }); + + }) + .await; +} + +#[tokio::test] +#[serial] +async fn can_reject_invalid_magic_link_token() { + configure_insta!(); + request::(|request, ctx| async move { + seed::(&ctx.db).await.unwrap(); + + let magic_link_response = request.get("/api/auth/magic-link/invalid-token").await; + assert_eq!( + magic_link_response.status_code(), + 401, + "Magic link authentication should be rejected" + ); + }) + .await; +} \ No newline at end of file diff --git a/loco-new/base_template/tests/requests/snapshots/can_auth_with_magic_link@auth_request-2.snap b/loco-new/base_template/tests/requests/snapshots/can_auth_with_magic_link@auth_request-2.snap new file mode 100644 index 000000000..199985703 --- /dev/null +++ b/loco-new/base_template/tests/requests/snapshots/can_auth_with_magic_link@auth_request-2.snap @@ -0,0 +1,5 @@ +--- +source: tests/requests/auth.rs +expression: magic_link_response.text() +--- +"{\"token\":\"TOKEN\",\"pid\":\"PID\",\"name\":\"user1\",\"is_verified\":false}" diff --git a/loco-new/base_template/tests/requests/snapshots/can_auth_with_magic_link@auth_request.snap b/loco-new/base_template/tests/requests/snapshots/can_auth_with_magic_link@auth_request.snap new file mode 100644 index 000000000..85e266acb --- /dev/null +++ b/loco-new/base_template/tests/requests/snapshots/can_auth_with_magic_link@auth_request.snap @@ -0,0 +1,5 @@ +--- +source: tests/requests/auth.rs +expression: "deliveries.messages.first().expect(\"first message\").replace(\"=\\r\\n\", \"\")" +--- +"From: System \r\nTo: user1@example.com\r\nSubject: Magic link =?utf-8?b?ZXhhbXBsZQo=?MIME-Version: 1.0\r\nDate: DATE\r\nContent-Type: multipart/alternative;\r\n boundary=\"IDENTIFIER\"\r\n\r\n--IDENTIFIER\r\nContent-Type: text/plain; charset=utf-8\r\nContent-Transfer-Encoding: 7bit\r\n\r\nMagic link with this link: \r\nhttp://localhost:5150/api/auth/magic-link/[REDACT_TOKEN]\r\n--IDENTIFIER\r\nContent-Type: text/html; charset=utf-8\r\nContent-Transfer-Encoding: quoted-printable\r\n\r\n;\r\n\r\n

Magic link example:

\r\n\r\nVerify Your Account\r\n\r\n\r\n\r\n\r\n--IDENTIFIER--\r\n" diff --git a/loco-new/base_template/tests/requests/snapshots/can_register@auth_request-2.snap b/loco-new/base_template/tests/requests/snapshots/can_register@auth_request-2.snap index f380dd9f0..45c63cb14 100644 --- a/loco-new/base_template/tests/requests/snapshots/can_register@auth_request-2.snap +++ b/loco-new/base_template/tests/requests/snapshots/can_register@auth_request-2.snap @@ -3,6 +3,8 @@ source: tests/requests/auth.rs expression: ctx.mailer.unwrap().deliveries() --- Deliveries { - count: 0, - messages: [], + count: 1, + messages: [ + "From: System \r\nTo: test@loco.com\r\nSubject: Welcome =?utf-8?b?bG9jbwo=?=\r\nMIME-Version: 1.0\r\nDate: DATE\r\nContent-Type: multipart/alternative;\r\n boundary=\"IDENTIFIER\"\r\n\r\n--IDENTIFIER\r\nContent-Type: text/plain; charset=utf-8\r\nContent-Transfer-Encoding: 7bit\r\n\r\nWelcome loco, you can now log in.\r\n Verify your account with the link below:\r\n\r\n http://localhost/verify#RANDOM_ID\r\n\r\n--IDENTIFIER\r\nContent-Type: text/html; charset=utf-8\r\nContent-Transfer-Encoding: quoted-printable\r\n\r\n;\r\n\r\n\r\n Dear loco,\r\n Welcome to Loco! You can now log in to your account.\r\n Before you get started, please verify your account by clicking the link b=\r\nelow:\r\n \r\nTo: test@loco.com\r\nSubject: Welcome =?utf-8?b?bG9jbwo=?=\r\nMIME-Version: 1.0\r\nDate: DATE\r\nContent-Type: multipart/alternative;\r\n boundary=\"IDENTIFIER\"\r\n\r\n--IDENTIFIER\r\nContent-Type: text/plain; charset=utf-8\r\nContent-Transfer-Encoding: 7bit\r\n\r\nWelcome loco, you can now log in.\r\n Verify your account with the link below:\r\n\r\n http://localhost/verify#RANDOM_ID\r\n\r\n--IDENTIFIER\r\nContent-Type: text/html; charset=utf-8\r\nContent-Transfer-Encoding: quoted-printable\r\n\r\n;\r\n\r\n\r\n Dear loco,\r\n Welcome to Loco! You can now log in to your account.\r\n Before you get started, please verify your account by clicking the link b=\r\nelow:\r\n \r\nTo: test@loco.com\r\nSubject: Your reset password =?utf-8?b?bGluawo=?=\r\nMIME-Version: 1.0\r\nDate: DATE\r\nContent-Type: multipart/alternative;\r\n boundary=\"IDENTIFIER\"\r\n\r\n--IDENTIFIER\r\nContent-Type: text/plain; charset=utf-8\r\nContent-Transfer-Encoding: 7bit\r\n\r\nReset your password with this link:\r\n\r\nhttp://localhost/reset#RANDOM_ID\r\n\r\n--IDENTIFIER\r\nContent-Type: text/html; charset=utf-8\r\nContent-Transfer-Encoding: quoted-printable\r\n\r\n;\r\n\r\n\r\n Hey loco,\r\n Forgot your password? No worries! You can reset it by clicking the link b=\r\nelow:\r\n , ) -> Result { tracing::debug!(status = ?status, age_days = ?age_days, "getting jobs"); - let jobs = match self { + match self { #[cfg(feature = "bg_pg")] Self::Postgres(pool, _, _) => { let jobs = pg::get_jobs(pool, status, age_days) .await .map_err(Box::from)?; - serde_json::to_value(jobs)? + Ok(serde_json::to_value(jobs)?) } #[cfg(feature = "bg_sqlt")] Self::Sqlite(pool, _, _) => { @@ -327,25 +327,23 @@ impl Queue { .await .map_err(Box::from)?; - serde_json::to_value(jobs)? + Ok(serde_json::to_value(jobs)?) } #[cfg(feature = "bg_redis")] Self::Redis(_, _, _) => { tracing::error!("getting jobs for redis provider not implemented"); - return Err(Error::string( + Err(Error::string( "getting jobs not supported for redis provider", - )); + )) } Self::None => { tracing::error!( "no queue provider is configured: compile with at least one queue provider \ feature" ); - return Err(Error::string("provider not configure")); + Err(Error::string("provider not configure")) } - }; - - Ok(jobs) + } } /// Cancels jobs based on the given job name for the configured queue provider. diff --git a/src/cli.rs b/src/cli.rs index d380c8003..967afd326 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -26,13 +26,14 @@ cfg_if::cfg_if! { use std::path::PathBuf; +#[cfg(any(feature = "bg_redis", feature = "bg_pg", feature = "bg_sqlt"))] +use crate::bgworker::JobStatus; use clap::{ArgAction, Parser, Subcommand}; use duct::cmd; use loco_gen::{Component, ScaffoldKind}; use crate::{ app::{AppContext, Hooks}, - bgworker::JobStatus, boot::{ create_app, create_context, list_endpoints, list_middlewares, run_scheduler, run_task, start, RunDbCommand, ServeParams, StartMode, diff --git a/src/hash.rs b/src/hash.rs index d8a96c2b3..84236112c 100644 --- a/src/hash.rs +++ b/src/hash.rs @@ -1,9 +1,9 @@ +use crate::{Error, Result}; use argon2::{ password_hash::SaltString, Argon2, Params, PasswordHash, PasswordHasher, PasswordVerifier, Version, }; - -use crate::{Error, Result}; +use rand::{distributions::Alphanumeric, thread_rng, Rng}; /// Hashes a plain text password and returns the hashed result. /// @@ -57,6 +57,26 @@ pub fn verify_password(pass: &str, hashed_password: &str) -> bool { arg2.verify_password(pass.as_bytes(), &hash).is_ok() } +/// Generates a random alphanumeric string of the specified length. +/// +/// # Example +/// +/// ```rust +/// use loco_rs::hash; +/// +/// let rand_str = hash::random_string(10); +/// assert_eq!(rand_str.len(), 10); +/// assert_ne!(rand_str, hash::random_string(10)); +/// +/// ``` +pub fn random_string(length: usize) -> String { + thread_rng() + .sample_iter(&Alphanumeric) + .take(length) + .map(char::from) + .collect() +} + #[cfg(test)] mod tests { @@ -70,4 +90,14 @@ mod tests { assert!(verify_password(pass, &hash_pass)); } + + #[test] + fn can_random_string() { + let random_length = 32; + let first = random_string(random_length); + assert_eq!(first.len(), random_length); + let second: String = random_string(random_length); + assert_eq!(second.len(), random_length); + assert_ne!(first, second); + } }