From 2aba45c9fa1ffb7f9d5c86a46a054fc930f3e034 Mon Sep 17 00:00:00 2001 From: Tracewebstudio Dev Date: Tue, 5 May 2026 17:21:56 +0200 Subject: [PATCH] feat: password reset via 6-digit code instead of token link - Generate 6-digit code instead of UUID token for password reset - Store in Redis with 15 min TTL (was 1 hour) - Update email template to show code instead of reset link - Update ResetPasswordPayload to accept code instead of token - Update send_password_reset_email to accept code parameter --- apps/users/src/handlers/admin_email.rs | 2 +- apps/users/src/handlers/auth.rs | 19 ++++++++-------- crates/cache/src/token.rs | 2 +- crates/email/src/lib.rs | 7 ++---- crates/email/templates/password-reset.html | 25 +++++----------------- 5 files changed, 18 insertions(+), 37 deletions(-) diff --git a/apps/users/src/handlers/admin_email.rs b/apps/users/src/handlers/admin_email.rs index cd64151..5ff7f2b 100644 --- a/apps/users/src/handlers/admin_email.rs +++ b/apps/users/src/handlers/admin_email.rs @@ -388,7 +388,7 @@ async fn send_test_email( state.mail.send_verification_email(&req.to_email, first_name, "123456").await } "password-reset" => { - state.mail.send_password_reset_email(&req.to_email, first_name, "sample-token").await + state.mail.send_password_reset_email(&req.to_email, first_name, "123456").await } "profile-verified" => { state.mail.send_profile_verified_email(&req.to_email, first_name, "Photographer").await diff --git a/apps/users/src/handlers/auth.rs b/apps/users/src/handlers/auth.rs index bcedb1e..4466f18 100644 --- a/apps/users/src/handlers/auth.rs +++ b/apps/users/src/handlers/auth.rs @@ -78,7 +78,7 @@ pub struct ForgotPasswordPayload { #[derive(Deserialize)] pub struct ResetPasswordPayload { - pub token: String, + pub code: String, pub new_password: String, } @@ -634,23 +634,22 @@ async fn forgot_password( State(state): State, Json(payload): Json, ) -> Result)> { - let silent_ok = (StatusCode::OK, Json(serde_json::json!({ "message": "Reset link sent if email exists" }))); + let silent_ok = (StatusCode::OK, Json(serde_json::json!({ "message": "Reset code sent if email exists" }))); let user = match UserRepository::get_by_email(&state.pool, &payload.email.to_lowercase()).await { Ok(u) => u, Err(_) => return Ok(silent_ok), }; - let token = uuid::Uuid::new_v4().to_string(); + let code = format!("{:06}", rand::random::() % 1_000_000); let mut redis = state.redis.clone(); - // Store reset token in Redis (1-hour TTL, consumed single-use on reset) - cache::token::store_reset(&mut redis, &token, &user.id.to_string()) + cache::token::store_reset(&mut redis, &code, &user.id.to_string()) .await .map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string(), "CACHE_ERROR"))?; let user_name = format!("{} {}", user.first_name.unwrap_or_default(), user.last_name.unwrap_or_default()); - let _ = state.mail.send_password_reset_email(&user.email, &user_name, &token).await; + let _ = state.mail.send_password_reset_email(&user.email, &user_name, &code).await; Ok(silent_ok) } @@ -662,15 +661,15 @@ async fn reset_password( ) -> Result)> { let mut redis = state.redis.clone(); - // Consume reset token from Redis (single-use GETDEL) - let user_id_str = cache::token::consume_reset(&mut redis, &payload.token) + // Consume reset code from Redis (single-use GETDEL) + let user_id_str = cache::token::consume_reset(&mut redis, &payload.code) .await .map_err(|_| err(StatusCode::INTERNAL_SERVER_ERROR, "Cache error", "CACHE_ERROR"))? - .ok_or_else(|| err(StatusCode::UNAUTHORIZED, "Invalid or expired reset token", "INVALID_TOKEN"))?; + .ok_or_else(|| err(StatusCode::UNAUTHORIZED, "Invalid or expired reset code", "INVALID_CODE"))?; let user_id = user_id_str .parse::() - .map_err(|_| err(StatusCode::UNAUTHORIZED, "Invalid reset token", "INVALID_TOKEN"))?; + .map_err(|_| err(StatusCode::UNAUTHORIZED, "Invalid reset code", "INVALID_CODE"))?; if payload.new_password.len() < 8 { return Err(err(StatusCode::UNPROCESSABLE_ENTITY, "Password must be at least 8 characters", "VALIDATION_ERROR")); diff --git a/crates/cache/src/token.rs b/crates/cache/src/token.rs index abf3587..4ba4fba 100644 --- a/crates/cache/src/token.rs +++ b/crates/cache/src/token.rs @@ -12,7 +12,7 @@ use redis::AsyncCommands; use crate::RedisPool; const REFRESH_TTL: u64 = 30 * 24 * 3_600; // 30 days in seconds -const RESET_TTL: u64 = 3_600; // 1 hour +const RESET_TTL: u64 = 900; // 15 minutes // ── Refresh tokens ──────────────────────────────────────────────────────────── diff --git a/crates/email/src/lib.rs b/crates/email/src/lib.rs index 40ce0df..1256207 100644 --- a/crates/email/src/lib.rs +++ b/crates/email/src/lib.rs @@ -335,13 +335,10 @@ impl Mailer { self.send_html(to, "Verify Your Email Address", html).await } - pub async fn send_password_reset_email(&self, to: &str, name: &str, token: &str) -> Result<()> { - let frontend_url = env::var("FRONTEND_URL").unwrap_or_else(|_| "https://nxtgauge.com".to_string()); - let reset_url = format!("{}/reset-password?token={}", frontend_url, token); - + pub async fn send_password_reset_email(&self, to: &str, name: &str, code: &str) -> Result<()> { let vars = HashMap::from([ ("first_name", name), - ("reset_url", &reset_url), + ("reset_code", code), ]); let html = self.template_engine.render("password-reset", vars)?; self.send_html(to, "Reset Your Password", html).await diff --git a/crates/email/templates/password-reset.html b/crates/email/templates/password-reset.html index dcf9830..8ff0d7a 100644 --- a/crates/email/templates/password-reset.html +++ b/crates/email/templates/password-reset.html @@ -3,35 +3,20 @@

Hi {{first_name}},

- We received a request to reset your password. Click the button below to set a + We received a request to reset your password. Enter the code below to set a new password:

-
- Reset Password +
+ {{reset_code}}

🔒 Security Notice

- This link will expire in 1 hour. If you didn't request + This code will expire in 15 minutes. If you didn't request this, please ignore this email and your password will remain unchanged.

-

- If the button doesn't work, copy and paste this link into your browser: -

-

- {{reset_url}} -

- -

Best regards,
The Nxtgauge Team

+

Best regards,
The Nxtgauge Team

\ No newline at end of file