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 { 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, pub password: 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, 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, pub active_role: Option, } #[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) { (status, Json(ErrorResponse { error: msg.to_string(), code: code.to_string(), status_code: status.as_u16(), })) } // ── Handlers ────────────────────────────────────────────────────────────────── /// POST /api/auth/check-email async fn check_email( State(state): State, Json(payload): Json, ) -> 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, Json(payload): Json, ) -> Result)> { 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") } })?; // Store OTP in Redis (15-min TTL, keyed by code → user_id) let otp = format!("{:06}", rand::random::() % 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, Json(payload): Json, ) -> Result)> { 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, req: axum::http::Request, ) -> 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, req: axum::http::Request, ) -> Result)> { 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::() .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, ) -> Result)> { 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, Json(payload): Json, ) -> Result)> { 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::() .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, Json(payload): Json, ) -> Result)> { 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::() % 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, Json(payload): Json, ) -> Result)> { 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, Json(payload): Json, ) -> Result)> { 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::() .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, Json(payload): Json, ) -> Result)> { 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, Json(payload): Json, ) -> Result)> { 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 })))) }