From 01f3810c8f2ebfda0cc8d9bb7cf3b0d1d7ed3f98 Mon Sep 17 00:00:00 2001 From: Ashwin Kumar Sivakumar Date: Tue, 14 Jul 2026 23:28:39 +0530 Subject: [PATCH] feat(employees): add forgot-password/reset-password for admin accounts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Employees (internal admin/staff) had no self-service password reset — only /login, /logout, /session existed. Adds /api/admin/auth/forgot-password and /api/admin/auth/reset-password, mirroring the existing users-table flow but against EmployeeRepository and a distinct Redis key namespace (reset:employee:*) so a code for one identity store can never be consumed against the other. Co-Authored-By: Claude Sonnet 5 --- apps/employees/src/handlers/auth.rs | 78 ++++++++++++++++++++++++++++- crates/cache/src/token.rs | 22 ++++++++ 2 files changed, 99 insertions(+), 1 deletion(-) diff --git a/apps/employees/src/handlers/auth.rs b/apps/employees/src/handlers/auth.rs index 3d8872e..20e0791 100644 --- a/apps/employees/src/handlers/auth.rs +++ b/apps/employees/src/handlers/auth.rs @@ -1,5 +1,5 @@ use auth::{ - crypto::{verify_password}, + crypto::{hash_password, verify_password}, jwt::generate_tokens, }; use axum::{ @@ -19,6 +19,8 @@ pub fn router() -> Router { .route("/login", post(login)) .route("/logout", post(logout)) .route("/session", get(session)) + .route("/forgot-password", post(forgot_password)) + .route("/reset-password", post(reset_password)) } #[derive(Deserialize)] @@ -131,3 +133,77 @@ async fn session( "role_code": auth.claims.active_role, }))) } + +#[derive(Deserialize)] +pub struct ForgotPasswordPayload { + pub email: String, +} + +#[derive(Deserialize)] +pub struct ResetPasswordPayload { + pub code: String, + pub new_password: String, +} + +/// POST /api/admin/auth/forgot-password +/// Always responds 200 regardless of whether the email exists, to avoid +/// leaking which addresses have employee accounts. +async fn forgot_password( + State(state): State, + Json(payload): Json, +) -> Result)> { + let silent_ok = (StatusCode::OK, Json(serde_json::json!({ "message": "Reset code sent if email exists" }))); + let email = payload.email.to_lowercase(); + + let mut redis = state.redis.clone(); + if !cache::rate_limit::check(&mut redis, "employee_forgot_password", &email, 3, 900).await.unwrap_or(true) { + return Ok(silent_ok); + } + + let employee = match EmployeeRepository::get_by_email(&state.pool, &email).await { + Ok(Some(e)) => e, + _ => return Ok(silent_ok), + }; + + let code = format!("{:06}", rand::random::() % 1_000_000); + tracing::info!(otp = %code, email = %employee.email, "OTP generated for employee password reset"); + + cache::token::store_employee_reset(&mut redis, &code, &employee.id.to_string()) + .await + .map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string(), "CACHE_ERROR"))?; + + let name = format!("{} {}", employee.first_name, employee.last_name); + let _ = state.mail.send_password_reset_email(&employee.email, &name, &code).await; + + Ok(silent_ok) +} + +/// POST /api/admin/auth/reset-password +async fn reset_password( + State(state): State, + Json(payload): Json, +) -> Result)> { + let mut redis = state.redis.clone(); + + let employee_id_str = cache::token::consume_employee_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 code", "INVALID_CODE"))?; + + let employee_id = employee_id_str + .parse::() + .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")); + } + + let password_hash = hash_password(&payload.new_password) + .map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string(), "INTERNAL_ERROR"))?; + + EmployeeRepository::change_password(&state.pool, employee_id, &password_hash) + .await + .map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string(), "DB_ERROR"))?; + + Ok((StatusCode::OK, Json(serde_json::json!({ "message": "Password reset successfully" })))) +} diff --git a/crates/cache/src/token.rs b/crates/cache/src/token.rs index 0cc17c3..4d597bd 100644 --- a/crates/cache/src/token.rs +++ b/crates/cache/src/token.rs @@ -65,3 +65,25 @@ pub async fn consume_reset( let key = format!("reset:{token}"); redis.get_del(key).await } + +// ── Employee (internal admin) password-reset tokens ────────────────────────── +// Distinct key namespace from the user-facing reset tokens above, so a code +// generated for one identity store can never be consumed against the other. + +pub async fn store_employee_reset( + redis: &mut RedisPool, + token: &str, + employee_id: &str, +) -> Result<(), redis::RedisError> { + let key = format!("reset:employee:{token}"); + redis.set_ex(key, employee_id, RESET_TTL).await +} + +/// Atomically fetch and delete the employee reset token (single-use). +pub async fn consume_employee_reset( + redis: &mut RedisPool, + token: &str, +) -> Result, redis::RedisError> { + let key = format!("reset:employee:{token}"); + redis.get_del(key).await +}