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
|
state.mail.send_verification_email(&req.to_email, first_name, "123456").await
|
||||||
}
|
}
|
||||||
"password-reset" => {
|
"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" => {
|
"profile-verified" => {
|
||||||
state.mail.send_profile_verified_email(&req.to_email, first_name, "Photographer").await
|
state.mail.send_profile_verified_email(&req.to_email, first_name, "Photographer").await
|
||||||
|
|
|
||||||
|
|
@ -78,7 +78,7 @@ pub struct ForgotPasswordPayload {
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
pub struct ResetPasswordPayload {
|
pub struct ResetPasswordPayload {
|
||||||
pub token: String,
|
pub code: String,
|
||||||
pub new_password: String,
|
pub new_password: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -634,23 +634,22 @@ async fn forgot_password(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Json(payload): Json<ForgotPasswordPayload>,
|
Json(payload): Json<ForgotPasswordPayload>,
|
||||||
) -> Result<impl IntoResponse, (StatusCode, Json<ErrorResponse>)> {
|
) -> 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 {
|
let user = match UserRepository::get_by_email(&state.pool, &payload.email.to_lowercase()).await {
|
||||||
Ok(u) => u,
|
Ok(u) => u,
|
||||||
Err(_) => return Ok(silent_ok),
|
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();
|
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, &code, &user.id.to_string())
|
||||||
cache::token::store_reset(&mut redis, &token, &user.id.to_string())
|
|
||||||
.await
|
.await
|
||||||
.map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string(), "CACHE_ERROR"))?;
|
.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 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)
|
Ok(silent_ok)
|
||||||
}
|
}
|
||||||
|
|
@ -662,15 +661,15 @@ async fn reset_password(
|
||||||
) -> Result<impl IntoResponse, (StatusCode, Json<ErrorResponse>)> {
|
) -> Result<impl IntoResponse, (StatusCode, Json<ErrorResponse>)> {
|
||||||
let mut redis = state.redis.clone();
|
let mut redis = state.redis.clone();
|
||||||
|
|
||||||
// Consume reset token from Redis (single-use GETDEL)
|
// Consume reset code from Redis (single-use GETDEL)
|
||||||
let user_id_str = cache::token::consume_reset(&mut redis, &payload.token)
|
let user_id_str = cache::token::consume_reset(&mut redis, &payload.code)
|
||||||
.await
|
.await
|
||||||
.map_err(|_| err(StatusCode::INTERNAL_SERVER_ERROR, "Cache error", "CACHE_ERROR"))?
|
.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
|
let user_id = user_id_str
|
||||||
.parse::<uuid::Uuid>()
|
.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 {
|
if payload.new_password.len() < 8 {
|
||||||
return Err(err(StatusCode::UNPROCESSABLE_ENTITY, "Password must be at least 8 characters", "VALIDATION_ERROR"));
|
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;
|
use crate::RedisPool;
|
||||||
|
|
||||||
const REFRESH_TTL: u64 = 30 * 24 * 3_600; // 30 days in seconds
|
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 ────────────────────────────────────────────────────────────
|
// ── Refresh tokens ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -335,13 +335,10 @@ impl Mailer {
|
||||||
self.send_html(to, "Verify Your Email Address", html).await
|
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<()> {
|
pub async fn send_password_reset_email(&self, to: &str, name: &str, code: &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);
|
|
||||||
|
|
||||||
let vars = HashMap::from([
|
let vars = HashMap::from([
|
||||||
("first_name", name),
|
("first_name", name),
|
||||||
("reset_url", &reset_url),
|
("reset_code", code),
|
||||||
]);
|
]);
|
||||||
let html = self.template_engine.render("password-reset", vars)?;
|
let html = self.template_engine.render("password-reset", vars)?;
|
||||||
self.send_html(to, "Reset Your Password", html).await
|
self.send_html(to, "Reset Your Password", html).await
|
||||||
|
|
|
||||||
|
|
@ -3,35 +3,20 @@
|
||||||
|
|
||||||
<p>Hi {{first_name}},</p>
|
<p>Hi {{first_name}},</p>
|
||||||
<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:
|
new password:
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div style="text-align: center">
|
<div style="text-align: center; font-size: 28px; font-weight: bold; letter-spacing: 8px; margin: 30px 0;">
|
||||||
<a href="{{reset_url}}" class="cta-button">Reset Password</a>
|
{{reset_code}}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="info-box">
|
<div class="info-box">
|
||||||
<p class="info-box-title">🔒 Security Notice</p>
|
<p class="info-box-title">🔒 Security Notice</p>
|
||||||
<p style="margin: 0">
|
<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.
|
this, please ignore this email and your password will remain unchanged.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p style="margin-top: 30px">
|
<p>Best regards,<br /><strong>The Nxtgauge Team</strong></p>
|
||||||
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