832 lines
29 KiB
Rust
832 lines
29 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 uuid::Uuid;
|
|
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("/verify-otp", 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)]
|
|
#[allow(dead_code)]
|
|
pub struct RegisterPayload {
|
|
#[serde(default)]
|
|
pub first_name: Option<String>,
|
|
#[serde(default)]
|
|
pub last_name: Option<String>,
|
|
#[serde(default)]
|
|
pub name: Option<String>,
|
|
pub email: String,
|
|
pub phone: Option<String>,
|
|
pub password: String,
|
|
pub intent: Option<String>,
|
|
#[serde(alias = "role_key", alias = "roleKey")]
|
|
pub profession: Option<String>,
|
|
#[serde(default)]
|
|
pub test_mode: Option<bool>,
|
|
}
|
|
|
|
#[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 code: 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 name: String,
|
|
pub status: String,
|
|
pub email_verified: bool,
|
|
pub created_at: String,
|
|
pub otp: Option<String>,
|
|
}
|
|
|
|
#[derive(Serialize)]
|
|
pub struct SessionUser {
|
|
pub id: String,
|
|
pub email: String,
|
|
pub 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 = intent.map(normalize_role_key).unwrap_or_default();
|
|
let normalized_profession = profession.map(normalize_role_key).filter(|v| !v.is_empty());
|
|
|
|
if normalized_intent.is_empty() {
|
|
return vec![];
|
|
}
|
|
|
|
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![]
|
|
}
|
|
|
|
fn is_dummy_account_email(email: &str) -> bool {
|
|
email.ends_with("@demo.com")
|
|
|| email == "paymentgateway@demo.com"
|
|
|| email.contains("+dummy@")
|
|
|| email.starts_with("dummy+")
|
|
}
|
|
|
|
fn role_display_name_from_code(code: &str) -> String {
|
|
code
|
|
.split('_')
|
|
.filter(|part| !part.is_empty())
|
|
.map(|part| {
|
|
let lower = part.to_lowercase();
|
|
let mut chars = lower.chars();
|
|
match chars.next() {
|
|
Some(first) => format!("{}{}", first.to_uppercase(), chars.collect::<String>()),
|
|
None => String::new(),
|
|
}
|
|
})
|
|
.collect::<Vec<String>>()
|
|
.join(" ")
|
|
}
|
|
|
|
async fn ensure_role_exists(pool: &sqlx::PgPool, role_code: &str) -> Option<Uuid> {
|
|
let normalized = normalize_role_key(role_code);
|
|
if normalized.is_empty() {
|
|
return None;
|
|
}
|
|
|
|
if let Ok(found) = sqlx::query_scalar::<_, Uuid>("SELECT id FROM roles WHERE key = $1")
|
|
.bind(&normalized)
|
|
.fetch_optional(pool)
|
|
.await
|
|
{
|
|
if found.is_some() {
|
|
return found;
|
|
}
|
|
}
|
|
|
|
let display_name = role_display_name_from_code(&normalized);
|
|
let role_id = sqlx::query_scalar::<_, Uuid>(
|
|
r#"
|
|
INSERT INTO roles (key, name, audience, is_active)
|
|
VALUES ($1, $2, 'EXTERNAL', true)
|
|
ON CONFLICT (key)
|
|
DO UPDATE SET is_active = true
|
|
RETURNING id
|
|
"#,
|
|
)
|
|
.bind(&normalized)
|
|
.bind(display_name)
|
|
.fetch_one(pool)
|
|
.await
|
|
.ok()?;
|
|
|
|
Some(role_id)
|
|
}
|
|
|
|
// ── 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 user = UserRepository::get_by_email(&state.pool, &email).await.ok();
|
|
let exists = user.is_some();
|
|
let roles = if let Some(ref found_user) = user {
|
|
UserRepository::get_user_role_keys(&state.pool, found_user.id)
|
|
.await
|
|
.unwrap_or_default()
|
|
} else {
|
|
Vec::new()
|
|
};
|
|
let active_role = roles.first().cloned();
|
|
(
|
|
StatusCode::OK,
|
|
Json(serde_json::json!({
|
|
"exists": exists,
|
|
"active_role": active_role,
|
|
"roles": roles,
|
|
})),
|
|
)
|
|
}
|
|
|
|
/// 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 test_mode = payload.test_mode.unwrap_or(false);
|
|
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 first_name = payload.first_name.unwrap_or_default().trim().to_string();
|
|
let last_name = payload.last_name.unwrap_or_default().trim().to_string();
|
|
|
|
let user = UserRepository::create(&state.pool, CreateUserPayload {
|
|
first_name: Some(first_name),
|
|
last_name: Some(last_name),
|
|
email: email.clone(),
|
|
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")
|
|
}
|
|
})?;
|
|
|
|
// Check if this is a demo account (payment gateway integration)
|
|
let is_demo_account = is_dummy_account_email(&email);
|
|
|
|
// 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_id = ensure_role_exists(&state.pool, &role_key).await;
|
|
if let Some(role_id) = role_id {
|
|
// For demo accounts, auto-approve the role immediately
|
|
let status = if is_demo_account { "APPROVED" } else { "PENDING" };
|
|
let _ = sqlx::query(
|
|
r#"
|
|
UPDATE user_role_assignments
|
|
SET status = $3
|
|
WHERE user_id = $1 AND role_id = $2
|
|
"#,
|
|
)
|
|
.bind(user.id)
|
|
.bind(role_id)
|
|
.bind(status)
|
|
.execute(&state.pool)
|
|
.await;
|
|
|
|
let _ = sqlx::query(
|
|
r#"
|
|
INSERT INTO user_role_assignments (user_id, role_id, status)
|
|
SELECT $1, $2, $3
|
|
WHERE NOT EXISTS (
|
|
SELECT 1 FROM user_role_assignments WHERE user_id = $1 AND role_id = $2
|
|
)
|
|
"#,
|
|
)
|
|
.bind(user.id)
|
|
.bind(role_id)
|
|
.bind(status)
|
|
.execute(&state.pool)
|
|
.await;
|
|
break;
|
|
}
|
|
}
|
|
|
|
// For demo accounts: auto-verify email and skip OTP
|
|
if is_demo_account {
|
|
tracing::info!(email = %email, "Demo account auto-verified");
|
|
let _ = sqlx::query(
|
|
"UPDATE users SET email_verified = true, status = 'ACTIVE' WHERE id = $1"
|
|
)
|
|
.bind(user.id)
|
|
.execute(&state.pool)
|
|
.await;
|
|
|
|
// Return success with demo flag
|
|
let user_name = format!("{} {}", user.first_name.unwrap_or_default(), user.last_name.unwrap_or_default());
|
|
return Ok((StatusCode::CREATED, Json(RegisterResponse {
|
|
user_id: user.id.to_string(),
|
|
email: user.email,
|
|
phone: None,
|
|
name: user_name,
|
|
status: "ACTIVE".to_string(),
|
|
email_verified: true,
|
|
created_at: user.created_at.to_rfc3339(),
|
|
otp: Some("DEMO".to_string()), // Return dummy OTP for demo
|
|
})));
|
|
}
|
|
|
|
// Store OTP in Redis (15-min TTL, keyed by code → user_id)
|
|
let otp = format!("{:06}", rand::random::<u32>() % 1_000_000);
|
|
tracing::info!(otp = %otp, email = %email, "OTP generated for registration");
|
|
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 user_name = format!("{} {}", user.first_name.unwrap_or_default(), user.last_name.unwrap_or_default());
|
|
if let Err(e) = state.mail.send_verification_email(&user.email, &user_name, &otp).await {
|
|
tracing::error!(
|
|
error = %e,
|
|
email = %user.email,
|
|
endpoint = "/api/auth/register",
|
|
"Failed to send verification email - OTP still stored in Redis"
|
|
);
|
|
// OTP is already in Redis — do not fail registration if email sending fails
|
|
}
|
|
|
|
Ok((StatusCode::CREATED, Json(RegisterResponse {
|
|
user_id: user.id.to_string(),
|
|
email: user.email,
|
|
phone: None,
|
|
name: user_name,
|
|
status: user.status,
|
|
email_verified: user.email_verified,
|
|
created_at: user.created_at.to_rfc3339(),
|
|
otp: if test_mode { Some(otp) } else { None },
|
|
})))
|
|
}
|
|
|
|
/// 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"));
|
|
}
|
|
|
|
// Allow demo accounts to login without email verification
|
|
let is_demo_account = is_dummy_account_email(&email);
|
|
if !user.email_verified && !is_demo_account {
|
|
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();
|
|
|
|
let user_name = format!("{} {}", user.first_name.unwrap_or_default(), user.last_name.unwrap_or_default());
|
|
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,
|
|
"name": user_name,
|
|
"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();
|
|
|
|
let user_name = format!("{} {}", user.first_name.unwrap_or_default(), user.last_name.unwrap_or_default());
|
|
Ok(Json(SessionUser {
|
|
id: user.id.to_string(),
|
|
email: user.email,
|
|
name: user_name,
|
|
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"))?;
|
|
|
|
// Get user details for welcome email
|
|
if let Ok(user) = UserRepository::get_by_id(&state.pool, user_id).await {
|
|
let user_name = format!("{} {}", user.first_name.unwrap_or_default(), user.last_name.unwrap_or_default());
|
|
if let Err(e) = state.mail.send_welcome_email(&user.email, &user_name).await {
|
|
tracing::error!(
|
|
error = %e,
|
|
email = %user.email,
|
|
endpoint = "/api/auth/verify-email",
|
|
"Failed to send welcome email"
|
|
);
|
|
}
|
|
}
|
|
|
|
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);
|
|
tracing::info!(otp = %otp, email = %user.email, "OTP generated for resend");
|
|
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 user_name = format!("{} {}", user.first_name.unwrap_or_default(), user.last_name.unwrap_or_default());
|
|
if let Err(e) = state.mail.send_verification_email(&user.email, &user_name, &otp).await {
|
|
tracing::error!(
|
|
error = %e,
|
|
email = %user.email,
|
|
endpoint = "/api/auth/resend-otp",
|
|
"Failed to resend verification email"
|
|
);
|
|
return Err(err(
|
|
StatusCode::INTERNAL_SERVER_ERROR,
|
|
"Failed to resend verification email",
|
|
"SMTP_ERROR",
|
|
));
|
|
}
|
|
|
|
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 code 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 code = format!("{:06}", rand::random::<u32>() % 1_000_000);
|
|
tracing::info!(otp = %code, email = %user.email, "OTP generated for password reset");
|
|
let mut redis = state.redis.clone();
|
|
|
|
cache::token::store_reset(&mut redis, &code, &user.id.to_string())
|
|
.await
|
|
.map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string(), "CACHE_ERROR"))?;
|
|
|
|
let user_name = format!("{} {}", user.first_name.unwrap_or_default(), user.last_name.unwrap_or_default());
|
|
let _ = state.mail.send_password_reset_email(&user.email, &user_name, &code).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 code from Redis (single-use GETDEL)
|
|
let user_id_str = cache::token::consume_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 user_id = user_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"))?;
|
|
|
|
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 user_name = format!("{} {}", user.first_name.unwrap_or_default(), user.last_name.unwrap_or_default());
|
|
let _ = state.mail.send_password_changed_email(&user.email, &user_name).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 user_name = format!("{} {}", user.first_name.unwrap_or_default(), user.last_name.unwrap_or_default());
|
|
let _ = state.mail.send_password_changed_email(&user.email, &user_name).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
|
|
}))))
|
|
}
|
|
|
|
// ── V1 API Router (for backward compatibility) ─────────────────────────
|
|
|
|
pub fn v1_router() -> Router<AppState> {
|
|
Router::new()
|
|
.route("/sign-up", post(v1_sign_up))
|
|
.route("/verify-otp", post(v1_verify_otp))
|
|
.route("/resend-otp", post(resend_otp))
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
struct V1VerifyOtpPayload {
|
|
#[serde(alias = "code")]
|
|
otp: String,
|
|
}
|
|
|
|
/// POST /api/v1/users/sign-up
|
|
async fn v1_sign_up(
|
|
State(state): State<AppState>,
|
|
Json(payload): Json<RegisterPayload>,
|
|
) -> Result<impl IntoResponse, (StatusCode, Json<ErrorResponse>)> {
|
|
register(State(state), Json(payload)).await
|
|
}
|
|
|
|
/// POST /api/v1/users/verify-otp
|
|
async fn v1_verify_otp(
|
|
State(state): State<AppState>,
|
|
Json(payload): Json<V1VerifyOtpPayload>,
|
|
) -> Result<impl IntoResponse, (StatusCode, Json<ErrorResponse>)> {
|
|
verify_email(State(state), Json(VerifyEmailPayload { otp: payload.otp })).await
|
|
}
|