diff --git a/.env.example b/.env.example index 0bd625a..2a8474d 100644 --- a/.env.example +++ b/.env.example @@ -10,5 +10,17 @@ JWT_SECRET=your-super-secret-jwt-key-change-this-in-production JWT_ACCESS_TOKEN_EXPIRY=3600 JWT_REFRESH_TOKEN_EXPIRY=604800 +# SMTP Configuration (Gmail example) +SMTP_HOST=smtp.gmail.com +SMTP_PORT=587 +SMTP_USERNAME=your-email@gmail.com +SMTP_PASSWORD=your-app-specific-password +FROM_EMAIL=noreply@school.com +FROM_NAME=School API + +# Email URLs +RESET_PASSWORD_URL=https://school.com/reset-password +VERIFY_EMAIL_URL=https://school.com/verify-email + # Logging RUST_LOG=info diff --git a/Cargo.lock b/Cargo.lock index 3ba8f49..81941b3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -38,6 +38,15 @@ version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +[[package]] +name = "ar_archive_writer" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7eb93bbb63b9c227414f6eb3a0adfddca591a8ce1e9b60661bb08969b87e340b" +dependencies = [ + "object", +] + [[package]] name = "async-trait" version = "0.1.89" @@ -241,6 +250,16 @@ dependencies = [ "windows-link", ] +[[package]] +name = "chumsky" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8eebd66744a15ded14960ab4ccdbfb51ad3b81f51f3f04a80adac98c985396c9" +dependencies = [ + "hashbrown 0.14.5", + "stacker", +] + [[package]] name = "cipher" version = "0.4.4" @@ -266,6 +285,16 @@ version = "0.9.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "core-foundation-sys" version = "0.8.7" @@ -394,6 +423,22 @@ dependencies = [ "serde", ] +[[package]] +name = "email-encoding" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9298e6504d9b9e780ed3f7dfd43a61be8cd0e09eb07f7706a945b0072b6670b6" +dependencies = [ + "base64", + "memchr", +] + +[[package]] +name = "email_address" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e079f19b08ca6239f47f8ba8509c11cf3ea30095831f7fed61441475edd8c449" + [[package]] name = "equivalent" version = "1.0.2" @@ -477,6 +522,21 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + [[package]] name = "form_urlencoded" version = "1.2.2" @@ -605,6 +665,16 @@ dependencies = [ "wasip3", ] +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +dependencies = [ + "ahash", + "allocator-api2", +] + [[package]] name = "hashbrown" version = "0.15.5" @@ -670,6 +740,17 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "hostname" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "617aaa3557aef3810a6369d0a99fac8a080891b68bd9f9812a1eeda0c0730cbd" +dependencies = [ + "cfg-if", + "libc", + "windows-link", +] + [[package]] name = "http" version = "1.4.0" @@ -950,6 +1031,31 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" +[[package]] +name = "lettre" +version = "0.11.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e13e10e8818f8b2a60f52cb127041d388b89f3a96a62be9ceaffa22262fef7f" +dependencies = [ + "base64", + "chumsky", + "email-encoding", + "email_address", + "fastrand", + "futures-util", + "hostname", + "httpdate", + "idna", + "mime", + "native-tls", + "nom", + "percent-encoding", + "quoted_printable", + "socket2", + "tokio", + "url", +] + [[package]] name = "libc" version = "0.2.182" @@ -1070,6 +1176,32 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "native-tls" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2" +dependencies = [ + "libc", + "log", + "openssl", + "openssl-probe", + "openssl-sys", + "schannel", + "security-framework", + "security-framework-sys", + "tempfile", +] + +[[package]] +name = "nom" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" +dependencies = [ + "memchr", +] + [[package]] name = "nu-ansi-term" version = "0.50.3" @@ -1141,12 +1273,65 @@ dependencies = [ "libm", ] +[[package]] +name = "object" +version = "0.37.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe" +dependencies = [ + "memchr", +] + [[package]] name = "once_cell" version = "1.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" +[[package]] +name = "openssl" +version = "0.10.75" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08838db121398ad17ab8531ce9de97b244589089e290a384c900cb9ff7434328" +dependencies = [ + "bitflags", + "cfg-if", + "foreign-types", + "libc", + "once_cell", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "openssl-sys" +version = "0.9.111" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82cab2d520aa75e3c58898289429321eb788c3106963d0dc886ec7a5f4adc321" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + [[package]] name = "parking" version = "2.2.1" @@ -1347,6 +1532,12 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "quoted_printable" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "640c9bd8497b02465aeef5375144c26062e0dcd5939dfcbb0f5db76cb8c17c73" + [[package]] name = "r-efi" version = "5.3.0" @@ -1646,6 +1837,29 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "semver" version = "1.0.27" @@ -2041,6 +2255,20 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" +[[package]] +name = "stacker" +version = "0.1.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08d74a23609d509411d10e2176dc2a4346e3b4aea2e7b1869f19fdedbc71c013" +dependencies = [ + "cc", + "cfg-if", + "libc", + "psm", + "windows-sys 0.52.0", + "windows-sys 0.59.0", +] + [[package]] name = "stringprep" version = "0.1.5" @@ -2109,6 +2337,19 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "tempfile" +version = "3.26.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82a72c767771b47409d2345987fda8628641887d5466101319899796367354a0" +dependencies = [ + "fastrand", + "getrandom 0.4.1", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + [[package]] name = "thiserror" version = "1.0.69" @@ -2742,6 +2983,15 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + [[package]] name = "windows-sys" version = "0.60.2" diff --git a/migrations/002_create_otp_table.sql b/migrations/002_create_otp_table.sql new file mode 100644 index 0000000..0efe57f --- /dev/null +++ b/migrations/002_create_otp_table.sql @@ -0,0 +1,17 @@ +-- Create OTP records table +CREATE TABLE IF NOT EXISTS otp_records ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + otp_code VARCHAR(6) NOT NULL, + otp_type VARCHAR(50) NOT NULL, -- 'login', 'password_reset', 'email_verification' + is_used BOOLEAN NOT NULL DEFAULT FALSE, + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + expires_at TIMESTAMP WITH TIME ZONE NOT NULL, + CONSTRAINT valid_otp_type CHECK (otp_type IN ('login', 'password_reset', 'email_verification')) +); + +-- Create indexes for faster queries +CREATE INDEX idx_otp_user_id ON otp_records(user_id); +CREATE INDEX idx_otp_type ON otp_records(otp_type); +CREATE INDEX idx_otp_expires_at ON otp_records(expires_at); +CREATE INDEX idx_otp_user_type ON otp_records(user_id, otp_type); diff --git a/src/controllers/auth_controllers.rs b/src/controllers/auth_controllers.rs new file mode 100644 index 0000000..d484515 --- /dev/null +++ b/src/controllers/auth_controllers.rs @@ -0,0 +1,419 @@ +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, +}; +use crate::services::{AuthService, EmailService, OtpService}; +use crate::utils::{AuthError, JwtConfig}; + +#[derive(serde::Deserialize)] +pub struct OtpVerificationRequest { + pub email: String, + pub otp: String, +} + +#[derive(serde::Deserialize)] +pub struct ResendOtpRequest { + pub email: String, +} + +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.clone()).await?; + + // Send welcome email + let email_config = crate::services::EmailConfig::from_env()?; + let email_service = EmailService::new(email_config); + + let full_name = format!("{} {}", user.first_name, user.last_name); + if let Err(e) = email_service + .send_welcome_email(&user.email, &full_name, &user.role) + .await + { + tracing::warn!("Failed to send welcome email: {:?}", e); + } + + 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 with OTP + /// 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"])?; + + // Generate and send OTP + let otp = OtpService::create_otp(&pool, user.id, "login", 10).await?; + + let email_config = crate::services::EmailConfig::from_env()?; + let email_service = EmailService::new(email_config); + + let full_name = format!("{} {}", user.first_name, user.last_name); + email_service + .send_otp_email(&user.email, &otp, &full_name) + .await?; + + let response = json!({ + "message": "OTP sent to your email", + "user_id": user.id, + "email": user.email, + "requires_otp": true + }); + + Ok((StatusCode::OK, Json(response))) + } + + /// Verify OTP for login + /// POST /auth/verify-otp + pub async fn verify_otp_login( + State((pool, jwt_config)): State<(PgPool, JwtConfig)>, + Json(req): Json, + ) -> Result { + // Find user by email + let user = sqlx::query_as::<_, crate::models::User>( + "SELECT id, email, password_hash, first_name, last_name, role, is_active, created_at, updated_at FROM users WHERE email = $1" + ) + .bind(&req.email) + .fetch_optional(&pool) + .await + .map_err(|e| AuthError::DatabaseError(e.to_string()))? + .ok_or(AuthError::InvalidCredentials)?; + + // Verify OTP + OtpService::verify_otp(&pool, user.id, &req.otp, "login").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: 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 + /// POST /auth/resend-otp + pub async fn resend_otp( + State((pool, _jwt_config)): State<(PgPool, JwtConfig)>, + Json(req): Json, + ) -> Result { + let user = sqlx::query_as::<_, crate::models::User>( + "SELECT id, email, password_hash, first_name, last_name, role, is_active, created_at, updated_at FROM users WHERE email = $1" + ) + .bind(&req.email) + .fetch_optional(&pool) + .await + .map_err(|e| AuthError::DatabaseError(e.to_string()))? + .ok_or(AuthError::UserNotFound)?; + + let otp = OtpService::resend_otp(&pool, user.id, "login").await?; + + let email_config = crate::services::EmailConfig::from_env()?; + let email_service = EmailService::new(email_config); + + let full_name = format!("{} {}", user.first_name, user.last_name); + email_service + .send_otp_email(&user.email, &otp, &full_name) + .await?; + + let response = json!({ + "message": "OTP resent to your email", + "email": user.email + }); + + 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?; + + // Send welcome email + let email_config = crate::services::EmailConfig::from_env()?; + let email_service = EmailService::new(email_config); + + let full_name = format!("{} {}", user.first_name, user.last_name); + if let Err(e) = email_service + .send_welcome_email(&user.email, &full_name, &user.role) + .await + { + tracing::warn!("Failed to send welcome email: {:?}", e); + } + + 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?; + + // Send welcome email + let email_config = crate::services::EmailConfig::from_env()?; + let email_service = EmailService::new(email_config); + + let full_name = format!("{} {}", user.first_name, user.last_name); + if let Err(e) = email_service + .send_welcome_email(&user.email, &full_name, &user.role) + .await + { + tracing::warn!("Failed to send welcome email: {:?}", e); + } + + 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 + 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))) + } +} diff --git a/src/controllers/mod.rs b/src/controllers/mod.rs index 3ec5d32..fc81b7d 100644 --- a/src/controllers/mod.rs +++ b/src/controllers/mod.rs @@ -1,4 +1,5 @@ pub mod auth; +pub mod auth_controllers; pub mod admin; pub mod student; pub mod mentor; diff --git a/src/controllers/tests.rs b/src/controllers/tests.rs index 9aafe87..352fc1e 100644 --- a/src/controllers/tests.rs +++ b/src/controllers/tests.rs @@ -71,6 +71,7 @@ mod controller_tests { assert_eq!(admin.email, "admin@example.com"); assert_eq!(admin.user_id, user_id); + assert!(!admin.email.is_empty()); } #[test] @@ -893,6 +894,7 @@ mod controller_tests { assert!(school_admin.email.contains("admin")); assert_eq!(school_admin.user_id, user_id); + assert!(!school_admin.email.is_empty()); } #[test] diff --git a/src/routes/auth_routes.rs b/src/routes/auth_routes.rs index 8bd0dd7..e5b9098 100644 --- a/src/routes/auth_routes.rs +++ b/src/routes/auth_routes.rs @@ -25,6 +25,10 @@ pub fn auth_routes(pool: PgPool, jwt_config: JwtConfig) -> Router { .route("/mentor/register", post(register_mentor)) .route("/mentor/login", post(login_mentor)) + // OTP routes + .route("/verify-otp", post(AuthController::verify_otp_login)) + .route("/resend-otp", post(AuthController::resend_otp)) + // Common authentication routes .route("/refresh", post(refresh_token)) .route("/logout", post(logout)) diff --git a/src/services/email_service.rs b/src/services/email_service.rs new file mode 100644 index 0000000..a7da21e --- /dev/null +++ b/src/services/email_service.rs @@ -0,0 +1,347 @@ +use lettre::message::MultiPart; +use lettre::transport::smtp::authentication::Credentials; +use lettre::transport::smtp::SmtpTransport; +use lettre::{Message, Transport}; +use serde::{Deserialize, Serialize}; +use std::env; +use tracing::{error, info}; + +use crate::utils::AuthError; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct EmailConfig { + pub smtp_host: String, + pub smtp_port: u16, + pub smtp_username: String, + pub smtp_password: String, + pub from_email: String, + pub from_name: String, +} + +impl EmailConfig { + pub fn from_env() -> Result { + Ok(EmailConfig { + smtp_host: env::var("SMTP_HOST") + .unwrap_or_else(|_| "smtp.gmail.com".to_string()), + smtp_port: env::var("SMTP_PORT") + .unwrap_or_else(|_| "587".to_string()) + .parse() + .unwrap_or(587), + smtp_username: env::var("SMTP_USERNAME") + .map_err(|_| AuthError::InternalServerError)?, + smtp_password: env::var("SMTP_PASSWORD") + .map_err(|_| AuthError::InternalServerError)?, + from_email: env::var("FROM_EMAIL") + .map_err(|_| AuthError::InternalServerError)?, + from_name: env::var("FROM_NAME") + .unwrap_or_else(|_| "School API".to_string()), + }) + } +} + +#[derive(Debug, Clone)] +pub struct EmailService { + pub config: EmailConfig, +} + +impl EmailService { + pub fn new(config: EmailConfig) -> Self { + EmailService { config } + } + + /// Send email with HTML and plain text content + pub async fn send_email( + &self, + to_email: &str, + subject: &str, + html_body: &str, + text_body: &str, + ) -> Result<(), AuthError> { + let from = format!("{} <{}>", self.config.from_name, self.config.from_email) + .parse() + .map_err(|_| AuthError::InternalServerError)?; + + let to = to_email + .parse() + .map_err(|_| AuthError::InternalServerError)?; + + let message = Message::builder() + .from(from) + .to(to) + .subject(subject) + .multipart(MultiPart::alternative() + .singlepart(lettre::message::SinglePart::plain(text_body.to_string())) + .singlepart(lettre::message::SinglePart::html(html_body.to_string()))) + .map_err(|_| AuthError::InternalServerError)?; + + let creds = Credentials::new( + self.config.smtp_username.clone().into(), + self.config.smtp_password.clone().into(), + ); + + let mailer = SmtpTransport::relay(&self.config.smtp_host) + .map_err(|e| { + error!("SMTP relay error: {}", e); + AuthError::InternalServerError + })? + .port(self.config.smtp_port) + .credentials(creds) + .build(); + + mailer.send(&message).map_err(|e| { + error!("Failed to send email: {}", e); + AuthError::InternalServerError + })?; + + info!("Email sent successfully to {}", to_email); + Ok(()) + } + + /// Send OTP email + pub async fn send_otp_email( + &self, + to_email: &str, + otp: &str, + user_name: &str, + ) -> Result<(), AuthError> { + let subject = "Your OTP for School API"; + let html_body = EmailTemplate::otp_email(user_name, otp); + let text_body = format!( + "Hello {},\n\nYour OTP is: {}\n\nThis OTP will expire in 10 minutes.\n\nBest regards,\nSchool API Team", + user_name, otp + ); + + self.send_email(to_email, subject, &html_body, &text_body) + .await + } + + /// Send password reset email + pub async fn send_password_reset_email( + &self, + to_email: &str, + reset_token: &str, + user_name: &str, + ) -> Result<(), AuthError> { + let subject = "Password Reset Request"; + let reset_link = format!( + "{}?token={}", + env::var("RESET_PASSWORD_URL") + .unwrap_or_else(|_| "https://school.com/reset-password".to_string()), + reset_token + ); + let html_body = EmailTemplate::password_reset_email(user_name, &reset_link); + let text_body = format!( + "Hello {},\n\nClick the link below to reset your password:\n{}\n\nThis link will expire in 1 hour.\n\nBest regards,\nSchool API Team", + user_name, reset_link + ); + + self.send_email(to_email, subject, &html_body, &text_body) + .await + } + + /// Send welcome email + pub async fn send_welcome_email( + &self, + to_email: &str, + user_name: &str, + role: &str, + ) -> Result<(), AuthError> { + let subject = "Welcome to School API"; + let html_body = EmailTemplate::welcome_email(user_name, role); + let text_body = format!( + "Hello {},\n\nWelcome to School API! Your account has been created with role: {}.\n\nBest regards,\nSchool API Team", + user_name, role + ); + + self.send_email(to_email, subject, &html_body, &text_body) + .await + } + + /// Send account verification email + pub async fn send_verification_email( + &self, + to_email: &str, + verification_token: &str, + user_name: &str, + ) -> Result<(), AuthError> { + let subject = "Verify Your Email Address"; + let verify_link = format!( + "{}?token={}", + env::var("VERIFY_EMAIL_URL") + .unwrap_or_else(|_| "https://school.com/verify-email".to_string()), + verification_token + ); + let html_body = EmailTemplate::verification_email(user_name, &verify_link); + let text_body = format!( + "Hello {},\n\nClick the link below to verify your email:\n{}\n\nThis link will expire in 24 hours.\n\nBest regards,\nSchool API Team", + user_name, verify_link + ); + + self.send_email(to_email, subject, &html_body, &text_body) + .await + } +} + +/// Email template generator +pub struct EmailTemplate; + +impl EmailTemplate { + pub fn otp_email(user_name: &str, otp: &str) -> String { + format!( + r#" + + + + + + + +
+
+

School API

+
+
+

Hello {},

+

Your One-Time Password (OTP) for authentication is:

+
+
{}
+
+

Important: This OTP will expire in 10 minutes. Do not share this code with anyone.

+

If you did not request this OTP, please ignore this email.

+
+ +
+ + + "#, + user_name, otp + ) + } + + pub fn password_reset_email(user_name: &str, reset_link: &str) -> String { + format!( + r#" + + + + + + + +
+
+

Password Reset Request

+
+
+

Hello {},

+

We received a request to reset your password. Click the button below to proceed:

+ Reset Password +

Important: This link will expire in 1 hour.

+

If you did not request a password reset, please ignore this email and your password will remain unchanged.

+
+ +
+ + + "#, + user_name, reset_link + ) + } + + pub fn welcome_email(user_name: &str, role: &str) -> String { + format!( + r#" + + + + + + + +
+
+

Welcome to School API

+
+
+

Hello {},

+

Your account has been successfully created!

+

Your Role: {}

+

You can now log in to your account and start using School API.

+

If you have any questions, please contact our support team.

+
+ +
+ + + "#, + user_name, role + ) + } + + pub fn verification_email(user_name: &str, verify_link: &str) -> String { + format!( + r#" + + + + + + + +
+
+

Verify Your Email Address

+
+
+

Hello {},

+

Thank you for signing up! Please verify your email address by clicking the button below:

+ Verify Email +

Important: This link will expire in 24 hours.

+

If you did not create this account, please ignore this email.

+
+ +
+ + + "#, + user_name, verify_link + ) + } +} diff --git a/src/services/mod.rs b/src/services/mod.rs index 2702ed8..a9bb865 100644 --- a/src/services/mod.rs +++ b/src/services/mod.rs @@ -1,3 +1,10 @@ pub mod auth_services; +pub mod email_service; +pub mod otp_service; + +#[cfg(test)] +mod tests; pub use auth_services::*; +pub use email_service::*; +pub use otp_service::*; diff --git a/src/services/otp_service.rs b/src/services/otp_service.rs new file mode 100644 index 0000000..de8bc95 --- /dev/null +++ b/src/services/otp_service.rs @@ -0,0 +1,169 @@ +use chrono::{Duration, Utc}; +use rand::Rng; +use serde::{Deserialize, Serialize}; +use sqlx::PgPool; +use uuid::Uuid; + +use crate::utils::AuthError; + +#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)] +pub struct OtpRecord { + pub id: Uuid, + pub user_id: Uuid, + pub otp_code: String, + pub otp_type: String, // "login", "password_reset", "email_verification" + pub is_used: bool, + pub created_at: chrono::DateTime, + pub expires_at: chrono::DateTime, +} + +pub struct OtpService; + +impl OtpService { + /// Generate a random 6-digit OTP + pub fn generate_otp() -> String { + let mut rng = rand::thread_rng(); + let otp: u32 = rng.gen_range(100000..999999); + otp.to_string() + } + + /// Create and store OTP in database + pub async fn create_otp( + pool: &PgPool, + user_id: Uuid, + otp_type: &str, + expiry_minutes: i64, + ) -> Result { + let otp_code = Self::generate_otp(); + let now = Utc::now(); + let expires_at = now + Duration::minutes(expiry_minutes); + + sqlx::query( + "INSERT INTO otp_records (id, user_id, otp_code, otp_type, is_used, created_at, expires_at) + VALUES ($1, $2, $3, $4, $5, $6, $7)" + ) + .bind(Uuid::new_v4()) + .bind(user_id) + .bind(&otp_code) + .bind(otp_type) + .bind(false) + .bind(now) + .bind(expires_at) + .execute(pool) + .await + .map_err(|e| { + tracing::error!("Failed to create OTP: {}", e); + AuthError::DatabaseError(e.to_string()) + })?; + + Ok(otp_code) + } + + /// Verify OTP + pub async fn verify_otp( + pool: &PgPool, + user_id: Uuid, + otp_code: &str, + otp_type: &str, + ) -> Result { + let now = Utc::now(); + + let record = sqlx::query_as::<_, OtpRecord>( + "SELECT id, user_id, otp_code, otp_type, is_used, created_at, expires_at + FROM otp_records + WHERE user_id = $1 AND otp_code = $2 AND otp_type = $3 AND is_used = false + ORDER BY created_at DESC + LIMIT 1" + ) + .bind(user_id) + .bind(otp_code) + .bind(otp_type) + .fetch_optional(pool) + .await + .map_err(|e| { + tracing::error!("Failed to fetch OTP: {}", e); + AuthError::DatabaseError(e.to_string()) + })? + .ok_or(AuthError::InvalidToken)?; + + // Check if OTP is expired + if now > record.expires_at { + return Err(AuthError::InvalidToken); + } + + // Mark OTP as used + sqlx::query("UPDATE otp_records SET is_used = true WHERE id = $1") + .bind(record.id) + .execute(pool) + .await + .map_err(|e| { + tracing::error!("Failed to mark OTP as used: {}", e); + AuthError::DatabaseError(e.to_string()) + })?; + + Ok(true) + } + + /// Get latest OTP for user + pub async fn get_latest_otp( + pool: &PgPool, + user_id: Uuid, + otp_type: &str, + ) -> Result, AuthError> { + sqlx::query_as::<_, OtpRecord>( + "SELECT id, user_id, otp_code, otp_type, is_used, created_at, expires_at + FROM otp_records + WHERE user_id = $1 AND otp_type = $2 AND is_used = false + ORDER BY created_at DESC + LIMIT 1" + ) + .bind(user_id) + .bind(otp_type) + .fetch_optional(pool) + .await + .map_err(|e| { + tracing::error!("Failed to fetch OTP: {}", e); + AuthError::DatabaseError(e.to_string()) + }) + } + + /// Clean up expired OTPs + pub async fn cleanup_expired_otps(pool: &PgPool) -> Result { + let now = Utc::now(); + + let result = sqlx::query("DELETE FROM otp_records WHERE expires_at < $1") + .bind(now) + .execute(pool) + .await + .map_err(|e| { + tracing::error!("Failed to cleanup expired OTPs: {}", e); + AuthError::DatabaseError(e.to_string()) + })?; + + Ok(result.rows_affected()) + } + + /// Resend OTP (invalidate old one and create new) + pub async fn resend_otp( + pool: &PgPool, + user_id: Uuid, + otp_type: &str, + ) -> Result { + // Invalidate old OTPs + sqlx::query( + "UPDATE otp_records SET is_used = true + WHERE user_id = $1 AND otp_type = $2 AND is_used = false" + ) + .bind(user_id) + .bind(otp_type) + .execute(pool) + .await + .map_err(|e| { + tracing::error!("Failed to invalidate old OTPs: {}", e); + AuthError::DatabaseError(e.to_string()) + })?; + + // Create new OTP + Self::create_otp(pool, user_id, otp_type, 10).await + } +} diff --git a/src/services/tests.rs b/src/services/tests.rs new file mode 100644 index 0000000..d2136c4 --- /dev/null +++ b/src/services/tests.rs @@ -0,0 +1,264 @@ +#[cfg(test)] +mod email_template_tests { + use crate::services::{EmailTemplate, OtpService}; + + #[test] + fn test_otp_email_generation() { + let user_name = "Test User"; + let otp = "123456"; + let html = EmailTemplate::otp_email(user_name, otp); + + assert!(html.contains(user_name)); + assert!(html.contains(otp)); + assert!(html.contains("")); + } + + #[test] + fn test_otp_email_with_different_values() { + let user_name = "Test User"; + let otp = "654321"; + let html = EmailTemplate::otp_email(user_name, otp); + + assert!(html.contains(user_name)); + assert!(html.contains(otp)); + } + + #[test] + fn test_password_reset_email_generation() { + let user_name = "User"; + let reset_link = "https://example.com"; + let html = EmailTemplate::password_reset_email(user_name, reset_link); + + assert!(html.contains(user_name)); + assert!(html.contains(reset_link)); + assert!(html.contains("")); + } + + #[test] + fn test_password_reset_email_with_different_values() { + let user_name = "User"; + let reset_link = "https://example.com"; + let html = EmailTemplate::password_reset_email(user_name, reset_link); + + assert!(html.contains(user_name)); + assert!(html.contains(reset_link)); + } + + #[test] + fn test_password_reset_email_has_reset_button() { + let user_name = "User"; + let reset_link = "https://example.com"; + let html = EmailTemplate::password_reset_email(user_name, reset_link); + + assert!(html.contains("Reset Password")); + } + + #[test] + fn test_welcome_email_generation() { + let user_name = "User"; + let role = "student"; + let html = EmailTemplate::welcome_email(user_name, role); + + assert!(html.contains(user_name)); + assert!(html.contains(role)); + assert!(html.contains("")); + } + + #[test] + fn test_welcome_email_with_different_role() { + let user_name = "User"; + let role = "student"; + let html = EmailTemplate::welcome_email(user_name, role); + + assert!(html.contains(user_name)); + assert!(html.contains(role)); + } + + #[test] + fn test_welcome_email_with_multiple_roles() { + let roles = vec!["admin", "mentor", "student"]; + for role in roles { + let html = EmailTemplate::welcome_email("User", role); + assert!(html.contains(role)); + } + } + + #[test] + fn test_verification_email_generation() { + let user_name = "User"; + let verify_link = "https://example.com"; + let html = EmailTemplate::verification_email(user_name, verify_link); + + assert!(html.contains(user_name)); + assert!(html.contains(verify_link)); + assert!(html.contains("")); + } + + #[test] + fn test_verification_email_with_different_values() { + let user_name = "User"; + let verify_link = "https://example.com"; + let html = EmailTemplate::verification_email(user_name, verify_link); + + assert!(html.contains(user_name)); + assert!(html.contains(verify_link)); + } + + #[test] + fn test_email_templates_contain_footer() { + let templates = vec![ + EmailTemplate::otp_email("User", "123456"), + EmailTemplate::password_reset_email("User", "https://example.com"), + EmailTemplate::welcome_email("User", "admin"), + EmailTemplate::verification_email("User", "https://example.com"), + ]; + + for template in templates { + assert!(template.contains("footer")); + } + } + + #[test] + fn test_email_templates_contain_styles() { + let templates = vec![ + EmailTemplate::otp_email("User", "123456"), + EmailTemplate::password_reset_email("User", "https://example.com"), + EmailTemplate::welcome_email("User", "admin"), + EmailTemplate::verification_email("User", "https://example.com"), + ]; + + for template in templates { + assert!(template.contains("