630 lines
22 KiB
Rust
630 lines
22 KiB
Rust
use auth::{
|
|
crypto::{hash_password, verify_password},
|
|
jwt::generate_tokens,
|
|
};
|
|
use axum::{
|
|
extract::State,
|
|
http::{header::SET_COOKIE, StatusCode},
|
|
response::IntoResponse,
|
|
routing::{get, post},
|
|
Json, Router,
|
|
};
|
|
use db::models::user::{CreateUserPayload, UserRepository};
|
|
use serde::{Deserialize, Serialize};
|
|
use contracts::auth_middleware::AuthUser;
|
|
use crate::AppState;
|
|
|
|
pub fn router() -> Router<AppState> {
|
|
Router::new()
|
|
.route("/check-email", post(check_email))
|
|
.route("/register", post(register))
|
|
.route("/login", post(login))
|
|
.route("/logout", post(logout))
|
|
.route("/refresh", post(refresh))
|
|
.route("/session", get(session))
|
|
.route("/switch-role", post(switch_role))
|
|
.route("/verify-email", post(verify_email))
|
|
.route("/resend-otp", post(resend_otp))
|
|
.route("/forgot-password", post(forgot_password))
|
|
.route("/reset-password", post(reset_password))
|
|
.route("/change-password", post(change_password))
|
|
}
|
|
|
|
// ── DTOs ──────────────────────────────────────────────────────────────────────
|
|
|
|
#[derive(Deserialize)]
|
|
pub struct RegisterPayload {
|
|
pub full_name: String,
|
|
pub email: String,
|
|
pub phone: Option<String>,
|
|
pub password: String,
|
|
pub intent: Option<String>,
|
|
pub profession: Option<String>,
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
pub struct LoginPayload {
|
|
pub email: String,
|
|
pub password: String,
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
pub struct CheckEmailPayload {
|
|
pub email: String,
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
pub struct VerifyEmailPayload {
|
|
pub otp: String,
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
pub struct ResendOtpPayload {
|
|
pub email: String,
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
pub struct ForgotPasswordPayload {
|
|
pub email: String,
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
pub struct ResetPasswordPayload {
|
|
pub token: String,
|
|
pub new_password: String,
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
pub struct ChangePasswordPayload {
|
|
pub current_password: String,
|
|
pub new_password: String,
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
pub struct SwitchRolePayload {
|
|
pub role_key: String,
|
|
}
|
|
|
|
#[derive(Serialize)]
|
|
pub struct RegisterResponse {
|
|
pub user_id: String,
|
|
pub email: String,
|
|
pub phone: Option<String>,
|
|
pub full_name: String,
|
|
pub status: String,
|
|
pub email_verified: bool,
|
|
pub created_at: String,
|
|
}
|
|
|
|
#[derive(Serialize)]
|
|
pub struct SessionUser {
|
|
pub id: String,
|
|
pub email: String,
|
|
pub full_name: String,
|
|
pub email_verified: bool,
|
|
pub roles: Vec<String>,
|
|
pub active_role: Option<String>,
|
|
}
|
|
|
|
#[derive(Serialize)]
|
|
pub struct ErrorResponse {
|
|
pub error: String,
|
|
pub code: String,
|
|
#[serde(rename = "statusCode")]
|
|
pub status_code: u16,
|
|
}
|
|
|
|
fn err(status: StatusCode, msg: &str, code: &str) -> (StatusCode, Json<ErrorResponse>) {
|
|
(status, Json(ErrorResponse {
|
|
error: msg.to_string(),
|
|
code: code.to_string(),
|
|
status_code: status.as_u16(),
|
|
}))
|
|
}
|
|
|
|
fn normalize_role_key(raw: &str) -> String {
|
|
raw.trim().to_uppercase().replace(['-', ' '], "_")
|
|
}
|
|
|
|
fn resolve_signup_role_candidates(intent: Option<&str>, profession: Option<&str>) -> Vec<String> {
|
|
let normalized_intent = normalize_role_key(intent.unwrap_or("JOB_SEEKER"));
|
|
let normalized_profession = profession.map(normalize_role_key).filter(|v| !v.is_empty());
|
|
|
|
if normalized_intent.contains("COMPANY") {
|
|
return vec!["COMPANY".to_string()];
|
|
}
|
|
if normalized_intent.contains("CUSTOMER") {
|
|
return vec!["CUSTOMER".to_string()];
|
|
}
|
|
if normalized_intent.contains("JOB_SEEKER") || normalized_intent.contains("JOBSEEKER") {
|
|
return vec!["JOB_SEEKER".to_string()];
|
|
}
|
|
if normalized_intent.contains("PROFESSIONAL") {
|
|
if let Some(p) = normalized_profession {
|
|
return vec![p, "PHOTOGRAPHER".to_string(), "JOB_SEEKER".to_string()];
|
|
}
|
|
return vec!["PHOTOGRAPHER".to_string(), "JOB_SEEKER".to_string()];
|
|
}
|
|
|
|
vec!["JOB_SEEKER".to_string()]
|
|
}
|
|
|
|
// ── Handlers ──────────────────────────────────────────────────────────────────
|
|
|
|
/// POST /api/auth/check-email
|
|
async fn check_email(
|
|
State(state): State<AppState>,
|
|
Json(payload): Json<CheckEmailPayload>,
|
|
) -> impl IntoResponse {
|
|
let email = payload.email.trim().to_lowercase();
|
|
if email.is_empty() {
|
|
return (
|
|
StatusCode::BAD_REQUEST,
|
|
Json(serde_json::json!({
|
|
"exists": false,
|
|
"error": "Email is required"
|
|
})),
|
|
);
|
|
}
|
|
|
|
let exists = UserRepository::get_by_email(&state.pool, &email).await.is_ok();
|
|
(
|
|
StatusCode::OK,
|
|
Json(serde_json::json!({
|
|
"exists": exists
|
|
})),
|
|
)
|
|
}
|
|
|
|
/// POST /api/auth/register
|
|
async fn register(
|
|
State(state): State<AppState>,
|
|
Json(payload): Json<RegisterPayload>,
|
|
) -> Result<impl IntoResponse, (StatusCode, Json<ErrorResponse>)> {
|
|
let email = payload.email.to_lowercase();
|
|
let mut redis = state.redis.clone();
|
|
|
|
// Rate limit: max 10 registrations per hour per email
|
|
if !cache::rate_limit::check_register(&mut redis, &email).await.unwrap_or(true) {
|
|
return Err(err(StatusCode::TOO_MANY_REQUESTS, "Too many registration attempts. Try again later.", "RATE_LIMITED"));
|
|
}
|
|
|
|
if payload.password.len() < 8 {
|
|
return Err(err(StatusCode::UNPROCESSABLE_ENTITY, "Password must be at least 8 characters", "VALIDATION_ERROR"));
|
|
}
|
|
|
|
let password_hash = hash_password(&payload.password)
|
|
.map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string(), "INTERNAL_ERROR"))?;
|
|
|
|
let user = UserRepository::create(&state.pool, CreateUserPayload {
|
|
full_name: payload.full_name,
|
|
email: email.clone(),
|
|
phone: payload.phone.filter(|p| !p.trim().is_empty()),
|
|
password_hash,
|
|
})
|
|
.await
|
|
.map_err(|e| {
|
|
let msg = e.to_string();
|
|
if msg.contains("users_email_key") || (msg.contains("email") && msg.contains("unique")) {
|
|
err(StatusCode::CONFLICT, "Email already registered", "EMAIL_EXISTS")
|
|
} else if msg.contains("users_phone_key") || (msg.contains("phone") && msg.contains("unique")) {
|
|
err(StatusCode::CONFLICT, "Phone already registered", "PHONE_EXISTS")
|
|
} else {
|
|
err(StatusCode::INTERNAL_SERVER_ERROR, &msg, "DB_ERROR")
|
|
}
|
|
})?;
|
|
|
|
// Assign signup role immediately (intent-driven). Email verification is still required for login.
|
|
let role_candidates = resolve_signup_role_candidates(
|
|
payload.intent.as_deref(),
|
|
payload.profession.as_deref(),
|
|
);
|
|
for role_key in role_candidates {
|
|
let role = sqlx::query!(
|
|
"SELECT id FROM roles WHERE key = $1",
|
|
role_key
|
|
)
|
|
.fetch_optional(&state.pool)
|
|
.await
|
|
.ok()
|
|
.flatten();
|
|
|
|
if let Some(role_row) = role {
|
|
let _ = sqlx::query!(
|
|
r#"
|
|
INSERT INTO user_roles (user_id, role_id, status, approved_at)
|
|
VALUES ($1, $2, 'APPROVED', NOW())
|
|
ON CONFLICT (user_id, role_id)
|
|
DO UPDATE SET status = 'APPROVED', approved_at = NOW()
|
|
"#,
|
|
user.id,
|
|
role_row.id
|
|
)
|
|
.execute(&state.pool)
|
|
.await;
|
|
break;
|
|
}
|
|
}
|
|
|
|
// Store OTP in Redis (15-min TTL, keyed by code → user_id)
|
|
let otp = format!("{:06}", rand::random::<u32>() % 1_000_000);
|
|
cache::otp::set(&mut redis, &otp, &user.id.to_string())
|
|
.await
|
|
.map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string(), "CACHE_ERROR"))?;
|
|
cache::otp::record_resend(&mut redis, &user.id.to_string()).await.ok();
|
|
|
|
let _ = state.mail.send_verification_email(&user.email, &user.full_name.clone().unwrap_or_default(), &otp).await;
|
|
|
|
Ok((StatusCode::CREATED, Json(RegisterResponse {
|
|
user_id: user.id.to_string(),
|
|
email: user.email,
|
|
phone: user.phone,
|
|
full_name: user.full_name.unwrap_or_default(),
|
|
status: user.status,
|
|
email_verified: user.email_verified,
|
|
created_at: user.created_at.to_rfc3339(),
|
|
})))
|
|
}
|
|
|
|
/// POST /api/auth/login
|
|
async fn login(
|
|
State(state): State<AppState>,
|
|
Json(payload): Json<LoginPayload>,
|
|
) -> Result<impl IntoResponse, (StatusCode, Json<ErrorResponse>)> {
|
|
let email = payload.email.to_lowercase();
|
|
let mut redis = state.redis.clone();
|
|
|
|
// Rate limit: max 10 login attempts per 15 min per email
|
|
if !cache::rate_limit::check_login(&mut redis, &email).await.unwrap_or(true) {
|
|
return Err(err(StatusCode::TOO_MANY_REQUESTS, "Too many login attempts. Try again in 15 minutes.", "RATE_LIMITED"));
|
|
}
|
|
|
|
let user = UserRepository::get_by_email(&state.pool, &email)
|
|
.await
|
|
.map_err(|_| err(StatusCode::UNAUTHORIZED, "Invalid credentials", "INVALID_CREDENTIALS"))?;
|
|
|
|
if user.status == "SUSPENDED" {
|
|
return Err(err(StatusCode::FORBIDDEN, "Account suspended", "ACCOUNT_SUSPENDED"));
|
|
}
|
|
if !user.email_verified {
|
|
return Err(err(StatusCode::UNAUTHORIZED, "Email not verified. Check your inbox.", "EMAIL_NOT_VERIFIED"));
|
|
}
|
|
|
|
let is_valid = verify_password(&payload.password, &user.password_hash)
|
|
.map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string(), "INTERNAL_ERROR"))?;
|
|
if !is_valid {
|
|
return Err(err(StatusCode::UNAUTHORIZED, "Invalid credentials", "INVALID_CREDENTIALS"));
|
|
}
|
|
|
|
let user_roles = UserRepository::get_user_role_keys(&state.pool, user.id)
|
|
.await
|
|
.unwrap_or_default();
|
|
|
|
let jwt_secret = std::env::var("JWT_SECRET").expect("JWT_SECRET must be set");
|
|
let tokens = generate_tokens(
|
|
user.id.to_string(),
|
|
user.email.clone(),
|
|
user_roles.clone(),
|
|
user_roles.first().cloned(),
|
|
&jwt_secret,
|
|
)
|
|
.map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string(), "TOKEN_ERROR"))?;
|
|
|
|
// Refresh token → Redis (30-day TTL)
|
|
cache::token::store_refresh(&mut redis, &tokens.refresh_token, &user.id.to_string())
|
|
.await
|
|
.map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string(), "CACHE_ERROR"))?;
|
|
|
|
let cookie = format!(
|
|
"nxtgauge_refresh_token={}; HttpOnly; Secure; SameSite=Strict; Path=/; Max-Age=2592000",
|
|
tokens.refresh_token
|
|
);
|
|
let active_role = user_roles.first().cloned();
|
|
|
|
Ok((StatusCode::OK, [(SET_COOKIE, cookie)], Json(serde_json::json!({
|
|
"access_token": tokens.access_token,
|
|
"token_type": "Bearer",
|
|
"expires_in": 900,
|
|
"user": {
|
|
"id": user.id.to_string(),
|
|
"email": user.email,
|
|
"full_name": user.full_name.unwrap_or_default(),
|
|
"email_verified": user.email_verified,
|
|
"active_role": active_role,
|
|
"roles": user_roles,
|
|
}
|
|
}))))
|
|
}
|
|
|
|
/// POST /api/auth/logout
|
|
async fn logout(
|
|
State(state): State<AppState>,
|
|
req: axum::http::Request<axum::body::Body>,
|
|
) -> impl IntoResponse {
|
|
let cookie_header = req
|
|
.headers()
|
|
.get(axum::http::header::COOKIE)
|
|
.and_then(|v| v.to_str().ok())
|
|
.unwrap_or("");
|
|
|
|
if let Some(token) = cookie_header
|
|
.split(';')
|
|
.map(str::trim)
|
|
.find_map(|p| p.strip_prefix("nxtgauge_refresh_token="))
|
|
{
|
|
let mut redis = state.redis.clone();
|
|
let _ = cache::token::revoke_refresh(&mut redis, token).await;
|
|
}
|
|
|
|
let clear = "nxtgauge_refresh_token=; HttpOnly; Secure; SameSite=Strict; Path=/; Max-Age=0";
|
|
(StatusCode::OK, [(SET_COOKIE, clear)], Json(serde_json::json!({ "message": "Logged out" })))
|
|
}
|
|
|
|
/// POST /api/auth/refresh
|
|
async fn refresh(
|
|
State(state): State<AppState>,
|
|
req: axum::http::Request<axum::body::Body>,
|
|
) -> Result<impl IntoResponse, (StatusCode, Json<ErrorResponse>)> {
|
|
let cookie_header = req
|
|
.headers()
|
|
.get(axum::http::header::COOKIE)
|
|
.and_then(|v| v.to_str().ok())
|
|
.unwrap_or("");
|
|
|
|
let token = cookie_header
|
|
.split(';')
|
|
.map(str::trim)
|
|
.find_map(|p| p.strip_prefix("nxtgauge_refresh_token="))
|
|
.ok_or_else(|| err(StatusCode::UNAUTHORIZED, "Refresh token missing", "REFRESH_TOKEN_INVALID"))?;
|
|
|
|
let mut redis = state.redis.clone();
|
|
|
|
let user_id_str = cache::token::get_refresh(&mut redis, token)
|
|
.await
|
|
.map_err(|_| err(StatusCode::UNAUTHORIZED, "Refresh token invalid", "REFRESH_TOKEN_INVALID"))?
|
|
.ok_or_else(|| err(StatusCode::UNAUTHORIZED, "Refresh token expired", "REFRESH_TOKEN_INVALID"))?;
|
|
|
|
let user_id = user_id_str
|
|
.parse::<uuid::Uuid>()
|
|
.map_err(|_| err(StatusCode::UNAUTHORIZED, "Refresh token corrupt", "REFRESH_TOKEN_INVALID"))?;
|
|
|
|
let user = UserRepository::get_by_id(&state.pool, user_id)
|
|
.await
|
|
.map_err(|_| err(StatusCode::UNAUTHORIZED, "User not found", "INVALID_CREDENTIALS"))?;
|
|
|
|
// Rotate: revoke old, issue new
|
|
let _ = cache::token::revoke_refresh(&mut redis, token).await;
|
|
|
|
let user_roles = UserRepository::get_user_role_keys(&state.pool, user.id)
|
|
.await
|
|
.unwrap_or_default();
|
|
|
|
let jwt_secret = std::env::var("JWT_SECRET").expect("JWT_SECRET must be set");
|
|
let tokens = generate_tokens(
|
|
user.id.to_string(),
|
|
user.email.clone(),
|
|
user_roles.clone(),
|
|
user_roles.first().cloned(),
|
|
&jwt_secret,
|
|
)
|
|
.map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string(), "TOKEN_ERROR"))?;
|
|
|
|
cache::token::store_refresh(&mut redis, &tokens.refresh_token, &user.id.to_string())
|
|
.await
|
|
.map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string(), "CACHE_ERROR"))?;
|
|
|
|
let new_cookie = format!(
|
|
"nxtgauge_refresh_token={}; HttpOnly; Secure; SameSite=Strict; Path=/; Max-Age=2592000",
|
|
tokens.refresh_token
|
|
);
|
|
|
|
Ok((StatusCode::OK, [(SET_COOKIE, new_cookie)], Json(serde_json::json!({
|
|
"access_token": tokens.access_token,
|
|
"expires_in": 900
|
|
}))))
|
|
}
|
|
|
|
/// GET /api/auth/session
|
|
async fn session(
|
|
auth: AuthUser,
|
|
State(state): State<AppState>,
|
|
) -> Result<impl IntoResponse, (StatusCode, Json<ErrorResponse>)> {
|
|
let user = UserRepository::get_by_id(&state.pool, auth.user_id)
|
|
.await
|
|
.map_err(|_| err(StatusCode::UNAUTHORIZED, "User not found", "USER_NOT_FOUND"))?;
|
|
|
|
let user_roles = UserRepository::get_user_role_keys(&state.pool, user.id)
|
|
.await
|
|
.unwrap_or_default();
|
|
|
|
Ok(Json(SessionUser {
|
|
id: user.id.to_string(),
|
|
email: user.email,
|
|
full_name: user.full_name.unwrap_or_default(),
|
|
email_verified: user.email_verified,
|
|
active_role: user_roles.first().cloned(),
|
|
roles: user_roles,
|
|
}))
|
|
}
|
|
|
|
/// POST /api/auth/verify-email { "otp": "123456" }
|
|
async fn verify_email(
|
|
State(state): State<AppState>,
|
|
Json(payload): Json<VerifyEmailPayload>,
|
|
) -> Result<impl IntoResponse, (StatusCode, Json<ErrorResponse>)> {
|
|
let mut redis = state.redis.clone();
|
|
|
|
// Atomically consume OTP from Redis (GETDEL — single use, auto-expiry)
|
|
let user_id_str = cache::otp::consume(&mut redis, &payload.otp)
|
|
.await
|
|
.map_err(|_| err(StatusCode::INTERNAL_SERVER_ERROR, "Cache error", "CACHE_ERROR"))?
|
|
.ok_or_else(|| err(StatusCode::UNAUTHORIZED, "Invalid or expired verification code", "INVALID_CODE"))?;
|
|
|
|
let user_id = user_id_str
|
|
.parse::<uuid::Uuid>()
|
|
.map_err(|_| err(StatusCode::UNAUTHORIZED, "Invalid verification code", "INVALID_CODE"))?;
|
|
|
|
UserRepository::set_email_verified(&state.pool, user_id)
|
|
.await
|
|
.map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string(), "DB_ERROR"))?;
|
|
|
|
Ok((StatusCode::OK, Json(serde_json::json!({ "message": "Email verified successfully" }))))
|
|
}
|
|
|
|
/// POST /api/auth/resend-otp
|
|
async fn resend_otp(
|
|
State(state): State<AppState>,
|
|
Json(payload): Json<ResendOtpPayload>,
|
|
) -> Result<impl IntoResponse, (StatusCode, Json<ErrorResponse>)> {
|
|
let silent_ok = (StatusCode::OK, Json(serde_json::json!({
|
|
"message": "If the email is registered, a new code has been sent"
|
|
})));
|
|
|
|
let user = match UserRepository::get_by_email(&state.pool, &payload.email.to_lowercase()).await {
|
|
Ok(u) => u,
|
|
Err(_) => return Ok(silent_ok),
|
|
};
|
|
if user.email_verified {
|
|
return Ok(silent_ok);
|
|
}
|
|
|
|
let mut redis = state.redis.clone();
|
|
|
|
// Rate limit: max 3 resends per hour per user
|
|
if !cache::otp::resend_allowed(&mut redis, &user.id.to_string()).await.unwrap_or(true) {
|
|
return Err(err(StatusCode::TOO_MANY_REQUESTS, "Too many OTP requests. Try again in an hour.", "RATE_LIMITED"));
|
|
}
|
|
|
|
let otp = format!("{:06}", rand::random::<u32>() % 1_000_000);
|
|
cache::otp::set(&mut redis, &otp, &user.id.to_string())
|
|
.await
|
|
.map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string(), "CACHE_ERROR"))?;
|
|
cache::otp::record_resend(&mut redis, &user.id.to_string()).await.ok();
|
|
|
|
let _ = state.mail.send_verification_email(&user.email, &user.full_name.unwrap_or_default(), &otp).await;
|
|
|
|
Ok(silent_ok)
|
|
}
|
|
|
|
/// POST /api/auth/forgot-password
|
|
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 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 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())
|
|
.await
|
|
.map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string(), "CACHE_ERROR"))?;
|
|
|
|
let _ = state.mail.send_password_reset_email(&user.email, &user.full_name.unwrap_or_default(), &token).await;
|
|
|
|
Ok(silent_ok)
|
|
}
|
|
|
|
/// POST /api/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();
|
|
|
|
// Consume reset token from Redis (single-use GETDEL)
|
|
let user_id_str = cache::token::consume_reset(&mut redis, &payload.token)
|
|
.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"))?;
|
|
|
|
let user_id = user_id_str
|
|
.parse::<uuid::Uuid>()
|
|
.map_err(|_| err(StatusCode::UNAUTHORIZED, "Invalid reset token", "INVALID_TOKEN"))?;
|
|
|
|
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"))?;
|
|
|
|
UserRepository::update_password(&state.pool, user_id, &password_hash)
|
|
.await
|
|
.map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string(), "DB_ERROR"))?;
|
|
|
|
if let Ok(user) = UserRepository::get_by_id(&state.pool, user_id).await {
|
|
let _ = state.mail.send_password_changed_email(&user.email, user.full_name.as_deref().unwrap_or_default()).await;
|
|
}
|
|
|
|
Ok((StatusCode::OK, Json(serde_json::json!({ "message": "Password reset successfully" }))))
|
|
}
|
|
|
|
/// POST /api/auth/change-password
|
|
async fn change_password(
|
|
auth: AuthUser,
|
|
State(state): State<AppState>,
|
|
Json(payload): Json<ChangePasswordPayload>,
|
|
) -> Result<impl IntoResponse, (StatusCode, Json<ErrorResponse>)> {
|
|
let user = UserRepository::get_by_id(&state.pool, auth.user_id)
|
|
.await
|
|
.map_err(|_| err(StatusCode::UNAUTHORIZED, "User not found", "USER_NOT_FOUND"))?;
|
|
|
|
if !verify_password(&payload.current_password, &user.password_hash)
|
|
.map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string(), "AUTH_ERROR"))?
|
|
{
|
|
return Err(err(StatusCode::UNAUTHORIZED, "Incorrect current password", "INVALID_PASSWORD"));
|
|
}
|
|
|
|
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"))?;
|
|
|
|
UserRepository::update_password(&state.pool, user.id, &password_hash)
|
|
.await
|
|
.map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string(), "DB_ERROR"))?;
|
|
|
|
let _ = state.mail.send_password_changed_email(&user.email, user.full_name.as_deref().unwrap_or_default()).await;
|
|
|
|
Ok((StatusCode::OK, Json(serde_json::json!({ "message": "Password changed successfully" }))))
|
|
}
|
|
|
|
/// POST /api/auth/switch-role
|
|
async fn switch_role(
|
|
auth: AuthUser,
|
|
State(state): State<AppState>,
|
|
Json(payload): Json<SwitchRolePayload>,
|
|
) -> Result<impl IntoResponse, (StatusCode, Json<ErrorResponse>)> {
|
|
let user_roles = UserRepository::get_user_role_keys(&state.pool, auth.user_id)
|
|
.await
|
|
.unwrap_or_default();
|
|
|
|
let requested = payload.role_key.to_uppercase();
|
|
if !user_roles.contains(&requested) {
|
|
return Err(err(StatusCode::FORBIDDEN, "You do not have this role", "ROLE_NOT_FOUND"));
|
|
}
|
|
|
|
let jwt_secret = std::env::var("JWT_SECRET").expect("JWT_SECRET must be set");
|
|
let tokens = generate_tokens(
|
|
auth.user_id.to_string(),
|
|
auth.email.clone(),
|
|
user_roles,
|
|
Some(requested),
|
|
&jwt_secret,
|
|
)
|
|
.map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string(), "TOKEN_ERROR"))?;
|
|
|
|
Ok((StatusCode::OK, Json(serde_json::json!({
|
|
"access_token": tokens.access_token,
|
|
"expires_in": 900
|
|
}))))
|
|
}
|