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
This commit is contained in:
Tracewebstudio Dev 2026-05-05 17:21:56 +02:00
parent c443ff5b50
commit 2aba45c9fa
5 changed files with 18 additions and 37 deletions

View file

@ -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

View file

@ -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<AppState>,
Json(payload): Json<ForgotPasswordPayload>,
) -> Result<impl IntoResponse, (StatusCode, Json<ErrorResponse>)> {
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::<u32>() % 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<impl IntoResponse, (StatusCode, Json<ErrorResponse>)> {
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::<uuid::Uuid>()
.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"));

View file

@ -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 ────────────────────────────────────────────────────────────

View file

@ -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

View file

@ -3,35 +3,20 @@
<p>Hi {{first_name}},</p>
<p>
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:
</p>
<div style="text-align: center">
<a href="{{reset_url}}" class="cta-button">Reset Password</a>
<div style="text-align: center; font-size: 28px; font-weight: bold; letter-spacing: 8px; margin: 30px 0;">
{{reset_code}}
</div>
<div class="info-box">
<p class="info-box-title">🔒 Security Notice</p>
<p style="margin: 0">
This link will expire in <strong>1 hour</strong>. If you didn't request
This code will expire in <strong>15 minutes</strong>. If you didn't request
this, please ignore this email and your password will remain unchanged.
</p>
</div>
<p style="margin-top: 30px">
If the button doesn't work, copy and paste this link into your browser:
</p>
<p
style="
background: #f3f4f6;
padding: 10px;
border-radius: 4px;
font-size: 12px;
word-break: break-all;
"
>
{{reset_url}}
</p>
<p>Best regards,<br /><strong>The Nxtgauge Team</strong></p>