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:
parent
c443ff5b50
commit
2aba45c9fa
5 changed files with 18 additions and 37 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"));
|
||||
|
|
|
|||
2
crates/cache/src/token.rs
vendored
2
crates/cache/src/token.rs
vendored
|
|
@ -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 ────────────────────────────────────────────────────────────
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
Loading…
Add table
Reference in a new issue