nxtgauge-backend-rust/apps/users/src/handlers/auth.rs

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
}))))
}