From 0d908ccfe81b72c88db571866b6c4ee7f4446b01 Mon Sep 17 00:00:00 2001 From: Rik Heijmann Date: Sat, 15 Feb 2025 20:57:24 +0100 Subject: [PATCH] Extended the API security, added api documentation, added delete endpoint, more secure docker image. --- .env.example | 37 +- Bruno.json | 316 ----------------- Cargo.toml | 7 +- Dockerfile | 127 +++---- README.md | 297 ++++++++++------ compose.yaml | 32 -- docker-compose.yml | 53 +++ documentation/images/homepage.png | Bin 0 -> 64295 bytes documentation/images/swagger.png | Bin 0 -> 32680 bytes documentation/openapi.json | 1 + generate_ssl_key.bat | 1 - src/core/README.md | 9 + src/core/mod.rs | 1 - src/core/server.rs | 4 +- src/core/tls.rs | 145 -------- src/database/README.md | 29 ++ src/database/apikeys.rs | 232 +++++++++++++ src/database/connect.rs | 151 ++++++-- src/database/get_apikeys.rs | 16 - src/database/get_users.rs | 25 -- src/database/insert_usage.rs | 16 - src/database/mod.rs | 7 +- src/database/todos.rs | 107 ++++++ src/database/usage.rs | 68 ++++ src/database/users.rs | 141 ++++++++ src/handlers/README.md | 32 ++ src/{routes => handlers}/delete_apikeys.rs | 51 +-- src/{routes => handlers}/delete_todos.rs | 43 ++- src/{routes => handlers}/delete_users.rs | 45 ++- src/{routes => handlers}/get_apikeys.rs | 39 +-- src/{routes => handlers}/get_health.rs | 38 +-- src/{routes => handlers}/get_todos.rs | 48 ++- src/handlers/get_usage.rs | 62 ++++ src/handlers/get_users.rs | 74 ++++ src/handlers/homepage.rs | 115 +++++++ src/handlers/mod.rs | 16 +- src/{routes => handlers}/post_apikeys.rs | 101 ++---- src/{routes => handlers}/post_todos.rs | 36 +- src/handlers/post_users.rs | 66 ++++ src/handlers/protected.rs | 21 ++ src/handlers/rotate_apikeys.rs | 128 +++++++ src/handlers/signin.rs | 138 ++++++++ src/main.rs | 190 ++++++++--- src/middlewares/README.md | 40 +++ src/middlewares/auth.rs | 380 ++++++--------------- src/models/README.md | 31 ++ src/models/apikey.rs | 119 +++++-- src/models/auth.rs | 41 +++ src/models/documentation.rs | 13 +- src/models/health.rs | 52 +++ src/models/mod.rs | 8 +- src/models/usage.rs | 18 + src/models/user.rs | 66 ++-- src/routes/README.md | 43 +++ src/routes/apikey.rs | 29 ++ src/routes/auth.rs | 19 ++ src/routes/get_usage.rs | 84 ----- src/routes/get_users.rs | 121 ------- src/routes/health.rs | 12 + src/routes/homepage.rs | 88 +---- src/routes/mod.rs | 242 ++++++------- src/routes/post_users.rs | 98 ------ src/routes/rotate_apikeys.rs | 190 ----------- src/routes/todo.rs | 26 ++ src/routes/usage.rs | 19 ++ src/routes/user.rs | 26 ++ src/utils/README.md | 9 + src/utils/auth.rs | 186 ++++++++++ src/utils/mod.rs | 2 + src/{handlers => utils}/validate.rs | 0 70 files changed, 2945 insertions(+), 2082 deletions(-) delete mode 100644 Bruno.json delete mode 100644 compose.yaml create mode 100644 docker-compose.yml create mode 100644 documentation/images/homepage.png create mode 100644 documentation/images/swagger.png create mode 100644 documentation/openapi.json delete mode 100644 generate_ssl_key.bat create mode 100644 src/core/README.md delete mode 100644 src/core/tls.rs create mode 100644 src/database/README.md create mode 100644 src/database/apikeys.rs delete mode 100644 src/database/get_apikeys.rs delete mode 100644 src/database/get_users.rs delete mode 100644 src/database/insert_usage.rs create mode 100644 src/database/todos.rs create mode 100644 src/database/usage.rs create mode 100644 src/database/users.rs create mode 100644 src/handlers/README.md rename src/{routes => handlers}/delete_apikeys.rs (50%) rename src/{routes => handlers}/delete_todos.rs (55%) rename src/{routes => handlers}/delete_users.rs (50%) rename src/{routes => handlers}/get_apikeys.rs (74%) rename src/{routes => handlers}/get_health.rs (89%) rename src/{routes => handlers}/get_todos.rs (66%) create mode 100644 src/handlers/get_usage.rs create mode 100644 src/handlers/get_users.rs create mode 100644 src/handlers/homepage.rs rename src/{routes => handlers}/post_apikeys.rs (52%) rename src/{routes => handlers}/post_todos.rs (67%) create mode 100644 src/handlers/post_users.rs create mode 100644 src/handlers/protected.rs create mode 100644 src/handlers/rotate_apikeys.rs create mode 100644 src/handlers/signin.rs create mode 100644 src/middlewares/README.md create mode 100644 src/models/README.md create mode 100644 src/models/auth.rs create mode 100644 src/models/health.rs create mode 100644 src/models/usage.rs create mode 100644 src/routes/README.md create mode 100644 src/routes/apikey.rs create mode 100644 src/routes/auth.rs delete mode 100644 src/routes/get_usage.rs delete mode 100644 src/routes/get_users.rs create mode 100644 src/routes/health.rs delete mode 100644 src/routes/post_users.rs delete mode 100644 src/routes/rotate_apikeys.rs create mode 100644 src/routes/todo.rs create mode 100644 src/routes/usage.rs create mode 100644 src/routes/user.rs create mode 100644 src/utils/README.md create mode 100644 src/utils/auth.rs create mode 100644 src/utils/mod.rs rename src/{handlers => utils}/validate.rs (100%) diff --git a/.env.example b/.env.example index e315459..e783d47 100644 --- a/.env.example +++ b/.env.example @@ -1,15 +1,7 @@ # ============================== -# 📌 DATABASE CONFIGURATION +# ⚙️ GENERAL CONFIGURATION # ============================== - -# PostgreSQL connection URL (format: postgres://user:password@host/database) -DATABASE_URL="postgres://postgres:1234@localhost/database_name" - -# Maximum number of connections in the database pool -DATABASE_MAX_CONNECTIONS=20 - -# Minimum number of connections in the database pool -DATABASE_MIN_CONNECTIONS=5 +ENVIRONMENT="development" # "production" # ============================== # 🌍 SERVER CONFIGURATION @@ -24,6 +16,24 @@ SERVER_PORT="3000" # Enable tracing for debugging/logging (true/false) SERVER_TRACE_ENABLED=true +# Amount of threads used to run the server +SERVER_WORKER_THREADS=2 + + +# ============================== +# 🛢️ DATABASE CONFIGURATION +# ============================== + +# PostgreSQL connection URL (format: postgres://user:password@host/database) +DATABASE_URL="postgres://postgres:1234@localhost/database_name" + +# Maximum number of connections in the database pool +DATABASE_MAX_CONNECTIONS=20 + +# Minimum number of connections in the database pool +DATABASE_MIN_CONNECTIONS=5 + + # ============================== # 🔒 HTTPS CONFIGURATION # ============================== @@ -40,6 +50,7 @@ SERVER_HTTPS_CERT_FILE_PATH=cert.pem # Path to the SSL private key file (only used if SERVER_HTTPS_ENABLED=true) SERVER_HTTPS_KEY_FILE_PATH=key.pem + # ============================== # 🚦 RATE LIMIT CONFIGURATION # ============================== @@ -50,6 +61,7 @@ SERVER_RATE_LIMIT=5 # Time period (in seconds) for rate limiting SERVER_RATE_LIMIT_PERIOD=1 + # ============================== # 📦 COMPRESSION CONFIGURATION # ============================== @@ -60,9 +72,10 @@ SERVER_COMPRESSION_ENABLED=true # Compression level (valid range: 0-11, where 11 is the highest compression) SERVER_COMPRESSION_LEVEL=6 + # ============================== # 🔑 AUTHENTICATION CONFIGURATION # ============================== -# Argon2 salt for password hashing (must be kept secret!) -AUTHENTICATION_ARGON2_SALT="dMjQgtSmoQIH3Imi" \ No newline at end of file +# JWT secret key. +JWT_SECRET_KEY="fgr4fe34w2rfTwfe3444234edfewfw4e#f$#wferg23w2DFSdf" \ No newline at end of file diff --git a/Bruno.json b/Bruno.json deleted file mode 100644 index e5cb251..0000000 --- a/Bruno.json +++ /dev/null @@ -1,316 +0,0 @@ -{ - "name": "Axium", - "version": "1", - "items": [ - { - "type": "http", - "name": "Health", - "seq": 3, - "request": { - "url": "{{base_url}}/health", - "method": "GET", - "headers": [], - "params": [], - "body": { - "mode": "none", - "formUrlEncoded": [], - "multipartForm": [] - }, - "script": {}, - "vars": {}, - "assertions": [], - "tests": "", - "docs": "", - "auth": { - "mode": "none" - } - } - }, - { - "type": "http", - "name": "Protected", - "seq": 4, - "request": { - "url": "{{base_url}}/protected", - "method": "GET", - "headers": [ - { - "name": "Authorization", - "value": "Bearer {{token}}", - "enabled": true - } - ], - "params": [], - "body": { - "mode": "json", - "json": "", - "formUrlEncoded": [], - "multipartForm": [] - }, - "script": {}, - "vars": {}, - "assertions": [], - "tests": "", - "docs": "", - "auth": { - "mode": "none" - } - } - }, - { - "type": "http", - "name": "Sign-in", - "seq": 1, - "request": { - "url": "{{base_url}}/signin", - "method": "POST", - "headers": [], - "params": [], - "body": { - "mode": "json", - "json": "{\n \"email\":\"user@test.com\",\n \"password\":\"test\"\n}", - "formUrlEncoded": [], - "multipartForm": [] - }, - "script": {}, - "vars": {}, - "assertions": [], - "tests": "", - "docs": "", - "auth": { - "mode": "none" - } - } - }, - { - "type": "folder", - "name": "To do's", - "root": { - "request": { - "headers": [ - { - "name": "Authorization", - "value": "Bearer {{token}}", - "enabled": true, - "uid": "PRiX2eBEKKPlsc1xxRHeN" - } - ] - }, - "meta": { - "name": "To do's" - } - }, - "items": [ - { - "type": "http", - "name": "Get all", - "seq": 1, - "request": { - "url": "{{base_url}}/todos/all", - "method": "GET", - "headers": [], - "params": [], - "body": { - "mode": "none", - "formUrlEncoded": [], - "multipartForm": [] - }, - "script": {}, - "vars": {}, - "assertions": [], - "tests": "", - "docs": "", - "auth": { - "mode": "none" - } - } - }, - { - "type": "http", - "name": "Get by ID", - "seq": 2, - "request": { - "url": "{{base_url}}/todos/1", - "method": "GET", - "headers": [], - "params": [], - "body": { - "mode": "none", - "formUrlEncoded": [], - "multipartForm": [] - }, - "script": {}, - "vars": {}, - "assertions": [], - "tests": "", - "docs": "", - "auth": { - "mode": "none" - } - } - }, - { - "type": "http", - "name": "Post new", - "seq": 3, - "request": { - "url": "{{base_url}}/todos/", - "method": "POST", - "headers": [], - "params": [], - "body": { - "mode": "json", - "json": "{\n \"task\": \"Finish Rust project.\",\n \"description\": \"Complete the API endpoints for the todo app.\"\n}", - "formUrlEncoded": [], - "multipartForm": [] - }, - "script": {}, - "vars": {}, - "assertions": [], - "tests": "", - "docs": "", - "auth": { - "mode": "none" - } - } - } - ] - }, - { - "type": "folder", - "name": "Users", - "root": { - "request": { - "headers": [ - { - "name": "Authorization", - "value": "Bearer {{token}}", - "enabled": true, - "uid": "Dv1ZS2orRQaKpVNKRBmLf" - } - ] - }, - "meta": { - "name": "Users" - } - }, - "items": [ - { - "type": "http", - "name": "Get all", - "seq": 1, - "request": { - "url": "{{base_url}}/users/all", - "method": "GET", - "headers": [ - { - "name": "", - "value": "", - "enabled": true - } - ], - "params": [], - "body": { - "mode": "none", - "formUrlEncoded": [], - "multipartForm": [] - }, - "script": {}, - "vars": {}, - "assertions": [], - "tests": "", - "docs": "", - "auth": { - "mode": "none" - } - } - }, - { - "type": "http", - "name": "Post new", - "seq": 3, - "request": { - "url": "{{base_url}}/users/", - "method": "POST", - "headers": [], - "params": [], - "body": { - "mode": "json", - "json": "{\n \"username\": \"MyNewUser\",\n \"email\": \"MyNewUser@test.com\",\n \"password\": \"MyNewUser\",\n \"totp\": \"true\"\n}", - "formUrlEncoded": [], - "multipartForm": [] - }, - "script": {}, - "vars": {}, - "assertions": [], - "tests": "", - "docs": "", - "auth": { - "mode": "none" - } - } - }, - { - "type": "http", - "name": "Get by ID", - "seq": 2, - "request": { - "url": "{{base_url}}/users/1", - "method": "GET", - "headers": [], - "params": [], - "body": { - "mode": "none", - "formUrlEncoded": [], - "multipartForm": [] - }, - "script": {}, - "vars": {}, - "assertions": [], - "tests": "", - "docs": "", - "auth": { - "mode": "none" - } - } - } - ] - } - ], - "activeEnvironmentUid": "6LVIlBNVHmWamnS5xdrf0", - "environments": [ - { - "variables": [ - { - "name": "base_url", - "value": "http://127.0.0.1:3000", - "enabled": true, - "secret": false, - "type": "text" - }, - { - "name": "token", - "value": "", - "enabled": true, - "secret": true, - "type": "text" - } - ], - "name": "Default" - } - ], - "root": { - "request": { - "vars": {} - } - }, - "brunoConfig": { - "version": "1", - "name": "Axium", - "type": "collection", - "ignore": [ - "node_modules", - ".git" - ] - } -} \ No newline at end of file diff --git a/Cargo.toml b/Cargo.toml index d753109..8a840d6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,12 +7,15 @@ edition = "2021" # Web framework and server axum = { version = "0.8.1", features = ["json"] } # hyper = { version = "1.5.2", features = ["full"] } +axum-server = { version = "0.7", features = ["tls-rustls"] } # Database interaction sqlx = { version = "0.8.3", features = ["runtime-tokio-rustls", "postgres", "migrate", "uuid", "chrono"] } uuid = { version = "1.12.1", features = ["serde"] } rand = "0.8.5" rand_core = "0.6.4" # 2024-2-3: SQLx 0.8.3 does not support 0.9. +moka = { version = "0.12.10", features = ["future"] } +lazy_static = "1.5" # Serialization and deserialization serde = { version = "1.0.217", features = ["derive"] } @@ -24,9 +27,10 @@ argon2 = "0.5.3" totp-rs = { version = "5.6.0", features = ["gen_secret"] } base64 = "0.22.1" bcrypt = "0.17.0" +futures = "0.3.31" # Asynchronous runtime and traits -tokio = { version = "1.43.0", features = ["rt-multi-thread", "process"] } +tokio = { version = "1.43.0", features = ["rt-multi-thread", "process", "signal"] } # Configuration and environment dotenvy = "0.15.7" @@ -53,6 +57,7 @@ rustls-pemfile = "2.2.0" # Input validation validator = { version = "0.20.0", features = ["derive"] } regex = "1.11.1" +thiserror = "1.0" # Documentation utoipa = { version = "5.3.1", features = ["axum_extras", "chrono", "uuid"] } diff --git a/Dockerfile b/Dockerfile index d7266d5..d06453e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,73 +1,56 @@ +# --- Stage 1: Builder Stage --- + FROM rust:1.75-slim-bookworm AS builder - # syntax=docker/dockerfile:1 - - # Comments are provided throughout this file to help you get started. - # If you need more help, visit the Dockerfile reference guide at - # https://docs.docker.com/engine/reference/builder/ - - ################################################################################ - # Create a stage for building the application. - - ARG RUST_VERSION=1.78.0 - ARG APP_NAME=backend - FROM rust:${RUST_VERSION}-slim-bullseye AS build - ARG APP_NAME - WORKDIR /app - - # Build the application. - # Leverage a cache mount to /usr/local/cargo/registry/ - # for downloaded dependencies and a cache mount to /app/target/ for - # compiled dependencies which will speed up subsequent builds. - # Leverage a bind mount to the src directory to avoid having to copy the - # source code into the container. Once built, copy the executable to an - # output directory before the cache mounted /app/target is unmounted. - RUN --mount=type=bind,source=src,target=src \ - # --mount=type=bind,source=configuration.yaml,target=configuration.yaml \ - --mount=type=bind,source=Cargo.toml,target=Cargo.toml \ - --mount=type=bind,source=Cargo.lock,target=Cargo.lock \ - --mount=type=cache,target=/app/target/ \ - --mount=type=cache,target=/usr/local/cargo/registry/ \ - <, // Injected user - Json(todo): Json - ) -> impl IntoResponse { - if todo.user_id != user.id { - return Err((StatusCode::FORBIDDEN, Json(json!({ - "error": "Cannot create todos for others" - })))); - } - ``` -- **Observability** - Integrated tracing, -- **Documented codebase** - Extensive inline comments for easy modification and readability, -- **Latest dependencies** - Regularly updated Rust ecosystem crates, +} +``` + +### **Developer Ergonomics** +_Code with confidence_ +- Context-aware user injection system: +```rust +async fn create_todo( + Extension(User { id, role, .. }): Extension, // Auto-injected + Json(payload): Json +) -> Result { + // Business logic with direct user context +} +``` +- Structured logging with OpenTelemetry integration +- Compile-time configuration validation + +### **Maintenance & Compliance** +_Future-proof codebase management_ +- Automated dependency updates via Dependabot +- Security-focused dependency tree (cargo-audit compliant) +- Comprehensive inline documentation: +```rust +/// JWT middleware - Validates Authorization header +/// # Arguments +/// * `req` - Incoming request +/// * `next` - Next middleware layer +/// # Security +/// - Validates Bearer token format +/// - Checks token expiration +/// - Verifies cryptographic signature +``` ## 🛠️ Technology stack | Category | Key Technologies | @@ -55,53 +100,64 @@ ## 📂 Project structure ``` -Axium/ -├── migrations/ # SQL schema migrations. Creates the required tables and inserts demo data. -├── src/ -│ ├── core/ # Core modules: for reading configuration files, starting the server and configuring HTTPS/ -│ ├── database/ # Database connectivity, getters and setters for the database. -│ ├── middlewares/ # Currently just the authentication system. -│ ├── models/ # Data structures -│ └── routes/ # API endpoints -│ └── mod.rs # API endpoint router. -│ └── .env # Configuration file. -└── Dockerfile # Builds a docker container for the application. -└── compose.yaml # Docker-compose.yaml. Runs container for the application (also includes a PostgreSQL-container). +axium-api/ # Root project directory +├── 📁 migrations/ # Database schema migrations (SQLx) +│ +├── 📁 src/ # Application source code +│ ├── 📁 core/ # Core application infrastructure +│ │ ├── config.rs # Configuration loader (.env, env vars) +│ │ └── server.rs # HTTP/HTTPS server initialization +│ │ +│ ├── 📁 database/ # Database access layer +│ │ ├── connection.rs # Connection pool management +│ │ ├── queries/ # SQL query modules +│ │ └── models.rs # Database entity definitions +│ │ +│ ├── 📁 middlewares/ # Axum middleware components +│ ├── 📁 routes/ # API endpoint routing +│ │ └── mod.rs # Route aggregator +│ │ +│ ├── 📁 handlers/ # Request handlers +│ │ +│ ├── 📁 utils/ # Common utilities +│ │ +│ └── main.rs # Application entry point +│ +├── 📄 .env # Environment configuration +├── 📄 .env.example # Environment template +├── 📄 Dockerfile # Production container build +├── 📄 docker-compose.yml # Local development stack +└── 📄 Cargo.toml # Rust dependencies & metadata ``` +Each folder has a detailed README.md file which explains the folder in more detail. + ## 🌐 Default API endpoints -| Method | Endpoint | Auth Required | Allowed Roles | Description | -|--------|------------------------|---------------|---------------|--------------------------------------| -| POST | `/signin` | No | | Authenticate user and get JWT token | -| GET | `/protected` | Yes | 1, 2 | Test endpoint for authenticated users | -| GET | `/health` | No | | System health check with metrics | -| | | | | | -| **User routes** | | | | | -| GET | `/users/all` | No* | | Get all users | -| GET | `/users/{id}` | No* | | Get user by ID | -| POST | `/users/` | No* | | Create new user | -| | | | | | -| **Todo routes** | | | | | -| GET | `/todos/all` | No* | | Get all todos | -| POST | `/todos/` | Yes | 1, 2 | Create new todo | -| GET | `/todos/{id}` | No* | | Get todo by ID | - -**Key:** -🔒 = Requires JWT in `Authorization: Bearer ` header -\* Currently unprotected - recommend adding authentication for production -**Roles:** 1 = User, 2 = Administrator - -**Security notes:** -- All POST endpoints expect JSON payloads -- User creation endpoint should be protected in production -- Consider adding rate limiting to authentication endpoints -**Notes:** -- 🔒 = Requires JWT in `Authorization: Bearer ` header -- Roles: `1` = Regular User, `2` = Administrator -- *Marked endpoints currently unprotected - recommend adding middleware for production use -- All POST endpoints expect JSON payloads - +| Method | Endpoint | Auth Required | Administrator only | Description | +|--------|------------------------|---------------|-------------------|--------------------------------------| +| POST | `/signin` | 🚫 | 🚫 | Authenticate user and get JWT token | +| GET | `/protected` | ✅ | 🚫 | Test endpoint for authenticated users | +| GET | `/health` | 🚫 | 🚫 | System health check with metrics | +| | | | | | +| **Apikey routes** | | | | | +| GET | `/apikeys/all` | ✅ | ✅ | Get all apikeys of the current user. | +| POST | `/apikeys/` | ✅ | ✅ | Create a new apikey. | +| GET | `/apikeys/{id}` | ✅ | ✅ | Get an apikey by ID. | +| DELETE | `/apikeys/{id}` | ✅ | 🚫 | Delete an apikey by ID. | +| POST | `/apikeys/rotate/{id}` | ✅ | 🚫 | Rotates an API key, disables the old one (grace period 24 hours), returns a new one. | +| | | | | | +| **User routes** | | | | | +| GET | `/users/all` | ✅ | ✅ | Get all users. | +| POST | `/users/` | ✅ | ✅ | Create a new user. | +| GET | `/users/{id}` | ✅ | ✅ | Get a user by ID. | +| DELETE | `/users/{id}` | ✅ | ✅ | Delete a user by ID. | +| | | | | | +| **Todo routes** | | | | | +| GET | `/todos/all` | ✅ | 🚫 | Get all todos of the current user. | +| POST | `/todos/` | ✅ | 🚫 | Create a new todo. | +| GET | `/todos/{id}` | ✅ | 🚫 | Get a todo by ID. | +| DELETE | `/todos/{id}` | ✅ | 🚫 | Delete a todo by ID. | ## 📦 Installation & Usage ```bash @@ -126,27 +182,47 @@ cargo run --release | `admin@test.com` | `test` | Administrator | ⚠️ **Security recommendations:** -1. Rotate passwords immediately after initial setup -2. Disable default accounts before deploying to production -3. Implement proper user management endpoints +1. Rotate passwords immediately after initial setup. +2. Disable default accounts before deploying to production. +3. Implement proper user management endpoints. +#### Administrative password resets +*For emergency access recovery only* + +1. **Database Access** + Connect to PostgreSQL using privileged credentials: + ```bash + psql -U admin_user -d axium_db -h localhost + ``` + +2. **Secure Hash Generation** + Use the integrated CLI tool (never online generators): + ```bash + cargo run --bin argon2-cli -- "new_password" + # Output: $argon2id$v=19$m=19456,t=2,p=1$b2JqZWN0X2lkXzEyMzQ1$R7Zx7Y4W... + ``` + +3. **Database Update** + ```sql + UPDATE users + SET + password_hash = '$argon2id...', + updated_at = NOW() + WHERE email = 'user@example.com'; + ``` + +4. **Verification** + - Immediately test new credentials + - Force user password change on next login ### ⚙️ Configuration Create a .env file in the root of the project or configure the application using environment variables. ```env # ============================== -# 📌 DATABASE CONFIGURATION +# ⚙️ GENERAL CONFIGURATION # ============================== - -# PostgreSQL connection URL (format: postgres://user:password@host/database) -DATABASE_URL="postgres://postgres:1234@localhost/database_name" - -# Maximum number of connections in the database pool -DATABASE_MAX_CONNECTIONS=20 - -# Minimum number of connections in the database pool -DATABASE_MIN_CONNECTIONS=5 +ENVIRONMENT="development" # "production" # ============================== # 🌍 SERVER CONFIGURATION @@ -161,6 +237,24 @@ SERVER_PORT="3000" # Enable tracing for debugging/logging (true/false) SERVER_TRACE_ENABLED=true +# Amount of threads used to run the server +SERVER_WORKER_THREADS=2 + + +# ============================== +# 🛢️ DATABASE CONFIGURATION +# ============================== + +# PostgreSQL connection URL (format: postgres://user:password@host/database) +DATABASE_URL="postgres://postgres:1234@localhost/database_name" + +# Maximum number of connections in the database pool +DATABASE_MAX_CONNECTIONS=20 + +# Minimum number of connections in the database pool +DATABASE_MIN_CONNECTIONS=5 + + # ============================== # 🔒 HTTPS CONFIGURATION # ============================== @@ -177,6 +271,7 @@ SERVER_HTTPS_CERT_FILE_PATH=cert.pem # Path to the SSL private key file (only used if SERVER_HTTPS_ENABLED=true) SERVER_HTTPS_KEY_FILE_PATH=key.pem + # ============================== # 🚦 RATE LIMIT CONFIGURATION # ============================== @@ -187,6 +282,7 @@ SERVER_RATE_LIMIT=5 # Time period (in seconds) for rate limiting SERVER_RATE_LIMIT_PERIOD=1 + # ============================== # 📦 COMPRESSION CONFIGURATION # ============================== @@ -197,10 +293,11 @@ SERVER_COMPRESSION_ENABLED=true # Compression level (valid range: 0-11, where 11 is the highest compression) SERVER_COMPRESSION_LEVEL=6 + # ============================== # 🔑 AUTHENTICATION CONFIGURATION # ============================== -# Argon2 salt for password hashing (must be kept secret!) -AUTHENTICATION_ARGON2_SALT="dMjQgtSmoQIH3Imi" +# JWT secret key. +JWT_SECRET_KEY="fgr4fe34w2rfTwfe3444234edfewfw4e#f$#wferg23w2DFSdf" ``` diff --git a/compose.yaml b/compose.yaml deleted file mode 100644 index a46bd80..0000000 --- a/compose.yaml +++ /dev/null @@ -1,32 +0,0 @@ - -services: - server: - build: - context: . - target: final - ports: - - 80:80 - depends_on: - - db_image - networks: - - common-net - - db_image: - image: postgres:latest - environment: - POSTGRES_PORT: 3306 - POSTGRES_DATABASE: database_name - POSTGRES_USER: user - POSTGRES_PASSWORD: database_password - POSTGRES_ROOT_PASSWORD: strong_database_password - expose: - - 3306 - ports: - - "3307:3306" - networks: - - common-net - -networks: - common-net: {} - - \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..772c104 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,53 @@ +version: "3.9" + +services: + axium: + build: + context: . + dockerfile: Dockerfile + ports: + - "3000:3000" + environment: + - ENVIRONMENT=${ENVIRONMENT:-development} #default value if not defined. + - SERVER_IP=${SERVER_IP:-0.0.0.0} + - SERVER_PORT=${SERVER_PORT:-3000} + - SERVER_TRACE_ENABLED=${SERVER_TRACE_ENABLED:-true} + - SERVER_WORKER_THREADS=${SERVER_WORKER_THREADS:-2} + - DATABASE_URL=${DATABASE_URL:-postgres://postgres:1234@db/database_name} + - DATABASE_MAX_CONNECTIONS=${DATABASE_MAX_CONNECTIONS:-20} + - DATABASE_MIN_CONNECTIONS=${DATABASE_MIN_CONNECTIONS:-5} + - SERVER_HTTPS_ENABLED=${SERVER_HTTPS_ENABLED:-false} + - SERVER_HTTPS_HTTP2_ENABLED=${SERVER_HTTPS_HTTP2_ENABLED:-true} + # Mount volume for certs for HTTPS + - SERVER_HTTPS_CERT_FILE_PATH=/app/certs/cert.pem # Changed to /app/certs + - SERVER_HTTPS_KEY_FILE_PATH=/app/certs/key.pem # Changed to /app/certs + - SERVER_RATE_LIMIT=${SERVER_RATE_LIMIT:-5} + - SERVER_RATE_LIMIT_PERIOD=${SERVER_RATE_LIMIT_PERIOD:-1} + - SERVER_COMPRESSION_ENABLED=${SERVER_COMPRESSION_ENABLED:-true} + - SERVER_COMPRESSION_LEVEL=${SERVER_COMPRESSION_LEVEL:-6} + - JWT_SECRET_KEY=${JWT_SECRET_KEY:-fgr4fe34w2rfTwfe3444234edfewfw4e#f$#wferg23w2DFSdf} #VERY important to change this! + depends_on: + - db # Ensure the database is up before the app + volumes: + - ./certs:/app/certs # Mount volume for certs + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:3000/health"] + interval: 10s + timeout: 5s + retries: 3 + start_period: 15s + + db: + image: postgres:16-alpine + restart: always + environment: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: "1234" # Change this in production! + POSTGRES_DB: database_name # Matches the DB name in .env + ports: + - "5432:5432" + volumes: + - db_data:/var/lib/postgresql/data + +volumes: + db_data: diff --git a/documentation/images/homepage.png b/documentation/images/homepage.png new file mode 100644 index 0000000000000000000000000000000000000000..d817731ecd9ae8b4f04789fc5e868cda397fb4f7 GIT binary patch literal 64295 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%0A05WWNf3OQTgct?_)l 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%0D>{+oQYxr}K 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%0DxSqbq&)&r=LH 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∋}^Ed-ve!;^)1Hq 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 literal 0 HcmV?d00001 diff --git a/documentation/openapi.json b/documentation/openapi.json new file mode 100644 index 0000000..e096e85 --- /dev/null +++ b/documentation/openapi.json @@ -0,0 +1 @@ +{"openapi":"3.1.0","info":{"title":"Axium","description":"An example API built with Rust, Axum, SQLx, and PostgreSQL.","contact":{"url":"https://github.com/Riktastic/Axium"},"license":{"name":"MIT","url":"https://opensource.org/licenses/MIT"},"version":"1.0.0"},"paths":{"/apikeys":{"get":{"tags":["apikey"],"operationId":"get_all_apikeys","parameters":[{"name":"user_id","in":"path","description":"User ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"Get all API keys","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/ApiKeyResponse"}}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{}}}},"500":{"description":"Internal Server Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"security":[{"jwt_token":[]}]},"post":{"tags":["apikey"],"operationId":"post_apikey","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiKeyInsertBody"}}},"required":true},"responses":{"200":{"description":"API key created successfully","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiKeyInsertResponse"}}}},"400":{"description":"Validation error","content":{"text/plain":{"schema":{"type":"string"}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{}}}},"500":{"description":"Internal server error","content":{"text/plain":{"schema":{"type":"string"}}}}},"security":[{"jwt_token":[]}]}},"/apikeys/rotate/{id}":{"post":{"tags":["apikey"],"operationId":"rotate_apikey","parameters":[{"name":"id","in":"path","description":"API key identifier","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiKeyRotateBody"}}},"required":true},"responses":{"200":{"description":"API key rotated successfully","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiKeyRotateResponse"}}}},"400":{"description":"Validation error","content":{"text/plain":{"schema":{"type":"string"}}}},"404":{"description":"API key not found","content":{"text/plain":{"schema":{"type":"string"}}}},"500":{"description":"Internal server error","content":{"text/plain":{"schema":{"type":"string"}}}}},"security":[{"jwt_token":[]}]}},"/apikeys/{id}":{"get":{"tags":["apikey"],"operationId":"get_apikeys_by_id","parameters":[{"name":"id","in":"path","description":"API key ID","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"user_id","in":"path","description":"User ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"Get API key by ID","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiKeyByIDResponse"}}}},"400":{"description":"Invalid UUID format","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"API key not found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"Internal Server Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}},"delete":{"tags":["apikey"],"operationId":"delete_apikey_by_id","parameters":[{"name":"id","in":"path","description":"API key ID","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"API key deleted successfully","content":{"text/plain":{"schema":{"type":"string"}}}},"400":{"description":"Invalid UUID format","content":{"text/plain":{"schema":{"type":"string"}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{}}}},"404":{"description":"API key not found","content":{"text/plain":{"schema":{"type":"string"}}}},"500":{"description":"Internal server error","content":{"text/plain":{"schema":{"type":"string"}}}}},"security":[{"jwt_token":[]}]}},"/health":{"get":{"tags":["health"],"operationId":"get_health","responses":{"200":{"description":"Successfully fetched health status","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HealthResponse"}}}},"500":{"description":"Internal server error"}}}},"/protected":{"get":{"tags":["protected"],"operationId":"protected","responses":{"200":{"description":"Protected endpoint accessed successfully","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UserGetResponse"}}}},"401":{"description":"Unauthorized","content":{"text/plain":{"schema":{"type":"string"}}}}},"security":[{"jwt_token":[]}]}},"/signin":{"post":{"tags":["auth"],"summary":"User sign-in endpoint","description":"This endpoint allows users to sign in using their email, password, and optionally a TOTP code.\n\n# Parameters\n- `State(pool)`: The shared database connection pool.\n- `Json(user_data)`: The user sign-in data (email, password, and optional TOTP code).\n\n# Returns\n- `Ok(Json(serde_json::Value))`: A JSON response containing the JWT token if sign-in is successful.\n- `Err((StatusCode, Json(serde_json::Value)))`: An error response if sign-in fails.","operationId":"signin","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SignInData"}}},"required":true},"responses":{"200":{"description":"Successful sign-in","content":{"application/json":{"schema":{}}}},"400":{"description":"Bad request","content":{"application/json":{"schema":{}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{}}}},"500":{"description":"Internal server error","content":{"application/json":{"schema":{}}}}}}},"/todos":{"post":{"tags":["todo"],"operationId":"post_todo","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/TodoBody"}}},"required":true},"responses":{"200":{"description":"Todo created successfully","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Todo"}}}},"400":{"description":"Validation error","content":{"text/plain":{"schema":{"type":"string"}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{}}}},"500":{"description":"Internal server error","content":{"text/plain":{"schema":{"type":"string"}}}}},"security":[{"jwt_token":[]}]}},"/todos/all":{"get":{"tags":["todo"],"operationId":"get_all_todos","responses":{"200":{"description":"Successfully fetched all todos","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/Todo"}}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{}}}},"500":{"description":"Internal server error"}},"security":[{"jwt_token":[]}]}},"/todos/{id}":{"get":{"tags":["todo"],"operationId":"get_todos_by_id","parameters":[{"name":"id","in":"path","description":"Todo ID","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Successfully fetched todo by ID","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Todo"}}}},"400":{"description":"Invalid UUID format"},"404":{"description":"Todo not found"},"500":{"description":"Internal server error"}}},"delete":{"tags":["todo"],"operationId":"delete_todo_by_id","parameters":[{"name":"id","in":"path","description":"Todo ID","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"user_id","in":"path","description":"User ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"Todo deleted successfully","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SuccessResponse"}}}},"400":{"description":"Invalid UUID format","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{}}}},"404":{"description":"Todo not found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"Internal Server Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"security":[{"jwt_token":[]}]}},"/usage/lastday":{"get":{"tags":["usage"],"operationId":"get_usage_last_day","responses":{"200":{"description":"Successfully fetched usage for the last 24 hours","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UsageResponseLastDay"}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{}}}},"500":{"description":"Internal server error"}},"security":[{"jwt_token":[]}]}},"/usage/lastweek":{"get":{"tags":["usage"],"operationId":"get_usage_last_week","responses":{"200":{"description":"Successfully fetched usage for the last 7 days","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UsageResponseLastDay"}}}},"500":{"description":"Internal server error"}}}},"/users":{"post":{"tags":["user"],"operationId":"post_user","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UserInsertBody"}}},"required":true},"responses":{"200":{"description":"User created successfully","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UserInsertResponse"}}}},"400":{"description":"Validation error","content":{"text/plain":{"schema":{"type":"string"}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{}}}},"500":{"description":"Internal server error","content":{"text/plain":{"schema":{"type":"string"}}}}},"security":[{"jwt_token":[]}]}},"/users/all":{"get":{"tags":["user"],"operationId":"get_all_users","responses":{"200":{"description":"Successfully fetched all users","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/UserGetResponse"}}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{}}}},"500":{"description":"Internal server error"}},"security":[{"jwt_token":[]}]}},"/users/{id}":{"get":{"tags":["user"],"operationId":"get_users_by_id","parameters":[{"name":"id","in":"path","description":"User ID","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Successfully fetched user by ID","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UserGetResponse"}}}},"400":{"description":"Invalid UUID format"},"404":{"description":"User not found"},"500":{"description":"Internal server error"}}},"delete":{"tags":["user"],"operationId":"delete_user_by_id","parameters":[{"name":"id","in":"path","description":"User ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"User deleted successfully","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SuccessResponse"}}}},"400":{"description":"Invalid UUID format","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{}}}},"404":{"description":"User not found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"Internal Server Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"security":[{"jwt_token":[]}]}}},"components":{"schemas":{"ApiKey":{"type":"object","description":"Represents an API key in the system.","required":["id","key_hash","user_id","creation_date","disabled","access_read","access_modify"],"properties":{"access_modify":{"type":"boolean","description":"Whether the API key has modify access (default is false)."},"access_read":{"type":"boolean","description":"Whether the API key has read access (default is true)."},"creation_date":{"type":"string","format":"date","description":"The creation date of the API key (default is the current date)."},"description":{"type":["string","null"],"description":"The description/name of the API key."},"disabled":{"type":"boolean","description":"Whether the API key is disabled (default is false)."},"expiration_date":{"type":["string","null"],"format":"date","description":"The expiration date of the API key."},"id":{"type":"string","format":"uuid","description":"The unique id of the API key."},"key_hash":{"type":"string","description":"The hashed value of the API key."},"user_id":{"type":"string","format":"uuid","description":"The id of the user who owns the API key."}}},"ApiKeyByIDResponse":{"type":"object","description":"Response body for retrieving an API key by its ID.","required":["id","creation_date"],"properties":{"creation_date":{"type":"string","format":"date","description":"The creation date of the API key."},"description":{"type":["string","null"],"description":"The description of the API key."},"expiration_date":{"type":["string","null"],"format":"date","description":"The expiration date of the API key."},"id":{"type":"string","format":"uuid","description":"The unique id of the API key."}}},"ApiKeyByUserIDResponse":{"type":"object","description":"Response body for retrieving API keys by user ID.","required":["id","key_hash"],"properties":{"expiration_date":{"type":["string","null"],"format":"date","description":"The expiration date of the API key."},"id":{"type":"string","format":"uuid","description":"The unique id of the API key."},"key_hash":{"type":"string","description":"The hashed value of the API key."}}},"ApiKeyGetActiveForUserResponse":{"type":"object","description":"Response body for retrieving active API keys for a user.","required":["id"],"properties":{"description":{"type":["string","null"],"description":"The description of the API key."},"id":{"type":"string","format":"uuid","description":"The unique id of the API key."}}},"ApiKeyInsertBody":{"type":"object","description":"Request body for creating a new API key.","properties":{"description":{"type":["string","null"],"description":"Optional description of the API key (max 50 characters)."},"expiration_date":{"type":["string","null"],"description":"Optional expiration date of the API key (must be in the future)."}}},"ApiKeyInsertResponse":{"type":"object","description":"Response body for creating a new API key.","required":["id","api_key","description","expiration_date"],"properties":{"api_key":{"type":"string","description":"The actual API key value."},"description":{"type":"string","description":"The description of the API key."},"expiration_date":{"type":"string","description":"The expiration date of the API key."},"id":{"type":"string","format":"uuid","description":"The unique id of the created API key."}}},"ApiKeyNewBody":{"type":"object","description":"Request body for creating a new API key (deprecated).","properties":{"description":{"type":["string","null"],"description":"The description of the API key."},"expiration_date":{"type":["string","null"],"format":"date","description":"The expiration date of the API key."}}},"ApiKeyResponse":{"type":"object","description":"Response body for retrieving an API key.","required":["id","user_id","creation_date"],"properties":{"creation_date":{"type":"string","format":"date","description":"The creation date of the API key."},"description":{"type":["string","null"],"description":"The description of the API key."},"expiration_date":{"type":["string","null"],"format":"date","description":"The expiration date of the API key."},"id":{"type":"string","format":"uuid","description":"The unique id of the API key."},"user_id":{"type":"string","format":"uuid","description":"The id of the user who owns the API key."}}},"ApiKeyRotateBody":{"type":"object","properties":{"description":{"type":["string","null"]},"expiration_date":{"type":["string","null"]}}},"ApiKeyRotateResponse":{"type":"object","required":["id","api_key","description","expiration_date","rotation_info"],"properties":{"api_key":{"type":"string"},"description":{"type":"string"},"expiration_date":{"type":"string","format":"date"},"id":{"type":"string","format":"uuid"},"rotation_info":{"$ref":"#/components/schemas/ApiKeyRotateResponseInfo"}}},"ApiKeyRotateResponseInfo":{"type":"object","required":["original_key","disabled_at"],"properties":{"disabled_at":{"type":"string","format":"date"},"original_key":{"type":"string","format":"uuid"}}},"Claims":{"type":"object","description":"Represents the claims to be included in a JWT payload.","required":["sub","iat","exp","iss","aud"],"properties":{"aud":{"type":"string","description":"Intended audience for the token (optional)."},"exp":{"type":"integer","description":"Timestamp when the token will expire.","minimum":0},"iat":{"type":"integer","description":"Timestamp when the token was issued.","minimum":0},"iss":{"type":"string","description":"Issuer of the token (optional)."},"sub":{"type":"string","description":"Subject of the token (e.g., user ID or email)."}}},"CpuUsage":{"type":"object","description":"Represents CPU usage information.","required":["available_percentage","status"],"properties":{"available_percentage":{"type":"string","description":"Percentage of CPU available, represented as a string."},"status":{"type":"string","description":"Status of the CPU (e.g., \"OK\", \"Warning\", \"Critical\")."}}},"DatabaseStatus":{"type":"object","description":"Represents database status information.","required":["status"],"properties":{"status":{"type":"string","description":"Status of the database (e.g., \"Connected\", \"Disconnected\")."}}},"DiskUsage":{"type":"object","description":"Represents disk usage information.","required":["status","used_percentage"],"properties":{"status":{"type":"string","description":"Status of the disk (e.g., \"OK\", \"Warning\", \"Critical\")."},"used_percentage":{"type":"string","description":"Percentage of disk space used, represented as a string."}}},"ErrorResponse":{"type":"object","description":"Represents an error response from the API.","required":["error"],"properties":{"error":{"type":"string","description":"A description of the error that occurred."}}},"HealthResponse":{"type":"object","description":"Represents the overall health status of the system.","required":["cpu_usage","database","disk_usage","memory"],"properties":{"cpu_usage":{"$ref":"#/components/schemas/CpuUsage","description":"CPU usage information."},"database":{"$ref":"#/components/schemas/DatabaseStatus","description":"Database status information."},"disk_usage":{"$ref":"#/components/schemas/DiskUsage","description":"Disk usage information."},"memory":{"$ref":"#/components/schemas/MemoryStatus","description":"Memory status information."}}},"MemoryStatus":{"type":"object","description":"Represents memory status information.","required":["available_mb","status"],"properties":{"available_mb":{"type":"integer","format":"int64","description":"Amount of available memory in megabytes."},"status":{"type":"string","description":"Status of the memory (e.g., \"OK\", \"Warning\", \"Critical\")."}}},"Role":{"type":"object","description":"Represents a user role in the system.","required":["id","level","role","name"],"properties":{"creation_date":{"type":["string","null"],"format":"date","description":"Date when the role was created."},"description":{"type":["string","null"],"description":"Description of the role."},"id":{"type":"string","format":"uuid","description":"ID of the role."},"level":{"type":"integer","format":"int32","description":"Level of the role."},"name":{"type":"string","description":"The name of the role."},"role":{"type":"string","description":"System name of the role."}}},"SignInData":{"type":"object","required":["email","password"],"properties":{"email":{"type":"string"},"password":{"type":"string"},"totp":{"type":["string","null"]}}},"SuccessResponse":{"type":"object","description":"Represents a successful response from the API.","required":["message"],"properties":{"message":{"type":"string","description":"A message describing the successful operation."}}},"Todo":{"type":"object","description":"Represents a to-do item.","required":["id","task","user_id","creation_date"],"properties":{"completed":{"type":["boolean","null"],"description":"Whether the task is completed."},"completion_date":{"type":["string","null"],"format":"date","description":"The date the task was completed (if any)."},"creation_date":{"type":"string","format":"date","description":"The date the task was created."},"description":{"type":["string","null"],"description":"An optional detailed description of the task."},"id":{"type":"string","format":"uuid","description":"The unique identifier for the to-do item."},"task":{"type":"string","description":"The task description."},"user_id":{"type":"string","format":"uuid","description":"The unique identifier of the user who created the to-do item."}}},"TodoBody":{"type":"object","required":["task"],"properties":{"description":{"type":["string","null"]},"task":{"type":"string"}}},"UsageResponseLastDay":{"type":"object","description":"Represents the usage statistics for the last 24 hours.","required":["requests_last_24_hours"],"properties":{"requests_last_24_hours":{"type":"integer","format":"int64","description":"The number of requests made in the last 24 hours."}}},"UsageResponseLastWeek":{"type":"object","description":"Represents the usage statistics for the last 7 days.","required":["requests_last_7_days"],"properties":{"requests_last_7_days":{"type":"integer","format":"int64","description":"The number of requests made in the last 7 days."}}},"User":{"type":"object","description":"Represents a user in the system.","required":["id","username","email","password_hash","role_level","tier_level"],"properties":{"creation_date":{"type":["string","null"],"format":"date","description":"Date when the user was created."},"email":{"type":"string","description":"The email of the user."},"id":{"type":"string","format":"uuid","description":"The unique identifier for the user."},"password_hash":{"type":"string","description":"The hashed password for the user."},"role_level":{"type":"integer","format":"int32","description":"Current role of the user."},"tier_level":{"type":"integer","format":"int32","description":"Current tier level of the user."},"totp_secret":{"type":["string","null"],"description":"The TOTP secret for the user."},"username":{"type":"string","description":"The username of the user."}}},"UserGetResponse":{"type":"object","description":"Represents a user response for GET requests.","required":["id","username","email","role_level","tier_level"],"properties":{"creation_date":{"type":["string","null"],"format":"date","description":"Date when the user was created."},"email":{"type":"string","description":"The email of the user."},"id":{"type":"string","format":"uuid","description":"The unique identifier for the user."},"role_level":{"type":"integer","format":"int32","description":"Current role of the user."},"tier_level":{"type":"integer","format":"int32","description":"Current tier level of the user."},"username":{"type":"string","description":"The username of the user."}}},"UserInsertBody":{"type":"object","description":"Request body for inserting a new user.","required":["username","email","password"],"properties":{"email":{"type":"string","description":"The email of the new user."},"password":{"type":"string","description":"The password for the new user."},"totp":{"type":["string","null"],"description":"Optional TOTP secret for the new user."},"username":{"type":"string","description":"The username of the new user."}}},"UserInsertResponse":{"type":"object","description":"Response body for a successful user insertion.","required":["id","username","email","role_level","tier_level","creation_date"],"properties":{"creation_date":{"type":"string","format":"date-time","description":"The creation date and time of the newly created user."},"email":{"type":"string","description":"The email of the newly created user."},"id":{"type":"string","format":"uuid","description":"The unique identifier for the newly created user."},"role_level":{"type":"integer","format":"int32","description":"The role level assigned to the newly created user."},"tier_level":{"type":"integer","format":"int32","description":"The tier level assigned to the newly created user."},"totp_secret":{"type":["string","null"],"description":"The TOTP secret for the newly created user, if provided."},"username":{"type":"string","description":"The username of the newly created user."}}}}},"tags":[{"name":"user","description":"User related endpoints."},{"name":"apikey","description":"API key related endpoints."},{"name":"usage","description":"Usage related endpoints."},{"name":"todo","description":"Todo related endpoints."},{"name":"health","description":"Health check endpoint."}]} \ No newline at end of file diff --git a/generate_ssl_key.bat b/generate_ssl_key.bat deleted file mode 100644 index a40bb7e..0000000 --- a/generate_ssl_key.bat +++ /dev/null @@ -1 +0,0 @@ -openssl req -x509 -newkey rsa:4096 -keyout key.pem -out cert.pem -days 365 -nodes -subj "/CN=localhost" -addext "subjectAltName = DNS:localhost, IP:127.0.0.1" \ No newline at end of file diff --git a/src/core/README.md b/src/core/README.md new file mode 100644 index 0000000..878ef43 --- /dev/null +++ b/src/core/README.md @@ -0,0 +1,9 @@ +# Core + +The `core` module contains the fundamental components for setting up and configuring the API backend. It includes server creation, environment configuration management, and middleware layers that enhance the overall performance and observability of the API. + +## Contributing +Ensure new middleware is well-documented, includes error handling, and integrates with the existing architecture. + +## License +This project is licensed under the MIT License. \ No newline at end of file diff --git a/src/core/mod.rs b/src/core/mod.rs index b8dcadd..316a55c 100644 --- a/src/core/mod.rs +++ b/src/core/mod.rs @@ -1,4 +1,3 @@ pub mod config; pub mod server; -pub mod tls; diff --git a/src/core/server.rs b/src/core/server.rs index ffbd882..2fe0d9d 100644 --- a/src/core/server.rs +++ b/src/core/server.rs @@ -23,7 +23,7 @@ pub async fn create_server() -> Router { // Enable tracing middleware if configured if config::get_env_bool("SERVER_TRACE_ENABLED", true) { app = app.layer(TraceLayer::new_for_http()); - println!("✔️ Trace hads been enabled."); + println!("✔️ Trace hads been enabled."); } // Enable compression middleware if configured @@ -32,7 +32,7 @@ pub async fn create_server() -> Router { let level = config::get_env("SERVER_COMPRESSION_LEVEL").parse().unwrap_or(6); // Apply compression layer with Brotli (br) enabled and the specified compression level app = app.layer(CompressionLayer::new().br(true).quality(CompressionLevel::Precise(level))); - println!("✔️ Brotli compression enabled with compression quality level {}.", level); + println!("✔️ Brotli compression enabled with compression quality level {}.", level); } diff --git a/src/core/tls.rs b/src/core/tls.rs deleted file mode 100644 index da70fb8..0000000 --- a/src/core/tls.rs +++ /dev/null @@ -1,145 +0,0 @@ -// Standard library imports -use std::{ - future::Future, - net::SocketAddr, - pin::Pin, - sync::Arc, - task::{Context, Poll}, - io::BufReader, - fs::File, - iter, -}; - -// External crate imports -use axum::serve::Listener; -use rustls::{self, server::ServerConfig, pki_types::{PrivateKeyDer, CertificateDer}}; -use rustls_pemfile::{Item, read_one, certs}; -use tokio::io::{AsyncRead, AsyncWrite}; -use tracing; - -// Local crate imports -use crate::config; // Import env config helper - -// Function to load TLS configuration from files -pub fn load_tls_config() -> ServerConfig { - // Get certificate and key file paths from the environment - let cert_path = config::get_env("SERVER_HTTPS_CERT_FILE_PATH"); - let key_path = config::get_env("SERVER_HTTPS_KEY_FILE_PATH"); - - // Open the certificate and key files - let cert_file = File::open(cert_path).expect("❌ Failed to open certificate file."); - let key_file = File::open(key_path).expect("❌ Failed to open private key file."); - - // Read the certificate chain and private key from the files - let mut cert_reader = BufReader::new(cert_file); - let mut key_reader = BufReader::new(key_file); - - // Read and parse the certificate chain - let cert_chain: Vec = certs(&mut cert_reader) - .map(|cert| cert.expect("❌ Failed to read certificate.")) - .map(CertificateDer::from) - .collect(); - - // Ensure certificates are found - if cert_chain.is_empty() { - panic!("❌ No valid certificates found."); - } - - // Read the private key from the file - let key = iter::from_fn(|| read_one(&mut key_reader).transpose()) - .find_map(|item| match item.unwrap() { - Item::Pkcs1Key(key) => Some(PrivateKeyDer::from(key)), - Item::Pkcs8Key(key) => Some(PrivateKeyDer::from(key)), - Item::Sec1Key(key) => Some(PrivateKeyDer::from(key)), - _ => None, - }) - .expect("❌ Failed to read a valid private key."); - - // Build and return the TLS server configuration - ServerConfig::builder() - .with_no_client_auth() // No client authentication - .with_single_cert(cert_chain, key) // Use the provided cert and key - .expect("❌ Failed to create TLS configuration.") -} - -// Custom listener that implements axum::serve::Listener -#[derive(Clone)] -pub struct TlsListener { - pub inner: Arc, // Inner TCP listener - pub acceptor: tokio_rustls::TlsAcceptor, // TLS acceptor for handling TLS handshakes -} - -impl Listener for TlsListener { - type Io = TlsStreamWrapper; // Type of I/O stream - type Addr = SocketAddr; // Type of address (Socket address) - - // Method to accept incoming connections and establish a TLS handshake - fn accept(&mut self) -> impl Future + Send { - let acceptor = self.acceptor.clone(); // Clone the acceptor for async use - - async move { - loop { - // Accept a TCP connection - let (stream, addr) = match self.inner.accept().await { - Ok((stream, addr)) => (stream, addr), - Err(e) => { - tracing::error!("❌ Error accepting TCP connection: {}", e); - continue; // Retry on error - } - }; - - // Perform TLS handshake - match acceptor.accept(stream).await { - Ok(tls_stream) => { - tracing::info!("Successful TLS handshake with {}.", addr); - return (TlsStreamWrapper(tls_stream), addr); // Return TLS stream and address - }, - Err(e) => { - tracing::warn!("TLS handshake failed: {} (Client may not trust certificate).", e); - continue; // Retry on error - } - } - } - } - } - - // Method to retrieve the local address of the listener - fn local_addr(&self) -> std::io::Result { - self.inner.local_addr() - } -} - -// Wrapper for a TLS stream, implementing AsyncRead and AsyncWrite -#[derive(Debug)] -pub struct TlsStreamWrapper(tokio_rustls::server::TlsStream); - -impl AsyncRead for TlsStreamWrapper { - fn poll_read( - mut self: Pin<&mut Self>, - cx: &mut Context<'_>, - buf: &mut tokio::io::ReadBuf<'_>, - ) -> Poll> { - Pin::new(&mut self.0).poll_read(cx, buf) // Delegate read operation to the underlying TLS stream - } -} - -impl AsyncWrite for TlsStreamWrapper { - fn poll_write( - mut self: Pin<&mut Self>, - cx: &mut Context<'_>, - buf: &[u8], - ) -> Poll> { - Pin::new(&mut self.0).poll_write(cx, buf) // Delegate write operation to the underlying TLS stream - } - - fn poll_flush(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { - Pin::new(&mut self.0).poll_flush(cx) // Flush operation for the TLS stream - } - - fn poll_shutdown(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { - Pin::new(&mut self.0).poll_shutdown(cx) // Shutdown operation for the TLS stream - } -} - -// Allow the TLS stream wrapper to be used in non-blocking contexts (needed for async operations) -impl Unpin for TlsStreamWrapper {} diff --git a/src/database/README.md b/src/database/README.md new file mode 100644 index 0000000..0cc210d --- /dev/null +++ b/src/database/README.md @@ -0,0 +1,29 @@ +# Database +This folder contains the database interaction layer for Axium, handling database connections, migrations, and queries related to API keys and usage metrics. + +## Overview +The `/src/database` folder includes functions for inserting, retrieving, modifying, and deleting API keys, along with usage tracking and database connection management. + +### Key Components +- **SQLx:** Asynchronous database operations for PostgreSQL. +- **Chrono:** Date and time manipulation. +- **UUID:** Handling unique identifiers for users and keys. +- **Dotenvy:** Securely loads environment variables. +- **ThisError:** Provides structured error handling. + +## Usage +Database functions are called by route handlers for secure data operations. Ensure environment variables like `DATABASE_URL` are properly configured before running the API. + +## Dependencies +- [SQLx](https://docs.rs/sqlx/latest/sqlx/) +- [Chrono](https://docs.rs/chrono/latest/chrono/) +- [UUID](https://docs.rs/uuid/latest/uuid/) +- [Dotenvy](https://docs.rs/dotenvy/latest/dotenvy/) +- [ThisError](https://docs.rs/thiserror/latest/thiserror/) + +## Contributing +Ensure database queries are secure, optimized, and well-documented. Validate all user inputs before performing database operations. + +## License +This project is licensed under the MIT License. + diff --git a/src/database/apikeys.rs b/src/database/apikeys.rs new file mode 100644 index 0000000..58a0ca9 --- /dev/null +++ b/src/database/apikeys.rs @@ -0,0 +1,232 @@ +use chrono::NaiveDate; +use sqlx::postgres::PgPool; +use uuid::Uuid; +use crate::models::apikey::{ApiKeyResponse, ApiKeyByIDResponse, ApiKeyByUserIDResponse, ApiKeyInsertResponse, ApiKeyGetActiveForUserResponse}; + +// --------------------------- +// Key Creation Functions +// --------------------------- + +/// Inserts a new API key into the database for the specified user. +/// +/// # Parameters +/// - `pool`: PostgreSQL connection pool +/// - `key_hash`: SHA-256 hash of the generated API key +/// - `description`: Human-readable key description +/// - `expiration_date`: Optional key expiration date +/// - `user_id`: Owner's user ID +/// +/// # Returns +/// `ApiKeyInsertResponse` with metadata (actual key not stored in DB) +/// +/// # Security +/// - Uses parameterized queries to prevent SQL injection +/// - Caller must validate inputs before invocation +pub async fn insert_api_key_into_db( + pool: &PgPool, + key_hash: String, + description: String, + expiration_date: NaiveDate, + user_id: Uuid, +) -> Result { + let row = sqlx::query!( + r#" + INSERT INTO apikeys (key_hash, description, expiration_date, user_id) + VALUES ($1, $2, $3, $4) + RETURNING id, description, expiration_date + "#, + key_hash, + description, + expiration_date, + user_id + ) + .fetch_one(pool) + .await?; + + Ok(ApiKeyInsertResponse { + id: row.id, + api_key: "".to_string(), // Placeholder for post-processing + description: row.description.unwrap_or_default(), + expiration_date: row.expiration_date + .map(|d| d.to_string()) + .unwrap_or_else(|| "Never".to_string()), + }) +} + +// --------------------------- +// Key Retrieval Functions +// --------------------------- + +/// Retrieves all API keys (including revoked/expired) for a user +/// +/// # Security +/// - Always filters by user_id to prevent cross-user access +pub async fn fetch_all_apikeys_from_db( + pool: &PgPool, + user_id: Uuid +) -> Result, sqlx::Error> { + sqlx::query_as!( + ApiKeyResponse, + r#" + SELECT id, user_id, description, expiration_date, creation_date + FROM apikeys + WHERE user_id = $1 + "#, + user_id + ) + .fetch_all(pool) + .await +} + +/// Gets detailed metadata for a specific API key +/// +/// # Security +/// - Verifies both key ID and user_id ownership +pub async fn fetch_apikey_by_id_from_db( + pool: &PgPool, + id: Uuid, + user_id: Uuid +) -> Result, sqlx::Error> { + sqlx::query_as!( + ApiKeyByIDResponse, + r#" + SELECT id, description, expiration_date, creation_date + FROM apikeys + WHERE id = $1 AND user_id = $2 + "#, + id, + user_id + ) + .fetch_optional(pool) + .await +} + +/// Retrieves active keys for user with security checks +/// +/// # Security +/// - Excludes disabled keys and expired keys +pub async fn fetch_active_apikeys_by_user_id_from_db( + pool: &PgPool, + user_id: Uuid +) -> Result, sqlx::Error> { + sqlx::query_as!( + ApiKeyByUserIDResponse, + r#" + SELECT id, key_hash, expiration_date + FROM apikeys + WHERE + user_id = $1 + AND disabled = FALSE + AND (expiration_date IS NULL OR expiration_date > CURRENT_DATE) + "#, + user_id + ) + .fetch_all(pool) + .await +} + +// --------------------------- +// Key Modification Functions +// --------------------------- + +/// Disables an API key and sets short expiration grace period +/// +/// # Security +/// - Requires matching user_id to prevent unauthorized revocation +pub async fn disable_apikey_in_db( + pool: &PgPool, + apikey_id: Uuid, + user_id: Uuid +) -> Result { + let result = sqlx::query!( + r#" + UPDATE apikeys + SET + disabled = TRUE, + expiration_date = CURRENT_DATE + INTERVAL '1 day' + WHERE id = $1 AND user_id = $2 + "#, + apikey_id, + user_id + ) + .execute(pool) + .await?; + + Ok(result.rows_affected()) +} + +// --------------------------- +// Key Deletion Functions +// --------------------------- + +/// Permanently removes an API key from the system +/// +/// # Security +/// - Requires matching user_id to prevent unauthorized deletion +pub async fn delete_apikey_from_db( + pool: &PgPool, + id: Uuid, + user_id: Uuid +) -> Result { + let result = sqlx::query!( + r#" + DELETE FROM apikeys + WHERE id = $1 AND user_id = $2 + "#, + id, + user_id + ) + .execute(pool) + .await?; + + Ok(result.rows_affected()) +} + +// --------------------------- +// Validation Functions +// --------------------------- + +/// Checks active key count against rate limits +/// +/// # Security +/// - Used to enforce business logic limits +pub async fn check_existing_api_key_count( + pool: &PgPool, + user_id: Uuid +) -> Result { + let row = sqlx::query!( + r#" + SELECT COUNT(*) as count + FROM apikeys + WHERE + user_id = $1 + AND disabled = FALSE + AND (expiration_date IS NULL OR expiration_date >= CURRENT_DATE) + "#, + user_id + ) + .fetch_one(pool) + .await?; + + Ok(row.count.unwrap_or(0)) +} + +/// Validates key existence and ownership before operations +pub async fn fetch_existing_apikey( + pool: &PgPool, + user_id: Uuid, + apikey_id: Uuid +) -> Result, sqlx::Error> { + sqlx::query_as!( + ApiKeyGetActiveForUserResponse, + r#" + SELECT id, description + FROM apikeys + WHERE user_id = $1 AND id = $2 AND disabled = FALSE + "#, + user_id, + apikey_id + ) + .fetch_optional(pool) + .await +} \ No newline at end of file diff --git a/src/database/connect.rs b/src/database/connect.rs index 484d333..3bb9f74 100644 --- a/src/database/connect.rs +++ b/src/database/connect.rs @@ -1,51 +1,140 @@ use dotenvy::dotenv; -use sqlx::{PgPool, migrate::Migrator, postgres::PgPoolOptions}; -use std::fs; -use std::env; -use std::path::Path; +use sqlx::{PgPool, migrate::Migrator, migrate::MigrateError, postgres::PgPoolOptions}; +use std::{env, fs, path::Path, time::Duration}; +use thiserror::Error; -/// Connects to the database using the DATABASE_URL environment variable. -pub async fn connect_to_database() -> Result { - dotenv().ok(); - let database_url = &env::var("DATABASE_URL").expect("❌ 'DATABASE_URL' environment variable not fount."); +// --------------------------- +// Error Handling +// --------------------------- - // Read max and min connection values from environment variables, with defaults - let max_connections: u32 = env::var("DATABASE_MAX_CONNECTIONS") - .unwrap_or_else(|_| "10".to_string()) // Default to 10 - .parse() - .expect("❌ Invalid 'DATABASE_MAX_CONNECTIONS' value; must be a number."); +#[derive(Debug, Error)] +pub enum DatabaseError { + #[error("❌ Environment error: {0}")] + EnvError(String), - let min_connections: u32 = env::var("DATABASE_MIN_CONNECTIONS") - .unwrap_or_else(|_| "2".to_string()) // Default to 2 - .parse() - .expect("❌ Invalid 'DATABASE_MIN_CONNECTIONS' value; must be a number."); + #[error("❌ Connection error: {0}")] + ConnectionError(#[from] sqlx::Error), + + #[error("❌ File system error: {0}")] + FileSystemError(String), + + #[error("❌ Configuration error: {0}")] + ConfigError(String), + + #[error("❌ Migration error: {0}")] + MigrationError(#[from] MigrateError), +} + +// --------------------------- +// Database Connection +// --------------------------- + +/// Establishes a secure connection to PostgreSQL with connection pooling +/// +/// # Security Features +/// - Validates database URL format +/// - Enforces connection limits +/// - Uses environment variables securely +/// - Implements connection timeouts +/// +/// # Returns +/// `Result` - Connection pool or detailed error +pub async fn connect_to_database() -> Result { + // Load environment variables securely + dotenv().ok(); + + // Validate database URL presence and format + let database_url = env::var("DATABASE_URL") + .map_err(|_| DatabaseError::EnvError("DATABASE_URL not found".to_string()))?; + + if !database_url.starts_with("postgres://") { + return Err(DatabaseError::ConfigError( + "❌ Invalid DATABASE_URL format - must start with postgres://".to_string() + )); + } + + // Configure connection pool with safety defaults + let max_connections = parse_env_var("DATABASE_MAX_CONNECTIONS", 10)?; + let min_connections = parse_env_var("DATABASE_MIN_CONNECTIONS", 2)?; - // Create and configure the connection pool let pool = PgPoolOptions::new() .max_connections(max_connections) .min_connections(min_connections) + .acquire_timeout(Duration::from_secs(5)) // Prevent hanging connections + .idle_timeout(Duration::from_secs(300)) // Clean up idle connections + .test_before_acquire(true) // Validate connections .connect(&database_url) - .await?; - + .await + .map_err(|e| DatabaseError::ConnectionError(e))?; + Ok(pool) } -/// Run database migrations -pub async fn run_database_migrations(pool: &PgPool) -> Result<(), sqlx::Error> { - // Define the path to the migrations folder - let migrations_path = Path::new("./migrations"); +/// Helper function to safely parse environment variables +fn parse_env_var(name: &str, default: T) -> Result +where + T::Err: std::fmt::Display, +{ + match env::var(name) { + Ok(val) => val.parse().map_err(|e| DatabaseError::ConfigError( + format!("❌ Invalid {} value: {}", name, e) + )), + Err(_) => Ok(default), + } +} - // Check if the migrations folder exists, and if not, create it +// --------------------------- +// Database Migrations +// --------------------------- + +/// Executes database migrations with safety checks +/// +/// # Security Features +/// - Validates migrations directory existence +/// - Limits migration execution to development/staging environments +/// - Uses transactional migrations where supported +/// +/// # Returns +/// `Result<(), DatabaseError>` - Success or detailed error +pub async fn run_database_migrations(pool: &PgPool) -> Result<(), DatabaseError> { + let migrations_path = Path::new("./migrations"); + + // Validate migrations directory if !migrations_path.exists() { - fs::create_dir_all(migrations_path).expect("❌ Failed to create migrations directory. Make sure you have the necessary permissions."); - println!("✔️ Created migrations directory: {:?}", migrations_path); + fs::create_dir_all(migrations_path) + .map_err(|e| DatabaseError::FileSystemError( + format!("❌ Failed to create migrations directory: {}", e) + ))?; } - // Create a migrator instance that looks for migrations in the `./migrations` folder - let migrator = Migrator::new(migrations_path).await?; + // Verify directory permissions + let metadata = fs::metadata(migrations_path) + .map_err(|e| DatabaseError::FileSystemError( + format!("❌ Cannot access migrations directory: {}", e) + ))?; + + if metadata.permissions().readonly() { + return Err(DatabaseError::FileSystemError( + "❌ Migrations directory is read-only".to_string() + )); + } - // Run all pending migrations - migrator.run(pool).await?; + // Initialize migrator with production safety checks + let migrator = Migrator::new(migrations_path) + .await + .map_err(|e| DatabaseError::MigrationError(e))?; + + // Execute migrations in transaction if supported + if env::var("ENVIRONMENT").unwrap_or_else(|_| "development".into()) == "production" { + println!("🛑 Migration execution blocked in production."); + return Err(DatabaseError::ConfigError( + "🛑 Direct migrations disabled in production.".to_string() + )); + } + + migrator.run(pool) + .await + .map_err(DatabaseError::MigrationError)?; Ok(()) } \ No newline at end of file diff --git a/src/database/get_apikeys.rs b/src/database/get_apikeys.rs deleted file mode 100644 index 07fd7db..0000000 --- a/src/database/get_apikeys.rs +++ /dev/null @@ -1,16 +0,0 @@ -use sqlx::postgres::PgPool; -use uuid::Uuid; -use crate::models::apikey::*; - -pub async fn get_active_apikeys_by_user_id(pool: &PgPool, user_id: Uuid) -> Result, sqlx::Error> { - sqlx::query_as!(ApiKeyByUserIDResponse, - r#" - SELECT id, key_hash, expiration_date::DATE - FROM apikeys - WHERE user_id = $1 AND (expiration_date IS NULL OR expiration_date > NOW()::DATE) - "#, - user_id - ) - .fetch_all(pool) - .await -} \ No newline at end of file diff --git a/src/database/get_users.rs b/src/database/get_users.rs deleted file mode 100644 index 7a70631..0000000 --- a/src/database/get_users.rs +++ /dev/null @@ -1,25 +0,0 @@ -use sqlx::postgres::PgPool; -use crate::models::user::*; // Import the User struct - -// Get all users -pub async fn get_user_by_email(pool: &PgPool, email: String) -> Result { - // Use a string literal directly in the macro - let user = sqlx::query_as!( - User, // Struct type to map the query result - r#" - SELECT id, username, email, password_hash, totp_secret, role_level, tier_level, creation_date - FROM users - WHERE email = $1 - "#, - email // Bind the `email` parameter - ) - .fetch_optional(pool) - .await - .map_err(|e| format!("Database error: {}", e))?; // Handle database errors - - // Handle optional result - match user { - Some(user) => Ok(user), - None => Err(format!("User with email '{}' not found.", email)), - } -} \ No newline at end of file diff --git a/src/database/insert_usage.rs b/src/database/insert_usage.rs deleted file mode 100644 index c1db2fd..0000000 --- a/src/database/insert_usage.rs +++ /dev/null @@ -1,16 +0,0 @@ -use sqlx::postgres::PgPool; -use uuid::Uuid; - -pub async fn insert_usage(pool: &PgPool, user_id: Uuid, endpoint: String) -> Result<(), sqlx::Error> { - sqlx::query!( - r#"INSERT INTO usage - (endpoint, user_id) - VALUES ($1, $2)"#, - endpoint, - user_id - ) - .execute(pool) - .await?; - - Ok(()) -} \ No newline at end of file diff --git a/src/database/mod.rs b/src/database/mod.rs index 586561c..b155b40 100644 --- a/src/database/mod.rs +++ b/src/database/mod.rs @@ -1,5 +1,6 @@ // Module declarations pub mod connect; -pub mod get_users; -pub mod get_apikeys; -pub mod insert_usage; +pub mod users; +pub mod apikeys; +pub mod usage; +pub mod todos; \ No newline at end of file diff --git a/src/database/todos.rs b/src/database/todos.rs new file mode 100644 index 0000000..dbea4e2 --- /dev/null +++ b/src/database/todos.rs @@ -0,0 +1,107 @@ +use sqlx::postgres::PgPool; +use uuid::Uuid; +use crate::models::todo::*; + +/// Inserts a new Todo into the database with robust input validation and ownership enforcement +/// +/// # Validation +/// - Task must be 1-100 characters after trimming +/// - Description (if provided) must be ≤500 characters after trimming +/// - Automatically associates todo with the requesting user +/// +/// # Security +/// - Uses parameterized queries to prevent SQL injection +/// - Trims input to prevent whitespace abuse +pub async fn insert_todo_into_db( + pool: &PgPool, + task: String, + description: Option, + user_id: Uuid, +) -> Result { + // Sanitize and validate task + let task = task.trim(); + if task.is_empty() { + return Err(sqlx::Error::Protocol("Task cannot be empty".into())); + } + if task.len() > 100 { + return Err(sqlx::Error::Protocol("Task exceeds maximum length of 100 characters".into())); + } + + // Sanitize and validate optional description + let description = description.map(|d| d.trim().to_string()) + .filter(|d| !d.is_empty()); + if let Some(desc) = &description { + if desc.len() > 500 { + return Err(sqlx::Error::Protocol("Description exceeds maximum length of 500 characters".into())); + } + } + + // Insert with ownership enforcement + let row = sqlx::query_as!( + Todo, + "INSERT INTO todos (task, description, user_id) + VALUES ($1, $2, $3) + RETURNING id, user_id, task, description, creation_date, completion_date, completed", + task, + description, + user_id + ) + .fetch_one(pool) + .await?; + + Ok(row) +} + +/// Retrieves all Todos for a specific user with strict ownership filtering +/// +/// # Security +/// - Uses WHERE clause with user_id to ensure data isolation +/// - Parameterized query prevents SQL injection +pub async fn fetch_all_todos_from_db(pool: &PgPool, user_id: Uuid) -> Result, sqlx::Error> { + let todos = sqlx::query_as!( + Todo, + "SELECT id, user_id, task, description, creation_date, completion_date, completed + FROM todos WHERE user_id = $1", + user_id + ) + .fetch_all(pool) + .await?; + + Ok(todos) +} + +/// Safely retrieves a single Todo by ID with ownership verification +/// +/// # Security +/// - Combines ID and user_id in WHERE clause to prevent unauthorized access +/// - Returns Option to avoid exposing existence of other users' todos +pub async fn fetch_todo_by_id_from_db(pool: &PgPool, id: Uuid, user_id: Uuid) -> Result, sqlx::Error> { + let todo = sqlx::query_as!( + Todo, + "SELECT id, user_id, task, description, creation_date, completion_date, completed + FROM todos WHERE id = $1 AND user_id = $2", + id, + user_id + ) + .fetch_optional(pool) + .await?; + + Ok(todo) +} + +/// Securely deletes a Todo by ID with ownership confirmation +/// +/// # Security +/// - Requires both ID and user_id for deletion +/// - Returns affected row count without exposing existence of other users' todos +pub async fn delete_todo_from_db(pool: &PgPool, id: Uuid, user_id: Uuid) -> Result { + let result = sqlx::query!( + "DELETE FROM todos WHERE id = $1 AND user_id = $2", + id, + user_id + ) + .execute(pool) + .await?; + + Ok(result.rows_affected()) +} \ No newline at end of file diff --git a/src/database/usage.rs b/src/database/usage.rs new file mode 100644 index 0000000..05129a3 --- /dev/null +++ b/src/database/usage.rs @@ -0,0 +1,68 @@ +use sqlx::postgres::PgPool; +use uuid::Uuid; + +/// Records API usage with validation and security protections +/// +/// # Validation +/// - Endpoint must be 1-100 characters after trimming +/// - Rejects empty or whitespace-only endpoints +/// +/// # Security +/// - Uses parameterized queries to prevent SQL injection +/// - Automatically trims and sanitizes endpoint input +/// - Enforces user ownership through database constraints +// pub async fn insert_usage_into_db( +// pool: &PgPool, +// user_id: Uuid, +// endpoint: String, +// ) -> Result<(), sqlx::Error> { +// // Sanitize and validate endpoint +// let endpoint = endpoint.trim(); +// if endpoint.is_empty() { +// return Err(sqlx::Error::Protocol("Endpoint cannot be empty".into())); +// } +// if endpoint.len() > 100 { +// return Err(sqlx::Error::Protocol("Endpoint exceeds maximum length of 100 characters".into())); +// } + +// sqlx::query!( +// r#"INSERT INTO usage (endpoint, user_id) +// VALUES ($1, $2)"#, +// endpoint, +// user_id +// ) +// .execute(pool) +// .await?; + +// Ok(()) +// } + +/// Safely retrieves usage count for a user within a specified time period +/// +/// # Security +/// - Uses parameterized query with interval casting to prevent SQL injection +/// - Explicit user ownership check +/// - COALESCE ensures always returns a number (0 if no usage) +/// +/// # Example Interval Formats +/// - '1 hour' +/// - '7 days' +/// - '30 minutes' +pub async fn fetch_usage_count_from_db( + pool: &PgPool, + user_id: Uuid, + interval: &str, +) -> Result { + let count: i64 = sqlx::query_scalar( + r#"SELECT COALESCE(COUNT(*), 0) + FROM usage + WHERE user_id = $1 + AND creation_date > NOW() - CAST($2 AS INTERVAL)"# + ) + .bind(user_id) + .bind(interval) + .fetch_one(pool) + .await?; + + Ok(count) +} \ No newline at end of file diff --git a/src/database/users.rs b/src/database/users.rs new file mode 100644 index 0000000..e1ffd1a --- /dev/null +++ b/src/database/users.rs @@ -0,0 +1,141 @@ +use sqlx::postgres::PgPool; +use uuid::Uuid; +use crate::models::user::*; +use regex::Regex; +use sqlx::Error; + +/// Retrieves all users with security considerations +/// +/// # Security +/// - Requires admin privileges (enforced at application layer) +/// - Excludes sensitive fields like password_hash and totp_secret +/// - Limits maximum results in production (enforced at application layer) +pub async fn fetch_all_users_from_db(pool: &PgPool) -> Result, sqlx::Error> { + sqlx::query_as!( + UserGetResponse, + "SELECT id, username, email, role_level, tier_level, creation_date + FROM users" + ) + .fetch_all(pool) + .await +} + +/// Safely retrieves user by allowed fields using whitelist validation +/// +/// # Allowed Fields +/// - id (UUID) +/// - email (valid email format) +/// - username (valid username format) +/// +/// # Security +/// - Field whitelisting prevents SQL injection +/// - Parameterized query for value +pub async fn fetch_user_by_field_from_db( + pool: &PgPool, + field: &str, + value: &str, +) -> Result, sqlx::Error> { + let query = match field { + "id" => "SELECT * FROM users WHERE id = $1", + "email" => "SELECT * FROM users WHERE email = $1", + "username" => "SELECT * FROM users WHERE username = $1", + _ => return Err(sqlx::Error::ColumnNotFound(field.to_string())), + }; + + sqlx::query_as::<_, User>(query) + .bind(value) + .fetch_optional(pool) + .await +} + +/// Retrieves user by email with validation +/// +/// # Security +/// - Parameterized query prevents SQL injection +/// - Returns Option to avoid user enumeration risks +pub async fn fetch_user_by_email_from_db( + pool: &PgPool, + email: &str, +) -> Result, sqlx::Error> { + sqlx::query_as!( + User, + r#"SELECT id, username, email, password_hash, totp_secret, + role_level, tier_level, creation_date + FROM users WHERE email = $1"#, + email + ) + .fetch_optional(pool) + .await +} + +/// Securely deletes a user by ID +/// +/// # Security +/// - Requires authentication and authorization +/// - Parameterized query prevents SQL injection +/// - Returns affected rows without sensitive data +pub async fn delete_user_from_db(pool: &PgPool, id: Uuid) -> Result { + let result = sqlx::query!("DELETE FROM users WHERE id = $1", id) + .execute(pool) + .await?; + + Ok(result.rows_affected()) +} + +/// Creates new user with comprehensive validation +/// +/// # Validation +/// - Username: 3-30 alphanumeric characters +/// - Email: Valid format with domain verification +/// - Password: Minimum strength requirements (enforced at application layer) +pub async fn insert_user_into_db( + pool: &PgPool, + username: &str, + email: &str, + password_hash: &str, + totp_secret: &str, + role_level: i32, + tier_level: i32, +) -> Result { + // Validate username + let username = username.trim(); + if username.len() < 3 || username.len() > 30 { + return Err(Error::Protocol("Username must be between 3 and 30 characters.".into())); + } + if !username.chars().all(|c| c.is_alphanumeric() || c == '_') { + return Err(Error::Protocol("Invalid username format: only alphanumeric and underscores allowed.".into())); + } + + // Validate email + let email = email.trim().to_lowercase(); + if !is_valid_email(&email) { + return Err(Error::Protocol("Invalid email format.".into())); + } + + // Insert user into database + let row = sqlx::query_as!( + UserInsertResponse, + r#"INSERT INTO users + (username, email, password_hash, totp_secret, role_level, tier_level, creation_date) + VALUES ($1, $2, $3, $4, $5, $6, NOW()::timestamp) + RETURNING id, username, email, totp_secret, role_level, tier_level, creation_date"#, + username, + email, + password_hash, + totp_secret, + role_level, + tier_level, + ) + .fetch_one(pool) + .await?; + + Ok(row) +} + +/// Email validation helper function +fn is_valid_email(email: &str) -> bool { + let email_regex = Regex::new( + r"^[a-z0-9_+]+([a-z0-9_.-]*[a-z0-9_+])?@[a-z0-9]+([-.][a-z0-9]+)*\.[a-z]{2,6}$" + ).unwrap(); + email_regex.is_match(email) +} diff --git a/src/handlers/README.md b/src/handlers/README.md new file mode 100644 index 0000000..79b8b5b --- /dev/null +++ b/src/handlers/README.md @@ -0,0 +1,32 @@ +# Handlers Module for Rust API + +This folder contains the route handlers used in the Rust API, responsible for processing incoming HTTP requests and generating responses. + +## Overview +The `/src/handlers` folder includes implementations of route handlers for API keys, usage metrics, and the homepage. + +### Key Components +- **Axum Handlers:** Built using Axum's handler utilities for routing and extracting request data. +- **SQLx:** Manages database operations like fetching usage and deleting API keys. +- **UUID and Serde:** Handles unique IDs and JSON serialization. +- **Tracing:** Provides structured logging for monitoring and debugging. + +## Usage +Handlers are linked to Axum routes using `route` and `handler` methods: +```rust +route("/apikeys/:id", delete(delete_apikey_by_id)) + .route("/usage/lastday", get(get_usage_last_day)) +``` + +## Dependencies +- [Axum](https://docs.rs/axum/latest/axum/) +- [SQLx](https://docs.rs/sqlx/latest/sqlx/) +- [UUID](https://docs.rs/uuid/latest/uuid/) +- [Serde](https://docs.rs/serde/latest/serde/) +- [Tracing](https://docs.rs/tracing/latest/tracing/) + +## Contributing +Ensure new handlers are well-documented, include proper error handling, and maintain compatibility with existing routes. + +## License +This project is licensed under the MIT License. \ No newline at end of file diff --git a/src/routes/delete_apikeys.rs b/src/handlers/delete_apikeys.rs similarity index 50% rename from src/routes/delete_apikeys.rs rename to src/handlers/delete_apikeys.rs index febff3f..a915ef5 100644 --- a/src/routes/delete_apikeys.rs +++ b/src/handlers/delete_apikeys.rs @@ -1,26 +1,32 @@ use axum::{ - extract::{State, Extension, Path}, - Json, - response::IntoResponse, - http::StatusCode + extract::{State, Extension, Path}, + Json, + http::StatusCode, }; use sqlx::postgres::PgPool; use uuid::Uuid; use serde_json::json; use tracing::instrument; // For logging use crate::models::user::User; +use crate::database::apikeys::delete_apikey_from_db; + +// --- Route Handler --- // Delete a API key by id #[utoipa::path( delete, path = "/apikeys/{id}", tag = "apikey", + security( + ("jwt_token" = []) + ), params( ("id" = String, Path, description = "API key ID") ), responses( (status = 200, description = "API key deleted successfully", body = String), (status = 400, description = "Invalid UUID format", body = String), + (status = 401, description = "Unauthorized", body = serde_json::Value), (status = 404, description = "API key not found", body = String), (status = 500, description = "Internal server error", body = String) ) @@ -30,28 +36,35 @@ pub async fn delete_apikey_by_id( State(pool): State, Extension(user): Extension, Path(id): Path, // Use Path extractor here -) -> impl IntoResponse { +) -> Result<(StatusCode, Json), (StatusCode, Json)> { // Parse the id string to UUID let uuid = match Uuid::parse_str(&id) { Ok(uuid) => uuid, - Err(_) => return (StatusCode::BAD_REQUEST, Json(json!({ "error": format!("Invalid UUID format.")}))), + Err(_) => { + return Err(( + StatusCode::BAD_REQUEST, + Json(json!({ "error": format!("Invalid UUID format.") })), + )); + } }; - let result = sqlx::query!("DELETE FROM apikeys WHERE id = $1 AND user_id = $2", uuid, user.id) - .execute(&pool) // Borrow the connection pool - .await; - - match result { - Ok(res) => { - if res.rows_affected() == 0 { - (StatusCode::NOT_FOUND, Json(json!({ "error": format!("API key with ID '{}' not found.", id) }))) + match delete_apikey_from_db(&pool, uuid, user.id).await { + Ok(rows_affected) => { + if rows_affected == 0 { + Err(( + StatusCode::NOT_FOUND, + Json(json!({ "error": format!("API key with ID '{}' not found.", id) })), + )) } else { - (StatusCode::OK, Json(json!({ "success": format!("API key with ID '{}' deleted.", id)}))) + Ok(( + StatusCode::OK, + Json(json!({ "success": format!("API key with ID '{}' deleted.", id) })), + )) } } - Err(_err) => ( + Err(_err) => Err(( StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({ "error": format!("Could not delete API key '{}'.", id)})) - ), + Json(json!({ "error": format!("Could not delete API key '{}'.", id) })) + )), } -} \ No newline at end of file +} diff --git a/src/routes/delete_todos.rs b/src/handlers/delete_todos.rs similarity index 55% rename from src/routes/delete_todos.rs rename to src/handlers/delete_todos.rs index 83722c8..5de54da 100644 --- a/src/routes/delete_todos.rs +++ b/src/handlers/delete_todos.rs @@ -1,8 +1,7 @@ use axum::{ - extract::{State, Extension, Path}, - Json, - response::IntoResponse, - http::StatusCode + extract::{State, Extension, Path}, + Json, + http::StatusCode, }; use sqlx::postgres::PgPool; use uuid::Uuid; @@ -10,15 +9,22 @@ use serde_json::json; use tracing::instrument; // For logging use crate::models::user::User; use crate::models::documentation::{ErrorResponse, SuccessResponse}; +use crate::database::todos::delete_todo_from_db; + +// --- Route Handler --- // Delete a todo by id #[utoipa::path( delete, path = "/todos/{id}", tag = "todo", + security( + ("jwt_token" = []) + ), responses( (status = 200, description = "Todo deleted successfully", body = SuccessResponse), (status = 400, description = "Invalid UUID format", body = ErrorResponse), + (status = 401, description = "Unauthorized", body = serde_json::Value), (status = 404, description = "Todo not found", body = ErrorResponse), (status = 500, description = "Internal Server Error", body = ErrorResponse) ), @@ -32,22 +38,29 @@ pub async fn delete_todo_by_id( State(pool): State, Extension(user): Extension, Path(id): Path, // Use Path extractor here -) -> impl IntoResponse { +) -> Result<(StatusCode, Json), (StatusCode, Json)> { let uuid = match Uuid::parse_str(&id) { Ok(uuid) => uuid, - Err(_) => return Err((StatusCode::BAD_REQUEST, Json(json!({ "error": "Invalid UUID format." })),)), + Err(_) => { + return Err(( + StatusCode::BAD_REQUEST, + Json(json!({ "error": "Invalid UUID format." })), + )); + } }; - let result = sqlx::query!("DELETE FROM todos WHERE id = $1 AND user_id = $2", uuid, user.id) - .execute(&pool) // Borrow the connection pool - .await; - - match result { - Ok(res) => { - if (res.rows_affected() == 0) { - Err((StatusCode::NOT_FOUND, Json(json!({ "error": format!("Todo with ID '{}' not found.", id) })),)) + match delete_todo_from_db(&pool, uuid, user.id).await { + Ok(rows_affected) => { + if rows_affected == 0 { + Err(( + StatusCode::NOT_FOUND, + Json(json!({ "error": format!("Todo with ID '{}' not found.", id) })), + )) } else { - Ok((StatusCode::OK, Json(json!({ "success": format!("Todo with ID '{}' deleted.", id) })),)) + Ok(( + StatusCode::OK, + Json(json!({ "success": format!("Todo with ID '{}' deleted.", id) })), + )) } } Err(_err) => Err(( diff --git a/src/routes/delete_users.rs b/src/handlers/delete_users.rs similarity index 50% rename from src/routes/delete_users.rs rename to src/handlers/delete_users.rs index 24f7590..ee2b7e0 100644 --- a/src/routes/delete_users.rs +++ b/src/handlers/delete_users.rs @@ -1,24 +1,30 @@ use axum::{ - extract::{State, Path}, - Json, - response::IntoResponse, - http::StatusCode + extract::{State, Path}, + Json, + + http::StatusCode, }; use sqlx::postgres::PgPool; use uuid::Uuid; use serde_json::json; use tracing::instrument; // For logging use crate::models::documentation::{ErrorResponse, SuccessResponse}; +use crate::database::users::delete_user_from_db; +// --- Route Handler --- // Delete a user by id #[utoipa::path( delete, path = "/users/{id}", tag = "user", + security( + ("jwt_token" = []) + ), responses( (status = 200, description = "User deleted successfully", body = SuccessResponse), (status = 400, description = "Invalid UUID format", body = ErrorResponse), + (status = 401, description = "Unauthorized", body = serde_json::Value), (status = 404, description = "User not found", body = ErrorResponse), (status = 500, description = "Internal Server Error", body = ErrorResponse) ), @@ -30,27 +36,34 @@ use crate::models::documentation::{ErrorResponse, SuccessResponse}; pub async fn delete_user_by_id( State(pool): State, Path(id): Path, // Use Path extractor here -) -> impl IntoResponse { +) -> Result<(StatusCode, Json), (StatusCode, Json)> { let uuid = match Uuid::parse_str(&id) { Ok(uuid) => uuid, - Err(_) => return Err((StatusCode::BAD_REQUEST, Json(json!({ "error": "Invalid UUID format." })),)), + Err(_) => { + return Err(( + StatusCode::BAD_REQUEST, + Json(json!({ "error": "Invalid UUID format." })), + )); + } }; - let result = sqlx::query_as!(User, "DELETE FROM USERS WHERE id = $1", uuid) - .execute(&pool) // Borrow the connection pool - .await; - - match result { - Ok(res) => { - if res.rows_affected() == 0 { - Err((StatusCode::NOT_FOUND, Json(json!({ "error": format!("User with ID '{}' not found.", id) })),)) + match delete_user_from_db(&pool, uuid).await { + Ok(rows_affected) => { + if rows_affected == 0 { + Err(( + StatusCode::NOT_FOUND, + Json(json!({ "error": format!("User with ID '{}' not found.", id) })), + )) } else { - Ok((StatusCode::OK, Json(json!({ "success": format!("User with ID '{}' deleted.", id) })),)) + Ok(( + StatusCode::OK, + Json(json!({ "success": format!("User with ID '{}' deleted.", id) })), + )) } } Err(_err) => Err(( StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({ "error": "Could not delete the user."})), + Json(json!({ "error": "Could not delete the user." })), )), } } diff --git a/src/routes/get_apikeys.rs b/src/handlers/get_apikeys.rs similarity index 74% rename from src/routes/get_apikeys.rs rename to src/handlers/get_apikeys.rs index e3945eb..2ef5b23 100644 --- a/src/routes/get_apikeys.rs +++ b/src/handlers/get_apikeys.rs @@ -1,7 +1,6 @@ use axum::{ extract::{State, Extension, Path}, - Json, - response::IntoResponse, + Json, http::StatusCode }; use sqlx::postgres::PgPool; @@ -12,14 +11,21 @@ use crate::models::apikey::*; use crate::models::user::*; use crate::models::documentation::ErrorResponse; use crate::models::apikey::ApiKeyResponse; +use crate::database::apikeys::{fetch_all_apikeys_from_db, fetch_apikey_by_id_from_db}; + +// --- Route Handlers --- // Get all API keys #[utoipa::path( get, path = "/apikeys", tag = "apikey", + security( + ("jwt_token" = []) + ), responses( (status = 200, description = "Get all API keys", body = [ApiKeyResponse]), + (status = 401, description = "Unauthorized", body = serde_json::Value), (status = 500, description = "Internal Server Error", body = ErrorResponse) ), params( @@ -30,19 +36,12 @@ use crate::models::apikey::ApiKeyResponse; pub async fn get_all_apikeys( State(pool): State, Extension(user): Extension, // Extract current user from the request extensions -) -> impl IntoResponse { - let apikeys = sqlx::query_as!(ApiKeyResponse, - "SELECT id, user_id, description, expiration_date, creation_date FROM apikeys WHERE user_id = $1", - user.id - ) - .fetch_all(&pool) // Borrow the connection pool - .await; - - match apikeys { +) -> Result>, (StatusCode, Json)> { + match fetch_all_apikeys_from_db(&pool, user.id).await { Ok(apikeys) => Ok(Json(apikeys)), // Return all API keys as JSON Err(_err) => Err(( StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({ "error": "Could not get the API key."})), + Json(json!({ "error": "Could not get the API keys."})), )), } } @@ -68,22 +67,14 @@ pub async fn get_apikeys_by_id( State(pool): State, Extension(user): Extension, // Extract current user from the request extensions Path(id): Path, // Use Path extractor here -) -> impl IntoResponse { +) -> Result, (StatusCode, Json)> { let uuid = match Uuid::parse_str(&id) { Ok(uuid) => uuid, Err(_) => return Err((StatusCode::BAD_REQUEST, Json(json!({ "error": "Invalid UUID format." })))), }; - let apikeys = sqlx::query_as!(ApiKeyByIDResponse, - "SELECT id, description, expiration_date, creation_date FROM apikeys WHERE id = $1 AND user_id = $2", - uuid, - user.id - ) - .fetch_optional(&pool) // Borrow the connection pool - .await; - - match apikeys { - Ok(Some(apikeys)) => Ok(Json(apikeys)), // Return the API key as JSON if found + match fetch_apikey_by_id_from_db(&pool, uuid, user.id).await { + Ok(Some(apikey)) => Ok(Json(apikey)), // Return the API key as JSON if found Ok(None) => Err(( StatusCode::NOT_FOUND, Json(json!({ "error": format!("API key with ID '{}' not found.", id) })), @@ -93,4 +84,4 @@ pub async fn get_apikeys_by_id( Json(json!({ "error": "Could not get the API key."})), )), } -} +} \ No newline at end of file diff --git a/src/routes/get_health.rs b/src/handlers/get_health.rs similarity index 89% rename from src/routes/get_health.rs rename to src/handlers/get_health.rs index fefd85d..ccaf659 100644 --- a/src/routes/get_health.rs +++ b/src/handlers/get_health.rs @@ -9,43 +9,7 @@ use sysinfo::{System, RefreshKind, Disks}; use tokio::{task, join}; use std::sync::{Arc, Mutex}; use tracing::instrument; // For logging -use utoipa::ToSchema; -use serde::{Deserialize, Serialize}; - -// Struct definitions -#[derive(Debug, Serialize, Deserialize, ToSchema)] -pub struct HealthResponse { - pub cpu_usage: CpuUsage, - pub database: DatabaseStatus, - pub disk_usage: DiskUsage, - pub memory: MemoryStatus, -} - -#[derive(Debug, Serialize, Deserialize, ToSchema)] -pub struct CpuUsage { - #[serde(rename = "available_percentage")] - pub available_pct: String, - pub status: String, -} - -#[derive(Debug, Serialize, Deserialize, ToSchema)] -pub struct DatabaseStatus { - pub status: String, -} - -#[derive(Debug, Serialize, Deserialize, ToSchema)] -pub struct DiskUsage { - pub status: String, - #[serde(rename = "used_percentage")] - pub used_pct: String, -} - -#[derive(Debug, Serialize, Deserialize, ToSchema)] -pub struct MemoryStatus { - #[serde(rename = "available_mb")] - pub available_mb: i64, - pub status: String, -} +use crate::models::health::HealthResponse; // Health check endpoint #[utoipa::path( diff --git a/src/routes/get_todos.rs b/src/handlers/get_todos.rs similarity index 66% rename from src/routes/get_todos.rs rename to src/handlers/get_todos.rs index 64db183..7cc33f5 100644 --- a/src/routes/get_todos.rs +++ b/src/handlers/get_todos.rs @@ -1,8 +1,7 @@ use axum::{ - extract::{State, Extension, Path}, - Json, - response::IntoResponse, - http::StatusCode + extract::{State, Extension, Path}, + Json, + http::StatusCode, }; use sqlx::postgres::PgPool; use uuid::Uuid; @@ -10,14 +9,21 @@ use serde_json::json; use tracing::instrument; // For logging use crate::models::todo::*; use crate::models::user::*; +use crate::database::todos::{fetch_all_todos_from_db, fetch_todo_by_id_from_db}; + +// --- Route Handlers --- // Get all todos #[utoipa::path( get, path = "/todos/all", tag = "todo", + security( + ("jwt_token" = []) + ), responses( (status = 200, description = "Successfully fetched all todos", body = [Todo]), + (status = 401, description = "Unauthorized", body = serde_json::Value), (status = 500, description = "Internal server error") ) )] @@ -25,16 +31,9 @@ use crate::models::user::*; pub async fn get_all_todos( State(pool): State, Extension(user): Extension, // Extract current user from the request extensions -) -> impl IntoResponse { - let todos = sqlx::query_as!(Todo, - "SELECT id, user_id, task, description, creation_date, completion_date, completed FROM todos WHERE user_id = $1", - user.id - ) - .fetch_all(&pool) // Borrow the connection pool - .await; - - match todos { - Ok(todos) => Ok(Json(todos)), // Return all todos as JSON +) -> Result>, (StatusCode, Json)> { + match fetch_all_todos_from_db(&pool, user.id).await { + Ok(todos) => Ok(Json(todos)), Err(_err) => Err(( StatusCode::INTERNAL_SERVER_ERROR, Json(json!({ "error": "Could not fetch the details of the todo." })), @@ -62,22 +61,19 @@ pub async fn get_todos_by_id( State(pool): State, Extension(user): Extension, // Extract current user from the request extensions Path(id): Path, // Use Path extractor here -) -> impl IntoResponse { +) -> Result, (StatusCode, Json)> { let uuid = match Uuid::parse_str(&id) { Ok(uuid) => uuid, - Err(_) => return Err((StatusCode::BAD_REQUEST, Json(json!({ "error": "Invalid UUID format." })))), + Err(_) => { + return Err(( + StatusCode::BAD_REQUEST, + Json(json!({ "error": "Invalid UUID format." })), + )); + } }; - let todo = sqlx::query_as!(Todo, - "SELECT id, user_id, task, description, creation_date, completion_date, completed FROM todos WHERE id = $1 AND user_id = $2", - uuid, - user.id - ) - .fetch_optional(&pool) // Borrow the connection pool - .await; - - match todo { - Ok(Some(todo)) => Ok(Json(todo)), // Return the todo as JSON if found + match fetch_todo_by_id_from_db(&pool, uuid, user.id).await { + Ok(Some(todo)) => Ok(Json(todo)), Ok(None) => Err(( StatusCode::NOT_FOUND, Json(json!({ "error": format!("Todo with ID '{}' not found.", id) })), diff --git a/src/handlers/get_usage.rs b/src/handlers/get_usage.rs new file mode 100644 index 0000000..8c0e39a --- /dev/null +++ b/src/handlers/get_usage.rs @@ -0,0 +1,62 @@ +use axum::{extract::{Extension, State}, Json}; +use axum::http::StatusCode; +use axum::response::IntoResponse; +use serde_json::json; +use sqlx::postgres::PgPool; +use tracing::instrument; + +use crate::models::user::*; +use crate::models::usage::*; +use crate::database::usage::fetch_usage_count_from_db; + +// Get usage for the last 24 hours +#[utoipa::path( + get, + path = "/usage/lastday", + tag = "usage", + security( + ("jwt_token" = []) + ), + responses( + (status = 200, description = "Successfully fetched usage for the last 24 hours", body = UsageResponseLastDay), + (status = 401, description = "Unauthorized", body = serde_json::Value), + (status = 500, description = "Internal server error") + ) +)] +#[instrument(skip(pool))] +pub async fn get_usage_last_day( + State(pool): State, + Extension(user): Extension, +) -> impl IntoResponse { + match fetch_usage_count_from_db(&pool, user.id, "24 hours").await { + Ok(count) => Ok(Json(json!({ "requests_last_24_hours": count }))), + Err(_) => Err(( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({ "error": "Could not fetch the usage data." })) + )), + } +} + +// Get usage for the last 7 days +#[utoipa::path( + get, + path = "/usage/lastweek", + tag = "usage", + responses( + (status = 200, description = "Successfully fetched usage for the last 7 days", body = UsageResponseLastDay), + (status = 500, description = "Internal server error") + ) +)] +#[instrument(skip(pool))] +pub async fn get_usage_last_week( + State(pool): State, + Extension(user): Extension, +) -> impl IntoResponse { + match fetch_usage_count_from_db(&pool, user.id, "7 days").await { + Ok(count) => Ok(Json(json!({ "requests_last_7_days": count }))), + Err(_) => Err(( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({ "error": "Could not fetch the usage data." })) + )), + } +} diff --git a/src/handlers/get_users.rs b/src/handlers/get_users.rs new file mode 100644 index 0000000..a5855b4 --- /dev/null +++ b/src/handlers/get_users.rs @@ -0,0 +1,74 @@ +use axum::extract::{Path, State}; +use axum::http::StatusCode; +use axum::Json; +use axum::response::IntoResponse; +use serde_json::json; +use sqlx::postgres::PgPool; +use tracing::instrument; +use uuid::Uuid; + +use crate::models::user::*; +use crate::database::users::{fetch_all_users_from_db, fetch_user_by_field_from_db}; + +// Get all users +#[utoipa::path( + get, + path = "/users/all", + tag = "user", + security( + ("jwt_token" = []) + ), + responses( + (status = 200, description = "Successfully fetched all users", body = [UserGetResponse]), + (status = 401, description = "Unauthorized", body = serde_json::Value), + (status = 500, description = "Internal server error") + ) +)] +#[instrument(skip(pool))] +pub async fn get_all_users(State(pool): State) -> impl IntoResponse { + match fetch_all_users_from_db(&pool).await { + Ok(users) => Ok(Json(users)), + Err(_) => Err(( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({ "error": "Could not fetch the users details." })), + )), + } +} + +// Get a single user by ID +#[utoipa::path( + get, + path = "/users/{id}", + tag = "user", + params( + ("id" = String, Path, description = "User ID") + ), + responses( + (status = 200, description = "Successfully fetched user by ID", body = UserGetResponse), + (status = 400, description = "Invalid UUID format"), + (status = 404, description = "User not found"), + (status = 500, description = "Internal server error") + ) +)] +#[instrument(skip(pool))] +pub async fn get_users_by_id( + State(pool): State, + Path(id): Path, +) -> impl IntoResponse { + let uuid = match Uuid::parse_str(&id) { + Ok(uuid) => uuid, + Err(_) => return Err((StatusCode::BAD_REQUEST, Json(json!({ "error": "Invalid UUID format." })))), + }; + + match fetch_user_by_field_from_db(&pool, "id", &uuid.to_string()).await { + Ok(Some(user)) => Ok(Json(user)), + Ok(None) => Err(( + StatusCode::NOT_FOUND, + Json(json!({ "error": format!("User with ID '{}' not found", id) })), + )), + Err(_) => Err(( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({ "error": "Could not fetch the users details." })), + )), + } +} diff --git a/src/handlers/homepage.rs b/src/handlers/homepage.rs new file mode 100644 index 0000000..5e86f10 --- /dev/null +++ b/src/handlers/homepage.rs @@ -0,0 +1,115 @@ +use axum::response::{IntoResponse, Html}; + +// Homepage route +pub async fn homepage() -> impl IntoResponse { + Html(r#" + + + + + + Axium API + + + + +
+

+ db 88 + d88b "" + d8'`8b + d8' `8b 8b, ,d8 88 88 88 88,dPYba,,adPYba, + d8YaaaaY8b `Y8, ,8P' 88 88 88 88P' "88" "8a + d8""""""""8b )888( 88 88 88 88 88 88 + d8' `8b ,d8" "8b, 88 "8a, ,a88 88 88 88 + d8' `8b 8P' `Y8 88 `"YbbdP'Y8 88 88 88 +

+
    +
  • 📖 Explore the API using Swagger UI or import the OpenAPI spec.
  • +
  • 🩺 Ensure your Docker setup is reliable, by pointing its healthcheck too /health.
  • +
+ + + View on GitHub + +
+ + + "#) +} diff --git a/src/handlers/mod.rs b/src/handlers/mod.rs index 840923d..e61a559 100644 --- a/src/handlers/mod.rs +++ b/src/handlers/mod.rs @@ -1,2 +1,16 @@ // Module declarations -pub mod validate; \ No newline at end of file +pub mod delete_apikeys; +pub mod delete_todos; +pub mod delete_users; +pub mod get_apikeys; +pub mod get_health; +pub mod get_todos; +pub mod get_usage; +pub mod get_users; +pub mod homepage; +pub mod post_apikeys; +pub mod post_todos; +pub mod post_users; +pub mod protected; +pub mod rotate_apikeys; +pub mod signin; \ No newline at end of file diff --git a/src/routes/post_apikeys.rs b/src/handlers/post_apikeys.rs similarity index 52% rename from src/routes/post_apikeys.rs rename to src/handlers/post_apikeys.rs index d8f20fc..5267494 100644 --- a/src/routes/post_apikeys.rs +++ b/src/handlers/post_apikeys.rs @@ -1,54 +1,39 @@ use axum::{extract::{Extension, State}, Json}; use axum::http::StatusCode; -use axum::response::IntoResponse; use chrono::{Duration, Utc}; -use serde::{Deserialize, Serialize}; use serde_json::json; use sqlx::postgres::PgPool; use tracing::{error, info}; -use utoipa::ToSchema; -use uuid::Uuid; use validator::Validate; -use crate::handlers::validate::validate_future_date; -use crate::middlewares::auth::{generate_api_key, hash_password}; +use crate::utils::auth::{generate_api_key, hash_password}; use crate::models::user::User; +use crate::database::apikeys::{check_existing_api_key_count, insert_api_key_into_db}; +use crate::models::apikey::{ApiKeyInsertBody, ApiKeyInsertResponse}; -// Define the request body structure -#[derive(Deserialize, Validate, ToSchema)] -pub struct ApiKeyBody { - #[validate(length(min = 0, max = 50))] - pub description: Option, - #[validate(custom(function = "validate_future_date"))] - pub expiration_date: Option, -} - -// Define the response body structure -#[derive(Serialize, ToSchema)] -pub struct ApiKeyResponse { - pub id: Uuid, - pub api_key: String, - pub description: String, - pub expiration_date: String, -} +// --- Route Handler --- // Define the API endpoint #[utoipa::path( post, path = "/apikeys", tag = "apikey", - request_body = ApiKeyBody, + security( + ("jwt_token" = []) + ), + request_body = ApiKeyInsertBody, responses( - (status = 200, description = "API key created successfully", body = ApiKeyResponse), + (status = 200, description = "API key created successfully", body = ApiKeyInsertResponse), (status = 400, description = "Validation error", body = String), + (status = 401, description = "Unauthorized", body = serde_json::Value), (status = 500, description = "Internal server error", body = String) ) )] pub async fn post_apikey( - State(pool): State, + State(pool): State, Extension(user): Extension, - Json(api_key_request): Json -) -> impl IntoResponse { + Json(api_key_request): Json +) -> Result, (StatusCode, Json)> { // Validate input if let Err(errors) = api_key_request.validate() { let error_messages: Vec = errors @@ -65,69 +50,49 @@ pub async fn post_apikey( info!("Received request to create API key for user: {}", user.id); // Check if the user already has 5 or more API keys - let existing_keys_count = sqlx::query!( - "SELECT COUNT(*) as count FROM apikeys WHERE user_id = $1 AND expiration_date >= CURRENT_DATE", - user.id - ) - .fetch_one(&pool) - .await; - - match existing_keys_count { - Ok(row) if row.count.unwrap_or(0) >= 5 => { - info!("User {} already has 5 API keys.", user.id); - return Err(( - StatusCode::BAD_REQUEST, - Json(json!({ "error": "You already have 5 API keys. Please delete an existing key before creating a new one." })) - )); - } - Err(_err) => { - error!("Failed to check the amount of API keys for user {}.", user.id); + let existing_keys_count = match check_existing_api_key_count(&pool, user.id).await { + Ok(count) => count, + Err(err) => { + error!("Failed to check the amount of API keys for user {}: {}", user.id, err); return Err(( StatusCode::INTERNAL_SERVER_ERROR, Json(json!({ "error": "Could not check the amount of API keys registered." })) )); } - _ => {} // Proceed if the user has fewer than 5 keys + }; + + if existing_keys_count >= 5 { + info!("User {} already has 5 API keys.", user.id); + return Err(( + StatusCode::BAD_REQUEST, + Json(json!({ "error": "You already have 5 API keys. Please delete an existing key before creating a new one." })) + )); } let current_date = Utc::now().naive_utc(); let description = api_key_request.description .unwrap_or_else(|| format!("API key created on {}", current_date.format("%Y-%m-%d"))); - + let expiration_date = api_key_request.expiration_date .and_then(|date| date.parse::().ok()) .unwrap_or_else(|| (current_date + Duration::days(365 * 2)).date()); let api_key = generate_api_key(); - let key_hash = hash_password(&api_key).expect("Failed to hash password."); - let row = sqlx::query!( - "INSERT INTO apikeys (key_hash, description, expiration_date, user_id) VALUES ($1, $2, $3, $4) RETURNING id, key_hash, description, expiration_date, user_id", - key_hash, - description, - expiration_date, - user.id - ) - .fetch_one(&pool) - .await; - - match row { - Ok(row) => { + match insert_api_key_into_db(&pool, key_hash, description, expiration_date, user.id).await { + Ok(mut api_key_response) => { info!("Successfully created API key for user: {}", user.id); - Ok(Json(ApiKeyResponse { - id: row.id, - api_key: api_key, - description: description.to_string(), - expiration_date: expiration_date.to_string() - })) - }, + // Restore generated api_key to response. It is not stored in database for security reasons. + api_key_response.api_key = api_key; + Ok(Json(api_key_response)) + } Err(err) => { error!("Error creating API key for user {}: {}", user.id, err); Err(( StatusCode::INTERNAL_SERVER_ERROR, Json(json!({ "error": format!("Error creating API key: {}.", err) })) )) - }, + } } } diff --git a/src/routes/post_todos.rs b/src/handlers/post_todos.rs similarity index 67% rename from src/routes/post_todos.rs rename to src/handlers/post_todos.rs index cc83457..dfed131 100644 --- a/src/routes/post_todos.rs +++ b/src/handlers/post_todos.rs @@ -1,4 +1,4 @@ -use axum::{extract::{Extension, State}, Json, response::IntoResponse}; +use axum::{extract::{Extension, State}, Json}; use axum::http::StatusCode; use serde::Deserialize; use serde_json::json; @@ -9,6 +9,7 @@ use validator::Validate; use crate::models::todo::Todo; use crate::models::user::User; +use crate::database::todos::insert_todo_into_db; // Define the request body structure #[derive(Deserialize, Validate, ToSchema)] @@ -19,24 +20,30 @@ pub struct TodoBody { pub description: Option, } +// --- Route Handler --- + // Define the API endpoint #[utoipa::path( post, path = "/todos", tag = "todo", + security( + ("jwt_token" = []) + ), request_body = TodoBody, responses( (status = 200, description = "Todo created successfully", body = Todo), (status = 400, description = "Validation error", body = String), + (status = 401, description = "Unauthorized", body = serde_json::Value), (status = 500, description = "Internal server error", body = String) ) )] #[instrument(skip(pool, user, todo))] pub async fn post_todo( - State(pool): State, + State(pool): State, Extension(user): Extension, Json(todo): Json -) -> impl IntoResponse { +) -> Result, (StatusCode, Json)> { // Validate input if let Err(errors) = todo.validate() { let error_messages: Vec = errors @@ -50,27 +57,8 @@ pub async fn post_todo( )); } - let row = sqlx::query!( - "INSERT INTO todos (task, description, user_id) - VALUES ($1, $2, $3) - RETURNING id, task, description, user_id, creation_date, completion_date, completed", - todo.task, - todo.description, - user.id - ) - .fetch_one(&pool) - .await; - - match row { - Ok(row) => Ok(Json(Todo { - id: row.id, - task: row.task, - description: row.description, - user_id: row.user_id, - creation_date: row.creation_date, - completion_date: row.completion_date, - completed: row.completed, - })), + match insert_todo_into_db(&pool, todo.task, todo.description, user.id).await { + Ok(new_todo) => Ok(Json(new_todo)), Err(_err) => Err(( StatusCode::INTERNAL_SERVER_ERROR, Json(json!({ "error": "Could not create a new todo." })) diff --git a/src/handlers/post_users.rs b/src/handlers/post_users.rs new file mode 100644 index 0000000..178d31b --- /dev/null +++ b/src/handlers/post_users.rs @@ -0,0 +1,66 @@ +use axum::{extract::State, Json}; +use axum::http::StatusCode; +use serde_json::json; +use sqlx::postgres::PgPool; +use tracing::instrument; +use validator::Validate; + +use crate::utils::auth::{hash_password, generate_totp_secret}; +use crate::database::users::insert_user_into_db; +use crate::models::user::{UserInsertResponse, UserInsertBody}; + +// --- Route Handler --- + +// Define the API endpoint +#[utoipa::path( + post, + path = "/users", + tag = "user", + security( + ("jwt_token" = []) + ), + request_body = UserInsertBody, + responses( + (status = 200, description = "User created successfully", body = UserInsertResponse), + (status = 400, description = "Validation error", body = String), + (status = 401, description = "Unauthorized", body = serde_json::Value), + (status = 500, description = "Internal server error", body = String) + ) +)] +#[instrument(skip(pool, user))] +pub async fn post_user( + State(pool): State, + Json(user): Json, +) -> Result, (StatusCode, Json)> { + // Validate input + if let Err(errors) = user.validate() { + let error_messages: Vec = errors + .field_errors() + .iter() + .flat_map(|(_, errors)| errors.iter().map(|e| e.message.clone().unwrap_or_default().to_string())) + .collect(); + return Err(( + StatusCode::BAD_REQUEST, + Json(json!({ "error": error_messages.join(", ") })) + )); + } + + // Hash the password before saving it + let hashed_password = hash_password(&user.password) + .map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({ "error": "Failed to hash password." }))))?; + + // Generate TOTP secret if totp is Some("true") + let totp_secret = if user.totp.as_deref() == Some("true") { + generate_totp_secret() + } else { + String::new() // or some other default value + }; + + match insert_user_into_db(&pool, &user.username, &user.email, &hashed_password, &totp_secret, 1, 1).await { + Ok(new_user) => Ok(Json(new_user)), + Err(_err) => Err(( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({ "error": "Could not create the user." })) + )), + } +} \ No newline at end of file diff --git a/src/handlers/protected.rs b/src/handlers/protected.rs new file mode 100644 index 0000000..48e349f --- /dev/null +++ b/src/handlers/protected.rs @@ -0,0 +1,21 @@ +use axum::{Extension, Json, response::IntoResponse}; +use crate::models::user::{User, UserGetResponse}; +use tracing::instrument; + +#[utoipa::path( + get, + path = "/protected", + tag = "protected", + security( + ("jwt_token" = []) + ), + responses( + (status = 200, description = "Protected endpoint accessed successfully", body = UserGetResponse), + (status = 401, description = "Unauthorized", body = String) + ) +)] +#[instrument(skip(user))] +pub async fn protected(Extension(user): Extension) -> impl IntoResponse { + Json(UserGetResponse {id:user.id,username:user.username,email:user.email, role_level: user.role_level, tier_level: user.tier_level, creation_date: user.creation_date + }) +} \ No newline at end of file diff --git a/src/handlers/rotate_apikeys.rs b/src/handlers/rotate_apikeys.rs new file mode 100644 index 0000000..4bde23c --- /dev/null +++ b/src/handlers/rotate_apikeys.rs @@ -0,0 +1,128 @@ +use axum::{extract::{Extension, Path, State}, Json}; +use axum::http::StatusCode; +use chrono::{Duration, NaiveDate, Utc}; +use serde_json::json; +use sqlx::postgres::PgPool; +use tracing::instrument; +use uuid::Uuid; +use validator::Validate; + +use crate::utils::auth::{generate_api_key, hash_password}; +use crate::models::user::User; +use crate::database::apikeys::{fetch_existing_apikey, insert_api_key_into_db, disable_apikey_in_db}; +use crate::models::apikey::{ApiKeyRotateBody, ApiKeyRotateResponse, ApiKeyRotateResponseInfo}; + +#[utoipa::path( + post, + path = "/apikeys/rotate/{id}", + tag = "apikey", + security( + ("jwt_token" = []) + ), + request_body = ApiKeyRotateBody, + responses( + (status = 200, description = "API key rotated successfully", body = ApiKeyRotateResponse), + (status = 400, description = "Validation error", body = String), + (status = 404, description = "API key not found", body = String), + (status = 500, description = "Internal server error", body = String) + ), + params( + ("id" = String, Path, description = "API key identifier") + ) +)] +#[instrument(skip(pool, user, apikeyrotatebody))] +pub async fn rotate_apikey( + State(pool): State, + Extension(user): Extension, + Path(id): Path, + Json(apikeyrotatebody): Json +) -> Result, (StatusCode, Json)> { + // Validate input + if let Err(errors) = apikeyrotatebody.validate() { + let error_messages: Vec = errors + .field_errors() + .iter() + .flat_map(|(_, errors)| errors.iter().map(|e| e.message.clone().unwrap_or_default().to_string())) + .collect(); + return Err(( + StatusCode::BAD_REQUEST, + Json(json!({ "error": error_messages.join(", ") })) + )); + } + + // Validate UUID format + let uuid = match Uuid::parse_str(&id) { + Ok(uuid) => uuid, + Err(_) => return Err((StatusCode::BAD_REQUEST, Json(json!({ "error": "Invalid API key identifier format" })))), + }; + + // Verify ownership of the old API key + let existing_key = fetch_existing_apikey(&pool, user.id, uuid).await.map_err(|e| { + tracing::error!("Database error: {}", e); + (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({ "error": "Internal server error" }))) + })?.ok_or_else(|| (StatusCode::NOT_FOUND, Json(json!({ "error": "API key not found or already disabled" }))))?; + + // Validate expiration date format + let expiration_date = match &apikeyrotatebody.expiration_date { + Some(date_str) => NaiveDate::parse_from_str(date_str, "%Y-%m-%d") + .map_err(|_| (StatusCode::BAD_REQUEST, Json(json!({ "error": "Invalid expiration date format. Use YYYY-MM-DD" }))))?, + None => (Utc::now() + Duration::days(365 * 2)).naive_utc().date(), + }; + + // Validate expiration date is in the future + if expiration_date <= Utc::now().naive_utc().date() { + return Err((StatusCode::BAD_REQUEST, Json(json!({ "error": "Expiration date must be in the future" })))); + } + + // Generate new secure API key + let api_key = generate_api_key(); + let key_hash = hash_password(&api_key).map_err(|e| { + tracing::error!("Hashing error: {}", e); + (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({ "error": "Internal server error" }))) + })?; + + // Create new key FIRST + let description = apikeyrotatebody.description.unwrap_or_else(|| + format!("Rotated from key {} - {}", existing_key.id, Utc::now().format("%Y-%m-%d")) + ); + + let new_key = insert_api_key_into_db(&pool, key_hash, description, expiration_date, user.id).await.map_err(|e| { + tracing::error!("Database error: {}", e); + (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({ "error": "Internal server error" }))) + })?; + + // Attempt to disable old key + let disable_result = match disable_apikey_in_db(&pool, uuid, user.id).await { + Ok(res) => res, + Err(e) => { + tracing::error!("Database error: {}", e); + // Rollback: Disable the newly created key + let _ = disable_apikey_in_db(&pool, new_key.id, user.id).await; + return Err((StatusCode::INTERNAL_SERVER_ERROR, Json(json!({ "error": "Internal server error" })))); + } + }; + + // Verify old key was actually disabled + if disable_result == 0 { + // Rollback: Disable new key + let _ = disable_apikey_in_db(&pool, new_key.id, user.id).await; + return Err(( + StatusCode::NOT_FOUND, + Json(json!({ "error": "Old API key not found or already disabled" })) + )); + } + + // Create the ApiKeyRotateResponse + let rotate_response = ApiKeyRotateResponse { + id: new_key.id, + api_key, + description: new_key.description, + expiration_date: expiration_date, + rotation_info: ApiKeyRotateResponseInfo { + original_key: existing_key.id, + disabled_at: Utc::now().date_naive(), + }, + }; + + Ok(Json(rotate_response)) +} diff --git a/src/handlers/signin.rs b/src/handlers/signin.rs new file mode 100644 index 0000000..5ed2d5b --- /dev/null +++ b/src/handlers/signin.rs @@ -0,0 +1,138 @@ +use axum::{ + extract::State, + http::StatusCode, + Json, +}; +use serde::Deserialize; +use serde_json::json; +use sqlx::PgPool; +use totp_rs::{Algorithm, TOTP}; +use tracing::{info, instrument}; +use utoipa::ToSchema; + +use crate::utils::auth::{encode_jwt, verify_hash}; +use crate::database::{apikeys::fetch_active_apikeys_by_user_id_from_db, users::fetch_user_by_email_from_db}; + +#[derive(Deserialize, ToSchema)] +pub struct SignInData { + pub email: String, + pub password: String, + pub totp: Option, +} + +/// User sign-in endpoint +/// +/// This endpoint allows users to sign in using their email, password, and optionally a TOTP code. +/// +/// # Parameters +/// - `State(pool)`: The shared database connection pool. +/// - `Json(user_data)`: The user sign-in data (email, password, and optional TOTP code). +/// +/// # Returns +/// - `Ok(Json(serde_json::Value))`: A JSON response containing the JWT token if sign-in is successful. +/// - `Err((StatusCode, Json(serde_json::Value)))`: An error response if sign-in fails. +#[utoipa::path( + post, + path = "/signin", + tag = "auth", + request_body = SignInData, + responses( + (status = 200, description = "Successful sign-in", body = serde_json::Value), + (status = 400, description = "Bad request", body = serde_json::Value), + (status = 401, description = "Unauthorized", body = serde_json::Value), + (status = 500, description = "Internal server error", body = serde_json::Value) + ) +)] +#[instrument(skip(pool, user_data))] +pub async fn signin( + State(pool): State, + Json(user_data): Json, +) -> Result, (StatusCode, Json)> { + let user = match fetch_user_by_email_from_db(&pool, &user_data.email).await { + Ok(Some(user)) => user, + Ok(None) | Err(_) => return Err(( + StatusCode::UNAUTHORIZED, + Json(json!({ "error": "Incorrect credentials." })) + )), + }; + + let api_key_hashes = match fetch_active_apikeys_by_user_id_from_db(&pool, user.id).await { + Ok(hashes) => hashes, + Err(_) => return Err(( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({ "error": "Internal server error." })) + )), + }; + + // Check API key first (async version) + let api_key_futures = api_key_hashes.iter().map(|api_key| { + let password = user_data.password.clone(); + let hash = api_key.key_hash.clone(); + async move { + verify_hash(&password, &hash) + .await + .unwrap_or(false) + } + }); + + let any_api_key_valid = futures::future::join_all(api_key_futures) + .await + .into_iter() + .any(|result| result); + + // Check password (async version) + let password_valid = verify_hash(&user_data.password, &user.password_hash) + .await + .map_err(|_| ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({ "error": "Internal server error." })) + ))?; + + let credentials_valid = any_api_key_valid || password_valid; + + if !credentials_valid { + return Err(( + StatusCode::UNAUTHORIZED, + Json(json!({ "error": "Incorrect credentials." })) + )); + } + + // Check TOTP if it's set up for the user + if let Some(totp_secret) = user.totp_secret { + match user_data.totp { + Some(totp_code) => { + let totp = TOTP::new( + Algorithm::SHA512, + 8, + 1, + 30, + totp_secret.into_bytes(), + ).map_err(|_| ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({ "error": "Internal server error." })) + ))?; + + if !totp.check_current(&totp_code).unwrap_or(false) { + return Err(( + StatusCode::UNAUTHORIZED, + Json(json!({ "error": "Invalid 2FA code." })) + )); + } + }, + None => return Err(( + StatusCode::BAD_REQUEST, + Json(json!({ "error": "2FA code required for this account." })) + )), + } + } + + let email = user.email.clone(); + let token = encode_jwt(user.email) + .map_err(|_| ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({ "error": "Internal server error." })) + ))?; + + info!("User signed in: {}", email); + Ok(Json(json!({ "token": token }))) +} diff --git a/src/main.rs b/src/main.rs index 88399e2..26d9d94 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2,8 +2,7 @@ // Core modules for the configuration, TLS setup, and server creation mod core; -use core::{config, tls, server}; -use core::tls::TlsListener; +use core::{config, server}; // Other modules for database, routes, models, and middlewares mod database; @@ -11,21 +10,53 @@ mod routes; mod models; mod middlewares; mod handlers; +mod utils; use std::net::{IpAddr, SocketAddr}; use std::sync::Arc; -use tokio::net::TcpListener; -use tokio_rustls::TlsAcceptor; -use axum::serve; +use tokio::signal; + +use axum_server::tls_rustls::RustlsConfig; + +async fn shutdown_signal() { + let ctrl_c = async { + signal::ctrl_c() + .await + .expect("❌ Failed to install Ctrl+C handler."); + }; + + #[cfg(unix)] + let terminate = async { + signal::unix::signal(signal::unix::SignalKind::terminate()) + .expect("failed to install signal handler") + .recv() + .await; + }; + + #[cfg(not(unix))] + let terminate = std::future::pending::<()>(); + + tokio::select! { + _ = ctrl_c => {}, + _ = terminate => {}, + } + + println!("\n⏳ Shutdown signal received, starting graceful shutdown."); +} + +fn display_additional_info(protocol: &str, ip: IpAddr, port: u16) { + println!("\n📖 Explore the API using Swagger ({0}://{1}:{2}/swagger)\n or import the OpenAPI spec ({0}://{1}:{2}/openapi.json).", protocol, ip, port); + println!("\n🩺 Ensure your Docker setup is reliable,\n by pointing its healthcheck to {0}://{1}:{2}/health", protocol, ip, port); + println!("\nPress [CTRL] + [C] to gracefully shutdown."); +} #[tokio::main] async fn main() { dotenvy::dotenv().ok(); // Load environment variables from a .env file + tracing_subscriber::fmt::init(); // Initialize the logging system - // Print a cool startup message with ASCII art and emojis/ println!("{}", r#" - db 88 d88b "" d8'`8b @@ -35,60 +66,129 @@ async fn main() { d8' `8b ,d8" "8b, 88 "8a, ,a88 88 88 88 d8' `8b 8P' `Y8 88 `"YbbdP'Y8 88 88 88 - Axium - An example API built with Rust, Axum, SQLx, and PostgreSQL - - GitHub: https://github.com/Riktastic/Axium - + - GitHub: https://github.com/Riktastic/Axium + - Version: 1.0 "#); - println!("🚀 Starting Axium..."); + println!("🦖 Starting Axium..."); - // Retrieve server IP and port from the environment, default to 127.0.0.1:3000 let ip: IpAddr = config::get_env_with_default("SERVER_IP", "127.0.0.1") .parse() - .expect("❌ Invalid IP address format. Please provide a valid IPv4 address. For example 0.0.0.0 or 127.0.0.1."); + .expect("❌ Invalid IP address format."); let port: u16 = config::get_env_u16("SERVER_PORT", 3000); - let socket_addr = SocketAddr::new(ip, port); - - // Create the Axum app instance using the server configuration + let addr = SocketAddr::new(ip, port); let app = server::create_server().await; - // Check if HTTPS is enabled in the environment configuration - if config::get_env_bool("SERVER_HTTPS_ENABLED", false) { - // If HTTPS is enabled, start the server with secure HTTPS. + let is_https = config::get_env_bool("SERVER_HTTPS_ENABLED", false); + let is_http2 = config::get_env_bool("SERVER_HTTPS_HTTP2_ENABLED", false); + let protocol = if is_https { "https" } else { "http" }; - // Bind TCP listener for incoming connections - let tcp_listener = TcpListener::bind(socket_addr) - .await - .expect("❌ Failed to bind to socket. Port might allready be in use."); // Explicit error handling - // Load the TLS configuration for secure HTTPS connections - let tls_config = tls::load_tls_config(); - let acceptor = TlsAcceptor::from(Arc::new(tls_config)); // Create a TLS acceptor - let listener = TlsListener { - inner: Arc::new(tcp_listener), // Wrap TCP listener in TlsListener - acceptor: acceptor, + if is_https { + // HTTPS + + // Ensure that the crypto provider is initialized before using rustls + rustls::crypto::aws_lc_rs::default_provider() + .install_default() + .unwrap_or_else(|e| { + eprintln!("❌ Crypto provider initialization failed: {:?}", e); + std::process::exit(1); + }); + + // Get certificate and key file paths from environment variables + let cert_path = config::get_env("SERVER_HTTPS_CERT_FILE_PATH"); + let key_path = config::get_env("SERVER_HTTPS_KEY_FILE_PATH"); + + // Set up Rustls config with HTTP/2 support + let (certs, key) = { + // Load certificate chain + let certs = tokio::fs::read(&cert_path) + .await + .unwrap_or_else(|e| { + eprintln!("❌ Failed to read certificate file: {}", e); + std::process::exit(1); + }); + + // Load private key + let key = tokio::fs::read(&key_path) + .await + .unwrap_or_else(|e| { + eprintln!("❌ Failed to read key file: {}", e); + std::process::exit(1); + }); + + // Parse certificates and private key + let certs = rustls_pemfile::certs(&mut &*certs) + .collect::, _>>() + .unwrap_or_else(|e| { + eprintln!("❌ Failed to parse certificates: {}", e); + std::process::exit(1); + }); + + let mut keys = rustls_pemfile::pkcs8_private_keys(&mut &*key) + .collect::, _>>() + .unwrap_or_else(|e| { + eprintln!("❌ Failed to parse private key: {}", e); + std::process::exit(1); + }); + + let key = keys.remove(0); + + // Wrap the private key in the correct type + let key = rustls::pki_types::PrivateKeyDer::Pkcs8(key); + + (certs, key) }; - println!("🔒 Server started with HTTPS at: https://{}:{}", ip, port); + let mut config = rustls::ServerConfig::builder() + .with_no_client_auth() + .with_single_cert(certs, key) + .unwrap_or_else(|e| { + eprintln!("❌ Failed to build TLS configuration: {}", e); + std::process::exit(1); + }); - // Serve the app using the TLS listener (HTTPS) - serve(listener, app.into_make_service()) - .await - .expect("❌ Server failed to start with HTTPS. Did you provide valid certificate and key files?"); + if is_http2 { + config.alpn_protocols = vec![b"h2".to_vec()]; + } + let rustls_config = RustlsConfig::from_config(Arc::new(config)); + + println!("🔒 Server started with HTTPS at: {}://{}:{}", protocol, ip, port); + + display_additional_info(protocol, ip, port); + + // Create the server future but don't await it yet + let server = axum_server::bind_rustls(addr, rustls_config) + .serve(app.into_make_service()); + + tokio::select! { + result = server => { + if let Err(e) = result { + eprintln!("❌ Server failed to start with HTTPS: {}", e); + } + }, + _ = shutdown_signal() => {}, + } } else { - // If HTTPS is not enabled, start the server with non-secure HTTP. + // HTTP - // Bind TCP listener for non-secure HTTP connections - let listener = TcpListener::bind(socket_addr) - .await - .expect("❌ Failed to bind to socket. Port might allready be in use."); // Explicit error handling + println!("🔓 Server started with HTTP at: {}://{}:{}", protocol, ip, port); - println!("🔓 Server started with HTTP at: http://{}:{}", ip, port); + display_additional_info(protocol, ip, port); - // Serve the app using the non-secure TCP listener (HTTP) - serve(listener, app.into_make_service()) - .await - .expect("❌ Server failed to start without HTTPS."); + // Create the server future but don't await it yet + let server = axum_server::bind(addr) + .serve(app.into_make_service()); + + tokio::select! { + result = server => { + if let Err(e) = result { + eprintln!("❌ Server failed to start with HTTP: {}", e); + } + }, + _ = shutdown_signal() => {}, + } } -} + println!("\n✔️ Server has shut down gracefully."); +} \ No newline at end of file diff --git a/src/middlewares/README.md b/src/middlewares/README.md new file mode 100644 index 0000000..d43b85b --- /dev/null +++ b/src/middlewares/README.md @@ -0,0 +1,40 @@ +# Middleware +This folder contains middleware functions used in Axium, providing essential utilities like authentication, authorization, and usage tracking. + +## Overview +The `/src/middlewares` folder includes middleware implementations for role-based access control (RBAC), JWT authentication, rate limiting, and batched usage tracking. + +### Key Components +- **Axum Middleware:** Utilizes Axum's middleware layer for request handling. +- **Moka Cache:** Provides caching for rate limits. +- **SQLx:** Facilitates database interactions. +- **UUID and Chrono:** Handles unique identifiers and timestamps. + +## Middleware Files +This folder includes: + +- **authorize:** Middleware to enforce role-based access by validating JWT tokens and checking user roles. +- **usage tracking:** Middleware to count and store usage metrics efficiently through batched database writes. + +## Usage +To apply middleware, use Axum's `layer` method: +```rust +.route("/path", get(handler).layer(from_fn(|req, next| { + let allowed_roles = vec![1, 2]; + authorize(req, next, allowed_roles) +}))) +``` + +## Extending Middleware +Add new middleware by creating Rust functions that implement Axum's `Next` trait. Ensure proper logging, error handling, and unit tests. + +## Dependencies +- [Axum](https://docs.rs/axum/latest/axum/) +- [SQLx](https://docs.rs/sqlx/latest/sqlx/) +- [Moka Cache](https://docs.rs/moka/latest/moka/) + +## Contributing +Ensure new middleware is well-documented, includes error handling, and integrates with the existing architecture. + +## License +This project is licensed under the MIT License. \ No newline at end of file diff --git a/src/middlewares/auth.rs b/src/middlewares/auth.rs index 4d5a3a9..436e695 100644 --- a/src/middlewares/auth.rs +++ b/src/middlewares/auth.rs @@ -1,5 +1,3 @@ -use std::{collections::HashSet, env}; - // Standard library imports for working with HTTP, environment variables, and other necessary utilities use axum::{ body::Body, @@ -9,93 +7,24 @@ use axum::{ middleware::Next, // For adding middleware layers to the request handling pipeline }; -// Importing `State` for sharing application state (such as a database connection) across request handlers -use axum::extract::State; - -// Importing necessary libraries for password hashing, JWT handling, and date/time management -use argon2::{ - password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString}, // For password hashing and verification - Argon2, -}; - -use chrono::{Duration, Utc}; // For working with time (JWT expiration, etc.) -use jsonwebtoken::{decode, encode, DecodingKey, EncodingKey, Header, TokenData, Validation}; // For encoding and decoding JWT tokens -use serde::Deserialize; // For serializing and deserializing JSON data use serde_json::json; // For constructing JSON data -use sqlx::PgPool; // For interacting with PostgreSQL databases asynchronously -use totp_rs::{Algorithm, Secret, TOTP}; // For generating TOTP secrets and tokens -use rand::rngs::OsRng; // For generating random numbers +use sqlx::{PgPool, Postgres, QueryBuilder}; // For interacting with PostgreSQL databases asynchronously use uuid::Uuid; // For working with UUIDs -use rand::Rng; -use tracing::{info, warn, error, instrument}; // For logging +use tracing::instrument; // For logging -use utoipa::ToSchema; // Import ToSchema for OpenAPI documentation +// New imports for caching and batched writes +use std::sync::Arc; +use std::time::Duration; +use moka::future::Cache; +use tokio::sync::Mutex; +use tokio::time::interval; +use chrono::Utc; // Importing custom database query functions -use crate::database::{get_users::get_user_by_email, get_apikeys::get_active_apikeys_by_user_id, insert_usage::insert_usage}; +use crate::database::users::fetch_user_by_email_from_db; -// Define the structure for JWT claims to be included in the token payload -#[derive(serde::Serialize, serde::Deserialize)] -struct Claims { - sub: String, // Subject (e.g., user ID or email) - iat: usize, // Issued At (timestamp) - exp: usize, // Expiration (timestamp) - iss: String, // Issuer (optional) - aud: String, // Audience (optional) -} - -// Custom error type for handling authentication errors -pub struct AuthError { - message: String, - status_code: StatusCode, // HTTP status code to be returned with the error -} - -// Function to verify a password against a stored hash using the Argon2 algorithm -#[instrument] -pub fn verify_hash(password: &str, hash: &str) -> Result { - let parsed_hash = PasswordHash::new(hash)?; // Parse the hash - // Verify the password using Argon2 - Ok(Argon2::default().verify_password(password.as_bytes(), &parsed_hash).is_ok()) -} - -// Function to hash a password using Argon2 and a salt retrieved from the environment variables -#[instrument] -pub fn hash_password(password: &str) -> Result { - // Get the salt from environment variables (must be set) - let salt = SaltString::generate(&mut OsRng); - let argon2 = Argon2::default(); // Create an Argon2 instance - // Hash the password with the salt - let password_hash = argon2.hash_password(password.as_bytes(), &salt)?.to_string(); - Ok(password_hash) -} - -#[instrument] -pub fn generate_totp_secret() -> String { - let totp = TOTP::new( - Algorithm::SHA512, - 8, - 1, - 30, - Secret::generate_secret().to_bytes().unwrap(), - ).expect("Failed to create TOTP."); - - let token = totp.generate_current().unwrap(); - - token -} - -#[instrument] -pub fn generate_api_key() -> String { - let mut rng = rand::thread_rng(); - (0..5) - .map(|_| { - (0..8) - .map(|_| format!("{:02x}", rng.gen::())) - .collect::() - }) - .collect::>() - .join("-") -} +use crate::models::auth::AuthError; // Import the AuthError struct for error handling +use crate::utils::auth::decode_jwt; // Implement the IntoResponse trait for AuthError to allow it to be returned as a response from the handler impl IntoResponse for AuthError { @@ -107,75 +36,71 @@ impl IntoResponse for AuthError { } } -// Function to encode a JWT token for the given email address -#[instrument] -pub fn encode_jwt(email: String) -> Result { - // Load secret key from environment variable for better security - let secret_key = env::var("JWT_SECRET_KEY") - .map_err(|_| { - error!("JWT_SECRET_KEY not set in environment variables"); - StatusCode::INTERNAL_SERVER_ERROR - })?; - - let now = Utc::now(); - let expire = Duration::hours(24); - let exp: usize = (now + expire).timestamp() as usize; - let iat: usize = now.timestamp() as usize; - - let claim = Claims { - sub: email.clone(), - iat, - exp, - iss: "your_issuer".to_string(), // Add issuer if needed - aud: "your_audience".to_string(), // Add audience if needed - }; - - // Use a secure HMAC algorithm (e.g., HS256) for signing the token - encode( - &Header::new(jsonwebtoken::Algorithm::HS256), - &claim, - &EncodingKey::from_secret(secret_key.as_ref()), - ) - .map_err(|e| { - error!("Failed to encode JWT: {:?}", e); - StatusCode::INTERNAL_SERVER_ERROR - }) +// New struct for caching rate limit data +#[derive(Clone)] +struct CachedRateLimit { + tier_limit: i64, + request_count: i64, } -// Function to decode a JWT token and extract the claims -#[instrument] -pub fn decode_jwt(jwt: String) -> Result, StatusCode> { - // Load secret key from environment variable for better security - let secret_key = env::var("JWT_SECRET_KEY") - .map_err(|_| { - error!("JWT_SECRET_KEY not set in environment variables"); - StatusCode::INTERNAL_SERVER_ERROR - })?; +// New struct for batched usage records +#[derive(Clone, Debug)] +struct UsageRecord { + user_id: Uuid, + path: String, +} - // Set up validation rules (e.g., check if token has expired, is from a valid issuer, etc.) - let mut validation = Validation::default(); - - // Use a HashSet for the audience and issuer validation - let mut audience_set = HashSet::new(); - audience_set.insert("your_audience".to_string()); +// Global cache and batched writes queue +lazy_static::lazy_static! { + static ref RATE_LIMIT_CACHE: Cache<(Uuid, i32), CachedRateLimit> = Cache::builder() + .time_to_live(Duration::from_secs(300)) // 5 minutes cache lifetime + .build(); + static ref USAGE_QUEUE: Arc>> = Arc::new(Mutex::new(Vec::new())); +} - let mut issuer_set = HashSet::new(); - issuer_set.insert("your_issuer".to_string()); +// Function to start the background task for batched writes +pub fn start_batched_writes(pool: PgPool) { + tokio::spawn(async move { + let mut interval = interval(Duration::from_secs(60)); // Run every minute + loop { + interval.tick().await; + flush_usage_queue(&pool).await; + } + }); +} - // Set up the validation with the HashSet for audience and issuer - validation.aud = Some(audience_set); - validation.iss = Some(issuer_set); +// Function to flush the usage queue and perform batch inserts +#[instrument(skip(pool))] +async fn flush_usage_queue(pool: &PgPool) { + let mut queue = USAGE_QUEUE.lock().await; + if queue.is_empty() { + return; + } - // Decode the JWT and extract the claims - decode::( - &jwt, - &DecodingKey::from_secret(secret_key.as_ref()), - &validation, - ) - .map_err(|e| { - warn!("Failed to decode JWT: {:?}", e); - StatusCode::UNAUTHORIZED - }) + // Prepare batch insert + let mut query_builder: QueryBuilder = QueryBuilder::new( + "INSERT INTO usage (user_id, path, creation_date) " + ); + + query_builder.push_values(queue.iter(), |mut b, record| { + b.push_bind(record.user_id) + .push_bind(&record.path) + .push_bind(Utc::now()); + }); + + // Execute batch insert + let result = query_builder.build().execute(pool).await; + + match result { + Ok(_) => { + tracing::info!("Successfully inserted {} usage records in batch.", queue.len()); + } + Err(e) => { + tracing::error!("Error inserting batch usage records: {}", e); + } + } + // Clear the queue + queue.clear(); } // Middleware for role-based access control (RBAC) @@ -218,8 +143,12 @@ pub async fn authorize( }; // Fetch the user from the database using the email from the decoded token - let current_user = match get_user_by_email(&pool, token_data.claims.sub).await { - Ok(user) => user, + let current_user = match fetch_user_by_email_from_db(&pool, &token_data.claims.sub).await { + Ok(Some(user)) => user, + Ok(None) => return Err(AuthError { + message: "User not found.".to_string(), + status_code: StatusCode::UNAUTHORIZED, + }), Err(_) => return Err(AuthError { message: "Unauthorized user.".to_string(), status_code: StatusCode::UNAUTHORIZED, @@ -234,134 +163,41 @@ pub async fn authorize( }); } - // Check rate limit. + // Check rate limit using cached data check_rate_limit(&pool, current_user.id, current_user.tier_level).await?; - // Insert the usage record into the database - insert_usage(&pool, current_user.id, req.uri().path().to_string()).await - .map_err(|_| AuthError { - message: "Failed to insert usage record.".to_string(), - status_code: StatusCode::INTERNAL_SERVER_ERROR, - })?; + // Queue the usage record for batch insert instead of immediate insertion + USAGE_QUEUE.lock().await.push(UsageRecord { + user_id: current_user.id, + path: req.uri().path().to_string(), + }); // Insert the current user into the request extensions for use in subsequent handlers req.extensions_mut().insert(current_user); - // Proceed to the next middleware or handler Ok(next.run(req).await) } -// Handler for user sign-in (authentication) -#[derive(Deserialize, ToSchema)] -pub struct SignInData { - pub email: String, - pub password: String, - pub totp: Option, -} - -/// User sign-in endpoint -/// -/// This endpoint allows users to sign in using their email, password, and optionally a TOTP code. -/// -/// # Parameters -/// - `State(pool)`: The shared database connection pool. -/// - `Json(user_data)`: The user sign-in data (email, password, and optional TOTP code). -/// -/// # Returns -/// - `Ok(Json(serde_json::Value))`: A JSON response containing the JWT token if sign-in is successful. -/// - `Err((StatusCode, Json(serde_json::Value)))`: An error response if sign-in fails. -#[utoipa::path( - post, - path = "/sign_in", - request_body = SignInData, - responses( - (status = 200, description = "Successful sign-in", body = serde_json::Value), - (status = 400, description = "Bad request", body = serde_json::Value), - (status = 401, description = "Unauthorized", body = serde_json::Value), - (status = 500, description = "Internal server error", body = serde_json::Value) - ) -)] -#[instrument(skip(pool, user_data))] -pub async fn sign_in( - State(pool): State, - Json(user_data): Json, -) -> Result, (StatusCode, Json)> { - let user = match get_user_by_email(&pool, user_data.email).await { - Ok(user) => user, - Err(_) => return Err(( - StatusCode::UNAUTHORIZED, - Json(json!({ "error": "Incorrect credentials." })) - )), - }; - - let api_key_hashes = match get_active_apikeys_by_user_id(&pool, user.id).await { - Ok(hashes) => hashes, - Err(_) => return Err(( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({ "error": "Internal server error." })) - )), - }; - - // Check API key first, then password - let credentials_valid = api_key_hashes.iter().any(|api_key| { - verify_hash(&user_data.password, &api_key.key_hash).unwrap_or(false) - }) || verify_hash(&user_data.password, &user.password_hash) - .map_err(|_| ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({ "error": "Internal server error." })) - ))?; - - if !credentials_valid { - return Err(( - StatusCode::UNAUTHORIZED, - Json(json!({ "error": "Incorrect credentials." })) - )); - } - - // Check TOTP if it's set up for the user - if let Some(totp_secret) = user.totp_secret { - match user_data.totp { - Some(totp_code) => { - let totp = TOTP::new( - Algorithm::SHA512, - 8, - 1, - 30, - totp_secret.into_bytes(), - ).map_err(|_| ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({ "error": "Internal server error." })) - ))?; - - if !totp.check_current(&totp_code).unwrap_or(false) { - return Err(( - StatusCode::UNAUTHORIZED, - Json(json!({ "error": "Invalid 2FA code." })) - )); - } - }, - None => return Err(( - StatusCode::BAD_REQUEST, - Json(json!({ "error": "2FA code required for this account." })) - )), - } - } - - let email = user.email.clone(); - let token = encode_jwt(user.email) - .map_err(|_| ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({ "error": "Internal server error." })) - ))?; - - info!("User signed in: {}", email); - Ok(Json(json!({ "token": token }))) -} - #[instrument(skip(pool))] -async fn check_rate_limit(pool: &PgPool, user_id: Uuid, tier_level: i32) -> Result<(), AuthError> { - // Get the user's tier requests_per_day +async fn check_rate_limit(pool: &PgPool, user_id: Uuid, tier_level: i32) -> Result<(), AuthError> { + // Try to get cached rate limit data + if let Some(cached) = RATE_LIMIT_CACHE.get(&(user_id, tier_level)).await { + if cached.request_count >= cached.tier_limit { + return Err(AuthError { + message: "Rate limit exceeded".to_string(), + status_code: StatusCode::TOO_MANY_REQUESTS, + }); + } + // Update cache with incremented request count + RATE_LIMIT_CACHE.insert((user_id, tier_level), CachedRateLimit { + tier_limit: cached.tier_limit, + request_count: cached.request_count + 1, + }).await; + return Ok(()); + } + + // If not in cache, fetch from database let tier_limit = sqlx::query!( "SELECT requests_per_day FROM tiers WHERE level = $1", tier_level @@ -372,7 +208,7 @@ async fn check_rate_limit(pool: &PgPool, user_id: Uuid, tier_level: i32) -> Resu message: "Failed to fetch tier information".to_string(), status_code: StatusCode::INTERNAL_SERVER_ERROR, })? - .requests_per_day; + .requests_per_day as i64; // Count user's requests for today let request_count = sqlx::query!( @@ -386,9 +222,15 @@ async fn check_rate_limit(pool: &PgPool, user_id: Uuid, tier_level: i32) -> Resu status_code: StatusCode::INTERNAL_SERVER_ERROR, })? .count - .unwrap_or(0); // Use 0 if count is NULL + .unwrap_or(0) as i64; // Use 0 if count is NULL - if request_count >= tier_limit as i64 { + // Cache the result + RATE_LIMIT_CACHE.insert((user_id, tier_level), CachedRateLimit { + tier_limit, + request_count, + }).await; + + if request_count >= tier_limit { return Err(AuthError { message: "Rate limit exceeded".to_string(), status_code: StatusCode::TOO_MANY_REQUESTS, @@ -396,4 +238,4 @@ async fn check_rate_limit(pool: &PgPool, user_id: Uuid, tier_level: i32) -> Resu } Ok(()) -} \ No newline at end of file +} diff --git a/src/models/README.md b/src/models/README.md new file mode 100644 index 0000000..83fa378 --- /dev/null +++ b/src/models/README.md @@ -0,0 +1,31 @@ +# Models +This folder contains data models used in Axium, primarily defined as Rust structs. These models are essential for data serialization, deserialization, and validation within the application. + +## Overview +The `/src/models` folder contains various struct definitions that represent key data structures, such as JWT claims and custom error types. + +### Key Components +- **Serde:** Provides serialization and deserialization capabilities. +- **Utoipa:** Facilitates API documentation through the `ToSchema` derive macro. +- **Axum StatusCode:** Used for HTTP status management within custom error types. + +## Usage +Import and utilize these models across your API routes, handlers, and services. For example: +```rust +use crate::models::Claims; +use crate::models::AuthError; +``` + +## Extending Models +You can extend the existing models by adding more fields, or create new models as needed for additional functionality. Ensure that any new models are properly documented and derive necessary traits. + +## Dependencies +- [Serde](https://docs.rs/serde/latest/serde/) +- [Utoipa](https://docs.rs/utoipa/latest/utoipa/) +- [Axum](https://docs.rs/axum/latest/axum/) + +## Contributing +When adding new models, ensure they are well-documented, derive necessary traits, and integrate seamlessly with the existing codebase. + +## License +This project is licensed under the MIT License. diff --git a/src/models/apikey.rs b/src/models/apikey.rs index b9b828d..2619a34 100644 --- a/src/models/apikey.rs +++ b/src/models/apikey.rs @@ -3,66 +3,133 @@ use sqlx::FromRow; use uuid::Uuid; use chrono::NaiveDate; use utoipa::ToSchema; +use validator::Validate; + +use crate::utils::validate::validate_future_date; /// Represents an API key in the system. #[derive(Deserialize, Debug, Serialize, FromRow, Clone, ToSchema)] -#[sqlx(rename_all = "snake_case")] // Ensures that field names are mapped to snake_case in SQL +#[sqlx(rename_all = "snake_case")] pub struct ApiKey { /// The unique id of the API key. pub id: Uuid, - /// The hashed value of the API key. pub key_hash: String, - /// The id of the user who owns the API key. pub user_id: Uuid, - /// The description/name of the API key. pub description: Option, - /// The expiration date of the API key. pub expiration_date: Option, - /// The creation date of the API key (default is the current date). pub creation_date: NaiveDate, - /// Whether the API key is disabled (default is false). pub disabled: bool, - /// Whether the API key has read access (default is true). pub access_read: bool, - /// Whether the API key has modify access (default is false). pub access_modify: bool, } +/// Request body for creating a new API key. +#[derive(Deserialize, Validate, ToSchema)] +pub struct ApiKeyInsertBody { + /// Optional description of the API key (max 50 characters). + #[validate(length(min = 0, max = 50))] + pub description: Option, + /// Optional expiration date of the API key (must be in the future). + #[validate(custom(function = "validate_future_date"))] + pub expiration_date: Option, +} +/// Response body for creating a new API key. +#[derive(Serialize, ToSchema)] +pub struct ApiKeyInsertResponse { + /// The unique id of the created API key. + pub id: Uuid, + /// The actual API key value. + pub api_key: String, + /// The description of the API key. + pub description: String, + /// The expiration date of the API key. + pub expiration_date: String, +} + +/// Response body for retrieving an API key. +#[derive(Serialize, ToSchema)] +pub struct ApiKeyResponse { + /// The unique id of the API key. + pub id: Uuid, + /// The id of the user who owns the API key. + pub user_id: Uuid, + /// The description of the API key. + pub description: Option, + /// The expiration date of the API key. + pub expiration_date: Option, + /// The creation date of the API key. + pub creation_date: NaiveDate, +} + +/// Response body for retrieving an API key by its ID. +#[derive(Serialize, ToSchema)] +pub struct ApiKeyByIDResponse { + /// The unique id of the API key. + pub id: Uuid, + /// The description of the API key. + pub description: Option, + /// The expiration date of the API key. + pub expiration_date: Option, + /// The creation date of the API key. + pub creation_date: NaiveDate, +} + +/// Response body for retrieving active API keys for a user. +#[derive(Serialize, ToSchema)] +pub struct ApiKeyGetActiveForUserResponse { + /// The unique id of the API key. + pub id: Uuid, + /// The description of the API key. + pub description: Option, +} + +/// Response body for retrieving API keys by user ID. +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +pub struct ApiKeyByUserIDResponse { + /// The unique id of the API key. + pub id: Uuid, + /// The hashed value of the API key. + pub key_hash: String, + /// The expiration date of the API key. + pub expiration_date: Option, +} + +/// Request body for creating a new API key (deprecated). #[derive(serde::Serialize, ToSchema)] pub struct ApiKeyNewBody { + /// The description of the API key. pub description: Option, + /// The expiration date of the API key. pub expiration_date: Option } -#[derive(serde::Serialize, ToSchema)] -pub struct ApiKeyResponse { +#[derive(Serialize, ToSchema)] +pub struct ApiKeyRotateResponse { pub id: Uuid, - pub user_id: Uuid, - pub description: Option, - pub expiration_date: Option, - pub creation_date: NaiveDate, + pub api_key: String, + pub description: String, + pub expiration_date: NaiveDate, + pub rotation_info: ApiKeyRotateResponseInfo, } -#[derive(serde::Serialize, ToSchema)] -pub struct ApiKeyByIDResponse { - pub id: Uuid, - pub description: Option, - pub expiration_date: Option, - pub creation_date: NaiveDate, +#[derive(Serialize, ToSchema)] +pub struct ApiKeyRotateResponseInfo { + pub original_key: Uuid, + pub disabled_at: NaiveDate, } -#[derive(sqlx::FromRow, ToSchema)] -pub struct ApiKeyByUserIDResponse { - pub id: Uuid, - pub key_hash: String, - pub expiration_date: Option, +#[derive(Deserialize, Validate, ToSchema)] +pub struct ApiKeyRotateBody { + #[validate(length(min = 1, max = 255))] + pub description: Option, + pub expiration_date: Option, } \ No newline at end of file diff --git a/src/models/auth.rs b/src/models/auth.rs new file mode 100644 index 0000000..1eeea9c --- /dev/null +++ b/src/models/auth.rs @@ -0,0 +1,41 @@ +use axum::http::StatusCode; +use utoipa::ToSchema; +use serde::{Serialize, Deserialize}; + +/// Represents the claims to be included in a JWT payload. +#[derive(Serialize, Deserialize, ToSchema)] +pub struct Claims { + /// Subject of the token (e.g., user ID or email). + pub sub: String, + + /// Timestamp when the token was issued. + pub iat: usize, + + /// Timestamp when the token will expire. + pub exp: usize, + + /// Issuer of the token (optional). + pub iss: String, + + /// Intended audience for the token (optional). + pub aud: String, +} + +/// Custom error type for handling authentication-related errors. +pub struct AuthError { + /// Descriptive error message. + pub message: String, + + /// HTTP status code to be returned with the error. + pub status_code: StatusCode, +} + +// Implement Display trait for AuthError if needed +// impl std::fmt::Display for AuthError { +// fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { +// write!(f, "{}", self.message) +// } +// } + +// Implement Error trait for AuthError if needed +// impl std::error::Error for AuthError {} diff --git a/src/models/documentation.rs b/src/models/documentation.rs index 5555c33..b558b6d 100644 --- a/src/models/documentation.rs +++ b/src/models/documentation.rs @@ -1,11 +1,16 @@ use utoipa::ToSchema; +use serde::Serialize; -#[derive(ToSchema)] +/// Represents a successful response from the API. +#[derive(Serialize, ToSchema)] pub struct SuccessResponse { - message: String, + /// A message describing the successful operation. + pub message: String, } -#[derive(ToSchema)] +/// Represents an error response from the API. +#[derive(Serialize, ToSchema)] pub struct ErrorResponse { - error: String, + /// A description of the error that occurred. + pub error: String, } diff --git a/src/models/health.rs b/src/models/health.rs new file mode 100644 index 0000000..cb0290c --- /dev/null +++ b/src/models/health.rs @@ -0,0 +1,52 @@ +use serde::{Deserialize, Serialize}; +use utoipa::ToSchema; + +/// Represents the overall health status of the system. +#[derive(Debug, Serialize, Deserialize, ToSchema)] +pub struct HealthResponse { + /// CPU usage information. + pub cpu_usage: CpuUsage, + /// Database status information. + pub database: DatabaseStatus, + /// Disk usage information. + pub disk_usage: DiskUsage, + /// Memory status information. + pub memory: MemoryStatus, +} + +/// Represents CPU usage information. +#[derive(Debug, Serialize, Deserialize, ToSchema)] +pub struct CpuUsage { + /// Percentage of CPU available, represented as a string. + #[serde(rename = "available_percentage")] + pub available_pct: String, + /// Status of the CPU (e.g., "OK", "Warning", "Critical"). + pub status: String, +} + +/// Represents database status information. +#[derive(Debug, Serialize, Deserialize, ToSchema)] +pub struct DatabaseStatus { + /// Status of the database (e.g., "Connected", "Disconnected"). + pub status: String, +} + +/// Represents disk usage information. +#[derive(Debug, Serialize, Deserialize, ToSchema)] +pub struct DiskUsage { + /// Status of the disk (e.g., "OK", "Warning", "Critical"). + pub status: String, + /// Percentage of disk space used, represented as a string. + #[serde(rename = "used_percentage")] + pub used_pct: String, +} + +/// Represents memory status information. +#[derive(Debug, Serialize, Deserialize, ToSchema)] +pub struct MemoryStatus { + /// Amount of available memory in megabytes. + #[serde(rename = "available_mb")] + pub available_mb: i64, + /// Status of the memory (e.g., "OK", "Warning", "Critical"). + pub status: String, +} diff --git a/src/models/mod.rs b/src/models/mod.rs index 060331b..1fb80bd 100644 --- a/src/models/mod.rs +++ b/src/models/mod.rs @@ -7,4 +7,10 @@ pub mod role; /// Module for to-do related models. pub mod todo; /// Module for documentation related models. -pub mod documentation; \ No newline at end of file +pub mod documentation; +/// Module for authentication related models. +pub mod auth; +/// Module for the health endpoint related models. +pub mod health; +/// Module for the health endpoint related models. +pub mod usage; \ No newline at end of file diff --git a/src/models/usage.rs b/src/models/usage.rs new file mode 100644 index 0000000..1e23f9b --- /dev/null +++ b/src/models/usage.rs @@ -0,0 +1,18 @@ +use serde::Serialize; +use utoipa::ToSchema; + +/// Represents the usage statistics for the last 24 hours. +#[derive(Debug, Serialize, ToSchema)] +pub struct UsageResponseLastDay { + /// The number of requests made in the last 24 hours. + #[serde(rename = "requests_last_24_hours")] + pub count: i64 +} + +/// Represents the usage statistics for the last 7 days. +#[derive(Debug, Serialize, ToSchema)] +pub struct UsageResponseLastWeek { + /// The number of requests made in the last 7 days. + #[serde(rename = "requests_last_7_days")] + pub count: i64 +} diff --git a/src/models/user.rs b/src/models/user.rs index 9526d14..01b8880 100644 --- a/src/models/user.rs +++ b/src/models/user.rs @@ -1,59 +1,83 @@ use serde::{Deserialize, Serialize}; use sqlx::FromRow; use uuid::Uuid; -use chrono::NaiveDate; +use chrono::{NaiveDate, NaiveDateTime}; use utoipa::ToSchema; +use validator::Validate; + +use crate::utils::validate::{validate_password, validate_username}; /// Represents a user in the system. #[derive(Deserialize, Debug, Serialize, FromRow, Clone, ToSchema)] -#[sqlx(rename_all = "snake_case")] // Ensures that field names are mapped to snake_case in SQL +#[sqlx(rename_all = "snake_case")] pub struct User { /// The unique identifier for the user. pub id: Uuid, - /// The username of the user. pub username: String, - /// The email of the user. pub email: String, - /// The hashed password for the user. pub password_hash: String, - /// The TOTP secret for the user. pub totp_secret: Option, - /// Current role of the user. pub role_level: i32, - /// Current tier level of the user. pub tier_level: i32, - /// Date when the user was created. - pub creation_date: Option, // Nullable, default value in SQL is CURRENT_DATE + pub creation_date: Option, } - - -/// Represents a user in the system. +/// Represents a user response for GET requests. #[derive(Deserialize, Debug, Serialize, FromRow, Clone, ToSchema)] -#[sqlx(rename_all = "snake_case")] // Ensures that field names are mapped to snake_case in SQL -pub struct UserResponse { +#[sqlx(rename_all = "snake_case")] +pub struct UserGetResponse { /// The unique identifier for the user. pub id: Uuid, - /// The username of the user. pub username: String, - /// The email of the user. pub email: String, - /// Current role of the user. pub role_level: i32, - /// Current tier level of the user. pub tier_level: i32, - /// Date when the user was created. - pub creation_date: Option, // Nullable, default value in SQL is CURRENT_DATE + pub creation_date: Option, +} + +/// Request body for inserting a new user. +#[derive(Deserialize, Validate, ToSchema)] +pub struct UserInsertBody { + /// The username of the new user. + #[validate(length(min = 3, max = 50), custom(function = "validate_username"))] + pub username: String, + /// The email of the new user. + #[validate(email)] + pub email: String, + /// The password for the new user. + #[validate(custom(function = "validate_password"))] + pub password: String, + /// Optional TOTP secret for the new user. + pub totp: Option, +} + +/// Response body for a successful user insertion. +#[derive(Serialize, ToSchema)] +pub struct UserInsertResponse { + /// The unique identifier for the newly created user. + pub id: Uuid, + /// The username of the newly created user. + pub username: String, + /// The email of the newly created user. + pub email: String, + /// The TOTP secret for the newly created user, if provided. + pub totp_secret: Option, + /// The role level assigned to the newly created user. + pub role_level: i32, + /// The tier level assigned to the newly created user. + pub tier_level: i32, + /// The creation date and time of the newly created user. + pub creation_date: NaiveDateTime, } diff --git a/src/routes/README.md b/src/routes/README.md new file mode 100644 index 0000000..8fe91f9 --- /dev/null +++ b/src/routes/README.md @@ -0,0 +1,43 @@ +# Routes + +This folder contains the route definitions for Axium, built using [Axum](https://docs.rs/axum/latest/axum/) and [SQLx](https://docs.rs/sqlx/latest/sqlx/). + +## Overview +The `/src/routes` folder manages the routing for various API endpoints, handling operations such as CRUD functionality, usage statistics, and more. Each route is associated with its handler and protected by an authorization middleware. + +### Key Components +- **Axum Router:** Sets up API routes and manages HTTP requests, see mod.rs. +- **SQLx PgPool:** Provides database connection pooling. +- **Authorization Middleware:** Ensures secure access based on user roles. + +## Middleware +The `authorize` middleware is defined in `src/middlewares/auth.rs`. It takes the request, a next handler, and a vector of allowed roles. It verifies that the user has one of the required roles before forwarding the request. Usage example: +```rust +.route("/path", get(handler).layer(from_fn(|req, next| { + let allowed_roles = vec![1, 2]; + authorize(req, next, allowed_roles) +}))) +``` +Ensure that the `authorize` function is imported and applied to each route that requires restricted access. +The `authorize` middleware ensures users have appropriate roles before accessing certain routes. + +## Handlers +Each route delegates its logic to handler functions found in the `src/handlers` folder, ensuring separation of concerns. + +## Usage +Integrate these routes into your main application router by nesting them appropriately: +```rust +let app = Router::new() + .nest("/todos", create_todo_routes()) + .nest("/usage", create_usage_routes()); +``` + +## Dependencies +- [Axum](https://docs.rs/axum/latest/axum/) +- [SQLx](https://docs.rs/sqlx/latest/sqlx/) + +## Contributing +Add new route files, update existing routes, or enhance the middleware and handlers. Document any changes for clarity. + +## License +This project is licensed under the MIT License. \ No newline at end of file diff --git a/src/routes/apikey.rs b/src/routes/apikey.rs new file mode 100644 index 0000000..dba6892 --- /dev/null +++ b/src/routes/apikey.rs @@ -0,0 +1,29 @@ +use axum::{ + Router, + routing::{get, post, delete}, + middleware::from_fn, +}; +use sqlx::PgPool; + +use crate::middlewares::auth::authorize; +use crate::handlers::{get_apikeys::{get_all_apikeys, get_apikeys_by_id}, post_apikeys::post_apikey, rotate_apikeys::rotate_apikey, delete_apikeys::delete_apikey_by_id}; + +pub fn create_apikey_routes() -> Router { + Router::new() + .route("/all", get(get_all_apikeys).layer(from_fn(|req, next| { + let allowed_roles = vec![1,2]; + authorize(req, next, allowed_roles)}))) + .route("/new", post(post_apikey).layer(from_fn(|req, next| { + let allowed_roles = vec![1,2]; + authorize(req, next, allowed_roles) + }))) + .route("/{id}", get(get_apikeys_by_id).layer(from_fn(|req, next| { + let allowed_roles = vec![1,2]; + authorize(req, next, allowed_roles)}))) + .route("/{id}", delete(delete_apikey_by_id).layer(from_fn(|req, next| { + let allowed_roles = vec![1,2]; + authorize(req, next, allowed_roles)}))) + .route("/rotate/{id}", post(rotate_apikey).layer(from_fn(|req, next| { + let allowed_roles = vec![1,2]; + authorize(req, next, allowed_roles)}))) +} \ No newline at end of file diff --git a/src/routes/auth.rs b/src/routes/auth.rs new file mode 100644 index 0000000..ebe7b36 --- /dev/null +++ b/src/routes/auth.rs @@ -0,0 +1,19 @@ +use axum::{ + Router, + routing::post, + routing::get, +}; +use sqlx::PgPool; + +use crate::handlers::{signin::signin, protected::protected}; +use crate::middlewares::auth::authorize; +use axum::middleware::from_fn; + +pub fn create_auth_routes() -> Router { + Router::new() + .route("/signin", post(signin)) + .route("/protected", get(protected).layer(from_fn(|req, next| { + let allowed_roles = vec![1, 2]; + authorize(req, next, allowed_roles) + }))) +} diff --git a/src/routes/get_usage.rs b/src/routes/get_usage.rs deleted file mode 100644 index fe72e78..0000000 --- a/src/routes/get_usage.rs +++ /dev/null @@ -1,84 +0,0 @@ -use axum::{extract::{Extension, State}, Json}; -use axum::http::StatusCode; -use axum::response::IntoResponse; -use serde::Serialize; -use serde_json::json; -use sqlx::postgres::PgPool; -use tracing::instrument; -use utoipa::ToSchema; - -use crate::models::user::*; - -#[derive(Debug, Serialize, ToSchema)] -pub struct UsageResponseLastDay { - #[serde(rename = "requests_last_24_hours")] - pub count: i64 // or usize depending on your count type -} - -// Get usage for the last 24 hours -#[utoipa::path( - get, - path = "/usage/lastday", - tag = "usage", - responses( - (status = 200, description = "Successfully fetched usage for the last 24 hours", body = UsageResponseLastDay), - (status = 500, description = "Internal server error") - ) -)] -#[instrument(skip(pool))] -pub async fn get_usage_last_day( - State(pool): State, - Extension(user): Extension, // Extract current user from the request extensions -) -> impl IntoResponse { - let result = sqlx::query!("SELECT count(*) FROM usage WHERE user_id = $1 AND creation_date > NOW() - INTERVAL '24 hours';", user.id) - .fetch_one(&pool) // Borrow the connection pool - .await; - - match result { - Ok(row) => { - let count = row.count.unwrap_or(0) as i64; - Ok(Json(json!({ "requests_last_24_hours": count }))) - }, - Err(_err) => Err(( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({ "error": "Could not fetch the usage data." }))), - ), - } -} - -#[derive(Debug, Serialize, ToSchema)] -pub struct UsageResponseLastWeek { - #[serde(rename = "requests_last_7_days")] - pub count: i64 // or usize depending on your count type -} - -// Get usage for the last 7 days -#[utoipa::path( - get, - path = "/usage/lastweek", - tag = "usage", - responses( - (status = 200, description = "Successfully fetched usage for the last 7 days", body = UsageResponseLastDay), - (status = 500, description = "Internal server error") - ) -)] -#[instrument(skip(pool))] -pub async fn get_usage_last_week( - State(pool): State, - Extension(user): Extension, // Extract current user from the request extensions -) -> impl IntoResponse { - let result = sqlx::query!("SELECT count(*) FROM usage WHERE user_id = $1 AND creation_date > NOW() - INTERVAL '7 days';", user.id) - .fetch_one(&pool) // Borrow the connection pool - .await; - - match result { - Ok(row) => { - let count = row.count.unwrap_or(0) as i64; - Ok(Json(json!({ "requests_last_7_days": count }))) - }, - Err(_err) => Err(( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({ "error": "Could not fetch the usage data." }))), - ), - } -} diff --git a/src/routes/get_users.rs b/src/routes/get_users.rs deleted file mode 100644 index eb27b82..0000000 --- a/src/routes/get_users.rs +++ /dev/null @@ -1,121 +0,0 @@ -use axum::extract::{Path, State}; -use axum::http::StatusCode; -use axum::Json; -use axum::response::IntoResponse; -use serde_json::json; -use sqlx::postgres::PgPool; -use tracing::instrument; -use uuid::Uuid; - -use crate::models::user::*; - -// Get all users -#[utoipa::path( - get, - path = "/users/all", - tag = "user", - responses( - (status = 200, description = "Successfully fetched all users", body = [UserResponse]), - (status = 500, description = "Internal server error") - ) -)] -#[instrument(skip(pool))] -pub async fn get_all_users(State(pool): State) -> impl IntoResponse { - let users = sqlx::query_as!(UserResponse, "SELECT id, username, email, role_level, tier_level, creation_date FROM users") - .fetch_all(&pool) - .await; - - match users { - Ok(users) => Ok(Json(users)), - Err(_err) => Err(( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({ "error": "Could not fetch the users details." })), - )), - } -} - -// Get a single user by ID -#[utoipa::path( - get, - path = "/users/{id}", - tag = "user", - params( - ("id" = String, Path, description = "User ID") - ), - responses( - (status = 200, description = "Successfully fetched user by ID", body = UserResponse), - (status = 400, description = "Invalid UUID format"), - (status = 404, description = "User not found"), - (status = 500, description = "Internal server error") - ) -)] -#[instrument(skip(pool))] -pub async fn get_users_by_id( - State(pool): State, - Path(id): Path, -) -> impl IntoResponse { - let uuid = match Uuid::parse_str(&id) { - Ok(uuid) => uuid, - Err(_) => return Err((StatusCode::BAD_REQUEST, Json(json!({ "error": "Invalid UUID format." })))), - }; - - let user = sqlx::query_as!(UserResponse, "SELECT id, username, email, role_level, tier_level, creation_date FROM users WHERE id = $1", uuid) - .fetch_optional(&pool) - .await; - - match user { - Ok(Some(user)) => Ok(Json(user)), - Ok(None) => Err(( - StatusCode::NOT_FOUND, - Json(json!({ "error": format!("User with ID '{}' not found", id) })), - )), - Err(_err) => Err(( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({ "error": "Could not fetch the users details." })), - )), - } -} - -// Get a single user by username -// pub async fn get_user_by_username( -// State(pool): State, -// Path(username): Path, -// ) -> impl IntoResponse { -// let user = sqlx::query_as!(User, "SELECT id, username, email, password_hash, totp_secret, role_level, tier_level, creation_date FROM users WHERE username = $1", username) -// .fetch_optional(&pool) -// .await; - -// match user { -// Ok(Some(user)) => Ok(Json(user)), -// Ok(None) => Err(( -// StatusCode::NOT_FOUND, -// Json(json!({ "error": format!("User with username '{}' not found", username) })), -// )), -// Err(err) => Err(( -// StatusCode::INTERNAL_SERVER_ERROR, -// Json(json!({ "error": "Could not fetch the users details." })), -// )), -// } -// } - -// Get a single user by email -// pub async fn get_user_by_email( -// State(pool): State, -// Path(email): Path, -// ) -> impl IntoResponse { -// let user = sqlx::query_as!(User, "SELECT id, username, email, password_hash, totp_secret, role_level, tier_level, creation_date FROM users WHERE email = $1", email) -// .fetch_optional(&pool) -// .await; - -// match user { -// Ok(Some(user)) => Ok(Json(user)), -// Ok(None) => Err(( -// StatusCode::NOT_FOUND, -// Json(json!({ "error": format!("User with email '{}' not found", email) })), -// )), -// Err(err) => Err(( -// StatusCode::INTERNAL_SERVER_ERROR, -// Json(json!({ "error": "Could not fetch the users details." })), -// )), -// } -// } diff --git a/src/routes/health.rs b/src/routes/health.rs new file mode 100644 index 0000000..5bbce47 --- /dev/null +++ b/src/routes/health.rs @@ -0,0 +1,12 @@ +use axum::{ + Router, + routing::get, +}; +use sqlx::PgPool; + +use crate::handlers::get_health::get_health; + +pub fn create_health_route() -> Router { + Router::new() + .route("/health", get(get_health)) +} diff --git a/src/routes/homepage.rs b/src/routes/homepage.rs index 57574d7..aa05226 100644 --- a/src/routes/homepage.rs +++ b/src/routes/homepage.rs @@ -1,80 +1,12 @@ -use axum::response::{IntoResponse, Html}; -use tracing::instrument; // For logging +use axum::{ + Router, + routing::get, +}; +use sqlx::PgPool; -#[instrument] -pub async fn homepage() -> impl IntoResponse { - Html(r#" - - - - - - Welcome to Axium! - - - -
-

- db 88 - d88b "" - d8'`8b - d8' `8b 8b, ,d8 88 88 88 88,dPYba,,adPYba, - d8YaaaaY8b `Y8, ,8P' 88 88 88 88P' "88" "8a - d8""""""""8b )888( 88 88 88 88 88 88 - d8' `8b ,d8" "8b, 88 "8a, ,a88 88 88 88 -d8' `8b 8P' `Y8 88 `"YbbdP'Y8 88 88 88 -

-

An example API built with Rust, Axum, SQLx, and PostgreSQL

-
    -
  • 🚀 Check out all endpoints by visiting Swagger, or import the OpenAPI file.
  • -
  • ⚡ Do not forget to update your Docker Compose configuration with a health check. Just point it to: /health
  • -
-
- - - "#) +use crate::handlers::homepage::homepage; + +pub fn create_homepage_route() -> Router { + Router::new() + .route("/", get(homepage)) } diff --git a/src/routes/mod.rs b/src/routes/mod.rs index d815be8..042fc30 100644 --- a/src/routes/mod.rs +++ b/src/routes/mod.rs @@ -1,45 +1,52 @@ -// Module declarations for different route handlers pub mod homepage; -pub mod get_todos; -pub mod get_users; -pub mod get_apikeys; -pub mod get_usage; -pub mod post_todos; -pub mod post_users; -pub mod post_apikeys; -pub mod rotate_apikeys; -pub mod get_health; -pub mod delete_users; -pub mod delete_todos; -pub mod delete_apikeys; -pub mod protected; +pub mod apikey; +pub mod auth; +pub mod health; +pub mod todo; +pub mod usage; +pub mod user; -// Re-exporting modules to make their contents available at this level -pub use homepage::*; -pub use get_todos::*; -pub use get_users::*; -pub use get_apikeys::*; -pub use get_usage::*; -pub use rotate_apikeys::*; -pub use post_todos::*; -pub use post_users::*; -pub use post_apikeys::*; -pub use get_health::*; -pub use delete_users::*; -pub use delete_todos::*; -pub use delete_apikeys::*; -pub use protected::*; - -use axum::{ - Router, - routing::{get, post, delete} -}; +use axum::Router; use sqlx::PgPool; use tower_http::trace::TraceLayer; -use utoipa::OpenApi; +use utoipa::openapi::security::{SecurityScheme, HttpBuilder, HttpAuthScheme}; +use utoipa::{Modify, OpenApi}; use utoipa_swagger_ui::SwaggerUi; -use crate::middlewares::auth::{sign_in, authorize}; +pub mod handlers { + pub use crate::handlers::*; +} + +pub mod models { + pub use crate::models::*; +} + +use self::{ + todo::create_todo_routes, + user::create_user_routes, + apikey::create_apikey_routes, + usage::create_usage_routes, + auth::create_auth_routes, + homepage::create_homepage_route, + health::create_health_route, +}; + +struct SecurityAddon; +impl Modify for SecurityAddon { + fn modify(&self, openapi: &mut utoipa::openapi::OpenApi) { + let components = openapi.components.get_or_insert_with(Default::default); + components.add_security_scheme( + "jwt_token", + SecurityScheme::Http( + HttpBuilder::new() + .scheme(HttpAuthScheme::Bearer) + .bearer_format("JWT") + .description(Some("Use JWT token obtained from /signin endpoint")) + .build() + ) + ); + } +} // Define the OpenAPI documentation structure #[derive(OpenApi)] @@ -57,33 +64,54 @@ use crate::middlewares::auth::{sign_in, authorize}; ) ), paths( - get_all_users, - get_users_by_id, - get_all_apikeys, - get_apikeys_by_id, - get_usage_last_day, - get_usage_last_week, - get_all_todos, - get_todos_by_id, - get_health, - post_user, - post_apikey, - post_todo, - rotate_apikey, - delete_user_by_id, - delete_apikey_by_id, - delete_todo_by_id, - protected, - //sign_in, // Add sign_in path + handlers::get_users::get_all_users, + handlers::get_users:: get_users_by_id, + handlers::get_apikeys::get_all_apikeys, + handlers::get_apikeys::get_apikeys_by_id, + handlers::get_usage::get_usage_last_day, + handlers::get_usage::get_usage_last_week, + handlers::get_todos::get_all_todos, + handlers::get_todos::get_todos_by_id, + handlers::get_health::get_health, + handlers::post_users::post_user, + handlers::post_apikeys::post_apikey, + handlers::post_todos::post_todo, + handlers::rotate_apikeys::rotate_apikey, + handlers::delete_users::delete_user_by_id, + handlers::delete_apikeys::delete_apikey_by_id, + handlers::delete_todos::delete_todo_by_id, + handlers::protected::protected, + handlers::signin::signin, ), components( schemas( - UserResponse, - // ApiKeyResponse, - // ApiKeyByIDResponse, - // Todo, - // SignInData, - // ...add other schemas as needed... + models::apikey::ApiKey, + models::apikey::ApiKeyInsertBody, + models::apikey::ApiKeyInsertResponse, + models::apikey::ApiKeyResponse, + models::apikey::ApiKeyByIDResponse, + models::apikey::ApiKeyGetActiveForUserResponse, + models::apikey::ApiKeyByUserIDResponse, + models::apikey::ApiKeyNewBody, + models::apikey::ApiKeyRotateResponse, + models::apikey::ApiKeyRotateResponseInfo, + models::apikey::ApiKeyRotateBody, + models::auth::Claims, + models::documentation::SuccessResponse, + models::documentation::ErrorResponse, + models::health::HealthResponse, + models::health::CpuUsage, + models::health::DatabaseStatus, + models::health::DiskUsage, + models::health::MemoryStatus, + models::role::Role, + models::todo::Todo, + models::usage::UsageResponseLastDay, + models::usage::UsageResponseLastWeek, + models::user::User, + models::user::UserGetResponse, + models::user::UserInsertBody, + models::user::UserInsertResponse ) ), tags( @@ -98,94 +126,24 @@ struct ApiDoc; /// Function to create and configure all routes pub fn create_routes(database_connection: PgPool) -> Router { - // Authentication routes - let auth_routes = Router::new() - .route("/signin", post(sign_in)) - .route("/protected", get(protected).route_layer(axum::middleware::from_fn(|req, next| { - let allowed_roles = vec![1, 2]; - authorize(req, next, allowed_roles) - }))); - - // User-related routes - let user_routes = Router::new() - .route("/all", get(get_all_users).layer(axum::middleware::from_fn(|req, next| { - let allowed_roles = vec![2]; - authorize(req, next, allowed_roles)}))) - .route("/new", post(post_user).layer(axum::middleware::from_fn(|req, next| { - let allowed_roles = vec![2]; - authorize(req, next, allowed_roles) - }))) - .route("/{id}", get(get_users_by_id).layer(axum::middleware::from_fn(|req, next| { - let allowed_roles = vec![2]; - authorize(req, next, allowed_roles)}))) - .route("/{id}", delete(delete_user_by_id).layer(axum::middleware::from_fn(|req, next| { - let allowed_roles = vec![2]; - authorize(req, next, allowed_roles)}))); - - // API key-related routes - let apikey_routes = Router::new() - .route("/all", get(get_all_apikeys).layer(axum::middleware::from_fn(|req, next| { - let allowed_roles = vec![1,2]; - authorize(req, next, allowed_roles)}))) - .route("/new", post(post_apikey).layer(axum::middleware::from_fn(|req, next| { - let allowed_roles = vec![1,2]; - authorize(req, next, allowed_roles) - }))) - .route("/{id}", get(get_apikeys_by_id).layer(axum::middleware::from_fn(|req, next| { - let allowed_roles = vec![1,2]; - authorize(req, next, allowed_roles)}))) - .route("/{id}", delete(delete_apikey_by_id).layer(axum::middleware::from_fn(|req, next| { - let allowed_roles = vec![1,2]; - authorize(req, next, allowed_roles)}))) - .route("/rotate/{id}", post(rotate_apikey).layer(axum::middleware::from_fn(|req, next| { - let allowed_roles = vec![1,2]; - authorize(req, next, allowed_roles)}))); - - // Usage related routes - let usage_routes = Router::new() - .route("/lastday", get(get_usage_last_day).layer(axum::middleware::from_fn(|req, next| { - let allowed_roles = vec![1,2]; - authorize(req, next, allowed_roles)}))) - .route("/lastweek", get(get_usage_last_week).layer(axum::middleware::from_fn(|req, next| { - let allowed_roles = vec![1,2]; - authorize(req, next, allowed_roles) - }))); - - // Todo-related routes - let todo_routes = Router::new() - .route("/all", get(get_all_todos).layer(axum::middleware::from_fn(|req, next| { - let allowed_roles = vec![1, 2]; - authorize(req, next, allowed_roles)}))) - .route("/new", post(post_todo).layer(axum::middleware::from_fn(|req, next| { - let allowed_roles = vec![1, 2]; - authorize(req, next, allowed_roles) - }))) - .route("/{id}", get(get_todos_by_id).layer(axum::middleware::from_fn(|req, next| { - let allowed_roles = vec![1, 2]; - authorize(req, next, allowed_roles)}))) - .route("/{id}", delete(delete_todo_by_id).layer(axum::middleware::from_fn(|req, next| { - let allowed_roles = vec![1,2]; - authorize(req, next, allowed_roles)}))); - - // Documentation: // Create OpenAPI specification let openapi = ApiDoc::openapi(); // Create Swagger UI - let swagger_ui = SwaggerUi::new("/swagger-ui") + let swagger_ui = SwaggerUi::new("/swagger") .url("/openapi.json", openapi.clone()); // Combine all routes and add middleware Router::new() - .route("/", get(homepage)) - .merge(auth_routes) // Add authentication routes + .merge(create_homepage_route()) + .merge(create_auth_routes()) .merge(swagger_ui) - .nest("/users", user_routes) // Add user routes under /users - .nest("/apikeys", apikey_routes) // Add API key routes under /apikeys - .nest("/usage", usage_routes) // Add usage routes under /usage - .nest("/todos", todo_routes) // Add todo routes under /todos - .route("/health", get(get_health)) // Add health check route - .layer(axum::Extension(database_connection.clone())) // Add database connection to all routes - .with_state(database_connection) // Add database connection as state - .layer(TraceLayer::new_for_http()) // Add tracing middleware -} \ No newline at end of file + .nest("/users", create_user_routes()) + .nest("/apikeys", create_apikey_routes()) + .nest("/usage", create_usage_routes()) + .nest("/todos", create_todo_routes()) + .merge(create_health_route()) + .layer(axum::Extension(database_connection.clone())) + .with_state(database_connection) + .layer(TraceLayer::new_for_http()) +} diff --git a/src/routes/post_users.rs b/src/routes/post_users.rs deleted file mode 100644 index 68768d8..0000000 --- a/src/routes/post_users.rs +++ /dev/null @@ -1,98 +0,0 @@ -use axum::{extract::State, Json, response::IntoResponse}; -use axum::http::StatusCode; -use serde::{Deserialize, Serialize}; -use serde_json::json; -use sqlx::postgres::PgPool; -use tracing::instrument; -use uuid::Uuid; -use utoipa::ToSchema; -use validator::Validate; - -use crate::handlers::validate::{validate_password, validate_username}; -use crate::middlewares::auth::{hash_password, generate_totp_secret}; - -// Define the request body structure -#[derive(Deserialize, Validate, ToSchema)] -pub struct UserBody { - #[validate(length(min = 3, max = 50), custom(function = "validate_username"))] - pub username: String, - #[validate(email)] - pub email: String, - #[validate(custom(function = "validate_password"))] - pub password: String, - pub totp: Option, -} - -// Define the response body structure -#[derive(Serialize, ToSchema)] -pub struct UserResponse { - pub id: Uuid, - pub username: String, - pub email: String, - pub totp_secret: Option, - pub role_level: i32, -} - -// Define the API endpoint -#[utoipa::path( - post, - path = "/users", - tag = "user", - request_body = UserBody, - responses( - (status = 200, description = "User created successfully", body = UserResponse), - (status = 400, description = "Validation error", body = String), - (status = 500, description = "Internal server error", body = String) - ) -)] -#[instrument(skip(pool, user))] -pub async fn post_user( - State(pool): State, - Json(user): Json, -) -> Result)> { - // Validate input - if let Err(errors) = user.validate() { - let error_messages: Vec = errors - .field_errors() - .iter() - .flat_map(|(_, errors)| errors.iter().map(|e| e.message.clone().unwrap_or_default().to_string())) - .collect(); - return Err(( - StatusCode::BAD_REQUEST, - Json(json!({ "error": error_messages.join(", ") })) - )); - } - - // Hash the password before saving it - let hashed_password = hash_password(&user.password) - .map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({ "error": "Failed to hash password." }))))?; - - // Generate TOTP secret if totp is Some("true") - let totp_secret = if user.totp.as_deref() == Some("true") { - Some(generate_totp_secret()) - } else { - None - }; - - let row = sqlx::query!( - "INSERT INTO users (username, email, password_hash, totp_secret, role_level) - VALUES ($1, $2, $3, $4, $5) - RETURNING id, username, email, password_hash, totp_secret, role_level", - user.username, - user.email, - hashed_password, - totp_secret, - 1, // Default role_level - ) - .fetch_one(&pool) - .await - .map_err(|_err| (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({ "error": "Could not create the user."}))))?; - - Ok(Json(UserResponse { - id: row.id, - username: row.username, - email: row.email, - totp_secret: row.totp_secret, - role_level: row.role_level, - })) -} diff --git a/src/routes/rotate_apikeys.rs b/src/routes/rotate_apikeys.rs deleted file mode 100644 index 0e7d36e..0000000 --- a/src/routes/rotate_apikeys.rs +++ /dev/null @@ -1,190 +0,0 @@ -use axum::{extract::{Extension, Path, State}, Json}; -use axum::http::StatusCode; -use axum::response::IntoResponse; -use chrono::{Duration, NaiveDate, Utc}; -use serde::{Deserialize, Serialize}; -use serde_json::json; -use sqlx::postgres::PgPool; -use tracing::instrument; -use utoipa::ToSchema; -use uuid::Uuid; -use validator::Validate; - -use crate::handlers::validate::validate_future_date; -use crate::middlewares::auth::{generate_api_key, hash_password}; -use crate::models::user::User; - -#[derive(Deserialize, Validate, ToSchema)] -pub struct ApiKeyBody { - #[validate(length(min = 0, max = 50))] - pub description: Option, - #[validate(custom(function = "validate_future_date"))] - pub expiration_date: Option, -} - - -#[derive(Serialize, ToSchema)] -pub struct ApiKeyResponse { - pub id: Uuid, - pub description: Option, -} - -#[utoipa::path( - post, - path = "/apikeys/rotate/{id}", - tag = "apikey", - request_body = ApiKeyBody, - responses( - (status = 200, description = "API key rotated successfully", body = ApiKeyResponse), - (status = 400, description = "Validation error", body = String), - (status = 404, description = "API key not found", body = String), - (status = 500, description = "Internal server error", body = String) - ), - params( - ("id" = String, Path, description = "API key identifier") - ) -)] -#[instrument(skip(pool, user, apikeybody))] -pub async fn rotate_apikey( - State(pool): State, - Extension(user): Extension, - Path(id): Path, - Json(apikeybody): Json -) -> impl IntoResponse { - // Validate input - if let Err(errors) = apikeybody.validate() { - let error_messages: Vec = errors - .field_errors() - .iter() - .flat_map(|(_, errors)| errors.iter().map(|e| e.message.clone().unwrap_or_default().to_string())) - .collect(); - return Err(( - StatusCode::BAD_REQUEST, - Json(json!({ "error": error_messages.join(", ") })) - )); - } - - // Validate UUID format - let uuid = match Uuid::parse_str(&id) { - Ok(uuid) => uuid, - Err(_) => return Err((StatusCode::BAD_REQUEST, - Json(json!({ "error": "Invalid API key identifier format" })))), - }; - - // Verify ownership of the API key - let existing_key = sqlx::query_as!(ApiKeyResponse, - "SELECT id, description FROM apikeys - WHERE user_id = $1 AND id = $2 AND disabled = FALSE", - user.id, - uuid - ) - .fetch_optional(&pool) - .await - .map_err(|e| { - tracing::error!("Database error: {}", e); - (StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({ "error": "Internal server error" }))) - })?; - - let existing_key = existing_key.ok_or_else(|| - (StatusCode::NOT_FOUND, - Json(json!({ "error": "API key not found or already disabled" }))) - )?; - - // Validate expiration date format - let expiration_date = match &apikeybody.expiration_date { - Some(date_str) => NaiveDate::parse_from_str(date_str, "%Y-%m-%d") - .map_err(|_| (StatusCode::BAD_REQUEST, - Json(json!({ "error": "Invalid expiration date format. Use YYYY-MM-DD" }))))?, - None => (Utc::now() + Duration::days(365 * 2)).naive_utc().date(), - }; - - // Validate expiration date is in the future - if expiration_date <= Utc::now().naive_utc().date() { - return Err((StatusCode::BAD_REQUEST, - Json(json!({ "error": "Expiration date must be in the future" })))); - } - - // Generate new secure API key - let api_key = generate_api_key(); - let key_hash = hash_password(&api_key) - .map_err(|e| { - tracing::error!("Hashing error: {}", e); - (StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({ "error": "Internal server error" }))) - })?; - - // Begin transaction - let mut tx = pool.begin().await.map_err(|e| { - tracing::error!("Transaction error: {}", e); - (StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({ "error": "Internal server error" }))) - })?; - - // Disable old key - let disable_result = sqlx::query!( - "UPDATE apikeys SET - disabled = TRUE, - expiration_date = CURRENT_DATE + INTERVAL '1 day' - WHERE id = $1 AND user_id = $2", - uuid, - user.id - ) - .execute(&mut *tx) - .await - .map_err(|e| { - tracing::error!("Database error: {}", e); - (StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({ "error": "Internal server error" }))) - })?; - - if disable_result.rows_affected() == 0 { - return Err((StatusCode::NOT_FOUND, - Json(json!({ "error": "API key not found or already disabled" })))); - } - - // Create new key with automatic description - let description = apikeybody.description.unwrap_or_else(|| - format!("Rotated from key {} - {}", - existing_key.id, - Utc::now().format("%Y-%m-%d")) - ); - - let new_key = sqlx::query!( - "INSERT INTO apikeys - (key_hash, description, expiration_date, user_id, access_read, access_modify) - VALUES ($1, $2, $3, $4, $5, $6) - RETURNING id, description, expiration_date", - key_hash, - description, - expiration_date, - user.id, - true, // Default read access - false // Default no modify access - ) - .fetch_one(&mut *tx) - .await - .map_err(|e| { - tracing::error!("Database error: {}", e); - (StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({ "error": "Internal server error" }))) - })?; - - tx.commit().await.map_err(|e| { - tracing::error!("Transaction commit error: {}", e); - (StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({ "error": "Internal server error" }))) - })?; - - Ok(Json(json!({ - "id": new_key.id, - "api_key": api_key, - "description": new_key.description, - "expiration_date": new_key.expiration_date, - "warning": "Store this key securely - it won't be shown again", - "rotation_info": { - "original_key": existing_key.id, - "disabled_at": Utc::now().to_rfc3339() - } - }))) -} \ No newline at end of file diff --git a/src/routes/todo.rs b/src/routes/todo.rs new file mode 100644 index 0000000..72b07dd --- /dev/null +++ b/src/routes/todo.rs @@ -0,0 +1,26 @@ +use axum::{ + Router, + routing::{get, post, delete}, + middleware::from_fn, +}; +use sqlx::PgPool; + +use crate::middlewares::auth::authorize; +use crate::handlers::{get_todos::{get_all_todos, get_todos_by_id}, post_todos::post_todo, delete_todos::delete_todo_by_id}; + +pub fn create_todo_routes() -> Router { + Router::new() + .route("/all", get(get_all_todos).layer(from_fn(|req, next| { + let allowed_roles = vec![1, 2]; + authorize(req, next, allowed_roles)}))) + .route("/new", post(post_todo).layer(from_fn(|req, next| { + let allowed_roles = vec![1, 2]; + authorize(req, next, allowed_roles) + }))) + .route("/{id}", get(get_todos_by_id).layer(from_fn(|req, next| { + let allowed_roles = vec![1, 2]; + authorize(req, next, allowed_roles)}))) + .route("/{id}", delete(delete_todo_by_id).layer(from_fn(|req, next| { + let allowed_roles = vec![1,2]; + authorize(req, next, allowed_roles)}))) +} diff --git a/src/routes/usage.rs b/src/routes/usage.rs new file mode 100644 index 0000000..0f1c448 --- /dev/null +++ b/src/routes/usage.rs @@ -0,0 +1,19 @@ +use axum::{ + Router, + routing::get, + middleware::from_fn, +}; +use sqlx::PgPool; + +use crate::middlewares::auth::authorize; +use crate::handlers::get_usage::{get_usage_last_day, get_usage_last_week}; + +pub fn create_usage_routes() -> Router { + Router::new() + .route("/lastday", get(get_usage_last_day).layer(from_fn(|req, next| { + let allowed_roles = vec![1,2]; + authorize(req, next, allowed_roles)}))) + .route("/lastweek", get(get_usage_last_week).layer(from_fn(|req, next| { + let allowed_roles = vec![1,2]; + authorize(req, next, allowed_roles)}))) +} diff --git a/src/routes/user.rs b/src/routes/user.rs new file mode 100644 index 0000000..500068a --- /dev/null +++ b/src/routes/user.rs @@ -0,0 +1,26 @@ +use axum::{ + Router, + routing::{get, post, delete}, + middleware::from_fn, +}; +use sqlx::PgPool; + +use crate::middlewares::auth::authorize; +use crate::handlers::{get_users::{get_all_users, get_users_by_id}, post_users::post_user, delete_users::delete_user_by_id}; + +pub fn create_user_routes() -> Router { + Router::new() + .route("/all", get(get_all_users).layer(from_fn(|req, next| { + let allowed_roles = vec![2]; + authorize(req, next, allowed_roles)}))) + .route("/new", post(post_user).layer(from_fn(|req, next| { + let allowed_roles = vec![2]; + authorize(req, next, allowed_roles) + }))) + .route("/{id}", get(get_users_by_id).layer(from_fn(|req, next| { + let allowed_roles = vec![2]; + authorize(req, next, allowed_roles)}))) + .route("/{id}", delete(delete_user_by_id).layer(from_fn(|req, next| { + let allowed_roles = vec![2]; + authorize(req, next, allowed_roles)}))) +} diff --git a/src/utils/README.md b/src/utils/README.md new file mode 100644 index 0000000..04a56a6 --- /dev/null +++ b/src/utils/README.md @@ -0,0 +1,9 @@ +# Utils + +The `/src/utils` folder includes functions for tasks like validating user input, handling JWT authentication, generating secure passwords, and working with time-based one-time passwords (TOTP). These utilities are used throughout the application to streamline common functionality and reduce redundancy. + +## Contributing +Add new route files, update existing routes, or enhance the middleware and handlers. Document any changes for clarity. + +## License +This project is licensed under the MIT License. \ No newline at end of file diff --git a/src/utils/auth.rs b/src/utils/auth.rs new file mode 100644 index 0000000..bc385f7 --- /dev/null +++ b/src/utils/auth.rs @@ -0,0 +1,186 @@ +use std::{collections::HashSet, env}; +use axum::http::StatusCode; +use argon2::{ + password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString, Error}, + Argon2, Params, Version, +}; +use chrono::{Duration, Utc}; +use jsonwebtoken::{decode, encode, DecodingKey, EncodingKey, Header, TokenData, Validation}; +use totp_rs::{Secret, TOTP}; +use rand::{rngs::OsRng, Rng}; +use tracing::{warn, error, instrument}; +use tokio::task; +use moka::future::Cache; +use lazy_static::lazy_static; + +use crate::models::auth::Claims; + +// Standard library imports for working with HTTP, environment variables, and other necessary utilities + +// Importing necessary libraries for password hashing, JWT handling, and date/time management + +// Cache for storing successful password verifications +lazy_static! { + static ref PASSWORD_CACHE: Cache = Cache::builder() + .time_to_live(std::time::Duration::from_secs(300)) // 5 minutes + .build(); +} + +#[instrument(skip(password, hash))] +pub async fn verify_hash(password: &str, hash: &str) -> Result { + // Check cache first + if let Some(result) = PASSWORD_CACHE.get(password).await { + return Ok(result); + } + + let password_owned = password.to_string(); + let hash_owned = hash.to_string(); + let password_clone = password_owned.clone(); + + let result = task::spawn_blocking(move || { + let parsed_hash = PasswordHash::new(&hash_owned)?; + + Argon2::default() + .verify_password(password_owned.as_bytes(), &parsed_hash) + .map(|_| true) // Remove the map_err conversion + }) + .await + .map_err(|_| argon2::Error::AlgorithmInvalid)??; // Keep double question mark + + if result { + PASSWORD_CACHE.insert(password_clone, true).await; + } + + Ok(result) +} + +// Function to hash a password using Argon2 and a salt retrieved from the environment variables +#[instrument(skip(password))] +pub fn hash_password(password: &str) -> Result { + // Generate random salt + let salt = SaltString::generate(&mut OsRng); + + // Configure Argon2id with recommended parameters + let argon2 = Argon2::new( + argon2::Algorithm::Argon2id, // Explicitly use Argon2id variant + Version::V0x13, // Latest version + Params::new( // OWASP-recommended parameters + 15360, // 15 MiB memory cost + 2, // 2 iterations + 1, // 1 parallelism + None // Default output length + )? + ); + + // Hash password with configured parameters + let password_hash = argon2.hash_password(password.as_bytes(), &salt)?.to_string(); + Ok(password_hash) +} + +#[instrument] +pub fn generate_totp_secret() -> String { + let totp = TOTP::new( + totp_rs::Algorithm::SHA512, + 8, + 1, + 30, + Secret::generate_secret().to_bytes().unwrap(), + ).expect("Failed to create TOTP."); + + totp.generate_current().unwrap() +} + +#[instrument] +pub fn generate_api_key() -> String { + let mut rng = rand::thread_rng(); + (0..5) + .map(|_| { + (0..8) + .map(|_| format!("{:02x}", rng.gen::())) + .collect::() + }) + .collect::>() + .join("-") +} + +// Function to encode a JWT token for the given email address +#[instrument(skip(email))] +pub fn encode_jwt(email: String) -> Result { + // Load secret key from environment variable for better security + let secret_key = env::var("JWT_SECRET_KEY") + .map_err(|_| { + error!("JWT_SECRET_KEY not set in environment variables"); + StatusCode::INTERNAL_SERVER_ERROR + })?; + + let now = Utc::now(); + let expire = Duration::hours(24); + let exp: usize = (now + expire).timestamp() as usize; + let iat: usize = now.timestamp() as usize; + + let claim = Claims { + sub: email.clone(), + iat, + exp, + iss: "your_issuer".to_string(), // Add issuer if needed + aud: "your_audience".to_string(), // Add audience if needed + }; + + // Use a secure HMAC algorithm (e.g., HS256) for signing the token + encode( + &Header::new(jsonwebtoken::Algorithm::HS256), + &claim, + &EncodingKey::from_secret(secret_key.as_ref()), + ) + .map_err(|e| { + error!("Failed to encode JWT: {:?}", e); + StatusCode::INTERNAL_SERVER_ERROR + }) +} + +// Function to decode a JWT token and extract the claims +#[instrument(skip(jwt))] +pub fn decode_jwt(jwt: String) -> Result, StatusCode> { + // Load secret key from environment variable for better security + let secret_key = env::var("JWT_SECRET_KEY") + .map_err(|_| { + error!("JWT_SECRET_KEY not set in environment variables"); + StatusCode::INTERNAL_SERVER_ERROR + })?; + + // Set up validation rules (e.g., check if token has expired, is from a valid issuer, etc.) + let mut validation = Validation::default(); + + // Use a HashSet for the audience and issuer validation + let mut audience_set = HashSet::new(); + audience_set.insert("your_audience".to_string()); + + let mut issuer_set = HashSet::new(); + issuer_set.insert("your_issuer".to_string()); + + // Set up the validation with the HashSet for audience and issuer + validation.aud = Some(audience_set); + validation.iss = Some(issuer_set); + + // Decode the JWT and extract the claims + decode::( + &jwt, + &DecodingKey::from_secret(secret_key.as_ref()), + &validation, + ) + .map_err(|e| { + warn!("Failed to decode JWT: {:?}", e); + StatusCode::UNAUTHORIZED + }) +} + +// Function to verify password asynchronously +#[instrument(skip(password, hash))] +pub async fn verify_password(password: String, hash: String) -> Result { + verify_hash(&password, &hash).await +} + +#[instrument(skip(password, hash))] +pub async fn verify_api_key(password: String, hash: String) -> Result { + verify_hash(&password, &hash).await +} \ No newline at end of file diff --git a/src/utils/mod.rs b/src/utils/mod.rs new file mode 100644 index 0000000..12bbbfb --- /dev/null +++ b/src/utils/mod.rs @@ -0,0 +1,2 @@ +pub mod validate; +pub mod auth; \ No newline at end of file diff --git a/src/handlers/validate.rs b/src/utils/validate.rs similarity index 100% rename from src/handlers/validate.rs rename to src/utils/validate.rs