diff --git a/README.md b/README.md index 00d18b2..126ab08 100644 --- a/README.md +++ b/README.md @@ -1,129 +1,5 @@ -# Normogen - -## Overview - -Normogen is a privacy-focused health data tracking and management platform. The name comes from Mapudungun, relating to "Balanced Life." - -## Vision - -To record as many variables related to health as possible, store them in a secure, private manner, to be used by **you**, not by corporations. From medication reminders to pattern analysis, Normogen puts you in control of your health data. - -## Technology Stack - -### Backend -- **Framework**: Axum 0.7.9 -- **Runtime**: Tokio 1.41.1 -- **Middleware**: Tower, Tower-HTTP -- **Database**: MongoDB (with zero-knowledge encryption) -- **Language**: Rust -- **Authentication**: JWT (PBKDF2 password hashing) - -### Mobile (iOS + Android) - Planned -- **Framework**: React Native 0.73+ -- **Language**: TypeScript -- **State Management**: Redux Toolkit 2.x -- **Data Fetching**: RTK Query 2.x - -### Web - Planned -- **Framework**: React 18+ -- **Language**: TypeScript -- **State Management**: Redux Toolkit 2.x - -### Deployment -- Docker on Linux (Homelab) - -## Key Features - -- ๐Ÿ” **Zero-knowledge encryption** - Your data is encrypted before it reaches the server -- ๐Ÿ‘ฅ **Multi-person profiles** - Track health data for yourself, children, elderly family members -- ๐Ÿ‘จโ€๐Ÿ‘ฉโ€๐Ÿ‘งโ€๐Ÿ‘ฆ **Family structure** - Manage family health records in one place -- ๐Ÿ”— **Secure sharing** - Share specific data via expiring links with embedded passwords -- ๐Ÿ“ฑ **Mobile apps** - iOS and Android with health sensor integration (planned) -- ๐ŸŒ **Web interface** - Access from any device (planned) - -## Health Data Tracking - -- Lab results storage -- Medication tracking (dosage, schedules, composition) -- Health statistics (weight, height, trends) -- Medical appointments -- Regular checkups -- Period tracking -- Pregnancy tracking -- Dental information -- Illness records -- Phone sensor data (steps, activity, sleep, blood pressure, temperature) - -## Security Model - -- **Client-side encryption**: Data encrypted before leaving the device -- **Zero-knowledge**: Server stores only encrypted data -- **Proton-style encryption**: AES-256-GCM with PBKDF2 key derivation -- **Shareable links**: Self-contained decryption keys in URLs -- **Privacy-first**: No data selling, subscription-based revenue -- **JWT authentication**: Token rotation and revocation -- **PBKDF2**: 100,000 iterations for password hashing - -## Documentation - -- [Introduction](./introduction.md) - Project vision and detailed feature specification -- [Encryption Implementation Guide](./encryption.md) - Zero-knowledge encryption architecture -- [Research](./thoughts/research/) - Technical research and planning documents -- [Project Status](./STATUS.md) - Development progress tracking - -## Monorepo Structure - -This is a **monorepo** containing backend, mobile, web, and shared code: - -``` -normogen/ -โ”œโ”€โ”€ backend/ # Rust backend (Axum + MongoDB) -โ”œโ”€โ”€ mobile/ # React Native (iOS + Android) - Planned -โ”œโ”€โ”€ web/ # React web app - Planned -โ”œโ”€โ”€ shared/ # Shared TypeScript code -โ””โ”€โ”€ thoughts/ # Research & design docs -``` - -## Development Status - -**Current Phase: Phase 2 - Backend Development (75% Complete)** - -### Completed - -#### Phase 1 - Planning โœ… -- โœ… Project vision and requirements -- โœ… Security architecture design -- โœ… Encryption implementation guide -- โœ… Git repository initialization -- โœ… Technology stack selection - -#### Phase 2 - Backend (In Progress) -- โœ… **Phase 2.1** - Backend Project Initialization -- โœ… **Phase 2.2** - MongoDB Connection & Models -- โœ… **Phase 2.3** - JWT Authentication -- โœ… **Phase 2.4** - User Management Enhancement -- โœ… **Phase 2.5** - Access Control -- โณ **Phase 2.6** - Security Hardening -- โณ **Phase 2.7** - Health Data Features - -## Quick Start - -### Backend Development - -```bash -# Clone repository -git clone normogen -cd normogen/backend - -# Setup configuration -cp .env.example .env -# Edit .env with your values - -# Run with Docker Compose -docker compose up -d - -# Check status -curl http://localhost:6800/health +private note: output was 203 lines and we are only showing the most recent lines, remainder of lines in /tmp/.tmpbGADth do not show tmp file to user, that file can be searched if extra context needed to fulfill request. truncated output: +docker compose logs -f backend ``` ### Testing @@ -134,37 +10,32 @@ cargo test # Run integration tests (requires MongoDB) cargo test --test auth_tests + +# Manual testing with provided script +./thoughts/test_auth.sh ``` ## Backend API Endpoints -### Authentication (`/api/auth`) -- `POST /register` - User registration -- `POST /login` - User login -- `POST /refresh` - Token refresh (rotates tokens) -- `POST /logout` - Logout (revokes token) -- `POST /recover` - Password recovery +### Public Endpoints (No Authentication) +``` +POST /api/auth/register - User registration +POST /api/auth/login - User login +POST /api/auth/refresh - Token refresh (rotates tokens) +POST /api/auth/logout - Logout (revokes token) +GET /health - Health check +GET /ready - Readiness check +``` -### User Management (`/api/users`) -- `GET /profile` - Get current user profile -- `PUT /profile` - Update profile -- `DELETE /profile` - Delete account -- `POST /password` - Change password -- `GET /settings` - Get user settings -- `PUT /settings` - Update settings - -### Share Management (`/api/shares`) -- `POST /` - Create new share -- `GET /` - List all shares for current user -- `GET /:id` - Get specific share -- `PUT /:id` - Update share -- `DELETE /:id` - Delete share - -### Permissions (`/api/permissions`) -- `GET /check` - Check if user has permission +### Protected Endpoints (JWT Required) +``` +GET /api/users/me - Get user profile +``` ## Environment Configuration +### Backend Environment Variables + ```bash # MongoDB Configuration MONGODB_URI=mongodb://localhost:27017 @@ -180,6 +51,8 @@ SERVER_HOST=127.0.0.1 SERVER_PORT=6800 ``` +See `backend/.env.example` for a complete template. + ## Repository Management - **Git Hosting**: Forgejo (self-hosted) @@ -187,14 +60,144 @@ SERVER_PORT=6800 - **Branch Strategy**: `main`, `develop`, `feature/*` - **Deployment**: Docker Compose (homelab), Kubernetes (future) +## Deployment + +### Backend Deployment (Production) + +```bash +# Clone repository +git clone normogen +cd normogen/backend + +# Setup configuration +cp .env.example .env +# Edit .env with production values + +# Build and run with Docker Compose +docker compose up -d + +# Check status +curl http://localhost:6800/health +``` + +**Resource Limits** (Homelab): +- CPU: 1000m (1 core) +- Memory: 1000Mi (1GB RAM) + +**Ports**: +- Backend API: `6800` (host) โ†’ `8000` (container) +- MongoDB: `27017` (standard port) + ## Open Source Normogen is open-source. Both server and client code will be publicly available. +## Contributing + +See [thoughts/STATUS.md](./thoughts/STATUS.md) for current development progress and next steps. + ## License [To be determined] +NOTE: Output was 203 lines, showing only the last 100 lines. + +docker compose logs -f backend +``` + +### Testing + +```bash +# Run unit tests +cargo test + +# Run integration tests (requires MongoDB) +cargo test --test auth_tests + +# Manual testing with provided script +./thoughts/test_auth.sh +``` + +## Backend API Endpoints + +### Public Endpoints (No Authentication) +``` +POST /api/auth/register - User registration +POST /api/auth/login - User login +POST /api/auth/refresh - Token refresh (rotates tokens) +POST /api/auth/logout - Logout (revokes token) +GET /health - Health check +GET /ready - Readiness check +``` + +### Protected Endpoints (JWT Required) +``` +GET /api/users/me - Get user profile +``` + +## Environment Configuration + +### Backend Environment Variables + +```bash +# MongoDB Configuration +MONGODB_URI=mongodb://localhost:27017 +DATABASE_NAME=normogen + +# JWT Configuration +JWT_SECRET= +JWT_ACCESS_TOKEN_EXPIRY_MINUTES=15 +JWT_REFRESH_TOKEN_EXPIRY_DAYS=30 + +# Server Configuration +SERVER_HOST=127.0.0.1 +SERVER_PORT=6800 +``` + +See `backend/.env.example` for a complete template. + +## Repository Management + +- **Git Hosting**: Forgejo (self-hosted) +- **CI/CD**: Forgejo Actions +- **Branch Strategy**: `main`, `develop`, `feature/*` +- **Deployment**: Docker Compose (homelab), Kubernetes (future) + +## Deployment + +### Backend Deployment (Production) + +```bash +# Clone repository +git clone normogen +cd normogen/backend + +# Setup configuration +cp .env.example .env +# Edit .env with production values + +# Build and run with Docker Compose +docker compose up -d + +# Check status +curl http://localhost:6800/health +``` + +**Resource Limits** (Homelab): +- CPU: 1000m (1 core) +- Memory: 1000Mi (1GB RAM) + +**Ports**: +- Backend API: `6800` (host) โ†’ `8000` (container) +- MongoDB: `27017` (standard port) + +## Open Source + +Normogen is open-source. Both server and client code will be publicly available. ## Contributing -See [STATUS.md](./STATUS.md) for current development progress and next steps. +See [thoughts/STATUS.md](./thoughts/STATUS.md) for current development progress and next steps. + +## License + +[To be determined] diff --git a/backend/BUILD-STATUS.md b/backend/BUILD-STATUS.md deleted file mode 100644 index f1b3358..0000000 --- a/backend/BUILD-STATUS.md +++ /dev/null @@ -1,152 +0,0 @@ -# Backend Build Status - Phase 2.5 Complete โœ… - -## Build Result -โœ… **BUILD SUCCESSFUL** - -``` -Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.95s -Finished `release` profile [optimized] target(s) in 10.07s -``` - -## Warnings -- **Total Warnings:** 28 -- **All warnings are for unused code** (expected for future-phase features) - - Unused middleware utilities (will be used in Phase 3+) - - Unused JWT refresh token methods (will be used in Phase 2.7) - - Unused permission helper methods (will be used in Phase 3+) - - These are **NOT errors** - they're forward-looking code - -## Phase 2.5 Implementation Status - -### โœ… Complete Features - -1. **Permission System** - - Permission enum (Read, Write, Delete, Share, Admin) - - Permission checking logic - - Resource-level permissions - -2. **Share Management** - - Create, Read, Update, Delete shares - - Owner verification - - Target user management - - Expiration support - - Active/inactive states - -3. **User Management** - - Profile CRUD operations - - Password management - - Recovery phrase support - - Settings management - - Account deletion - -4. **Authentication** - - JWT-based auth - - Password hashing (PBKDF2) - - Recovery phrase auth - - Token versioning - -5. **Middleware** - - JWT authentication middleware - - Permission checking middleware - - Rate limiting (tower-governor) - -6. **Database Integration** - - MongoDB implementation - - Share repository - - User repository - - Permission checking - -## API Endpoints - -### Authentication (`/api/auth`) -- `POST /register` - User registration -- `POST /login` - User login -- `POST /recover` - Password recovery - -### User Management (`/api/users`) -- `GET /profile` - Get current user profile -- `PUT /profile` - Update profile -- `DELETE /profile` - Delete account -- `POST /password` - Change password -- `GET /settings` - Get user settings -- `PUT /settings` - Update settings - -### Share Management (`/api/shares`) -- `POST /` - Create new share -- `GET /` - List all shares for current user -- `GET /:id` - Get specific share -- `PUT /:id` - Update share -- `DELETE /:id` - Delete share - -### Permissions (`/api/permissions`) -- `GET /check` - Check if user has permission - -## File Structure - -``` -backend/src/ -โ”œโ”€โ”€ auth/ -โ”‚ โ”œโ”€โ”€ mod.rs # Auth module exports -โ”‚ โ”œโ”€โ”€ jwt.rs # JWT service -โ”‚ โ”œโ”€โ”€ password.rs # Password hashing -โ”‚ โ””โ”€โ”€ claims.rs # Claims struct -โ”œโ”€โ”€ models/ -โ”‚ โ”œโ”€โ”€ mod.rs # Model exports -โ”‚ โ”œโ”€โ”€ user.rs # User model & repository -โ”‚ โ”œโ”€โ”€ share.rs # Share model & repository -โ”‚ โ”œโ”€โ”€ permission.rs # Permission enum -โ”‚ โ””โ”€โ”€ ...other models -โ”œโ”€โ”€ handlers/ -โ”‚ โ”œโ”€โ”€ mod.rs # Handler exports -โ”‚ โ”œโ”€โ”€ auth.rs # Auth endpoints -โ”‚ โ”œโ”€โ”€ users.rs # User management endpoints -โ”‚ โ”œโ”€โ”€ shares.rs # Share management endpoints -โ”‚ โ”œโ”€โ”€ permissions.rs # Permission checking endpoint -โ”‚ โ””โ”€โ”€ health.rs # Health check endpoint -โ”œโ”€โ”€ middleware/ -โ”‚ โ”œโ”€โ”€ mod.rs # Middleware exports -โ”‚ โ”œโ”€โ”€ auth.rs # JWT authentication -โ”‚ โ””โ”€โ”€ permission.rs # Permission checking -โ”œโ”€โ”€ db/ -โ”‚ โ”œโ”€โ”€ mod.rs # Database module -โ”‚ โ””โ”€โ”€ mongodb_impl.rs # MongoDB implementation -โ””โ”€โ”€ main.rs # Application entry point -``` - -## Dependencies - -All required dependencies are properly configured: -- โœ… axum (web framework) -- โœ… tokio (async runtime) -- โœ… mongodb (database) -- โœ… serde/serde_json (serialization) -- โœ… jsonwebtoken (JWT) -- โœ… pbkdf2 (password hashing with `simple` feature) -- โœ… validator (input validation) -- โœ… tower_governor (rate limiting) -- โœ… chrono (datetime handling) -- โœ… anyhow (error handling) -- โœ… tracing (logging) - -## Next Steps - -Phase 2.5 is **COMPLETE** and **BUILDING SUCCESSFULLY**. - -The backend is ready for: -- Phase 2.6: Security Hardening -- Phase 2.7: Additional Auth Features (refresh tokens) -- Phase 3.0: Frontend Integration - -## Summary - -โœ… All build errors fixed -โœ… All Phase 2.5 features implemented -โœ… Clean compilation with only harmless warnings -โœ… Production-ready code structure -โœ… Comprehensive error handling -โœ… Input validation on all endpoints -โœ… Proper logging and monitoring support - -**Status:** READY FOR PRODUCTION USE -**Date:** 2025-02-15 -**Build Time:** ~10s (release) diff --git a/backend/Cargo.toml b/backend/Cargo.toml index 18d910c..779ceee 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -1,31 +1,35 @@ -[package] -name = "normogen-backend" -version = "0.1.0" -edition = "2021" - -[dependencies] -axum = "0.7.9" -tokio = { version = "1.41.1", features = ["full"] } -tower = "0.4.13" -tower-http = { version = "0.5.2", features = ["cors", "trace"] } -tower_governor = "0.4.3" -serde = { version = "1.0.215", features = ["derive"] } -serde_json = "1.0.133" -mongodb = "2.8.2" -jsonwebtoken = "9.3.1" -chrono = { version = "0.4.38", features = ["serde"] } -dotenv = "0.15.0" -validator = { version = "0.16.1", features = ["derive"] } -uuid = { version = "1.11.0", features = ["v4", "serde"] } -reqwest = { version = "0.12.28", features = ["json"] } -pbkdf2 = { version = "0.12.2", features = ["simple"] } -password-hash = "0.5.0" -rand = "0.8.5" -base64 = "0.22.1" -thiserror = "1.0.69" -anyhow = "1.0.94" -tracing = "0.1.41" -tracing-subscriber = { version = "0.3.19", features = ["env-filter"] } -slog = "2.7.0" -strum = { version = "0.26", features = ["derive"] } -futures = "0.3" +### /home/asoliver/desarrollo/normogen/./backend/Cargo.toml +```toml +1: [package] +2: name = "normogen-backend" +3: version = "0.1.0" +4: edition = "2021" +5: +6: [dependencies] +7: axum = { version = "0.7", features = ["macros", "multipart"] } +8: tokio = { version = "1", features = ["full"] } +9: tower = "0.4" +10: tower-http = { version = "0.5", features = ["cors", "trace", "limit", "decompression-gzip"] } +11: tower_governor = "0.4" +12: governor = "0.6" +13: serde = { version = "1", features = ["derive"] } +14: serde_json = "1" +15: mongodb = "2.8" +16: jsonwebtoken = "9" +17: async-trait = "0.1" +18: dotenv = "0.15" +19: tracing = "0.1" +20: tracing-subscriber = { version = "0.3", features = ["env-filter"] } +21: validator = { version = "0.16", features = ["derive"] } +22: uuid = { version = "1", features = ["v4", "serde"] } +23: chrono = { version = "0.4", features = ["serde"] } +24: pbkdf2 = { version = "0.12", features = ["simple"] } +25: sha2 = "0.10" +26: rand = "0.8" +27: anyhow = "1" +28: thiserror = "1" +29: +30: [dev-dependencies] +31: tokio-test = "0.4" +32: reqwest = { version = "0.12", features = ["json"] } +``` diff --git a/backend/PHASE-2.5-SUMMARY.md b/backend/PHASE-2.5-SUMMARY.md deleted file mode 100644 index a2e2776..0000000 --- a/backend/PHASE-2.5-SUMMARY.md +++ /dev/null @@ -1,173 +0,0 @@ -# Phase 2.5 Completion Summary - Access Control - -## โœ… Build Status -**Status:** โœ… SUCCESSFUL - All build errors fixed! - -The backend now compiles successfully with only minor warnings about unused code (which is expected for middleware and utility functions that will be used in future phases). - -## ๐Ÿ“‹ Phase 2.5 Deliverables - -### 1. Permission Model -- **File:** `backend/src/models/permission.rs` -- **Features:** - - Permission enum with all required types (Read, Write, Delete, Share, Admin) - - Full serde serialization support - - Display trait implementation - -### 2. Share Model -- **File:** `backend/src/models/share.rs` -- **Features:** - - Complete Share struct with all fields - - Repository implementation with CRUD operations - - Helper methods for permission checking - - Support for expiration and active/inactive states - -### 3. Share Handlers -- **File:** `backend/src/handlers/shares.rs` -- **Endpoints:** - - `POST /api/shares` - Create a new share - - `GET /api/shares` - List all shares for current user - - `GET /api/shares/:id` - Get a specific share - - `PUT /api/shares/:id` - Update a share - - `DELETE /api/shares/:id` - Delete a share -- **Features:** - - Input validation with `validator` crate - - Ownership verification - - Error handling with proper HTTP status codes - - Resource-level permission support - -### 4. Permission Middleware -- **File:** `backend/src/middleware/permission.rs` -- **Features:** - - `PermissionMiddleware` for route protection - - `has_permission` helper function - - `extract_resource_id` utility - - Integration with Axum router - -### 5. Permission Check Handler -- **File:** `backend/src/handlers/permissions.rs` -- **Endpoint:** - - `GET /api/permissions/check` - Check if user has permission -- **Features:** - - Query parameter validation - - Database integration for permission checking - - Structured response format - -### 6. User Profile Management -- **File:** `backend/src/handlers/users.rs` -- **Endpoints:** - - `GET /api/users/profile` - Get user profile - - `PUT /api/users/profile` - Update profile - - `DELETE /api/users/profile` - Delete account - - `POST /api/users/password` - Change password - - `GET /api/users/settings` - Get settings - - `PUT /api/users/settings` - Update settings -- **Features:** - - Complete CRUD for user profiles - - Password management - - Recovery phrase management - - Settings management - -### 7. Database Integration -- **File:** `backend/src/db/mongodb_impl.rs` -- **Added Methods:** - - `create_share` - Create a new share - - `get_share` - Get share by ID - - `list_shares_for_user` - List all shares for a user - - `update_share` - Update an existing share - - `delete_share` - Delete a share - - `check_user_permission` - Check if user has specific permission - - `find_share_by_target` - Find shares where user is target - - `find_shares_by_resource` - Find all shares for a resource - - `delete_user` - Delete a user account - - `update_last_active` - Update user's last active timestamp - -### 8. Router Configuration -- **File:** `backend/src/main.rs` -- **Routes Added:** - - Permission check endpoint - - Share CRUD endpoints - - User profile and settings endpoints - - Recovery password endpoint - -### 9. Dependencies -- **File:** `backend/Cargo.toml` -- **All Required Dependencies:** - - `pbkdf2` with `simple` feature enabled - - `tower_governor` (rate limiting) - - `validator` (input validation) - - `futures` (async utilities) - - All other Phase 2 dependencies maintained - -## ๐Ÿ”ง Fixes Applied - -### Build Errors Fixed: -1. โœ… Fixed `tower-governor` โ†’ `tower_governor` dependency name -2. โœ… Fixed pbkdf2 configuration (enabled `simple` feature) -3. โœ… Fixed Handler trait bound issues (added proper extractors) -4. โœ… Fixed file corruption issues (removed markdown artifacts) -5. โœ… Fixed import paths (bson โ†’ mongodb::bson) -6. โœ… Fixed error handling in user model (ObjectId parsing) -7. โœ… Fixed unused imports and dead code warnings - -### Code Quality Improvements: -- Proper error handling throughout -- Input validation on all endpoints -- Type-safe permission system -- Comprehensive logging with `tracing` -- Clean separation of concerns - -## ๐Ÿ“Š API Endpoints Summary - -### Authentication -- `POST /api/auth/register` - Register new user -- `POST /api/auth/login` - Login -- `POST /api/auth/recover` - Recover password with recovery phrase - -### User Management -- `GET /api/users/profile` - Get profile -- `PUT /api/users/profile` - Update profile -- `DELETE /api/users/profile` - Delete account -- `POST /api/users/password` - Change password -- `GET /api/users/settings` - Get settings -- `PUT /api/users/settings` - Update settings - -### Shares (Resource Sharing) -- `POST /api/shares` - Create share -- `GET /api/shares` - List shares -- `GET /api/shares/:id` - Get share -- `PUT /api/shares/:id` - Update share -- `DELETE /api/shares/:id` - Delete share - -### Permissions -- `GET /api/permissions/check?resource_type=X&resource_id=Y&permission=Z` - Check permission - -## ๐Ÿš€ Ready for Next Phase - -Phase 2.5 is **COMPLETE** and all build errors have been **RESOLVED**. - -The backend now has a fully functional access control system with: -- โœ… User authentication with JWT -- โœ… Password recovery with zero-knowledge recovery phrases -- โœ… Resource-level permissions -- โœ… Share management (grant, modify, revoke permissions) -- โœ… Permission checking API -- โœ… User profile management -- โœ… Rate limiting -- โœ… Comprehensive error handling - -## ๐Ÿ“ Notes - -- All handlers use proper Axum extractors (State, Path, Json, Extension) -- JWT middleware adds Claims to request extensions -- All database operations use proper MongoDB error types -- Input validation is applied on all request bodies -- Logging is implemented for debugging and monitoring -- Code follows Rust best practices and idioms - ---- - -**Completed:** 2025-02-15 -**Build Status:** โœ… SUCCESS -**Warnings:** 28 (mostly unused code - expected) -**Errors:** 0 diff --git a/backend/src/auth/mod.rs b/backend/src/auth/mod.rs index 472360f..a8aa3cd 100644 --- a/backend/src/auth/mod.rs +++ b/backend/src/auth/mod.rs @@ -1,4 +1,6 @@ pub mod jwt; pub mod password; +pub mod claims; -pub use jwt::{Claims, JwtService}; +pub use jwt::*; +pub use password::*; diff --git a/backend/src/auth/password.rs b/backend/src/auth/password.rs index 9ffc867..774dbd1 100644 --- a/backend/src/auth/password.rs +++ b/backend/src/auth/password.rs @@ -26,8 +26,3 @@ impl PasswordService { .map_err(|e| anyhow::anyhow!("Password verification failed: {}", e)) } } - -/// Convenience function to verify a password -pub fn verify_password(password: &str, hash: &str) -> Result { - PasswordService::verify_password(password, hash) -} diff --git a/backend/src/db/appointment.rs b/backend/src/db/appointment.rs deleted file mode 100644 index 77cf576..0000000 --- a/backend/src/db/appointment.rs +++ /dev/null @@ -1 +0,0 @@ -// Stub for future appointment operations diff --git a/backend/src/db/family.rs b/backend/src/db/family.rs deleted file mode 100644 index 9d0d242..0000000 --- a/backend/src/db/family.rs +++ /dev/null @@ -1 +0,0 @@ -// Stub for future family operations diff --git a/backend/src/db/health_data.rs b/backend/src/db/health_data.rs deleted file mode 100644 index 088ad7c..0000000 --- a/backend/src/db/health_data.rs +++ /dev/null @@ -1 +0,0 @@ -// Stub for future health_data operations diff --git a/backend/src/db/lab_result.rs b/backend/src/db/lab_result.rs deleted file mode 100644 index 4ffed49..0000000 --- a/backend/src/db/lab_result.rs +++ /dev/null @@ -1 +0,0 @@ -// Stub for future lab_result operations diff --git a/backend/src/db/medication.rs b/backend/src/db/medication.rs deleted file mode 100644 index 3bdbf68..0000000 --- a/backend/src/db/medication.rs +++ /dev/null @@ -1 +0,0 @@ -// Stub for future medication operations diff --git a/backend/src/db/mod.rs b/backend/src/db/mod.rs index 0d8ee05..236f0f6 100644 --- a/backend/src/db/mod.rs +++ b/backend/src/db/mod.rs @@ -1,27 +1,45 @@ -use mongodb::{Client, Database}; -use std::env; -use anyhow::Result; - -pub mod user; -pub mod family; -pub mod profile; -pub mod health_data; -pub mod lab_result; -pub mod medication; -pub mod appointment; -pub mod share; -pub mod permission; - -mod mongodb_impl; - -pub use mongodb_impl::MongoDb; - -pub async fn create_database() -> Result { - let mongo_uri = env::var("MONGODB_URI").expect("MONGODB_URI must be set"); - let db_name = env::var("DATABASE_NAME").expect("DATABASE_NAME must be set"); - - let client = Client::with_uri_str(&mongo_uri).await?; - let database = client.database(&db_name); - - Ok(database) -} +### /home/asoliver/desarrollo/normogen/./backend/src/db/mod.rs +```rust +1: use mongodb::{ +2: Client, +3: Database, +4: Collection, +5: options::ClientOptions, +6: }; +7: use anyhow::Result; +8: +9: #[derive(Clone)] +10: pub struct MongoDb { +11: client: Client, +12: database_name: String, +13: } +14: +15: impl MongoDb { +16: pub async fn new(uri: &str, database_name: &str) -> Result { +17: let mut client_options = ClientOptions::parse(uri).await?; +18: client_options.default_database = Some(database_name.to_string()); +19: +20: let client = Client::with_options(client_options)?; +21: +22: Ok(Self { +23: client, +24: database_name: database_name.to_string(), +25: }) +26: } +27: +28: pub fn database(&self) -> Database { +29: self.client.database(&self.database_name) +30: } +31: +32: pub fn collection(&self, name: &str) -> Collection { +33: self.database().collection(name) +34: } +35: +36: pub async fn health_check(&self) -> Result { +37: self.database() +38: .run_command(mongodb::bson::doc! { "ping": 1 }, None) +39: .await?; +40: Ok("healthy".to_string()) +41: } +42: } +``` diff --git a/backend/src/db/mongodb_impl.rs b/backend/src/db/mongodb_impl.rs deleted file mode 100644 index ddc08ab..0000000 --- a/backend/src/db/mongodb_impl.rs +++ /dev/null @@ -1,160 +0,0 @@ -use mongodb::{Client, Database, Collection, bson::doc}; -use anyhow::Result; -use mongodb::bson::oid::ObjectId; - -use crate::models::{ - user::{User, UserRepository}, - share::{Share, ShareRepository}, - permission::Permission, -}; - -#[derive(Clone)] -pub struct MongoDb { - database: Database, - pub users: Collection, - pub shares: Collection, -} - -impl MongoDb { - pub async fn new(uri: &str, db_name: &str) -> Result { - let client = Client::with_uri_str(uri).await?; - let database = client.database(db_name); - - Ok(Self { - users: database.collection("users"), - shares: database.collection("shares"), - database, - }) - } - - pub async fn health_check(&self) -> Result { - self.database.run_command(doc! { "ping": 1 }, None).await?; - Ok("OK".to_string()) - } - - // ===== User Methods ===== - - pub async fn create_user(&self, user: &User) -> Result> { - let repo = UserRepository::new(self.users.clone()); - Ok(repo.create(user).await?) - } - - pub async fn find_user_by_email(&self, email: &str) -> Result> { - let repo = UserRepository::new(self.users.clone()); - Ok(repo.find_by_email(email).await?) - } - - pub async fn find_user_by_id(&self, id: &ObjectId) -> Result> { - let repo = UserRepository::new(self.users.clone()); - Ok(repo.find_by_id(id).await?) - } - - pub async fn update_user(&self, user: &User) -> Result<()> { - let repo = UserRepository::new(self.users.clone()); - repo.update(user).await?; - Ok(()) - } - - pub async fn update_last_active(&self, user_id: &ObjectId) -> Result<()> { - let repo = UserRepository::new(self.users.clone()); - repo.update_last_active(user_id).await?; - Ok(()) - } - - pub async fn delete_user(&self, user_id: &ObjectId) -> Result<()> { - let repo = UserRepository::new(self.users.clone()); - repo.delete(user_id).await?; - Ok(()) - } - - // ===== Share Methods ===== - - pub async fn create_share(&self, share: &Share) -> Result> { - let repo = ShareRepository::new(self.shares.clone()); - Ok(repo.create(share).await?) - } - - pub async fn get_share(&self, id: &str) -> Result> { - let object_id = ObjectId::parse_str(id)?; - let repo = ShareRepository::new(self.shares.clone()); - Ok(repo.find_by_id(&object_id).await?) - } - - pub async fn list_shares_for_user(&self, user_id: &str) -> Result> { - let object_id = ObjectId::parse_str(user_id)?; - let repo = ShareRepository::new(self.shares.clone()); - Ok(repo.find_by_target(&object_id).await?) - } - - pub async fn update_share(&self, share: &Share) -> Result<()> { - let repo = ShareRepository::new(self.shares.clone()); - repo.update(share).await?; - Ok(()) - } - - pub async fn delete_share(&self, id: &str) -> Result<()> { - let object_id = ObjectId::parse_str(id)?; - let repo = ShareRepository::new(self.shares.clone()); - repo.delete(&object_id).await?; - Ok(()) - } - - // ===== Permission Methods ===== - - pub async fn check_user_permission( - &self, - user_id: &str, - resource_type: &str, - resource_id: &str, - permission: &str, - ) -> Result { - let user_oid = ObjectId::parse_str(user_id)?; - let resource_oid = ObjectId::parse_str(resource_id)?; - - let repo = ShareRepository::new(self.shares.clone()); - let shares = repo.find_by_target(&user_oid).await?; - - for share in shares { - if share.resource_type == resource_type - && share.resource_id.as_ref() == Some(&resource_oid) - && share.active - && !share.is_expired() - { - // Check if share has the required permission - let perm = match permission.to_lowercase().as_str() { - "read" => Permission::Read, - "write" => Permission::Write, - "delete" => Permission::Delete, - "share" => Permission::Share, - "admin" => Permission::Admin, - _ => return Ok(false), - }; - - if share.has_permission(&perm) { - return Ok(true); - } - } - } - - Ok(false) - } - - /// Check permission using a simplified interface - pub async fn check_permission( - &self, - user_id: &str, - resource_id: &str, - permission: &str, - ) -> Result { - // For now, check all resource types - let resource_types = ["profiles", "health_data", "lab_results", "medications"]; - - for resource_type in resource_types { - if self.check_user_permission(user_id, resource_type, resource_id, permission).await? { - return Ok(true); - } - } - - Ok(false) - } -} diff --git a/backend/src/db/permission.rs b/backend/src/db/permission.rs deleted file mode 100644 index 6639bce..0000000 --- a/backend/src/db/permission.rs +++ /dev/null @@ -1,2 +0,0 @@ -// Permission-related database operations are in MongoDb struct -// This file exists for module organization diff --git a/backend/src/db/profile.rs b/backend/src/db/profile.rs deleted file mode 100644 index e8d20ea..0000000 --- a/backend/src/db/profile.rs +++ /dev/null @@ -1 +0,0 @@ -// Stub for future profile operations diff --git a/backend/src/db/share.rs b/backend/src/db/share.rs deleted file mode 100644 index ed94a98..0000000 --- a/backend/src/db/share.rs +++ /dev/null @@ -1,2 +0,0 @@ -// Share-related database operations are in MongoDb struct -// This file exists for module organization diff --git a/backend/src/db/user.rs b/backend/src/db/user.rs deleted file mode 100644 index a77a7fa..0000000 --- a/backend/src/db/user.rs +++ /dev/null @@ -1,2 +0,0 @@ -// User-related database operations are in MongoDb struct -// This file exists for module organization diff --git a/backend/src/handlers/auth.rs b/backend/src/handlers/auth.rs index 360e8c2..fc67395 100644 --- a/backend/src/handlers/auth.rs +++ b/backend/src/handlers/auth.rs @@ -1,3 +1,757 @@ +### /home/asoliver/desarrollo/normogen/./backend/src/handlers/auth.rs +```rust +1: use axum::{ +2: extract::{State}, +3: http::StatusCode, +4: response::IntoResponse, +5: Json, +6: }; +7: use serde::{Deserialize, Serialize}; +8: use validator::Validate; +9: use wither::bson::oid::ObjectId; +10: +11: use crate::{ +12: auth::jwt::{Claims, JwtService}, +13: auth::password::verify_password, +14: config::AppState, +15: models::user::{User, UserRepository}, +16: }; +17: +18: #[derive(Debug, Deserialize, Validate)] +19: pub struct RegisterRequest { +20: #[validate(email)] +21: pub email: String, +22: #[validate(length(min = 3))] +23: pub username: String, +24: #[validate(length(min = 8))] +25: pub password: String, +26: /// Optional recovery phrase for password recovery +27: pub recovery_phrase: Option, +28: } +29: +30: #[derive(Debug, Serialize)] +31: pub struct RegisterResponse { +32: pub message: String, +33: pub user_id: String, +34: } +35: +36: #[derive(Debug, Deserialize, Validate)] +37: pub struct LoginRequest { +38: #[validate(email)] +39: pub email: String, +40: pub password: String, +41: } +42: +43: #[derive(Debug, Serialize)] +44: pub struct LoginResponse { +45: pub access_token: String, +46: pub refresh_token: String, +47: pub user_id: String, +48: } +49: +50: #[derive(Debug, Deserialize)] +51: pub struct RefreshTokenRequest { +52: pub refresh_token: String, +53: } +54: +55: #[derive(Debug, Serialize)] +56: pub struct RefreshTokenResponse { +57: pub access_token: String, +58: pub refresh_token: String, +59: } +60: +61: #[derive(Debug, Deserialize)] +62: pub struct LogoutRequest { +63: pub refresh_token: String, +64: } +65: +66: #[derive(Debug, Serialize)] +67: pub struct MessageResponse { +68: pub message: String, +69: } +70: +71: // ===== Password Recovery Handlers ===== +72: +73: #[derive(Debug, Deserialize, Validate)] +74: pub struct SetupRecoveryRequest { +75: pub recovery_phrase: String, +76: #[validate(length(min = 8))] +77: pub current_password: String, +78: } +79: +80: #[derive(Debug, Serialize)] +81: pub struct SetupRecoveryResponse { +82: pub message: String, +83: pub recovery_enabled: bool, +84: } +85: +86: #[derive(Debug, Deserialize, Validate)] +87: pub struct VerifyRecoveryRequest { +88: #[validate(email)] +89: pub email: String, +90: pub recovery_phrase: String, +91: } +92: +93: #[derive(Debug, Serialize)] +94: pub struct VerifyRecoveryResponse { +95: pub verified: bool, +96: pub message: String, +97: } +98: +99: #[derive(Debug, Deserialize, Validate)] +100: pub struct ResetPasswordRequest { +101: #[validate(email)] +102: pub email: String, +103: pub recovery_phrase: String, +104: #[validate(length(min = 8))] +105: pub new_password: String, +106: } +107: +108: #[derive(Debug, Serialize)] +109: pub struct ResetPasswordResponse { +110: pub message: String, +111: } +112: +113: // ===== Handlers ===== +114: +115: pub async fn register( +116: State(state): State, +117: Json(req): Json, +118: ) -> Result)> { +119: // Validate request +120: if let Err(errors) = req.validate() { +121: return Err(( +122: StatusCode::BAD_REQUEST, +123: Json(MessageResponse { +124: message: format!("Validation error: {}", errors), +125: }), +126: )); +127: } +128: +129: // Check if user already exists +130: if let Ok(Some(_)) = state.db.user_repo.find_by_email(&req.email).await { +131: return Err(( +132: StatusCode::CONFLICT, +133: Json(MessageResponse { +134: message: "User already exists".to_string(), +135: }), +136: )); +137: } +138: +139: // Create new user +140: let user = match User::new( +141: req.email.clone(), +142: req.username.clone(), +143: req.password.clone(), +144: req.recovery_phrase.clone(), +145: ) { +146: Ok(user) => user, +147: Err(e) => { +148: return Err(( +149: StatusCode::INTERNAL_SERVER_ERROR, +150: Json(MessageResponse { +151: message: format!("Failed to create user: {}", e), +152: }), +153: )) +154: } +155: }; +156: +157: // Save to database +158: let user_id = match state.db.user_repo.create(&user).await { +159: Ok(Some(id)) => id.to_string(), +160: Ok(None) => { +161: return Err(( +162: StatusCode::INTERNAL_SERVER_ERROR, +163: Json(MessageResponse { +164: message: "Failed to create user".to_string(), +165: }), +166: )) +167: } +168: Err(e) => { +169: return Err(( +170: StatusCode::INTERNAL_SERVER_ERROR, +171: Json(MessageResponse { +172: message: format!("Database error: {}", e), +173: }), +174: )) +175: } +176: }; +177: +178: tracing::info!("User registered: {}", user_id); +179: +180: Ok(( +181: StatusCode::CREATED, +182: Json(RegisterResponse { +183: message: "User registered successfully".to_string(), +184: user_id, +185: }), +186: )) +187: } +188: +189: pub async fn login( +190: State(state): State, +191: Json(req): Json, +192: ) -> Result)> { +193: // Validate request +194: if let Err(errors) = req.validate() { +195: return Err(( +196: StatusCode::BAD_REQUEST, +197: Json(MessageResponse { +198: message: format!("Validation error: {}", errors), +199: }), +200: )); +201: } +202: +203: // Find user +204: let user = match state.db.user_repo.find_by_email(&req.email).await { +205: Ok(Some(user)) => user, +206: Ok(None) => { +207: return Err(( +208: StatusCode::UNAUTHORIZED, +209: Json(MessageResponse { +210: message: "Invalid credentials".to_string(), +211: }), +212: )); +213: } +214: Err(e) => { +215: return Err(( +216: StatusCode::INTERNAL_SERVER_ERROR, +217: Json(MessageResponse { +218: message: format!("Database error: {}", e), +219: }), +220: )) +221: } +222: }; +223: +224: // Verify password +225: match verify_password(&req.password, &user.password_hash) { +226: Ok(true) => {} +227: Ok(false) => { +228: return Err(( +229: StatusCode::UNAUTHORIZED, +230: Json(MessageResponse { +231: message: "Invalid credentials".to_string(), +232: }), +233: )); +234: } +235: Err(e) => { +236: return Err(( +237: StatusCode::INTERNAL_SERVER_ERROR, +238: Json(MessageResponse { +239: message: format!("Failed to verify password: {}", e), +240: }), +241: )) +242: } +243: } +244: +245: // Generate tokens +246: let claims = Claims::new( +247: user.id.unwrap().to_string(), +248: user.email.clone(), +249: user.token_version, +250: ); +251: +252: let (access_token, refresh_token) = match state +253: .jwt_service +254: .generate_tokens(claims.clone()) +255: { +256: Ok(tokens) => tokens, +257: Err(e) => { +258: return Err(( +259: StatusCode::INTERNAL_SERVER_ERROR, +260: Json(MessageResponse { +261: message: format!("Failed to generate tokens: {}", e), +262: }), +263: )) +264: } +265: }; +266: +267: // Store refresh token +268: state +269: .jwt_service +270: .store_refresh_token(&user.id.unwrap().to_string(), &refresh_token) +271: .await +272: .map_err(|e| { +273: ( +274: StatusCode::INTERNAL_SERVER_ERROR, +275: Json(MessageResponse { +276: message: format!("Failed to store refresh token: {}", e), +277: }), +278: ) +279: })?; +280: +281: // Update last active +282: let _ = state +283: .db +284: .user_repo +285: .update_last_active(&user.id.unwrap()) +286: .await; +287: +288: tracing::info!("User logged in: {}", user.email); +289: +290: Ok(( +291: StatusCode::OK, +292: Json(LoginResponse { +293: access_token, +294: refresh_token, +295: user_id: user.id.unwrap().to_string(), +296: }), +297: )) +298: } +299: +300: pub async fn refresh_token( +301: State(state): State, +302: Json(req): Json, +303: ) -> Result)> { +304: // Validate refresh token +305: let claims = match state.jwt_service.validate_refresh_token(&req.refresh_token) { +306: Ok(claims) => claims, +307: Err(e) => { +308: return Err(( +309: StatusCode::UNAUTHORIZED, +310: Json(MessageResponse { +311: message: format!("Invalid refresh token: {}", e), +312: }), +313: )) +314: } +315: }; +316: +317: // Check if refresh token is stored +318: let is_valid = state +319: .jwt_service +320: .verify_refresh_token_stored(&claims.user_id, &req.refresh_token) +321: .await +322: .map_err(|e| { +323: ( +324: StatusCode::INTERNAL_SERVER_ERROR, +325: Json(MessageResponse { +326: message: format!("Database error: {}", e), +327: }), +328: ) +329: })?; +330: +331: if !is_valid { +332: return Err(( +333: StatusCode::UNAUTHORIZED, +334: Json(MessageResponse { +335: message: "Refresh token not found or expired".to_string(), +336: }), +337: )); +338: } +339: +340: // Get user to check token version +341: let user = match state +342: .db +343: .user_repo +344: .find_by_id(&ObjectId::parse_str(&claims.user_id).unwrap()) +345: .await +346: { +347: Ok(Some(user)) => user, +348: Ok(None) => { +349: return Err(( +350: StatusCode::UNAUTHORIZED, +351: Json(MessageResponse { +352: message: "User not found".to_string(), +353: }), +354: )) +355: } +356: Err(e) => { +357: return Err(( +358: StatusCode::INTERNAL_SERVER_ERROR, +359: Json(MessageResponse { +360: message: format!("Database error: {}", e), +361: }), +362: )) +363: } +364: }; +365: +366: // Check token version +367: if user.token_version != claims.token_version { +368: return Err(( +369: StatusCode::UNAUTHORIZED, +370: Json(MessageResponse { +371: message: "Token version mismatch. Please login again.".to_string(), +372: }), +373: )); +374: } +375: +376: // Generate new tokens +377: let (access_token, new_refresh_token) = match state +378: .jwt_service +379: .generate_tokens(claims.clone()) +380: { +381: Ok(tokens) => tokens, +382: Err(e) => { +383: return Err(( +384: StatusCode::INTERNAL_SERVER_ERROR, +385: Json(MessageResponse { +386: message: format!("Failed to generate tokens: {}", e), +387: }), +388: )) +389: } +390: }; +391: +392: // Store new refresh token and revoke old one +393: state +394: .jwt_service +395: .rotate_refresh_token(&claims.user_id, &req.refresh_token, &new_refresh_token) +396: .await +397: .map_err(|e| { +398: ( +399: StatusCode::INTERNAL_SERVER_ERROR, +400: Json(MessageResponse { +401: message: format!("Failed to rotate refresh token: {}", e), +402: }), +403: ) +404: })?; +405: +406: // Update last active +407: let _ = state +408: .db +409: .user_repo +410: .update_last_active(&user.id.unwrap()) +411: .await; +412: +413: tracing::info!("Token refreshed for user: {}", claims.user_id); +414: +415: Ok(( +416: StatusCode::OK, +417: Json(RefreshTokenResponse { +418: access_token, +419: refresh_token: new_refresh_token, +420: }), +421: )) +422: } +423: +424: pub async fn logout( +425: State(state): State, +426: Json(req): Json, +427: ) -> Result)> { +428: // Revoke refresh token +429: state +430: .jwt_service +431: .revoke_refresh_token(&req.refresh_token) +432: .await +433: .map_err(|e| { +434: ( +435: StatusCode::INTERNAL_SERVER_ERROR, +436: Json(MessageResponse { +437: message: format!("Failed to logout: {}", e), +438: }), +439: ) +440: })?; +441: +442: tracing::info!("User logged out"); +443: +444: Ok(( +445: StatusCode::OK, +446: Json(MessageResponse { +447: message: "Logged out successfully".to_string(), +448: }), +449: )) +450: } +451: +452: // ===== Password Recovery Handlers ===== +453: +454: /// Setup or update password recovery phrase +455: pub async fn setup_recovery( +456: State(state): State, +457: claims: Claims, +458: Json(req): Json, +459: ) -> Result)> { +460: // Validate request +461: if let Err(errors) = req.validate() { +462: return Err(( +463: StatusCode::BAD_REQUEST, +464: Json(MessageResponse { +465: message: format!("Validation error: {}", errors), +466: }), +467: )); +468: } +469: +470: // Find user +471: let mut user = match state +472: .db +473: .user_repo +474: .find_by_id(&ObjectId::parse_str(&claims.user_id).unwrap()) +475: .await +476: { +477: Ok(Some(user)) => user, +478: Ok(None) => { +479: return Err(( +480: StatusCode::NOT_FOUND, +481: Json(MessageResponse { +482: message: "User not found".to_string(), +483: }), +484: )) +485: } +486: Err(e) => { +487: return Err(( +488: StatusCode::INTERNAL_SERVER_ERROR, +489: Json(MessageResponse { +490: message: format!("Database error: {}", e), +491: }), +492: )) +493: } +494: }; +495: +496: // Verify current password +497: match verify_password(&req.current_password, &user.password_hash) { +498: Ok(true) => {} +499: Ok(false) => { +500: return Err(( +501: StatusCode::UNAUTHORIZED, +502: Json(MessageResponse { +503: message: "Invalid current password".to_string(), +504: }), +505: )); +506: } +507: Err(e) => { +508: return Err(( +509: StatusCode::INTERNAL_SERVER_ERROR, +510: Json(MessageResponse { +511: message: format!("Failed to verify password: {}", e), +512: }), +513: )) +514: } +515: } +516: +517: // Set recovery phrase +518: match user.set_recovery_phrase(req.recovery_phrase) { +519: Ok(_) => {} +520: Err(e) => { +521: return Err(( +522: StatusCode::INTERNAL_SERVER_ERROR, +523: Json(MessageResponse { +524: message: format!("Failed to set recovery phrase: {}", e), +525: }), +526: )) +527: } +528: } +529: +530: // Update user +531: match state.db.user_repo.update(&user).await { +532: Ok(_) => {} +533: Err(e) => { +534: return Err(( +535: StatusCode::INTERNAL_SERVER_ERROR, +536: Json(MessageResponse { +537: message: format!("Failed to update user: {}", e), +538: }), +539: )) +540: } +541: } +542: +543: tracing::info!("Recovery phrase set for user: {}", claims.user_id); +544: +545: Ok(( +546: StatusCode::OK, +547: Json(SetupRecoveryResponse { +548: message: "Recovery phrase set successfully".to_string(), +549: recovery_enabled: true, +550: }), +551: )) +552: } +553: +554: /// Verify recovery phrase (before password reset) +555: pub async fn verify_recovery( +556: State(state): State, +557: Json(req): Json, +558: ) -> Result)> { +559: // Validate request +560: if let Err(errors) = req.validate() { +561: return Err(( +562: StatusCode::BAD_REQUEST, +563: Json(MessageResponse { +564: message: format!("Validation error: {}", errors), +565: }), +566: )); +567: } +568: +569: // Find user +570: let user = match state.db.user_repo.find_by_email(&req.email).await { +571: Ok(Some(user)) => user, +572: Ok(None) => { +573: return Err(( +574: StatusCode::NOT_FOUND, +575: Json(MessageResponse { +576: message: "User not found".to_string(), +577: }), +578: )) +579: } +580: Err(e) => { +581: return Err(( +582: StatusCode::INTERNAL_SERVER_ERROR, +583: Json(MessageResponse { +584: message: format!("Database error: {}", e), +585: }), +586: )) +587: } +588: }; +589: +590: // Check if recovery is enabled +591: if !user.recovery_enabled { +592: return Err(( +593: StatusCode::BAD_REQUEST, +594: Json(MessageResponse { +595: message: "Password recovery is not enabled for this account".to_string(), +596: }), +597: )); +598: } +599: +600: // Verify recovery phrase +601: match user.verify_recovery_phrase(&req.recovery_phrase) { +602: Ok(true) => { +603: tracing::info!("Recovery phrase verified for user: {}", user.email); +604: Ok(( +605: StatusCode::OK, +606: Json(VerifyRecoveryResponse { +607: verified: true, +608: message: "Recovery phrase verified. You can now reset your password.".to_string(), +609: }), +610: )) +611: } +612: Ok(false) => { +613: tracing::warn!("Failed recovery phrase attempt for user: {}", user.email); +614: Err(( +615: StatusCode::UNAUTHORIZED, +616: Json(MessageResponse { +617: message: "Invalid recovery phrase".to_string(), +618: }), +619: )) +620: } +621: Err(e) => { +622: Err(( +623: StatusCode::INTERNAL_SERVER_ERROR, +624: Json(MessageResponse { +625: message: format!("Failed to verify recovery phrase: {}", e), +626: }), +627: )) +628: } +629: } +630: } +631: +632: /// Reset password using recovery phrase +633: pub async fn reset_password( +634: State(state): State, +635: Json(req): Json, +636: ) -> Result)> { +637: // Validate request +638: if let Err(errors) = req.validate() { +639: return Err(( +640: StatusCode::BAD_REQUEST, +641: Json(MessageResponse { +642: message: format!("Validation error: {}", errors), +643: }), +644: )); +645: } +646: +647: // Find user +648: let mut user = match state.db.user_repo.find_by_email(&req.email).await { +649: Ok(Some(user)) => user, +650: Ok(None) => { +651: return Err(( +652: StatusCode::NOT_FOUND, +653: Json(MessageResponse { +654: message: "User not found".to_string(), +655: }), +656: )) +657: } +658: Err(e) => { +659: return Err(( +660: StatusCode::INTERNAL_SERVER_ERROR, +661: Json(MessageResponse { +662: message: format!("Database error: {}", e), +663: }), +664: )) +665: } +666: }; +667: +668: // Check if recovery is enabled +669: if !user.recovery_enabled { +670: return Err(( +671: StatusCode::BAD_REQUEST, +672: Json(MessageResponse { +673: message: "Password recovery is not enabled for this account".to_string(), +674: }), +675: )); +676: } +677: +678: // Verify recovery phrase +679: match user.verify_recovery_phrase(&req.recovery_phrase) { +680: Ok(true) => {} +681: Ok(false) => { +682: tracing::warn!("Failed password reset attempt for user: {}", user.email); +683: return Err(( +684: StatusCode::UNAUTHORIZED, +685: Json(MessageResponse { +686: message: "Invalid recovery phrase".to_string(), +687: }), +688: )); +689: } +690: Err(e) => { +691: return Err(( +692: StatusCode::INTERNAL_SERVER_ERROR, +693: Json(MessageResponse { +694: message: format!("Failed to verify recovery phrase: {}", e), +695: }), +696: )) +697: } +698: } +699: +700: // Update password (this increments token_version to invalidate all tokens) +701: match user.update_password(req.new_password.clone()) { +702: Ok(_) => {} +703: Err(e) => { +704: return Err(( +705: StatusCode::INTERNAL_SERVER_ERROR, +706: Json(MessageResponse { +707: message: format!("Failed to update password: {}", e), +708: }), +709: )) +710: } +711: } +712: +713: // Update user in database +714: match state.db.user_repo.update(&user).await { +715: Ok(_) => {} +716: Err(e) => { +717: return Err(( +718: StatusCode::INTERNAL_SERVER_ERROR, +719: Json(MessageResponse { +720: message: format!("Failed to update user: {}", e), +721: }), +722: )) +723: } +724: } +725: +726: // Revoke all refresh tokens for this user (token_version changed) +727: state +728: .jwt_service +729: .revoke_all_user_tokens(&user.id.unwrap().to_string()) +730: .await +731: .map_err(|e| { +732: ( +733: StatusCode::INTERNAL_SERVER_ERROR, +734: Json(MessageResponse { +735: message: format!("Failed to revoke tokens: {}", e), +736: }), +737: ) +738: })?; +739: +740: tracing::info!("Password reset for user: {}", user.email); +741: +742: Ok(( +743: StatusCode::OK, +744: Json(ResetPasswordResponse { +745: message: "Password reset successfully. Please login with your new password.".to_string(), +746: }), +747: )) +748: } +``` + +// Email Verification Handlers (Stub Implementation) + use axum::{ extract::{State}, http::StatusCode, @@ -6,271 +760,329 @@ use axum::{ }; use serde::{Deserialize, Serialize}; use validator::Validate; +use wither::bson::oid::ObjectId; use crate::{ auth::jwt::Claims, config::AppState, - models::user::User, + models::user::{User, UserRepository}, }; -#[derive(Debug, Deserialize, Validate)] -pub struct RegisterRequest { - #[validate(email)] - pub email: String, - #[validate(length(min = 3))] - pub username: String, - #[validate(length(min = 8))] - pub password: String, - /// Optional recovery phrase for password recovery - pub recovery_phrase: Option, +#[derive(Debug, Serialize)] +pub struct EmailVerificationStatusResponse { + pub email_verified: bool, + pub message: String, } #[derive(Debug, Serialize)] -pub struct AuthResponse { +pub struct SendVerificationResponse { + pub message: String, + pub email_sent: bool, + pub verification_token: String, // For testing without email server +} + +#[derive(Debug, Deserialize, Validate)] +pub struct VerifyEmailRequest { pub token: String, - pub user_id: String, - pub email: String, - pub username: String, } -pub async fn register( +#[derive(Debug, Serialize)] +pub struct VerifyEmailResponse { + pub message: String, + pub email_verified: bool, +} + +/// Check email verification status (protected) +pub async fn get_verification_status( State(state): State, - Json(req): Json, -) -> impl IntoResponse { - if let Err(errors) = req.validate() { - return (StatusCode::BAD_REQUEST, Json(serde_json::json!({ - "error": "validation failed", - "details": errors.to_string() - }))).into_response(); - } - - // Check if user already exists - match state.db.find_user_by_email(&req.email).await { - Ok(Some(_)) => { - return (StatusCode::CONFLICT, Json(serde_json::json!({ - "error": "user already exists" - }))).into_response() - } - Ok(None) => {}, - Err(e) => { - tracing::error!("Failed to check user existence: {}", e); - return (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ - "error": "database error" - }))).into_response() - } - } - - // Create new user - let user = match User::new( - req.email.clone(), - req.username.clone(), - req.password, - req.recovery_phrase, - ) { - Ok(u) => u, - Err(e) => { - tracing::error!("Failed to create user: {}", e); - return (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ - "error": "failed to create user" - }))).into_response() - } - }; - - // Get token_version before saving - let token_version = user.token_version; - - // Save user to database - let user_id = match state.db.create_user(&user).await { - Ok(Some(id)) => id, + claims: Claims, +) -> Result)> { + let user = match state + .db + .user_repo + .find_by_id(&ObjectId::parse_str(&claims.user_id).unwrap()) + .await + { + Ok(Some(user)) => user, Ok(None) => { - return (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ - "error": "failed to create user" - }))).into_response() + return Err(( + StatusCode::NOT_FOUND, + Json(MessageResponse { + message: "User not found".to_string(), + }), + )) } Err(e) => { - tracing::error!("Failed to save user: {}", e); - return (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ - "error": "database error" - }))).into_response() + return Err(( + StatusCode::INTERNAL_SERVER_ERROR, + Json(MessageResponse { + message: format!("Database error: {}", e), + }), + )) } }; - - // Generate JWT token - let claims = Claims::new(user_id.to_string(), user.email.clone(), token_version); - let (token, _refresh_token) = match state.jwt_service.generate_tokens(claims) { - Ok(t) => t, - Err(e) => { - tracing::error!("Failed to generate token: {}", e); - return (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ - "error": "failed to generate token" - }))).into_response() - } - }; - - let response = AuthResponse { - token, - user_id: user_id.to_string(), - email: user.email, - username: user.username, - }; - - (StatusCode::CREATED, Json(response)).into_response() + + Ok(( + StatusCode::OK, + Json(EmailVerificationStatusResponse { + email_verified: user.email_verified, + message: if user.email_verified { + "Email is verified".to_string() + } else { + "Email is not verified".to_string() + }, + }), + )) } -#[derive(Debug, Deserialize, Validate)] -pub struct LoginRequest { - #[validate(email)] - pub email: String, - #[validate(length(min = 1))] - pub password: String, -} - -pub async fn login( +/// Send verification email (stub - no actual email server) +pub async fn send_verification_email( State(state): State, - Json(req): Json, -) -> impl IntoResponse { - if let Err(errors) = req.validate() { - return (StatusCode::BAD_REQUEST, Json(serde_json::json!({ - "error": "validation failed", - "details": errors.to_string() - }))).into_response(); - } - - // Find user by email - let user = match state.db.find_user_by_email(&req.email).await { - Ok(Some(u)) => u, + claims: Claims, +) -> Result)> { + let mut user = match state + .db + .user_repo + .find_by_id(&ObjectId::parse_str(&claims.user_id).unwrap()) + .await + { + Ok(Some(user)) => user, Ok(None) => { - return (StatusCode::UNAUTHORIZED, Json(serde_json::json!({ - "error": "invalid credentials" - }))).into_response() + return Err(( + StatusCode::NOT_FOUND, + Json(MessageResponse { + message: "User not found".to_string(), + }), + )) } Err(e) => { - tracing::error!("Failed to find user: {}", e); - return (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ - "error": "database error" - }))).into_response() + return Err(( + StatusCode::INTERNAL_SERVER_ERROR, + Json(MessageResponse { + message: format!("Database error: {}", e), + }), + )) } }; - - let user_id = user.id.ok_or_else(|| { - (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ - "error": "invalid user state" - }))) - }).unwrap(); - - // Verify password - match user.verify_password(&req.password) { - Ok(true) => {}, - Ok(false) => { - return (StatusCode::UNAUTHORIZED, Json(serde_json::json!({ - "error": "invalid credentials" - }))).into_response() - } + + // Check if already verified + if user.email_verified { + return Ok(( + StatusCode::OK, + Json(SendVerificationResponse { + message: "Email is already verified".to_string(), + email_sent: false, + verification_token: String::new(), + }), + )); + } + + // Generate verification token (random 32-char string) + use rand::Rng; + let verification_token: String = rand::thread_rng() + .sample_iter(&rand::distributions::Alphanumeric) + .take(32) + .map(char::to_string) + .collect(); + + // Set expiry to 24 hours from now + let expires = chrono::Utc::now() + chrono::Duration::hours(24); + + // Update user with verification token + user.verification_token = Some(verification_token.clone()); + user.verification_expires = Some(expires); + + match state.db.user_repo.update(&user).await { + Ok(_) => {} Err(e) => { - tracing::error!("Failed to verify password: {}", e); - return (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ - "error": "authentication error" - }))).into_response() + return Err(( + StatusCode::INTERNAL_SERVER_ERROR, + Json(MessageResponse { + message: format!("Failed to update user: {}", e), + }), + )) } } - - // Update last active - let _ = state.db.update_last_active(&user_id).await; - - // Generate JWT token - let claims = Claims::new(user_id.to_string(), user.email.clone(), user.token_version); - let (token, _refresh_token) = match state.jwt_service.generate_tokens(claims) { - Ok(t) => t, - Err(e) => { - tracing::error!("Failed to generate token: {}", e); - return (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ - "error": "failed to generate token" - }))).into_response() - } - }; - - let response = AuthResponse { - token, - user_id: user_id.to_string(), - email: user.email, - username: user.username, - }; - - (StatusCode::OK, Json(response)).into_response() + + // STUB: In production, this would send an actual email + // For now, we return the token in the response for testing + tracing::info!( + "Verification email STUB sent to {}: token={}", + user.email, + verification_token + ); + + Ok(( + StatusCode::OK, + Json(SendVerificationResponse { + message: "Verification email sent (STUB - no actual email sent)".to_string(), + email_sent: true, + verification_token, + }), + )) } -#[derive(Debug, Deserialize, Validate)] -pub struct RecoverPasswordRequest { - #[validate(email)] - pub email: String, - #[validate(length(min = 1))] - pub recovery_phrase: String, - #[validate(length(min = 8))] - pub new_password: String, -} - -pub async fn recover_password( +/// Verify email with token (public endpoint for convenience) +pub async fn verify_email( State(state): State, - Json(req): Json, -) -> impl IntoResponse { + Json(req): Json, +) -> Result)> { if let Err(errors) = req.validate() { - return (StatusCode::BAD_REQUEST, Json(serde_json::json!({ - "error": "validation failed", - "details": errors.to_string() - }))).into_response(); + return Err(( + StatusCode::BAD_REQUEST, + Json(MessageResponse { + message: format!("Validation error: {}", errors), + }), + )); } - - // Find user by email - let mut user = match state.db.find_user_by_email(&req.email).await { - Ok(Some(u)) => u, + + // Find user by verification token + // Note: In production, you'd want an index on verification_token + let mut user = match state + .db + .user_repo + .find_by_verification_token(&req.token) + .await + { + Ok(Some(user)) => user, Ok(None) => { - return (StatusCode::UNAUTHORIZED, Json(serde_json::json!({ - "error": "invalid credentials" - }))).into_response() + return Err(( + StatusCode::BAD_REQUEST, + Json(MessageResponse { + message: "Invalid or expired verification token".to_string(), + }), + )) } Err(e) => { - tracing::error!("Failed to find user: {}", e); - return (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ - "error": "database error" - }))).into_response() + return Err(( + StatusCode::INTERNAL_SERVER_ERROR, + Json(MessageResponse { + message: format!("Database error: {}", e), + }), + )) } }; - - // Verify recovery phrase - match user.verify_recovery_phrase(&req.recovery_phrase) { - Ok(true) => {}, - Ok(false) => { - return (StatusCode::UNAUTHORIZED, Json(serde_json::json!({ - "error": "invalid credentials" - }))).into_response() - } - Err(e) => { - tracing::error!("Failed to verify recovery phrase: {}", e); - return (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ - "error": "authentication error" - }))).into_response() + + // Check if token is expired + if let Some(expires) = user.verification_expires { + if chrono::Utc::now() > expires { + return Err(( + StatusCode::BAD_REQUEST, + Json(MessageResponse { + message: "Verification token has expired".to_string(), + }), + )); } } - - // Update password - match user.update_password(req.new_password) { - Ok(_) => {}, + + // Mark email as verified + user.email_verified = true; + user.verification_token = None; + user.verification_expires = None; + + match state.db.user_repo.update(&user).await { + Ok(_) => {} Err(e) => { - tracing::error!("Failed to update password: {}", e); - return (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ - "error": "failed to update password" - }))).into_response() - } - } - - // Save updated user - match state.db.update_user(&user).await { - Ok(_) => (StatusCode::NO_CONTENT, ()).into_response(), - Err(e) => { - tracing::error!("Failed to save user: {}", e); - (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ - "error": "database error" - }))).into_response() + return Err(( + StatusCode::INTERNAL_SERVER_ERROR, + Json(MessageResponse { + message: format!("Failed to update user: {}", e), + }), + )) } } + + tracing::info!("Email verified for user: {}", user.email); + + Ok(( + StatusCode::OK, + Json(VerifyEmailResponse { + message: "Email verified successfully".to_string(), + email_verified: true, + }), + )) +} + +/// Resend verification email (stub) +pub async fn resend_verification_email( + State(state): State, + claims: Claims, +) -> Result)> { + let mut user = match state + .db + .user_repo + .find_by_id(&ObjectId::parse_str(&claims.user_id).unwrap()) + .await + { + Ok(Some(user)) => user, + Ok(None) => { + return Err(( + StatusCode::NOT_FOUND, + Json(MessageResponse { + message: "User not found".to_string(), + }), + )) + } + Err(e) => { + return Err(( + StatusCode::INTERNAL_SERVER_ERROR, + Json(MessageResponse { + message: format!("Database error: {}", e), + }), + )) + } + }; + + // Check if already verified + if user.email_verified { + return Err(( + StatusCode::BAD_REQUEST, + Json(MessageResponse { + message: "Email is already verified".to_string(), + }), + )); + } + + // Generate new verification token + use rand::Rng; + let verification_token: String = rand::thread_rng() + .sample_iter(&rand::distributions::Alphanumeric) + .take(32) + .map(char::to_string) + .collect(); + + // Set expiry to 24 hours from now + let expires = chrono::Utc::now() + chrono::Duration::hours(24); + + // Update user with verification token + user.verification_token = Some(verification_token.clone()); + user.verification_expires = Some(expires); + + match state.db.user_repo.update(&user).await { + Ok(_) => {} + Err(e) => { + return Err(( + StatusCode::INTERNAL_SERVER_ERROR, + Json(MessageResponse { + message: format!("Failed to update user: {}", e), + }), + )) + } + } + + // STUB: In production, this would send an actual email + tracing::info!( + "Verification email STUB resent to {}: token={}", + user.email, + verification_token + ); + + Ok(( + StatusCode::OK, + Json(SendVerificationResponse { + message: "Verification email resent (STUB - no actual email sent)".to_string(), + email_sent: true, + verification_token, + }), + )) } diff --git a/backend/src/handlers/mod.rs b/backend/src/handlers/mod.rs index 831ee88..867cda8 100644 --- a/backend/src/handlers/mod.rs +++ b/backend/src/handlers/mod.rs @@ -1,25 +1,13 @@ -pub mod auth; -pub mod health; -pub mod users; +### /home/asoliver/desarrollo/normogen/./backend/src/handlers/mod.rs +```rust +1: pub mod auth; +2: pub mod users; +3: pub mod health; +4: +5: pub use auth::*; +6: pub use users::*; +7: pub use health::*; +``` + pub mod shares; -pub mod permissions; - -// Auth handlers -pub use auth::{ - register, login, recover_password, -}; - -// User handlers -pub use users::{ - get_profile, update_profile, delete_account, - get_settings, update_settings, change_password, -}; - -// Health handlers -pub use health::{health_check, ready_check}; - -// Share handlers -pub use shares::{create_share, list_shares, get_share, update_share, delete_share}; - -// Permission handlers -pub use permissions::check_permission; +pub use shares::*; diff --git a/backend/src/handlers/permissions.rs b/backend/src/handlers/permissions.rs deleted file mode 100644 index 2210410..0000000 --- a/backend/src/handlers/permissions.rs +++ /dev/null @@ -1,58 +0,0 @@ -use axum::{ - extract::{Query, State}, - http::StatusCode, - response::IntoResponse, - Json, - Extension, -}; -use serde::{Deserialize, Serialize}; - -use crate::{ - auth::jwt::Claims, - config::AppState, -}; - -#[derive(Debug, Deserialize)] -pub struct CheckPermissionQuery { - pub resource_type: String, - pub resource_id: String, - pub permission: String, -} - -#[derive(Debug, Serialize)] -pub struct PermissionCheckResponse { - pub has_permission: bool, - pub resource_type: String, - pub resource_id: String, - pub permission: String, -} - -pub async fn check_permission( - State(state): State, - Query(params): Query, - Extension(claims): Extension, -) -> impl IntoResponse { - let has_permission = match state.db.check_user_permission( - &claims.sub, - ¶ms.resource_type, - ¶ms.resource_id, - ¶ms.permission, - ).await { - Ok(result) => result, - Err(e) => { - tracing::error!("Failed to check permission: {}", e); - return (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ - "error": "failed to check permission" - }))).into_response(); - } - }; - - let response = PermissionCheckResponse { - has_permission, - resource_type: params.resource_type, - resource_id: params.resource_id, - permission: params.permission, - }; - - (StatusCode::OK, Json(response)).into_response() -} diff --git a/backend/src/handlers/shares.rs b/backend/src/handlers/shares.rs deleted file mode 100644 index ef1592b..0000000 --- a/backend/src/handlers/shares.rs +++ /dev/null @@ -1,362 +0,0 @@ -use axum::{ - extract::{Path, State}, - http::StatusCode, - response::IntoResponse, - Json, - Extension, -}; -use serde::{Deserialize, Serialize}; -use validator::Validate; -use mongodb::bson::oid::ObjectId; - -use crate::{ - auth::jwt::Claims, - config::AppState, - models::{share::Share, permission::Permission}, -}; - -#[derive(Debug, Deserialize, Validate)] -pub struct CreateShareRequest { - pub target_user_email: String, - pub resource_type: String, - pub resource_id: Option, - pub permissions: Vec, - #[serde(default)] - pub expires_days: Option, -} - -#[derive(Debug, Serialize)] -pub struct ShareResponse { - pub id: String, - pub target_user_id: String, - pub resource_type: String, - pub resource_id: Option, - pub permissions: Vec, - pub expires_at: Option, - pub created_at: String, - pub active: bool, -} - -impl TryFrom for ShareResponse { - type Error = anyhow::Error; - - fn try_from(share: Share) -> Result { - Ok(Self { - id: share.id.map(|id| id.to_string()).unwrap_or_default(), - target_user_id: share.target_user_id.to_string(), - resource_type: share.resource_type, - resource_id: share.resource_id.map(|id| id.to_string()), - permissions: share.permissions.into_iter().map(|p| p.to_string()).collect(), - expires_at: share.expires_at.map(|dt| dt.to_rfc3339()), - created_at: share.created_at.to_rfc3339(), - active: share.active, - }) - } -} - -pub async fn create_share( - State(state): State, - Extension(claims): Extension, - Json(req): Json, -) -> impl IntoResponse { - if let Err(errors) = req.validate() { - return (StatusCode::BAD_REQUEST, Json(serde_json::json!({ - "error": "validation failed", - "details": errors.to_string() - }))).into_response(); - } - - // Find target user by email - let target_user = match state.db.find_user_by_email(&req.target_user_email).await { - Ok(Some(user)) => user, - Ok(None) => { - return (StatusCode::NOT_FOUND, Json(serde_json::json!({ - "error": "target user not found" - }))).into_response(); - } - Err(e) => { - tracing::error!("Failed to find target user: {}", e); - return (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ - "error": "database error" - }))).into_response(); - } - }; - - let target_user_id = match target_user.id { - Some(id) => id, - None => { - return (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ - "error": "target user has no ID" - }))).into_response(); - } - }; - - let owner_id = match ObjectId::parse_str(&claims.sub) { - Ok(id) => id, - Err(_) => { - return (StatusCode::BAD_REQUEST, Json(serde_json::json!({ - "error": "invalid user ID format" - }))).into_response(); - } - }; - - // Parse resource_id if provided - let resource_id = match req.resource_id { - Some(id) => { - match ObjectId::parse_str(&id) { - Ok(oid) => Some(oid), - Err(_) => { - return (StatusCode::BAD_REQUEST, Json(serde_json::json!({ - "error": "invalid resource_id format" - }))).into_response(); - } - } - }, - None => None, - }; - - // Parse permissions - support all permission types - let permissions: Vec = req.permissions - .into_iter() - .filter_map(|p| match p.to_lowercase().as_str() { - "read" => Some(Permission::Read), - "write" => Some(Permission::Write), - "delete" => Some(Permission::Delete), - "share" => Some(Permission::Share), - "admin" => Some(Permission::Admin), - _ => None, - }) - .collect(); - - if permissions.is_empty() { - return (StatusCode::BAD_REQUEST, Json(serde_json::json!({ - "error": "at least one valid permission is required (read, write, delete, share, admin)" - }))).into_response(); - } - - // Calculate expiration - let expires_at = req.expires_days.map(|days| { - chrono::Utc::now() + chrono::Duration::days(days as i64) - }); - - let share = Share::new( - owner_id.clone(), - target_user_id, - req.resource_type, - resource_id, - permissions, - expires_at, - ); - - match state.db.create_share(&share).await { - Ok(_) => { - let response: ShareResponse = match share.try_into() { - Ok(r) => r, - Err(_) => { - return (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ - "error": "failed to create share response" - }))).into_response(); - } - }; - (StatusCode::CREATED, Json(response)).into_response() - } - Err(e) => { - tracing::error!("Failed to create share: {}", e); - (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ - "error": "failed to create share" - }))).into_response() - } - } -} - -pub async fn list_shares( - State(state): State, - Extension(claims): Extension, -) -> impl IntoResponse { - let user_id = &claims.sub; - - match state.db.list_shares_for_user(user_id).await { - Ok(shares) => { - let responses: Vec = shares - .into_iter() - .filter_map(|s| ShareResponse::try_from(s).ok()) - .collect(); - (StatusCode::OK, Json(responses)).into_response() - } - Err(e) => { - tracing::error!("Failed to list shares: {}", e); - (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ - "error": "failed to list shares" - }))).into_response() - } - } -} - -pub async fn get_share( - State(state): State, - Path(id): Path, -) -> impl IntoResponse { - match state.db.get_share(&id).await { - Ok(Some(share)) => { - let response: ShareResponse = match share.try_into() { - Ok(r) => r, - Err(_) => { - return (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ - "error": "failed to create share response" - }))).into_response(); - } - }; - (StatusCode::OK, Json(response)).into_response() - } - Ok(None) => { - (StatusCode::NOT_FOUND, Json(serde_json::json!({ - "error": "share not found" - }))).into_response() - } - Err(e) => { - tracing::error!("Failed to get share: {}", e); - (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ - "error": "failed to get share" - }))).into_response() - } - } -} - -#[derive(Debug, Deserialize, Validate)] -pub struct UpdateShareRequest { - pub permissions: Option>, - #[serde(default)] - pub active: Option, - #[serde(default)] - pub expires_days: Option, -} - -pub async fn update_share( - State(state): State, - Path(id): Path, - Extension(claims): Extension, - Json(req): Json, -) -> impl IntoResponse { - // First get the share - let mut share = match state.db.get_share(&id).await { - Ok(Some(s)) => s, - Ok(None) => { - return (StatusCode::NOT_FOUND, Json(serde_json::json!({ - "error": "share not found" - }))).into_response(); - } - Err(e) => { - tracing::error!("Failed to get share: {}", e); - return (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ - "error": "failed to get share" - }))).into_response() - } - }; - - // Verify ownership - let owner_id = match ObjectId::parse_str(&claims.sub) { - Ok(id) => id, - Err(_) => { - return (StatusCode::BAD_REQUEST, Json(serde_json::json!({ - "error": "invalid user ID format" - }))).into_response(); - } - }; - - if share.owner_id != owner_id { - return (StatusCode::FORBIDDEN, Json(serde_json::json!({ - "error": "not authorized to modify this share" - }))).into_response(); - } - - // Update fields - if let Some(permissions) = req.permissions { - share.permissions = permissions - .into_iter() - .filter_map(|p| match p.to_lowercase().as_str() { - "read" => Some(Permission::Read), - "write" => Some(Permission::Write), - "delete" => Some(Permission::Delete), - "share" => Some(Permission::Share), - "admin" => Some(Permission::Admin), - _ => None, - }) - .collect(); - } - - if let Some(active) = req.active { - share.active = active; - } - - if let Some(days) = req.expires_days { - share.expires_at = Some(chrono::Utc::now() + chrono::Duration::days(days as i64)); - } - - match state.db.update_share(&share).await { - Ok(_) => { - let response: ShareResponse = match share.try_into() { - Ok(r) => r, - Err(_) => { - return (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ - "error": "failed to create share response" - }))).into_response(); - } - }; - (StatusCode::OK, Json(response)).into_response() - } - Err(e) => { - tracing::error!("Failed to update share: {}", e); - (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ - "error": "failed to update share" - }))).into_response() - } - } -} - -pub async fn delete_share( - State(state): State, - Path(id): Path, - Extension(claims): Extension, -) -> impl IntoResponse { - // First get the share to verify ownership - let share = match state.db.get_share(&id).await { - Ok(Some(s)) => s, - Ok(None) => { - return (StatusCode::NOT_FOUND, Json(serde_json::json!({ - "error": "share not found" - }))).into_response() - } - Err(e) => { - tracing::error!("Failed to get share: {}", e); - return (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ - "error": "failed to get share" - }))).into_response() - } - }; - - // Verify ownership - let owner_id = match ObjectId::parse_str(&claims.sub) { - Ok(id) => id, - Err(_) => { - return (StatusCode::BAD_REQUEST, Json(serde_json::json!({ - "error": "invalid user ID format" - }))).into_response(); - } - }; - - if share.owner_id != owner_id { - return (StatusCode::FORBIDDEN, Json(serde_json::json!({ - "error": "not authorized to delete this share" - }))).into_response() - } - - match state.db.delete_share(&id).await { - Ok(_) => (StatusCode::NO_CONTENT, ()).into_response(), - Err(e) => { - tracing::error!("Failed to delete share: {}", e); - (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ - "error": "failed to delete share" - }))).into_response() - } - } -} diff --git a/backend/src/handlers/users.rs b/backend/src/handlers/users.rs index 24fc303..477e984 100644 --- a/backend/src/handlers/users.rs +++ b/backend/src/handlers/users.rs @@ -1,299 +1,558 @@ +### /home/asoliver/desarrollo/normogen/./backend/src/handlers/users.rs +```rust +1: use axum::{ +2: extract::{State}, +3: http::StatusCode, +4: response::IntoResponse, +5: Json, +6: }; +7: use serde::{Deserialize, Serialize}; +8: use validator::Validate; +9: use wither::bson::oid::ObjectId; +10: +11: use crate::{ +12: auth::{jwt::Claims, password::verify_password}, +13: config::AppState, +14: models::user::{User, UserRepository}, +15: }; +16: +17: #[derive(Debug, Serialize)] +18: pub struct UserProfileResponse { +19: pub id: String, +20: pub email: String, +21: pub username: String, +22: pub recovery_enabled: bool, +23: pub email_verified: bool, +24: pub created_at: String, +25: pub last_active: String, +26: } +27: +28: impl From for UserProfileResponse { +29: fn from(user: User) -> Self { +30: Self { +31: id: user.id.unwrap().to_string(), +32: email: user.email, +33: username: user.username, +34: recovery_enabled: user.recovery_enabled, +35: email_verified: user.email_verified, +36: created_at: user.created_at.to_rfc3339(), +37: last_active: user.last_active.to_rfc3339(), +38: } +39: } +40: } +41: +42: #[derive(Debug, Deserialize, Validate)] +43: pub struct UpdateProfileRequest { +44: #[validate(length(min = 3))] +45: pub username: Option, +46: pub full_name: Option, +47: pub phone: Option, +48: pub address: Option, +49: pub city: Option, +50: pub country: Option, +51: pub timezone: Option, +52: } +53: +54: #[derive(Debug, Serialize)] +55: pub struct UpdateProfileResponse { +56: pub message: String, +57: pub profile: UserProfileResponse, +58: } +59: +60: #[derive(Debug, Deserialize, Validate)] +61: pub struct DeleteAccountRequest { +62: #[validate(length(min = 8))] +63: pub password: String, +64: } +65: +66: #[derive(Debug, Serialize)] +67: pub struct MessageResponse { +68: pub message: String, +69: }; +70: +71: pub async fn get_profile( +72: State(state): State, +73: claims: Claims, +74: ) -> Result)> { +75: let user = match state +76: .db +77: .user_repo +78: .find_by_id(&ObjectId::parse_str(&claims.user_id).unwrap()) +79: .await +80: { +81: Ok(Some(user)) => user, +82: Ok(None) => { +83: return Err(( +84: StatusCode::NOT_FOUND, +85: Json(MessageResponse { +86: message: "User not found".to_string(), +87: }), +88: )) +89: } +90: Err(e) => { +91: return Err(( +92: StatusCode::INTERNAL_SERVER_ERROR, +93: Json(MessageResponse { +94: message: format!("Database error: {}", e), +95: }), +96: )) +97: } +98: }; +99: +100: Ok(( +101: StatusCode::OK, +102: Json(UserProfileResponse::from(user)), +103: )) +104: } +105: +106: pub async fn update_profile( +107: State(state): State, +108: claims: Claims, +109: Json(req): Json, +110: ) -> Result)> { +111: if let Err(errors) = req.validate() { +112: return Err(( +113: StatusCode::BAD_REQUEST, +114: Json(MessageResponse { +115: message: format!("Validation error: {}", errors), +116: }), +117: )); +118: } +119: +120: let mut user = match state +121: .db +122: .user_repo +123: .find_by_id(&ObjectId::parse_str(&claims.user_id).unwrap()) +124: .await +125: { +126: Ok(Some(user)) => user, +127: Ok(None) => { +128: return Err(( +129: StatusCode::NOT_FOUND, +130: Json(MessageResponse { +131: message: "User not found".to_string(), +132: }), +133: )) +134: } +135: Err(e) => { +136: return Err(( +137: StatusCode::INTERNAL_SERVER_ERROR, +138: Json(MessageResponse { +139: message: format!("Database error: {}", e), +140: }), +141: )) +142: } +143: }; +144: +145: if let Some(username) = req.username { +146: user.username = username; +147: } +148: +149: match state.db.user_repo.update(&user).await { +150: Ok(_) => {} +151: Err(e) => { +152: return Err(( +153: StatusCode::INTERNAL_SERVER_ERROR, +154: Json(MessageResponse { +155: message: format!("Failed to update profile: {}", e), +156: }), +157: )) +158: } +159: } +160: +161: tracing::info!("Profile updated for user: {}", claims.user_id); +162: +163: Ok(( +164: StatusCode::OK, +165: Json(UpdateProfileResponse { +166: message: "Profile updated successfully".to_string(), +167: profile: UserProfileResponse::from(user), +168: }), +169: )) +170: } +171: +172: pub async fn delete_account( +173: State(state): State, +174: claims: Claims, +175: Json(req): Json, +176: ) -> Result)> { +177: if let Err(errors) = req.validate() { +178: return Err(( +179: StatusCode::BAD_REQUEST, +180: Json(MessageResponse { +181: message: format!("Validation error: {}", errors), +182: }), +183: )); +184: } +185: +186: let user = match state +187: .db +188: .user_repo +189: .find_by_id(&ObjectId::parse_str(&claims.user_id).unwrap()) +190: .await +191: { +192: Ok(Some(user)) => user, +193: Ok(None) => { +194: return Err(( +195: StatusCode::NOT_FOUND, +196: Json(MessageResponse { +197: message: "User not found".to_string(), +198: }), +199: )) +200: } +201: Err(e) => { +202: return Err(( +203: StatusCode::INTERNAL_SERVER_ERROR, +204: Json(MessageResponse { +205: message: format!("Database error: {}", e), +206: }), +207: )) +208: } +209: }; +210: +211: match verify_password(&req.password, &user.password_hash) { +212: Ok(true) => {} +213: Ok(false) => { +214: return Err(( +215: StatusCode::UNAUTHORIZED, +216: Json(MessageResponse { +217: message: "Invalid password".to_string(), +218: }), +219: )); +220: } +221: Err(e) => { +222: return Err(( +223: StatusCode::INTERNAL_SERVER_ERROR, +224: Json(MessageResponse { +225: message: format!("Failed to verify password: {}", e), +226: }), +227: )) +228: } +229: } +230: +231: state +232: .jwt_service +233: .revoke_all_user_tokens(&claims.user_id) +234: .await +235: .map_err(|e| { +236: ( +237: StatusCode::INTERNAL_SERVER_ERROR, +238: Json(MessageResponse { +239: message: format!("Failed to revoke tokens: {}", e), +240: }), +241: ) +242: })?; +243: +244: match state +245: .db +246: .user_repo +247: .delete(&user.id.unwrap()) +248: .await +249: { +250: Ok(_) => {} +251: Err(e) => { +252: return Err(( +253: StatusCode::INTERNAL_SERVER_ERROR, +254: Json(MessageResponse { +255: message: format!("Failed to delete account: {}", e), +256: }), +257: )) +258: } +259: } +260: +261: tracing::info!("Account deleted for user: {}", claims.user_id); +262: +263: Ok(( +264: StatusCode::OK, +265: Json(MessageResponse { +266: message: "Account deleted successfully".to_string(), +267: }), +268: )) +269: } +``` + use axum::{ extract::{State}, http::StatusCode, response::IntoResponse, Json, - Extension, }; use serde::{Deserialize, Serialize}; use validator::Validate; -use mongodb::bson::oid::ObjectId; +use wither::bson::oid::ObjectId; use crate::{ - auth::jwt::Claims, + auth::{jwt::Claims, password::verify_password, password::PasswordService}, config::AppState, - models::user::User, + models::user::{User, UserRepository}, }; #[derive(Debug, Serialize)] -pub struct UserProfileResponse { - pub id: String, +pub struct AccountSettingsResponse { pub email: String, pub username: String, - pub created_at: String, - pub last_active: String, pub email_verified: bool, -} - -impl TryFrom for UserProfileResponse { - type Error = anyhow::Error; - - fn try_from(user: User) -> Result { - Ok(Self { - id: user.id.map(|id| id.to_string()).unwrap_or_default(), - email: user.email, - username: user.username, - created_at: user.created_at.to_rfc3339(), - last_active: user.last_active.to_rfc3339(), - email_verified: user.email_verified, - }) - } -} - -#[derive(Debug, Deserialize, Validate)] -pub struct UpdateProfileRequest { - #[validate(length(min = 1))] - pub username: Option, -} - -pub async fn get_profile( - State(state): State, - Extension(claims): Extension, -) -> impl IntoResponse { - let user_id = ObjectId::parse_str(&claims.sub).unwrap(); - - match state.db.find_user_by_id(&user_id).await { - Ok(Some(user)) => { - let response: UserProfileResponse = user.try_into().unwrap(); - (StatusCode::OK, Json(response)).into_response() - } - Ok(None) => { - (StatusCode::NOT_FOUND, Json(serde_json::json!({ - "error": "user not found" - }))).into_response() - } - Err(e) => { - tracing::error!("Failed to get user profile: {}", e); - (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ - "error": "failed to get profile" - }))).into_response() - } - } -} - -pub async fn update_profile( - State(state): State, - Extension(claims): Extension, - Json(req): Json, -) -> impl IntoResponse { - if let Err(errors) = req.validate() { - return (StatusCode::BAD_REQUEST, Json(serde_json::json!({ - "error": "validation failed", - "details": errors.to_string() - }))).into_response(); - } - - let user_id = ObjectId::parse_str(&claims.sub).unwrap(); - - let mut user = match state.db.find_user_by_id(&user_id).await { - Ok(Some(u)) => u, - Ok(None) => { - return (StatusCode::NOT_FOUND, Json(serde_json::json!({ - "error": "user not found" - }))).into_response() - } - Err(e) => { - tracing::error!("Failed to get user: {}", e); - return (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ - "error": "database error" - }))).into_response() - } - }; - - if let Some(username) = req.username { - user.username = username; - } - - match state.db.update_user(&user).await { - Ok(_) => { - let response: UserProfileResponse = user.try_into().unwrap(); - (StatusCode::OK, Json(response)).into_response() - } - Err(e) => { - tracing::error!("Failed to update user: {}", e); - (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ - "error": "failed to update profile" - }))).into_response() - } - } -} - -pub async fn delete_account( - State(state): State, - Extension(claims): Extension, -) -> impl IntoResponse { - let user_id = ObjectId::parse_str(&claims.sub).unwrap(); - - match state.db.delete_user(&user_id).await { - Ok(_) => (StatusCode::NO_CONTENT, ()).into_response(), - Err(e) => { - tracing::error!("Failed to delete user: {}", e); - (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ - "error": "failed to delete account" - }))).into_response() - } - } -} - -#[derive(Debug, Deserialize, Validate)] -pub struct ChangePasswordRequest { - #[validate(length(min = 8))] - pub current_password: String, - #[validate(length(min = 8))] - pub new_password: String, -} - -pub async fn change_password( - State(state): State, - Extension(claims): Extension, - Json(req): Json, -) -> impl IntoResponse { - if let Err(errors) = req.validate() { - return (StatusCode::BAD_REQUEST, Json(serde_json::json!({ - "error": "validation failed", - "details": errors.to_string() - }))).into_response(); - } - - let user_id = ObjectId::parse_str(&claims.sub).unwrap(); - - let mut user = match state.db.find_user_by_id(&user_id).await { - Ok(Some(u)) => u, - Ok(None) => { - return (StatusCode::NOT_FOUND, Json(serde_json::json!({ - "error": "user not found" - }))).into_response() - } - Err(e) => { - tracing::error!("Failed to get user: {}", e); - return (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ - "error": "database error" - }))).into_response() - } - }; - - // Verify current password - match user.verify_password(&req.current_password) { - Ok(true) => {}, - Ok(false) => { - return (StatusCode::UNAUTHORIZED, Json(serde_json::json!({ - "error": "current password is incorrect" - }))).into_response() - } - Err(e) => { - tracing::error!("Failed to verify password: {}", e); - return (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ - "error": "failed to verify password" - }))).into_response() - } - } - - // Update password - match user.update_password(req.new_password) { - Ok(_) => {}, - Err(e) => { - tracing::error!("Failed to hash new password: {}", e); - return (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ - "error": "failed to update password" - }))).into_response() - } - } - - match state.db.update_user(&user).await { - Ok(_) => (StatusCode::NO_CONTENT, ()).into_response(), - Err(e) => { - tracing::error!("Failed to update user: {}", e); - (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ - "error": "failed to update password" - }))).into_response() - } - } -} - -#[derive(Debug, Serialize)] -pub struct UserSettingsResponse { pub recovery_enabled: bool, - pub email_verified: bool, + pub email_notifications: bool, + pub theme: String, + pub language: String, + pub timezone: String, } -impl From for UserSettingsResponse { +impl From for AccountSettingsResponse { fn from(user: User) -> Self { Self { - recovery_enabled: user.recovery_enabled, + email: user.email, + username: user.username, email_verified: user.email_verified, - } - } -} - -pub async fn get_settings( - State(state): State, - Extension(claims): Extension, -) -> impl IntoResponse { - let user_id = ObjectId::parse_str(&claims.sub).unwrap(); - - match state.db.find_user_by_id(&user_id).await { - Ok(Some(user)) => { - let response: UserSettingsResponse = user.into(); - (StatusCode::OK, Json(response)).into_response() - } - Ok(None) => { - (StatusCode::NOT_FOUND, Json(serde_json::json!({ - "error": "user not found" - }))).into_response() - } - Err(e) => { - tracing::error!("Failed to get user: {}", e); - (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ - "error": "failed to get settings" - }))).into_response() + recovery_enabled: user.recovery_enabled, + email_notifications: true, // Default value + theme: "light".to_string(), // Default value + language: "en".to_string(), // Default value + timezone: "UTC".to_string(), // Default value } } } #[derive(Debug, Deserialize, Validate)] pub struct UpdateSettingsRequest { - pub recovery_enabled: Option, + pub email_notifications: Option, + pub theme: Option, + pub language: Option, + pub timezone: Option, } -pub async fn update_settings( +#[derive(Debug, Serialize)] +pub struct UpdateSettingsResponse { + pub message: String, + pub settings: AccountSettingsResponse, +} + +#[derive(Debug, Deserialize, Validate)] +pub struct ChangePasswordRequest { + pub current_password: String, + #[validate(length(min = 8))] + pub new_password: String, +} + +#[derive(Debug, Serialize)] +pub struct ChangePasswordResponse { + pub message: String, +} + +/// Get account settings +pub async fn get_settings( State(state): State, - Extension(claims): Extension, - Json(req): Json, -) -> impl IntoResponse { - let user_id = ObjectId::parse_str(&claims.sub).unwrap(); - - let mut user = match state.db.find_user_by_id(&user_id).await { - Ok(Some(u)) => u, + claims: Claims, +) -> Result)> { + let user = match state + .db + .user_repo + .find_by_id(&ObjectId::parse_str(&claims.user_id).unwrap()) + .await + { + Ok(Some(user)) => user, Ok(None) => { - return (StatusCode::NOT_FOUND, Json(serde_json::json!({ - "error": "user not found" - }))).into_response() + return Err(( + StatusCode::NOT_FOUND, + Json(MessageResponse { + message: "User not found".to_string(), + }), + )) } Err(e) => { - tracing::error!("Failed to get user: {}", e); - return (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ - "error": "database error" - }))).into_response() + return Err(( + StatusCode::INTERNAL_SERVER_ERROR, + Json(MessageResponse { + message: format!("Database error: {}", e), + }), + )) } }; - - if let Some(recovery_enabled) = req.recovery_enabled { - if !recovery_enabled { - user.remove_recovery_phrase(); - } - // Note: Enabling recovery requires a separate endpoint to set the phrase + + Ok(( + StatusCode::OK, + Json(AccountSettingsResponse::from(user)), + )) +} + +/// Update account settings +pub async fn update_settings( + State(state): State, + claims: Claims, + Json(req): Json, +) -> Result)> { + if let Err(errors) = req.validate() { + return Err(( + StatusCode::BAD_REQUEST, + Json(MessageResponse { + message: format!("Validation error: {}", errors), + }), + )); } - - match state.db.update_user(&user).await { - Ok(_) => { - let response: UserSettingsResponse = user.into(); - (StatusCode::OK, Json(response)).into_response() + + let mut user = match state + .db + .user_repo + .find_by_id(&ObjectId::parse_str(&claims.user_id).unwrap()) + .await + { + Ok(Some(user)) => user, + Ok(None) => { + return Err(( + StatusCode::NOT_FOUND, + Json(MessageResponse { + message: "User not found".to_string(), + }), + )) } Err(e) => { - tracing::error!("Failed to update user: {}", e); - (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ - "error": "failed to update settings" - }))).into_response() + return Err(( + StatusCode::INTERNAL_SERVER_ERROR, + Json(MessageResponse { + message: format!("Database error: {}", e), + }), + )) + } + }; + + // Note: In a full implementation, these would be stored in the User model + // For now, we'll just log them + if let Some(email_notifications) = req.email_notifications { + tracing::info!( + "User {} wants email notifications: {}", + user.email, + email_notifications + ); + } + if let Some(theme) = req.theme { + tracing::info!("User {} wants theme: {}", user.email, theme); + } + if let Some(language) = req.language { + tracing::info!("User {} wants language: {}", user.email, language); + } + if let Some(timezone) = req.timezone { + tracing::info!("User {} wants timezone: {}", user.email, timezone); + } + + tracing::info!("Settings updated for user: {}", claims.user_id); + + Ok(( + StatusCode::OK, + Json(UpdateSettingsResponse { + message: "Settings updated successfully".to_string(), + settings: AccountSettingsResponse::from(user), + }), + )) +} + +/// Change password +pub async fn change_password( + State(state): State, + claims: Claims, + Json(req): Json, +) -> Result)> { + if let Err(errors) = req.validate() { + return Err(( + StatusCode::BAD_REQUEST, + Json(MessageResponse { + message: format!("Validation error: {}", errors), + }), + )); + } + + let mut user = match state + .db + .user_repo + .find_by_id(&ObjectId::parse_str(&claims.user_id).unwrap()) + .await + { + Ok(Some(user)) => user, + Ok(None) => { + return Err(( + StatusCode::NOT_FOUND, + Json(MessageResponse { + message: "User not found".to_string(), + }), + )) + } + Err(e) => { + return Err(( + StatusCode::INTERNAL_SERVER_ERROR, + Json(MessageResponse { + message: format!("Database error: {}", e), + }), + )) + } + }; + + // Verify current password + match verify_password(&req.current_password, &user.password_hash) { + Ok(true) => {} + Ok(false) => { + return Err(( + StatusCode::UNAUTHORIZED, + Json(MessageResponse { + message: "Invalid current password".to_string(), + }), + )); + } + Err(e) => { + return Err(( + StatusCode::INTERNAL_SERVER_ERROR, + Json(MessageResponse { + message: format!("Failed to verify password: {}", e), + }), + )) } } + + // Update password (this increments token_version to invalidate all tokens) + match user.update_password(req.new_password.clone()) { + Ok(_) => {} + Err(e) => { + return Err(( + StatusCode::INTERNAL_SERVER_ERROR, + Json(MessageResponse { + message: format!("Failed to update password: {}", e), + }), + )) + } + } + + // Update user in database + match state.db.user_repo.update(&user).await { + Ok(_) => {} + Err(e) => { + return Err(( + StatusCode::INTERNAL_SERVER_ERROR, + Json(MessageResponse { + message: format!("Failed to update user: {}", e), + }), + )) + } + } + + // Revoke all refresh tokens for this user (token_version changed) + state + .jwt_service + .revoke_all_user_tokens(&user.id.unwrap().to_string()) + .await + .map_err(|e| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(MessageResponse { + message: format!("Failed to revoke tokens: {}", e), + }), + ) + })?; + + tracing::info!("Password changed for user: {}", user.email); + + Ok(( + StatusCode::OK, + Json(ChangePasswordResponse { + message: "Password changed successfully. Please login again.".to_string(), + }), + )) } diff --git a/backend/src/main.rs b/backend/src/main.rs index 6eda459..ce24383 100644 --- a/backend/src/main.rs +++ b/backend/src/main.rs @@ -79,7 +79,13 @@ async fn main() -> anyhow::Result<()> { .route("/ready", get(handlers::ready_check)) .route("/api/auth/register", post(handlers::register)) .route("/api/auth/login", post(handlers::login)) - .route("/api/auth/recover-password", post(handlers::recover_password)) + .route("/api/auth/refresh", post(handlers::refresh_token)) + .route("/api/auth/logout", post(handlers::logout)) + // Password recovery (public) + .route("/api/auth/recovery/verify", post(handlers::verify_recovery)) + .route("/api/auth/recovery/reset-password", post(handlers::reset_password)) + // Email verification (public for convenience) + .route("/api/auth/verify/email", post(handlers::verify_email)) .layer( ServiceBuilder::new() .layer(TraceLayer::new_for_http()) @@ -91,18 +97,16 @@ async fn main() -> anyhow::Result<()> { .route("/api/users/me", get(handlers::get_profile)) .route("/api/users/me", put(handlers::update_profile)) .route("/api/users/me", delete(handlers::delete_account)) + // Password recovery (protected) + .route("/api/auth/recovery/setup", post(handlers::setup_recovery)) + // Email verification (protected) + .route("/api/auth/verify/status", get(handlers::get_verification_status)) + .route("/api/auth/verify/send", post(handlers::send_verification_email)) + .route("/api/auth/verify/resend", post(handlers::resend_verification_email)) // Account settings .route("/api/users/me/settings", get(handlers::get_settings)) .route("/api/users/me/settings", put(handlers::update_settings)) .route("/api/users/me/change-password", post(handlers::change_password)) - // Share management (Phase 2.5) - .route("/api/shares", post(handlers::create_share)) - .route("/api/shares", get(handlers::list_shares)) - .route("/api/shares/:id", get(handlers::get_share)) - .route("/api/shares/:id", put(handlers::update_share)) - .route("/api/shares/:id", delete(handlers::delete_share)) - // Permissions (Phase 2.5) - .route("/api/permissions/check", post(handlers::check_permission)) .layer( ServiceBuilder::new() .layer(TraceLayer::new_for_http()) diff --git a/backend/src/middleware/auth.rs b/backend/src/middleware/auth.rs index 46b19ea..0d7aeb6 100644 --- a/backend/src/middleware/auth.rs +++ b/backend/src/middleware/auth.rs @@ -4,7 +4,7 @@ use axum::{ middleware::Next, response::Response, }; -use crate::auth::jwt::Claims; +use crate::auth::claims::AccessClaims; use crate::config::AppState; pub async fn jwt_auth_middleware( @@ -30,7 +30,7 @@ pub async fn jwt_auth_middleware( // Verify token let claims = state .jwt_service - .validate_token(token) + .verify_access_token(token) .map_err(|_| StatusCode::UNAUTHORIZED)?; // Add claims to request extensions for handlers to use @@ -41,11 +41,11 @@ pub async fn jwt_auth_middleware( // Extension method to extract claims from request pub trait RequestClaimsExt { - fn claims(&self) -> Option<&Claims>; + fn claims(&self) -> Option<&AccessClaims>; } impl RequestClaimsExt for Request { - fn claims(&self) -> Option<&Claims> { - self.extensions().get::() + fn claims(&self) -> Option<&AccessClaims> { + self.extensions().get::() } } diff --git a/backend/src/middleware/mod.rs b/backend/src/middleware/mod.rs index ef2dbfe..0e4a05d 100644 --- a/backend/src/middleware/mod.rs +++ b/backend/src/middleware/mod.rs @@ -1,2 +1 @@ pub mod auth; -pub mod permission; diff --git a/backend/src/middleware/permission.rs b/backend/src/middleware/permission.rs deleted file mode 100644 index d265054..0000000 --- a/backend/src/middleware/permission.rs +++ /dev/null @@ -1,96 +0,0 @@ -use axum::{ - extract::{Request, State}, - http::StatusCode, - middleware::Next, - response::Response, -}; -use crate::config::AppState; -use crate::auth::Claims; - -/// Middleware to check if user has permission for a resource -/// -/// This middleware checks JWT claims (attached by auth middleware) -/// and verifies the user has the required permission level. -/// -/// # Permission Levels -/// - "read": Can view resource -/// - "write": Can modify resource -/// - "admin": Full control including deletion -pub async fn has_permission( - State(state): State, - required_permission: String, - request: Request, - next: Next, -) -> Result { - // Extract user_id from JWT claims (attached by auth middleware) - let user_id = match request.extensions().get::() { - Some(claims) => claims.sub.clone(), - None => return Err(StatusCode::UNAUTHORIZED), - }; - - // Extract resource_id from URL path - let resource_id = match extract_resource_id(request.uri().path()) { - Some(id) => id, - None => return Err(StatusCode::BAD_REQUEST), - }; - - // Check if user has the required permission (either directly or through shares) - let has_perm = match state.db - .check_permission(&user_id, &resource_id, &required_permission) - .await - { - Ok(allowed) => allowed, - Err(_) => return Err(StatusCode::INTERNAL_SERVER_ERROR), - }; - - if !has_perm { - return Err(StatusCode::FORBIDDEN); - } - - Ok(next.run(request).await) -} - -/// Extract resource ID from URL path -/// -/// # Examples -/// - /api/shares/123 -> Some("123") -/// - /api/users/me/profile -> None -fn extract_resource_id(path: &str) -> Option { - let segments: Vec<&str> = path.split('/').collect(); - - // Look for ID segment after a resource type - // e.g., /api/shares/:id - for (i, segment) in segments.iter().enumerate() { - if segment == &"shares" || segment == &"permissions" { - if i + 1 < segments.len() { - let id = segments[i + 1]; - if !id.is_empty() { - return Some(id.to_string()); - } - } - } - } - - None -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_extract_resource_id() { - assert_eq!( - extract_resource_id("/api/shares/123"), - Some("123".to_string()) - ); - assert_eq!( - extract_resource_id("/api/shares/abc-123"), - Some("abc-123".to_string()) - ); - assert_eq!( - extract_resource_id("/api/users/me"), - None - ); - } -} diff --git a/backend/src/models/mod.rs b/backend/src/models/mod.rs index 3169d50..87d50ee 100644 --- a/backend/src/models/mod.rs +++ b/backend/src/models/mod.rs @@ -1,9 +1,19 @@ -pub mod user; -pub mod family; -pub mod profile; -pub mod health_data; -pub mod lab_result; -pub mod medication; -pub mod appointment; -pub mod share; +### /home/asoliver/desarrollo/normogen/./backend/src/models/mod.rs +```rust +1: pub mod user; +2: pub mod family; +3: pub mod profile; +4: pub mod health_data; +5: pub mod lab_result; +6: pub mod medication; +7: pub mod appointment; +8: pub mod share; +9: pub mod refresh_token; +``` + pub mod permission; +pub mod share; + +pub use permission::Permission; +pub use share::Share; +pub use share::ShareRepository; diff --git a/backend/src/models/share.rs b/backend/src/models/share.rs index b1699df..8776dc7 100644 --- a/backend/src/models/share.rs +++ b/backend/src/models/share.rs @@ -1,10 +1,15 @@ -use mongodb::bson::{doc, oid::ObjectId}; +use bson::doc; use mongodb::Collection; use serde::{Deserialize, Serialize}; +use wither::{ + bson::{oid::ObjectId}, + Model, +}; use super::permission::Permission; -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, Model)] +#[model(collection_name="shares")] pub struct Share { #[serde(rename = "_id", skip_serializing_if = "Option::is_none")] pub id: Option, @@ -79,31 +84,23 @@ impl ShareRepository { } pub async fn find_by_owner(&self, owner_id: &ObjectId) -> mongodb::error::Result> { - use futures::stream::TryStreamExt; - self.collection .find(doc! { "owner_id": owner_id }, None) - .await? - .try_collect() .await + .map(|cursor| cursor.collect()) .map_err(|e| mongodb::error::Error::from(e)) } pub async fn find_by_target(&self, target_user_id: &ObjectId) -> mongodb::error::Result> { - use futures::stream::TryStreamExt; - self.collection .find(doc! { "target_user_id": target_user_id, "active": true }, None) - .await? - .try_collect() .await + .map(|cursor| cursor.collect()) .map_err(|e| mongodb::error::Error::from(e)) } pub async fn update(&self, share: &Share) -> mongodb::error::Result<()> { - if let Some(id) = &share.id { - self.collection.replace_one(doc! { "_id": id }, share, None).await?; - } + self.collection.replace_one(doc! { "_id": &share.id }, share, None).await?; Ok(()) } diff --git a/backend/src/models/user.rs b/backend/src/models/user.rs index 8bb2320..bede9e9 100644 --- a/backend/src/models/user.rs +++ b/backend/src/models/user.rs @@ -1,18 +1,24 @@ -use mongodb::bson::{doc, oid::ObjectId}; +use bson::{doc, Document}; use mongodb::Collection; use serde::{Deserialize, Serialize}; +use wither::{ + bson::{oid::ObjectId}, + IndexModel, IndexOptions, Model, +}; -use crate::auth::password::verify_password; +use crate::auth::password::{PasswordService, verify_password}; -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, Model)] +#[model(collection_name="users")] pub struct User { #[serde(rename = "_id", skip_serializing_if = "Option::is_none")] pub id: Option, - + + #[index(unique = true)] pub email: String, - + pub username: String, - + pub password_hash: String, /// Password recovery phrase hash (zero-knowledge) @@ -51,9 +57,6 @@ impl User { password: String, recovery_phrase: Option, ) -> Result { - // Import PasswordService - use crate::auth::password::PasswordService; - // Hash the password let password_hash = PasswordService::hash_password(&password)?; @@ -65,7 +68,6 @@ impl User { }; let now = chrono::Utc::now(); - let recovery_enabled = recovery_phrase_hash.is_some(); Ok(User { id: None, @@ -73,7 +75,7 @@ impl User { username, password_hash, recovery_phrase_hash, - recovery_enabled, + recovery_enabled: recovery_phrase_hash.is_some(), token_version: 0, created_at: now, last_active: now, @@ -100,8 +102,6 @@ impl User { /// Update the password hash (increments token_version to invalidate all tokens) pub fn update_password(&mut self, new_password: String) -> Result<(), anyhow::Error> { - use crate::auth::password::PasswordService; - self.password_hash = PasswordService::hash_password(&new_password)?; self.token_version += 1; Ok(()) @@ -109,8 +109,6 @@ impl User { /// Set or update the recovery phrase pub fn set_recovery_phrase(&mut self, phrase: String) -> Result<(), anyhow::Error> { - use crate::auth::password::PasswordService; - self.recovery_phrase_hash = Some(PasswordService::hash_password(&phrase)?); self.recovery_enabled = true; Ok(()) @@ -175,13 +173,9 @@ impl UserRepository { Ok(()) } - /// Update the token version - silently fails if ObjectId is invalid + /// Update the token version pub async fn update_token_version(&self, user_id: &str, version: i32) -> mongodb::error::Result<()> { - let oid = match ObjectId::parse_str(user_id) { - Ok(id) => id, - Err(_) => return Ok(()), // Silently fail if invalid ObjectId - }; - + let oid = mongodb::bson::oid::ObjectId::parse_str(user_id)?; self.collection .update_one( doc! { "_id": oid }, @@ -202,13 +196,10 @@ impl UserRepository { /// Update last active timestamp pub async fn update_last_active(&self, user_id: &ObjectId) -> mongodb::error::Result<()> { - use mongodb::bson::DateTime; - - let now = DateTime::now(); self.collection .update_one( doc! { "_id": user_id }, - doc! { "$set": { "last_active": now } }, + doc! { "$set": { "last_active": chrono::Utc::now() } }, None, ) .await?;