diff --git a/.env b/.env new file mode 100644 index 0000000..85a810e --- /dev/null +++ b/.env @@ -0,0 +1,5 @@ +# .env +DATABASE_URL=mysql://lms_user:lms_password@db:3306/lms_db +SERVER_ADDR=0.0.0.0:8080 +# SECRET_KEY=your_very_secret_jwt_key_here # Needed if using JWT later +RUST_LOG=actix_web=info,lms_backend=info # Logging level \ No newline at end of file diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..bbf80b3 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,26 @@ +[package] +name = "lms-backend" +version = "0.1.0" +edition = "2024" + +[dependencies] +actix-web = "4" +actix-cors = "0.7" # For enabling Cross-Origin Resource Sharing +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +dotenv = "0.15" # For loading .env files +sqlx = { version = "0.7", features = ["runtime-actix-native-tls", "mysql", "chrono", "macros"] } +chrono = { version = "0.4", features = ["serde"] } +# srp = "0.6" # For PAKE SRP (NOTE: Need careful state management for handshake) +# We'll outline the SRP flow, but full implementation requires managing server state (b value) between requests. +# Using simple password hashing for now as a placeholder until full SRP state management is added. +argon2 = "0.5" +rand = "0.8" # Needed for salt generation with Argon2 +thiserror = "1.0" # For cleaner error handling +env_logger = "0.11" # For logging +futures = "0.3" # Often needed for async operations +uuid = { version = "1", features = ["v4", "serde"] } # For potential session tokens +hex = "0.4" # For handling hex representations (e.g., for SRP values if used) + +# PAKE SRP dependency (choose one, `srp` is common) +srp = "0.6" \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..17ec955 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,54 @@ +# Dockerfile + +# ---- Builder Stage ---- + FROM rust:1.78-slim AS builder + + # Install build dependencies (like openssl for native-tls feature of sqlx) + # Use Debian package names + RUN apt-get update && apt-get install -y --no-install-recommends \ + pkg-config \ + libssl-dev \ + && rm -rf /var/lib/apt/lists/* + + WORKDIR /app + + # Copy manifests first to leverage Docker cache for dependencies + COPY Cargo.toml Cargo.lock ./ + # Dummy src/main.rs to build dependencies only if needed + RUN mkdir src && echo "fn main(){}" > src/main.rs + # Build dependencies only (can take time) + # Use --release for optimized dependencies if desired, but target/debug might be faster for iteration + # RUN cargo build --release + RUN cargo build + + # Copy the actual source code + COPY src ./src + + # Build the final application binary (optimized for release) + # Ensure previous build steps are cleaned if needed + RUN touch src/main.rs && cargo build --release + + + # ---- Runtime Stage ---- + FROM debian:stable-slim AS runtime + + # Install runtime dependencies (like ca-certificates and openssl libs) + RUN apt-get update && apt-get install -y --no-install-recommends \ + ca-certificates \ + libssl3 \ + && rm -rf /var/lib/apt/lists/* + + WORKDIR /app + + # Copy the compiled binary from the builder stage + COPY --from=builder /app/target/release/lms-backend /usr/local/bin/lms-backend + + # Copy .env file - Alternatively, manage via Docker Compose environment vars + # COPY .env .env # If you want .env inside the image (less flexible) + + # Expose the port the application listens on (from .env or default) + EXPOSE 8080 + + # Set the entrypoint to run the application + # Use environment variables for configuration (DATABASE_URL, SERVER_ADDR) passed via Docker Compose + CMD ["lms-backend"] \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..54b2d7b --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,59 @@ +# docker-compose.yml +version: '3.8' + +services: + db: + image: mariadb:10.11 # Use a specific stable version + container_name: lms_mariadb + restart: unless-stopped + environment: + MARIADB_ROOT_PASSWORD: ${MARIADB_ROOT_PASSWORD:-supersecretroot} # Use default if not set in host .env + MARIADB_DATABASE: ${MARIADB_DATABASE:-lms_db} + MARIADB_USER: ${MARIADB_USER:-lms_user} + MARIADB_PASSWORD: ${MARIADB_PASSWORD:-lms_password} + volumes: + - mariadb_data:/var/lib/mysql # Persist data + ports: + # Only expose if you need direct access from host machine, otherwise backend connects via internal network + # - "3306:3306" + - "13306:3306" # Expose on 13306 to avoid host conflicts + networks: + - lms_network + healthcheck: + test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "${MARIADB_USER:-lms_user}", "-p${MARIADB_PASSWORD:-lms_password}"] + interval: 10s + timeout: 5s + retries: 5 + + backend: + container_name: lms_backend + build: + context: ./lms-backend # Path to the directory containing the Dockerfile + dockerfile: Dockerfile + restart: unless-stopped + depends_on: + db: + condition: service_healthy # Wait for DB to be healthy + environment: + # Pass necessary environment variables to the backend container + # Ensure these match the .env file used by the Rust app OR override them here + DATABASE_URL: mysql://${MARIADB_USER:-lms_user}:${MARIADB_PASSWORD:-lms_password}@db:3306/${MARIADB_DATABASE:-lms_db} + SERVER_ADDR: 0.0.0.0:8080 + RUST_LOG: ${RUST_LOG:-actix_web=info,lms_backend=info} + # SECRET_KEY: ${SECRET_KEY:-your_fallback_secret} # If using JWT + ports: + - "8080:8080" # Map container port 8080 to host port 8080 + networks: + - lms_network + # Optional: Mount local code for development (use with caution, requires rebuilds/restarts) + # volumes: + # - ./lms-backend/src:/app/src + # - ./lms-backend/Cargo.toml:/app/Cargo.toml + # - ./lms-backend/Cargo.lock:/app/Cargo.lock + +volumes: + mariadb_data: # Define the named volume for persistence + +networks: + lms_network: # Define the network for services communication + driver: bridge \ No newline at end of file diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..02897d0 --- /dev/null +++ b/src/config.rs @@ -0,0 +1,21 @@ +use std::env; + +#[derive(Clone)] // Clone needed for potential app_data sharing +pub struct Config { + pub database_url: String, + pub server_addr: String, + // pub secret_key: String, // Add if using JWT +} + +impl Config { + pub fn from_env() -> Result { + let database_url = env::var("DATABASE_URL")?; + let server_addr = env::var("SERVER_ADDR")?; + // let secret_key = env::var("SECRET_KEY")?; // Add if using JWT + Ok(Config { + database_url, + server_addr, + // secret_key, + }) + } +} \ No newline at end of file diff --git a/src/db.rs b/src/db.rs new file mode 100644 index 0000000..0188196 --- /dev/null +++ b/src/db.rs @@ -0,0 +1,217 @@ +use crate::errors::AppError; +use crate::models::{Course, Student, Teacher}; +use sqlx::{MySql, Pool}; + +// Function to create tables if they don't exist +pub async fn init_db(pool: &Pool) -> Result<(), AppError> { + log::info!("Creating tables if they do not exist..."); + + // Use UNSIGNED for IDs if they are always non-negative and you might need larger ranges + // Use TEXT or appropriate VARCHAR length for SRP salt/verifier (hex encoded can be long) + sqlx::query!( + r#" + CREATE TABLE IF NOT EXISTS students ( + user_id INT PRIMARY KEY, + first_name VARCHAR(100) NOT NULL, + last_name VARCHAR(100) NOT NULL, + birthday DATE NULL, + srp_salt TEXT NULL, + srp_verifier TEXT NULL, + school_id VARCHAR(50) NULL, -- Added based on frontend expectations + profile_picture VARCHAR(255) NULL -- Added based on frontend expectations + ) + "# + ) + .execute(pool) + .await?; + + sqlx::query!( + r#" + CREATE TABLE IF NOT EXISTS teachers ( + user_id INT PRIMARY KEY, + first_name VARCHAR(100) NOT NULL, + last_name VARCHAR(100) NOT NULL, + birthday DATE NULL, + srp_salt TEXT NULL, + srp_verifier TEXT NULL, + profile_picture VARCHAR(255) NULL -- Added based on frontend expectations + ) + "# + ) + .execute(pool) + .await?; + + sqlx::query!( + r#" + CREATE TABLE IF NOT EXISTS courses ( + course_code INT PRIMARY KEY, -- As per spec + course_name VARCHAR(255) NOT NULL + ) + "# + ) + .execute(pool) + .await?; + + sqlx::query!( + r#" + CREATE TABLE IF NOT EXISTS enrollments ( + student_id INT NOT NULL, + course_id INT NOT NULL, + enrollment_date DATE NULL, + PRIMARY KEY (student_id, course_id), + FOREIGN KEY (student_id) REFERENCES students(user_id) ON DELETE CASCADE, + FOREIGN KEY (course_id) REFERENCES courses(course_code) ON DELETE CASCADE + ) + "# + ) + .execute(pool) + .await?; + + log::info!("Tables checked/created successfully."); + Ok(()) +} + +// Function to populate some initial data (e.g., courses) - MAKE THIS IDEMPOTENT +pub async fn populate_initial_data(pool: &Pool) -> Result<(), AppError> { + log::info!("Attempting to populate initial data (Courses)..."); + + // Check if courses already exist to avoid errors on restart + let count: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM courses") + .fetch_one(pool) + .await?; + + if count.0 == 0 { + log::info!("No existing courses found, adding sample data..."); + let courses = vec![ + (101, "Introduction to Programming"), + (102, "Data Structures"), + (201, "Calculus I"), + (205, "Linear Algebra"), + ]; + + for (code, name) in courses { + sqlx::query!( + "INSERT INTO courses (course_code, course_name) VALUES (?, ?)", + code, + name + ) + .execute(pool) + .await?; + } + log::info!("Sample courses added."); + + // Add a sample student and teacher WITHOUT passwords/SRP data + // User ID 123456 is used in frontend mock login + sqlx::query!( + r#" + INSERT INTO students (user_id, first_name, last_name, school_id, birthday) + VALUES (?, ?, ?, ?, ?) + ON DUPLICATE KEY UPDATE user_id=user_id -- Do nothing if exists + "#, + 123456, "John", "Doe", "S123456", chrono::NaiveDate::from_ymd_opt(2000, 1, 1) + ).execute(pool).await?; + log::info!("Sample student added (ID: 123456)."); + + // Add a sample teacher (maybe overlapping ID to test login logic) + sqlx::query!( + r#" + INSERT INTO teachers (user_id, first_name, last_name, birthday) + VALUES (?, ?, ?, ?) + ON DUPLICATE KEY UPDATE user_id=user_id -- Do nothing if exists + "#, + 5001, "Alice", "Smith", chrono::NaiveDate::from_ymd_opt(1985, 5, 15) + ).execute(pool).await?; + log::info!("Sample teacher added (ID: 5001)."); + + // Example of overlapping ID (same as student) + // This teacher won't be able to log in until password/SRP is set, + // and login logic must handle potential ID collision if SRP data exists for both. + sqlx::query!( + r#" + INSERT INTO teachers (user_id, first_name, last_name, birthday) + VALUES (?, ?, ?, ?) + ON DUPLICATE KEY UPDATE user_id=user_id -- Do nothing if exists + "#, + 123456, "Jane", "Teacher", chrono::NaiveDate::from_ymd_opt(1990, 3, 10) + ).execute(pool).await?; + log::info!("Sample teacher added with overlapping ID (ID: 123456)."); + + + } else { + log::info!("Courses table already populated."); + } + + + Ok(()) +} + +// --- Example Fetch Functions --- + +pub async fn find_student_by_id(pool: &Pool, user_id: i32) -> Result, AppError> { + let student = sqlx::query_as!( + Student, + r#" + SELECT user_id, first_name, last_name, birthday, srp_salt, srp_verifier, school_id, profile_picture + FROM students + WHERE user_id = ? + "#, + user_id + ) + .fetch_optional(pool) + .await?; + Ok(student) +} + +pub async fn find_teacher_by_id(pool: &Pool, user_id: i32) -> Result, AppError> { + let teacher = sqlx::query_as!( + Teacher, + r#" + SELECT user_id, first_name, last_name, birthday, srp_salt, srp_verifier, profile_picture + FROM teachers + WHERE user_id = ? + "#, + user_id + ) + .fetch_optional(pool) + .await?; + Ok(teacher) +} + +// --- Count Functions --- +pub async fn count_students(pool: &Pool) -> Result { + let result = sqlx::query!("SELECT COUNT(*) as count FROM students") + .fetch_one(pool) + .await?; + Ok(result.count) +} + +pub async fn count_teachers(pool: &Pool) -> Result { + let result = sqlx::query!("SELECT COUNT(*) as count FROM teachers") + .fetch_one(pool) + .await?; + Ok(result.count) +} + +// --- Update Functions --- +// Add functions to update SRP salt/verifier when a user sets/changes their password +pub async fn update_student_srp(pool: &Pool, user_id: i32, salt: &str, verifier: &str) -> Result<(), AppError> { + sqlx::query!( + "UPDATE students SET srp_salt = ?, srp_verifier = ? WHERE user_id = ?", + salt, verifier, user_id + ) + .execute(pool) + .await?; + Ok(()) +} + +pub async fn update_teacher_srp(pool: &Pool, user_id: i32, salt: &str, verifier: &str) -> Result<(), AppError> { + sqlx::query!( + "UPDATE teachers SET srp_salt = ?, srp_verifier = ? WHERE user_id = ?", + salt, verifier, user_id + ) + .execute(pool) + .await?; + Ok(()) +} + +// TODO: Add functions for get_student_list (with joins/filtering), manage students, enrollments etc. \ No newline at end of file diff --git a/src/errors.rs b/src/errors.rs new file mode 100644 index 0000000..f83cdc1 --- /dev/null +++ b/src/errors.rs @@ -0,0 +1,81 @@ +use actix_web::{HttpResponse, ResponseError}; +use serde::Serialize; +use sqlx::Error as SqlxError; +use std::fmt; + +#[derive(Debug, thiserror::Error)] +pub enum AppError { + #[error("Database error: {0}")] + DatabaseError(#[from] SqlxError), + + #[error("Configuration error: {0}")] + ConfigError(#[from] std::env::VarError), + + #[error("Not Found: {0}")] + NotFound(String), + + #[error("Authentication Failed: {0}")] + AuthenticationError(String), + + #[error("Invalid Input: {0}")] + InvalidInput(String), + + #[error("Authorization Failed: {0}")] + AuthorizationError(String), + + #[error("Internal Server Error")] + InternalError(String), // Keep internal details out of response +} + +#[derive(Serialize)] +struct ErrorResponse { + success: bool, + message: String, +} + +impl ResponseError for AppError { + fn error_response(&self) -> HttpResponse { + log::error!("Error handled: {:?}", self); // Log the full error + + let (status_code, message) = match self { + AppError::DatabaseError(_) => ( + actix_web::http::StatusCode::INTERNAL_SERVER_ERROR, + "Database error occurred.".to_string(), + ), + AppError::ConfigError(_) => ( + actix_web::http::StatusCode::INTERNAL_SERVER_ERROR, + "Configuration error.".to_string(), + ), + AppError::NotFound(ref msg) => (actix_web::http::StatusCode::NOT_FOUND, msg.clone()), + AppError::AuthenticationError(ref msg) => { + (actix_web::http::StatusCode::UNAUTHORIZED, msg.clone()) + } + AppError::InvalidInput(ref msg) => { + (actix_web::http::StatusCode::BAD_REQUEST, msg.clone()) + } + AppError::AuthorizationError(ref msg) => { + (actix_web::http::StatusCode::FORBIDDEN, msg.clone()) + } + AppError::InternalError(_) => ( + actix_web::http::StatusCode::INTERNAL_SERVER_ERROR, + "An internal error occurred.".to_string(), + ), + }; + + HttpResponse::build(status_code).json(ErrorResponse { + success: false, + message, + }) + } +} + +// Helper for converting Option to AppError::NotFound +pub trait OptionExt { + fn ok_or_not_found(self, message: &str) -> Result; +} + +impl OptionExt for Option { + fn ok_or_not_found(self, message: &str) -> Result { + self.ok_or_else(|| AppError::NotFound(message.to_string())) + } +} \ No newline at end of file diff --git a/src/handlers.rs b/src/handlers.rs new file mode 100644 index 0000000..004b0ed --- /dev/null +++ b/src/handlers.rs @@ -0,0 +1,496 @@ +use actix_web::{web, get, post, put, HttpResponse, Responder, Scope}; +use sqlx::{MySql, Pool}; +use crate::{ + db, + errors::{AppError, OptionExt}, + models::*, +}; +use srp::server::{SrpServer, SrpServerChallenge}; // Using the `srp` crate +use srp::{types::*, EphemeralKey, PublicEphemeralKey}; // Import necessary SRP types +use hex; // For encoding/decoding SRP values +use std::collections::HashMap; // Example for temporary state store +use std::sync::Mutex; // For thread-safe access to state store +use uuid::Uuid; + +// --- Temporary State Store for SRP Handshake --- +// WARNING: This in-memory store is NOT suitable for production. +// It will lose state on restart and doesn't scale across multiple instances. +// Use a proper distributed cache (like Redis) or database table for production. +struct AppState { + srp_challenges: Mutex, String)>>, // Key: user_id_type (e.g., "123456_student"), Value: (Challenge state, User Type) + active_sessions: Mutex>, // Key: session_token (UUID), Value: User Data +} + +impl AppState { + fn new() -> Self { + AppState { + srp_challenges: Mutex::new(HashMap::new()), + active_sessions: Mutex::new(HashMap::new()), + } + } +} +// --- Helper Functions --- + +fn create_api_response(data: Option, success: bool, message: Option) -> HttpResponse { + HttpResponse::Ok().json(ApiResponse { + success, + data, + message, + }) +} + +// Helper to parse user_id string to i32 +fn parse_user_id(user_id_str: &str) -> Result { + user_id_str.parse::().map_err(|_| AppError::InvalidInput(format!("Invalid user ID format: {}", user_id_str))) +} + + +// --- Route Handlers --- + +#[get("/health")] +async fn health_check() -> impl Responder { + HttpResponse::Ok().json(ApiResponse { + success: true, + data: Some("Healthy"), + message: None, + }) +} + +// --- SRP Login Step 1: Start --- +#[post("/auth/srp/start")] +async fn srp_login_start( + pool: web::Data>, + app_state: web::Data, + req: web::Json, +) -> Result { + let user_id_str = &req.user_id; + let user_id = parse_user_id(user_id_str)?; + + // --- Handle potential overlapping IDs --- + let student_data = db::find_student_by_id(&pool, user_id).await?; + let teacher_data = db::find_teacher_by_id(&pool, user_id).await?; + + let (salt_hex, verifier_hex, user_type) = match (student_data, teacher_data) { + // Found only student with SRP data + (Some(s), None) if s.srp_salt.is_some() && s.srp_verifier.is_some() => { + log::info!("SRP Start: Found student {} with SRP data.", user_id); + (s.srp_salt.unwrap(), s.srp_verifier.unwrap(), "student") + }, + // Found only teacher with SRP data + (None, Some(t)) if t.srp_salt.is_some() && t.srp_verifier.is_some() => { + log::info!("SRP Start: Found teacher {} with SRP data.", user_id); + (t.srp_salt.unwrap(), t.srp_verifier.unwrap(), "teacher") + }, + // Found both, but only student has SRP data + (Some(s), Some(_)) if s.srp_salt.is_some() && s.srp_verifier.is_some() => { + log::warn!("SRP Start: Found student {} and teacher {} (overlapping ID), using student SRP data.", user_id, user_id); + (s.srp_salt.unwrap(), s.srp_verifier.unwrap(), "student") + }, + // Found both, but only teacher has SRP data (less likely scenario if student usually registers first) + (Some(_), Some(t)) if t.srp_salt.is_some() && t.srp_verifier.is_some() => { + log::warn!("SRP Start: Found student {} and teacher {} (overlapping ID), using teacher SRP data.", user_id, user_id); + (t.srp_salt.unwrap(), t.srp_verifier.unwrap(), "teacher") + }, + // Found both with SRP data - ambiguous! Prefer student? Or return error? + (Some(s), Some(t)) if s.srp_salt.is_some() && s.srp_verifier.is_some() && t.srp_salt.is_some() && t.srp_verifier.is_some() => { + log::error!("SRP Start: Ambiguous login for user ID {}. Both student and teacher have SRP data set.", user_id); + // For now, let's arbitrarily choose student. A better approach might involve asking the user or having unique usernames. + log::warn!("SRP Start: Ambiguous login for user ID {}. Defaulting to student.", user_id); + (s.srp_salt.unwrap(), s.srp_verifier.unwrap(), "student") + // Alternatively, return an error: + // return Err(AppError::AuthenticationError("Ambiguous user ID. Please contact support.".to_string())); + }, + // User exists but SRP data not set (password needs to be created) + (Some(_), _) | (_, Some(_)) => { + log::warn!("SRP Start: User ID {} found, but SRP salt/verifier not set. Cannot log in.", user_id); + return Err(AppError::AuthenticationError( + "Account exists but password not set. Cannot initiate login.".to_string(), + )); + } + // User not found in either table + (None, None) => { + log::warn!("SRP Start: User ID {} not found.", user_id); + return Err(AppError::NotFound(format!("User ID {} not found.", user_id))); + } + }; + + // Decode hex salt and verifier from DB + let salt = Salt::from_hex(&salt_hex) + .map_err(|e| AppError::InternalError(format!("Failed to decode salt: {}", e)))?; + let verifier = Verifier::from_hex(&verifier_hex) + .map_err(|e| AppError::InternalError(format!("Failed to decode verifier: {}", e)))?; + + // Generate server ephemeral keys and challenge + // Using SHA256 as the hash function, adjust if needed (must match client) + let server = SrpServer::::new(&verifier); + let challenge = server.challenge(&salt).map_err(|e| { + log::error!("SRP challenge generation error: {}", e); + AppError::InternalError("Failed to generate SRP challenge".to_string()) + })?; + + let server_public_ephemeral_hex = challenge.public_ephemeral().to_hex(); + + // --- Store challenge state TEMPORARILY --- + // Associate the challenge state with the user_id and type for the verify step + let state_key = format!("{}_{}", user_id_str, user_type); + let mut challenges = app_state.srp_challenges.lock().unwrap(); // Handle potential poisoning in production + challenges.insert(state_key.clone(), (challenge, user_type.to_string())); + log::info!("Stored SRP challenge state for key: {}", state_key); + // TODO: Add TTL for stored challenges to prevent memory leaks + + Ok(create_api_response( + Some(SrpLoginStartResponse { + salt: salt_hex, // Send back the original hex salt + server_public_ephemeral: server_public_ephemeral_hex, + user_type: user_type.to_string(), + }), + true, + None, + )) +} + + +// --- SRP Login Step 2: Verify --- +#[post("/auth/srp/verify")] +async fn srp_login_verify( + pool: web::Data>, + app_state: web::Data, + req: web::Json, +) -> Result { + let user_id_str = &req.user_id; + let user_type = &req.user_type; // Get user type received from client (who got it from /start) + let state_key = format!("{}_{}", user_id_str, user_type); + + log::info!("SRP Verify: Attempting for state key: {}", state_key); + + // --- Retrieve challenge state --- + let challenge_state_tuple = { // Scope the lock + let mut challenges = app_state.srp_challenges.lock().unwrap(); + // IMPORTANT: Remove the state after retrieving it to prevent replay attacks + challenges.remove(&state_key) + .ok_or_else(|| { + log::warn!("SRP Verify: No active SRP challenge found for key: {}", state_key); + AppError::AuthenticationError("SRP session expired or invalid.".to_string()) + })? + }; + let (challenge_state, retrieved_user_type) = challenge_state_tuple; + if &retrieved_user_type != user_type { + log::error!("SRP Verify: User type mismatch for key {}. Expected {}, got {}", state_key, retrieved_user_type, user_type); + return Err(AppError::AuthenticationError("SRP session data mismatch.".to_string())); + } + + + // Decode client public ephemeral (A) and proof (M1) + let client_public_ephemeral = PublicEphemeralKey::from_hex(&req.client_public_ephemeral) + .map_err(|_| AppError::InvalidInput("Invalid client public ephemeral key format.".to_string()))?; + let client_proof = ClientProof::from_hex(&req.client_proof) + .map_err(|_| AppError::InvalidInput("Invalid client proof format.".to_string()))?; + + + // Verify the client's proof using the stored challenge state + let verification = challenge_state.verify_client(&client_public_ephemeral, &client_proof); + + match verification { + Ok(session) => { + // Verification successful! Generate server proof (M2) and session token + let server_proof_hex = session.server_proof().to_hex(); + let session_key = session.session_key(); // Use this for encrypted communication if needed + log::info!("SRP Verify: Client proof verified for key: {}. Session key generated (first 4 bytes): {:x?}", state_key, &session_key[..4]); + + + // --- Authentication successful, fetch user details --- + let user_id = parse_user_id(user_id_str)?; + let (full_name, profile_picture, school_id, birthdate) = + match user_type.as_str() { + "student" => { + let student = db::find_student_by_id(&pool, user_id).await? + .ok_or_not_found("Student data not found after successful SRP verification.")?; + ( + format!("{} {}", student.first_name, student.last_name), + student.profile_picture, + student.school_id, + student.birthday.map(|d| d.format("%Y-%m-%d").to_string()), + ) + } + "teacher" => { + let teacher = db::find_teacher_by_id(&pool, user_id).await? + .ok_or_not_found("Teacher data not found after successful SRP verification.")?; + ( + format!("{} {}", teacher.first_name, teacher.last_name), + teacher.profile_picture, + None, // Teachers might not have a school_id in this schema + teacher.birthday.map(|d| d.format("%Y-%m-%d").to_string()), + ) + } + _ => return Err(AppError::InternalError("Invalid user type encountered.".to_string())), + }; + + + // --- Generate a session token (e.g., UUID) --- + let session_token = Uuid::new_v4().to_string(); + + // Create login success data + let login_data = LoginSuccessData { + user_id: user_id_str.clone(), + full_name, + profile_picture, + school_id, + birthdate, + token: session_token.clone(), + user_type: user_type.clone(), + }; + + // Store active session (replace with DB/Redis in production) + { + let mut sessions = app_state.active_sessions.lock().unwrap(); + sessions.insert(session_token.clone(), login_data.clone()); // Clone data for response + log::info!("SRP Verify: Stored active session for token: {}", session_token); + // TODO: Implement session expiry + } + + + // Respond with server proof and login data + Ok(create_api_response( + Some(SrpLoginVerifyResponse { + server_proof: server_proof_hex, + login_data: Some(login_data), + }), + true, + None, + )) + } + Err(e) => { + // Verification failed + log::warn!("SRP Verify: Client proof verification failed for key {}: {}", state_key, e); + Err(AppError::AuthenticationError("Invalid login credentials (SRP verification failed).".to_string())) + } + } +} + + +// TODO: Add logout handler (needs to invalidate token/session) +#[post("/auth/logout")] +async fn logout(app_state: web::Data, /* TODO: Extract token from header */ ) -> Result { + // 1. Extract token from Authorization header + // let token = extract_token_from_request(req)?; + let token = "placeholder_token_to_remove"; // Replace with actual extraction + + // 2. Remove session from state store + let mut sessions = app_state.active_sessions.lock().unwrap(); + if sessions.remove(token).is_some() { + log::info!("Logout: Removed session for token: {}", token); + Ok(create_api_response::<()>(None, true, Some("Logged out successfully.".to_string()))) + } else { + log::warn!("Logout: Attempted to log out with invalid or expired token: {}", token); + // Still return success, as the user is effectively logged out from the server's perspective. + Ok(create_api_response::<()>(None, true, Some("Logged out successfully.".to_string()))) + // Or return an error if strict feedback is needed: + // Err(AppError::AuthenticationError("Invalid session token.".to_string())) + } +} + + +// TODO: Add get_profile handler (needs token validation middleware) +#[get("/profile/{user_id}")] +async fn get_profile( + pool: web::Data>, + app_state: web::Data, // Needed if validating token here + path: web::Path, + // TODO: Add middleware to extract validated user info from token + // validated_user: AuthenticatedUser, // Example struct from middleware +) -> Result { + let requested_user_id_str = path.into_inner(); + let requested_user_id = parse_user_id(&requested_user_id_str)?; + + // --- Authorization --- + // TODO: Check if the requesting user (from token) is allowed to see this profile. + // Example: Allow viewing own profile or if requester is admin. + // if validated_user.user_id != requested_user_id_str && !validated_user.is_admin { + // return Err(AppError::AuthorizationError("You are not authorized to view this profile.".to_string())); + // } + + // Fetch student or teacher data + let student = db::find_student_by_id(&pool, requested_user_id).await?; + let teacher = db::find_teacher_by_id(&pool, requested_user_id).await?; + + match (student, teacher) { + (Some(s), _) => { // Prioritize student if ID overlaps? Or check based on validated_user.user_type? + let profile_data = ProfileData { + user_id: s.user_id.to_string(), + full_name: format!("{} {}", s.first_name, s.last_name), + profile_picture: s.profile_picture, + school_id: s.school_id, + // Only show birthdate if viewing own profile (needs auth check) + birthdate: s.birthday.map(|d| d.format("%Y-%m-%d").to_string()), + user_type: "student".to_string(), + }; + Ok(create_api_response(Some(profile_data), true, None)) + }, + (None, Some(t)) => { + let profile_data = ProfileData { + user_id: t.user_id.to_string(), + full_name: format!("{} {}", t.first_name, t.last_name), + profile_picture: t.profile_picture, + school_id: None, + birthdate: t.birthday.map(|d| d.format("%Y-%m-%d").to_string()), + user_type: "teacher".to_string(), + }; + Ok(create_api_response(Some(profile_data), true, None)) + }, + (None, None) => Err(AppError::NotFound(format!("User profile {} not found.", requested_user_id_str))), + } +} + + +// TODO: Add update_account_settings handler (needs token validation middleware) +#[put("/profile/settings")] +async fn update_account_settings( + pool: web::Data>, + // validated_user: AuthenticatedUser, // From middleware + payload: web::Json, +) -> Result { + // 1. Get user_id and user_type from validated_user token + // let user_id = parse_user_id(&validated_user.user_id)?; + // let user_type = &validated_user.user_type; + + // --- Mocked user for now --- + let user_id = 123456; // Example user ID + let user_type = "student"; // Example user type + // --- --- + + log::info!("Updating settings for {} ID: {}", user_type, user_id); + + if let Some(pic) = &payload.profile_picture { + match user_type { + "student" => { + sqlx::query!("UPDATE students SET profile_picture = ? WHERE user_id = ?", pic, user_id) + .execute(&**pool).await?; + }, + "teacher" => { + sqlx::query!("UPDATE teachers SET profile_picture = ? WHERE user_id = ?", pic, user_id) + .execute(&**pool).await?; + }, + _ => return Err(AppError::InternalError("Invalid user type.".to_string())), + } + log::info!("Updated profile picture for {} ID: {}", user_type, user_id); + } + + // Handle other updatable fields (e.g., non-sensitive ones) + // Password change requires a separate, secure flow (e.g., SRP re-registration or old/new password check) + + Ok(create_api_response::<()>(None, true, Some("Settings updated successfully.".to_string()))) +} + + +// --- Admin Handlers --- + +#[get("/admin/dashboard")] +async fn get_admin_dashboard_data(pool: web::Data>, /* TODO: Admin Auth Middleware */) -> Result { + let students_count = db::count_students(&pool).await?; + let teachers_count = db::count_teachers(&pool).await?; + + let data = AdminDashboardData { + students_count, + teachers_count, + }; + Ok(create_api_response(Some(data), true, None)) +} + +#[get("/admin/students")] +async fn get_student_list(pool: web::Data>, /* TODO: Admin Auth Middleware */) -> Result { + // Basic implementation: get all students, construct name. + // TODO: Add filtering, pagination, course info joins. + let students = sqlx::query_as::<_, StudentListData>( + r#" + SELECT + user_id as id, + CONCAT(first_name, ' ', last_name) as name + -- Add columns for year_level, courses (requires more complex query/joins) + FROM students + ORDER BY last_name, first_name + "# + ) + .fetch_all(&**pool) + .await?; + + Ok(create_api_response(Some(students), true, None)) +} + +#[get("/admin/students/{student_id}/financials")] +async fn get_student_financial_data( + _pool: web::Data>, // Mark as unused for now + path: web::Path, + /* TODO: Admin Auth Middleware */ +) -> Result { + let student_id_str = path.into_inner(); + let _student_id = parse_user_id(&student_id_str)?; // Validate ID format + + // TODO: Check if student_id exists + + log::info!("Fetching financial data for student ID: {}", _student_id); + // Mock data as per frontend api.ts, since no financial table exists + let financial_data = StudentFinancialData { + tuition_fee: "5000".to_string(), + miscellaneous_fee: "500".to_string(), + lab_fee: "200".to_string(), + current_account: "-100".to_string(), + down_payment: "2000".to_string(), + midterm_payment: "1500".to_string(), + prefinal_payment: "1000".to_string(), + final_payment: "500".to_string(), + }; + + Ok(create_api_response(Some(financial_data), true, None)) +} + + +// --- Configure Routes --- +pub fn configure_routes(cfg: &mut web::ServiceConfig) { + // Initialize App State (Singleton for the App) + let app_state = web::Data::new(AppState::new()); + + + cfg.app_data(app_state.clone()) // Make AppState available to handlers + .service(health_check) + .service( + web::scope("/api") + // Authentication (No auth middleware needed) + .service( + web::scope("/auth") + .service(srp_login_start) + .service(srp_login_verify) + .service(logout) // TODO: Add auth middleware if logout requires token + ) + // Profile routes (TODO: Add auth middleware) + .service( + web::scope("/profile") + .service(get_profile) // Dynamic part {user_id} + .service(update_account_settings) // Path defined in handler + ) + // Admin routes (TODO: Add admin auth middleware) + .service( + web::scope("/admin") + .service(get_admin_dashboard_data) + .service(get_student_list) + .service(get_student_financial_data) // Dynamic part {student_id} + // TODO: Add routes for managing students/teachers/courses/enrollments + ) + // TODO: Add routes for classrooms, posts, etc. + ); +} + +// TODO: Implement Authentication Middleware +// - Extract token from "Authorization: Bearer " header. +// - Verify token against the active_sessions store (or JWT verification). +// - If valid, extract user info (user_id, user_type) and attach it to the request extensions. +// - If invalid, return 401 Unauthorized. + +// TODO: Implement Authorization Middleware (or checks within handlers) +// - Check if the authenticated user has the necessary role/permissions (e.g., admin) for the requested route. +// - If not authorized, return 403 Forbidden. + +// TODO: Implement SRP Registration/Password Change Handler +// - Takes username and password. +// - Generates salt and verifier using srp::server::generate_verifier. +// - Stores hex-encoded salt and verifier in the appropriate (student/teacher) table. +// - This is needed for users to set their initial password or change it. \ No newline at end of file diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..34d2693 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,62 @@ +use actix_cors::Cors; +use actix_web::{middleware::Logger, web, App, HttpServer}; +use dotenv::dotenv; +use sqlx::mysql::MySqlPoolOptions; +use std::env; + +mod config; +mod db; +mod errors; +mod handlers; +mod models; +// mod auth; // Keep auth logic within handlers for now or create dedicated module + +#[actix_web::main] +async fn main() -> std::io::Result<()> { + dotenv().ok(); // Load .env file + env_logger::init_from_env(env_logger::Env::new().default_filter_or("info")); // Use RUST_LOG from .env + + let app_config = config::Config::from_env().expect("Failed to load configuration"); + + log::info!("Connecting to database: {}", &app_config.database_url); + let db_pool = MySqlPoolOptions::new() + .max_connections(10) + .connect(&app_config.database_url) + .await + .expect("Failed to create database connection pool"); + + log::info!("Running database migrations/setup..."); + db::init_db(&db_pool) + .await + .expect("Database initialization failed"); + log::info!("Database setup complete."); + + // Optional: Populate initial data if needed (courses, etc.) + // Ensure this is idempotent or only runs once + if let Err(e) = db::populate_initial_data(&db_pool).await { + log::warn!("Could not populate initial data (might already exist): {}", e); + } else { + log::info!("Initial data populated (if tables were empty)."); + } + + + log::info!("Starting server at {}", &app_config.server_addr); + + HttpServer::new(move || { + let cors = Cors::default() + .allow_any_origin() // Adjust for production! + .allow_any_method() + .allow_any_header() + .max_age(3600); + + App::new() + .app_data(web::Data::new(db_pool.clone())) + // .app_data(web::Data::new(app_config.clone())) // If config needed in handlers + .wrap(Logger::default()) // Enable request logging + .wrap(cors) // Enable CORS + .configure(handlers::configure_routes) // Register API routes + }) + .bind(&app_config.server_addr)? + .run() + .await +} \ No newline at end of file diff --git a/src/models.rs b/src/models.rs new file mode 100644 index 0000000..8b289f1 --- /dev/null +++ b/src/models.rs @@ -0,0 +1,166 @@ +use chrono::NaiveDate; +use serde::{Deserialize, Serialize}; +use sqlx::FromRow; + +// --- Database Table Models --- + +#[derive(Debug, Serialize, Deserialize, FromRow)] +pub struct Student { + pub user_id: i32, // Assuming INT translates to i32 + pub first_name: String, + pub last_name: String, + pub birthday: Option, // Use Option if nullable + // pub password_hash: String, // Replaced by SRP fields + pub srp_salt: Option, // Store as hex string or base64 + pub srp_verifier: Option, // Store as hex string or base64 + // Add other fields like school_id if needed based on frontend expectations + pub school_id: Option, // Matches frontend LoginResponseData + pub profile_picture: Option, // Matches frontend LoginResponseData +} + +#[derive(Debug, Serialize, Deserialize, FromRow)] +pub struct Teacher { + pub user_id: i32, + pub first_name: String, + pub last_name: String, + pub birthday: Option, + // pub password_hash: String, // Replaced by SRP fields + pub srp_salt: Option, + pub srp_verifier: Option, + // 'enrolled' was listed as a list of courses, this needs a join table (Enrollments) + // This struct represents the core teacher info. Enrollment is handled separately. + pub profile_picture: Option, // Matches frontend LoginResponseData (assumption) +} + +#[derive(Debug, Serialize, Deserialize, FromRow)] +pub struct Course { + pub course_code: i32, // As per spec, though VARCHAR might be better + pub course_name: String, +} + +#[derive(Debug, Serialize, Deserialize, FromRow)] +pub struct Enrollment { + pub student_id: i32, + pub course_id: i32, + pub enrollment_date: Option, +} + +// --- API Request/Response Structs (Matching frontend's api.ts where possible) --- + +// Generic API Response Wrapper +#[derive(Serialize)] +pub struct ApiResponse { + pub success: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub data: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub message: Option, +} + +// Data structure for successful login (after SRP verification) +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct LoginSuccessData { + pub user_id: String, // Use String to match frontend potentially using non-numeric IDs? Keep consistent. + pub full_name: String, + pub profile_picture: Option, // Allow null/optional + pub school_id: Option, // Allow null/optional + pub birthdate: Option, // Format as YYYY-MM-DD string + pub token: String, // Session token (e.g., UUID or JWT) + pub user_type: String, // "student" or "teacher" +} + +// Data structure for profile page +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ProfileData { + pub user_id: String, + pub full_name: String, + pub profile_picture: Option, + pub school_id: Option, + pub birthdate: Option, // Only show birthdate if viewing own profile? Needs logic. + // Add posts if implementing that feature + // pub posts: Vec, + pub user_type: String, // "student" or "teacher" +} + +// Placeholder for Post data if needed later +// #[derive(Debug, Serialize)] +// pub struct PostData { +// pub id: i32, +// pub content: String, +// } + + +// Admin Dashboard Data +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct AdminDashboardData { + pub students_count: i64, // Use i64 for counts from SQL COUNT(*) + pub teachers_count: i64, +} + +// Student List Data for Admin Page +#[derive(Debug, Serialize, FromRow)] +#[serde(rename_all = "camelCase")] +pub struct StudentListData { + pub id: i32, // Assuming user_id is the ID here + pub name: String, // Need to construct this (first_name + last_name) in query + // These fields need to be fetched/calculated, perhaps via joins or separate queries + // pub year_level: Option, // Not in original schema + // pub courses: Vec, // Need to query Enrollments and Courses +} + +// Student Financial Data (Mocked as per frontend) +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct StudentFinancialData { + pub tuition_fee: String, + pub miscellaneous_fee: String, + pub lab_fee: String, + pub current_account: String, + pub down_payment: String, + pub midterm_payment: String, + pub prefinal_payment: String, + pub final_payment: String, +} + + +// --- SRP Related Structs --- + +#[derive(Deserialize)] +pub struct SrpLoginStartRequest { + pub user_id: String, // User identifier (I) +} + +#[derive(Serialize)] +pub struct SrpLoginStartResponse { + pub salt: String, // Hex encoded salt (s) + pub server_public_ephemeral: String, // Hex encoded server public ephemeral (B) + pub user_type: String, // "student" or "teacher" (needed for verify step) +} + +#[derive(Deserialize)] +pub struct SrpLoginVerifyRequest { + pub user_id: String, // Pass user_id again to correlate state if needed + pub client_public_ephemeral: String, // Hex encoded client public ephemeral (A) + pub client_proof: String, // Hex encoded client proof (M1) + pub user_type: String, // "student" or "teacher" +} + +#[derive(Serialize)] +pub struct SrpLoginVerifyResponse { + pub server_proof: String, // Hex encoded server proof (M2) + // On successful verification, include the LoginSuccessData + #[serde(flatten)] // Embed fields directly + pub login_data: Option, // Only present on success +} + +// --- Other API Structs --- +#[derive(Deserialize)] +pub struct UpdateAccountSettingsPayload { + pub profile_picture: Option, + // Password change needs separate secure flow (e.g., old+new password or SRP re-registration) + // pub new_password: Option, + // Other editable fields? +} \ No newline at end of file