feat(employees): add forgot-password/reset-password for admin accounts
All checks were successful
build-and-release / build (cron) (push) Successful in 4m55s
build-and-release / build (catering-services) (push) Successful in 9m18s
build-and-release / build (developers) (push) Successful in 9m14s
build-and-release / build (employees) (push) Successful in 9m15s
build-and-release / build (customers) (push) Successful in 9m35s
build-and-release / build (companies) (push) Successful in 9m49s
build-and-release / build (gateway) (push) Successful in 3m2s
build-and-release / build (fitness-trainers) (push) Successful in 8m20s
build-and-release / build (jobs) (push) Successful in 4m22s
build-and-release / build (graphic-designers) (push) Successful in 8m41s
build-and-release / build (job-seekers) (push) Successful in 9m14s
build-and-release / build (leads) (push) Successful in 9m10s
build-and-release / build (makeup-artists) (push) Successful in 8m51s
build-and-release / build (payments) (push) Successful in 8m51s
build-and-release / build (photographers) (push) Successful in 8m39s
build-and-release / build (ugc-content-creators) (push) Successful in 6m57s
build-and-release / build (social-media-managers) (push) Successful in 8m25s
build-and-release / build (tutors) (push) Successful in 8m28s
build-and-release / build (video-editors) (push) Successful in 8m43s
build-and-release / build (users) (push) Successful in 11m8s
All checks were successful
build-and-release / build (cron) (push) Successful in 4m55s
build-and-release / build (catering-services) (push) Successful in 9m18s
build-and-release / build (developers) (push) Successful in 9m14s
build-and-release / build (employees) (push) Successful in 9m15s
build-and-release / build (customers) (push) Successful in 9m35s
build-and-release / build (companies) (push) Successful in 9m49s
build-and-release / build (gateway) (push) Successful in 3m2s
build-and-release / build (fitness-trainers) (push) Successful in 8m20s
build-and-release / build (jobs) (push) Successful in 4m22s
build-and-release / build (graphic-designers) (push) Successful in 8m41s
build-and-release / build (job-seekers) (push) Successful in 9m14s
build-and-release / build (leads) (push) Successful in 9m10s
build-and-release / build (makeup-artists) (push) Successful in 8m51s
build-and-release / build (payments) (push) Successful in 8m51s
build-and-release / build (photographers) (push) Successful in 8m39s
build-and-release / build (ugc-content-creators) (push) Successful in 6m57s
build-and-release / build (social-media-managers) (push) Successful in 8m25s
build-and-release / build (tutors) (push) Successful in 8m28s
build-and-release / build (video-editors) (push) Successful in 8m43s
build-and-release / build (users) (push) Successful in 11m8s
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 <noreply@anthropic.com>
This commit is contained in:
parent
308daf4959
commit
01f3810c8f
2 changed files with 99 additions and 1 deletions
|
|
@ -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<AppState> {
|
|||
.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<AppState>,
|
||||
Json(payload): Json<ForgotPasswordPayload>,
|
||||
) -> Result<impl IntoResponse, (StatusCode, Json<ErrorResponse>)> {
|
||||
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::<u32>() % 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<AppState>,
|
||||
Json(payload): Json<ResetPasswordPayload>,
|
||||
) -> Result<impl IntoResponse, (StatusCode, Json<ErrorResponse>)> {
|
||||
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::<uuid::Uuid>()
|
||||
.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" }))))
|
||||
}
|
||||
|
|
|
|||
22
crates/cache/src/token.rs
vendored
22
crates/cache/src/token.rs
vendored
|
|
@ -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<Option<String>, redis::RedisError> {
|
||||
let key = format!("reset:employee:{token}");
|
||||
redis.get_del(key).await
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue