From c04eecdf8dbde30b83e517fedbc136c922518f1a Mon Sep 17 00:00:00 2001 From: blackghost Date: Thu, 5 Mar 2026 15:26:17 +0100 Subject: [PATCH] feature:authentication --- Cargo.lock | 25 ++- src/controllers/auth.rs | 356 +++++++++++++++++++++++++++++++++- src/models/user.rs | 11 ++ src/services/auth_services.rs | 15 ++ 4 files changed, 393 insertions(+), 14 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index b0a0033..e3b2291 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -599,9 +599,9 @@ dependencies = [ [[package]] name = "getrandom" -version = "0.4.1" +version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "139ef39800118c7683f2fd3c98c1b23c09ae076556b435f8e9064ae108aaeeec" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" dependencies = [ "cfg-if", "libc", @@ -1411,9 +1411,9 @@ dependencies = [ [[package]] name = "quote" -version = "1.0.44" +version = "1.0.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "21b2ebcf727b7760c461f091f9f0f539b77b8e87f2fd88131e7f1b433b3cece4" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" dependencies = [ "proc-macro2", ] @@ -1426,9 +1426,9 @@ checksum = "640c9bd8497b02465aeef5375144c26062e0dcd5939dfcbb0f5db76cb8c17c73" [[package]] name = "r-efi" -version = "5.3.0" +version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" [[package]] name = "rand" @@ -2031,7 +2031,6 @@ dependencies = [ "cfg-if", "libc", "psm", - "windows-sys 0.52.0", "windows-sys 0.59.0", ] @@ -2087,7 +2086,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "82a72c767771b47409d2345987fda8628641887d5466101319899796367354a0" dependencies = [ "fastrand", - "getrandom 0.4.1", + "getrandom 0.4.2", "once_cell", "rustix", "windows-sys 0.61.2", @@ -2200,9 +2199,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" -version = "1.49.0" +version = "1.50.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72a2903cd7736441aac9df9d7688bd0ce48edccaadf181c3b90be801e81d3d86" +checksum = "27ad5e34374e03cfffefc301becb44e9dc3c17584f414349ebe29ed26661822d" dependencies = [ "bytes", "libc", @@ -2217,9 +2216,9 @@ dependencies = [ [[package]] name = "tokio-macros" -version = "2.6.0" +version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" +checksum = "5c55a2eff8b69ce66c84f85e1da1c233edc36ceb85a2058d11b0d6a3c7e7569c" dependencies = [ "proc-macro2", "quote", @@ -2406,7 +2405,7 @@ version = "1.21.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b672338555252d43fd2240c714dc444b8c6fb0a5c5335e65a07bba7742735ddb" dependencies = [ - "getrandom 0.4.1", + "getrandom 0.4.2", "js-sys", "serde_core", "wasm-bindgen", diff --git a/src/controllers/auth.rs b/src/controllers/auth.rs index d9ef018..2f6580f 100644 --- a/src/controllers/auth.rs +++ b/src/controllers/auth.rs @@ -1 +1,355 @@ -pub use crate::controllers::auth_controllers::*; +use axum::{ + extract::State, + http::{header, StatusCode}, + response::IntoResponse, + Json, +}; +use serde_json::json; +use sqlx::PgPool; +use uuid::Uuid; + +use crate::models::{ + AuthResponse, LoginRequest, RefreshTokenRequest, RegisterRequest, UserResponse, + VerifyOtpRequest, ResendOtpRequest, +}; +use crate::services::{AuthService, OtpService}; +use crate::utils::{AuthError, JwtConfig}; + +pub struct AuthController; + +impl AuthController { + /// Register a new Admin user + /// POST /auth/admin/register + pub async fn register_admin( + State((pool, jwt_config)): State<(PgPool, JwtConfig)>, + Json(mut req): Json, + ) -> Result { + // Force role to admin + req.role = "admin".to_string(); + + let user = AuthService::register(&pool, req).await?; + + let (access_token, refresh_token) = crate::utils::generate_tokens( + user.id, + user.email.clone(), + user.role.clone(), + &jwt_config, + ) + .map_err(|_| AuthError::InternalServerError)?; + + let response = AuthResponse { + user, + access_token, + refresh_token, + token_type: "Bearer".to_string(), + expires_in: jwt_config.access_token_expiry, + }; + + Ok((StatusCode::CREATED, Json(response))) + } + + /// Login as Admin + /// POST /auth/admin/login + pub async fn login_admin( + State((pool, jwt_config)): State<(PgPool, JwtConfig)>, + Json(req): Json, + ) -> Result { + let user = AuthService::login(&pool, req).await?; + + // Verify user is admin + AuthService::validate_role(&user.role, &["admin"])?; + + let (access_token, refresh_token) = crate::utils::generate_tokens( + user.id, + user.email.clone(), + user.role.clone(), + &jwt_config, + ) + .map_err(|_| AuthError::InternalServerError)?; + + let response = AuthResponse { + user: UserResponse::from(user), + access_token, + refresh_token, + token_type: "Bearer".to_string(), + expires_in: jwt_config.access_token_expiry, + }; + + Ok((StatusCode::OK, Json(response))) + } + + /// Register a new Student user + /// POST /auth/student/register + pub async fn register_student( + State((pool, jwt_config)): State<(PgPool, JwtConfig)>, + Json(mut req): Json, + ) -> Result { + // Force role to student + req.role = "student".to_string(); + + let user = AuthService::register(&pool, req).await?; + + let (access_token, refresh_token) = crate::utils::generate_tokens( + user.id, + user.email.clone(), + user.role.clone(), + &jwt_config, + ) + .map_err(|_| AuthError::InternalServerError)?; + + let response = AuthResponse { + user, + access_token, + refresh_token, + token_type: "Bearer".to_string(), + expires_in: jwt_config.access_token_expiry, + }; + + Ok((StatusCode::CREATED, Json(response))) + } + + /// Login as Student + /// POST /auth/student/login + pub async fn login_student( + State((pool, jwt_config)): State<(PgPool, JwtConfig)>, + Json(req): Json, + ) -> Result { + let user = AuthService::login(&pool, req).await?; + + // Verify user is student + AuthService::validate_role(&user.role, &["student"])?; + + let (access_token, refresh_token) = crate::utils::generate_tokens( + user.id, + user.email.clone(), + user.role.clone(), + &jwt_config, + ) + .map_err(|_| AuthError::InternalServerError)?; + + let response = AuthResponse { + user: UserResponse::from(user), + access_token, + refresh_token, + token_type: "Bearer".to_string(), + expires_in: jwt_config.access_token_expiry, + }; + + Ok((StatusCode::OK, Json(response))) + } + + /// Register a new Mentor user + /// POST /auth/mentor/register + pub async fn register_mentor( + State((pool, jwt_config)): State<(PgPool, JwtConfig)>, + Json(mut req): Json, + ) -> Result { + // Force role to mentor + req.role = "mentor".to_string(); + + let user = AuthService::register(&pool, req).await?; + + let (access_token, refresh_token) = crate::utils::generate_tokens( + user.id, + user.email.clone(), + user.role.clone(), + &jwt_config, + ) + .map_err(|_| AuthError::InternalServerError)?; + + let response = AuthResponse { + user, + access_token, + refresh_token, + token_type: "Bearer".to_string(), + expires_in: jwt_config.access_token_expiry, + }; + + Ok((StatusCode::CREATED, Json(response))) + } + + /// Login as Mentor + /// POST /auth/mentor/login + pub async fn login_mentor( + State((pool, jwt_config)): State<(PgPool, JwtConfig)>, + Json(req): Json, + ) -> Result { + let user = AuthService::login(&pool, req).await?; + + // Verify user is mentor + AuthService::validate_role(&user.role, &["mentor"])?; + + let (access_token, refresh_token) = crate::utils::generate_tokens( + user.id, + user.email.clone(), + user.role.clone(), + &jwt_config, + ) + .map_err(|_| AuthError::InternalServerError)?; + + let response = AuthResponse { + user: UserResponse::from(user), + access_token, + refresh_token, + token_type: "Bearer".to_string(), + expires_in: jwt_config.access_token_expiry, + }; + + Ok((StatusCode::OK, Json(response))) + } + + /// Refresh access token for any authenticated user + /// POST /auth/refresh + /// POST /auth/refreshdddd + pub async fn refresh_token( + State((pool, jwt_config)): State<(PgPool, JwtConfig)>, + Json(req): Json, + ) -> Result { + // Verify refresh token + let claims = crate::utils::verify_token(&req.refresh_token, &jwt_config) + .map_err(|_| AuthError::InvalidToken)?; + + // Ensure it's a refresh token + if claims.token_type != "refresh" { + return Err(AuthError::InvalidToken); + } + + let user_id = Uuid::parse_str(&claims.sub) + .map_err(|_| AuthError::InvalidToken)?; + + let (access_token, expires_in) = + AuthService::refresh_access_token(&pool, user_id, &jwt_config).await?; + + let response = json!({ + "access_token": access_token, + "token_type": "Bearer", + "expires_in": expires_in, + }); + + Ok((StatusCode::OK, Json(response))) + } + + /// Logout user (client-side token invalidation) + /// POST /auth/logout + pub async fn logout() -> impl IntoResponse { + let response = json!({ + "message": "Logged out successfully. Please discard the tokens on client side." + }); + + (StatusCode::OK, Json(response)) + } + + /// Get current user profile (requires valid access token) + /// GET /auth/me + pub async fn get_current_user( + State((pool, jwt_config)): State<(PgPool, JwtConfig)>, + headers: axum::http::HeaderMap, + ) -> Result { + // Extract token from Authorization header + let auth_header = headers + .get(header::AUTHORIZATION) + .and_then(|h| h.to_str().ok()) + .ok_or(AuthError::Unauthorized)?; + + let token = crate::utils::extract_token_from_header(auth_header) + .ok_or(AuthError::InvalidToken)?; + + // Verify token + let claims = crate::utils::verify_token(&token, &jwt_config) + .map_err(|_| AuthError::InvalidToken)?; + + // Ensure it's an access token + if claims.token_type != "access" { + return Err(AuthError::InvalidToken); + } + + let user_id = Uuid::parse_str(&claims.sub) + .map_err(|_| AuthError::InvalidToken)?; + + let user = AuthService::get_user_by_id(&pool, user_id).await?; + + Ok((StatusCode::OK, Json(UserResponse::from(user)))) + } + + /// Verify token validity + /// POST /auth/verify + pub async fn verify_token_endpoint( + State((_pool, jwt_config)): State<(PgPool, JwtConfig)>, + headers: axum::http::HeaderMap, + ) -> Result { + let auth_header = headers + .get(header::AUTHORIZATION) + .and_then(|h| h.to_str().ok()) + .ok_or(AuthError::Unauthorized)?; + + let token = crate::utils::extract_token_from_header(auth_header) + .ok_or(AuthError::InvalidToken)?; + + let claims = crate::utils::verify_token(&token, &jwt_config) + .map_err(|_| AuthError::InvalidToken)?; + + let response = json!({ + "valid": true, + "user_id": claims.sub, + "email": claims.email, + "role": claims.role, + "token_type": claims.token_type, + }); + + Ok((StatusCode::OK, Json(response))) + } + + /// Verify OTP and login + /// POST /auth/verify-otp + pub async fn verify_otp_login( + State((pool, jwt_config)): State<(PgPool, JwtConfig)>, + Json(req): Json, + ) -> Result { + // Get user by email + let user = AuthService::get_user_by_email(&pool, &req.email).await?; + + // Verify OTP + OtpService::verify_otp(&pool, user.id, &req.otp_code, "login").await?; + + // Generate tokens + let (access_token, refresh_token) = crate::utils::generate_tokens( + user.id, + user.email.clone(), + user.role.clone(), + &jwt_config, + ) + .map_err(|_| AuthError::InternalServerError)?; + + let response = AuthResponse { + user: UserResponse::from(user), + access_token, + refresh_token, + token_type: "Bearer".to_string(), + expires_in: jwt_config.access_token_expiry, + }; + + Ok((StatusCode::OK, Json(response))) + } + + /// Resend OTP to user email + /// POST /auth/resend-otp + pub async fn resend_otp( + State((pool, _jwt_config)): State<(PgPool, JwtConfig)>, + Json(req): Json, + ) -> Result { + // Get user by email + let user = AuthService::get_user_by_email(&pool, &req.email).await?; + + // Resend OTP + let otp_code = OtpService::resend_otp(&pool, user.id, "login").await?; + + // TODO: Send OTP via email using EmailService + // For now, just return the OTP (remove in production) + let response = json!({ + "message": "OTP resent successfully", + "otp": otp_code, // Remove this in production + }); + + Ok((StatusCode::OK, Json(response))) + } +} diff --git a/src/models/user.rs b/src/models/user.rs index 08c11e2..1b407c9 100644 --- a/src/models/user.rs +++ b/src/models/user.rs @@ -122,3 +122,14 @@ pub struct Claims { pub iat: i64, // issued at pub token_type: String, // "access" or "refresh" } + +#[derive(Debug, Deserialize)] +pub struct VerifyOtpRequest { + pub email: String, + pub otp_code: String, +} + +#[derive(Debug, Deserialize)] +pub struct ResendOtpRequest { + pub email: String, +} diff --git a/src/services/auth_services.rs b/src/services/auth_services.rs index 2d7b175..a44c0fa 100644 --- a/src/services/auth_services.rs +++ b/src/services/auth_services.rs @@ -104,6 +104,21 @@ impl AuthService { .ok_or(AuthError::UserNotFound) } + /// Get user by email + pub async fn get_user_by_email( + pool: &PgPool, + email: &str, + ) -> Result { + sqlx::query_as::<_, User>( + "SELECT id, email, password_hash, first_name, last_name, role, is_active, created_at, updated_at FROM users WHERE email = $1" + ) + .bind(email) + .fetch_optional(pool) + .await + .map_err(|e| AuthError::DatabaseError(e.to_string()))? + .ok_or(AuthError::UserNotFound) + } + /// Verify refresh token and generate new access token pub async fn refresh_access_token( pool: &PgPool,