Compare commits

..

15 Commits

Author SHA1 Message Date
fbd53f821f Merge branch 'main' of https://git.gabens.lol/thatnewyorker/employee-tracking-backend 2025-08-20 21:14:24 -04:00
f6d0d9b5ec Update MongoDB URI to use Docker hostname 2025-08-20 21:14:23 -04:00
d3feeef996 Rename employee-tracking-backend to purenotify_backend and add OFFLINE
mode

- Rename crate and update dependencies to newer versions - Add OFFLINE
runtime mode for loopback-only server without DB - Refactor state
handling with typed Axum routers and state injection - Rename mongodb
module to mongo and fix imports accordingly - Update Cargo.lock with
updated and removed dependencies - Remove no-auth feature and related
code - Simplify health and user routes to generic state parameter Rename
backend to purenotify_backend and add OFFLINE mode

Use OFFLINE env var to run server without DB, binding to loopback only.
Rename mongodb module to mongo and update dependencies. Update
dependencies and fix router state handling.
2025-08-20 20:53:22 -04:00
e79d16b87f implement Password utils 2025-08-17 17:07:07 -04:00
73e4701daa implement MongoDB user 2025-08-17 16:39:56 -04:00
26832acc31 implement MongoDB user repository with async support 2025-08-17 16:36:07 -04:00
f004dcf0c9 dockercompose file fix 2025-08-16 07:00:42 -04:00
5919966954 Add user registration endpoint 2025-08-16 06:56:21 -04:00
ed612bd717 feat: add MongoDB support with connection pooling and repository pattern 2025-08-16 06:38:30 -04:00
8a05f4edac add user endpoint 2025-08-15 18:38:57 -04:00
34a6f211de small fix 2025-08-15 18:09:09 -04:00
611301dfda Add a better structure in the project 2025-08-15 18:02:30 -04:00
985443ca91 docs: Add documentation for Crypto and JWT modules
This commit adds detailed documentation for the  and  modules.

The new markdown files ( and ) provide a comprehensive overview of each module's functionality, including:
- Dependencies and setup.
- Error handling strategies.
- Detailed descriptions of structs and functions.
- Practical usage examples for key operations.
2025-08-15 17:09:25 -04:00
29dbca70c4 feat: Implement Crypto and JWT utility modules in Rust
This commit introduces a Rust implementation of the cryptographic and JWT handling utilities, translated from the original TypeScript codebase.

The new `CryptoUtils` module provides core cryptographic functionalities, including:
- AES-256-CBC encryption and decryption.
- Generation, saving, and loading of RSA-4096 key pairs.
- It leverages the `openssl`, `sha2`, and `hex` crates.

The new `JWTUtils` module handles JSON Web Tokens manually, without relying on the `jsonwebtoken` crate. Its features include:
- Creating and signing JWTs using RSA-SHA256.
- Verifying the signature and expiration of tokens.
- Decoding tokens and validating claims.
- This implementation uses the `openssl` crate for signing and verification, ensuring alignment with the `CryptoUtils` module.

Additionally, minor compiler warnings, such as unused imports in `main.rs` and `config.rs`, have been resolved.
2025-08-15 17:03:23 -04:00
3d7da03bcf Initialize project with basic backend setup 2025-08-15 16:30:26 -04:00
31 changed files with 2856 additions and 716 deletions

4
.env
View File

@@ -1,5 +1,5 @@
// .env.example (copy to .env for local use)
# .env.example (copy to .env for local use)
RUST_LOG=info
BIND_ADDRESS=127.0.0.1:3000
# DATABASE_URL=postgres://gerard@localhost/db (not used yet)
MONGO_URI=mongodb://mongodb:27017

1675
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,14 +1,19 @@
[package]
name = "employee-tracking-backend"
name = "purenotify_backend"
version = "0.1.0"
edition = "2024"
[dependencies]
axum = "0.8.4"
bson = { version = "2.15.0", features = ["chrono-0_4"] }
chrono = { version = "0.4.41", features = ["serde"] }
sha2 = "0.10.9"
rand = "0.9.2"
regex = "1.11.1"
dotenvy = "0.15.7"
mongodb = "3.2.4"
serde = { version = "1.0.219", features = ["derive"] }
serde_json = "1.0.142"
sqlx = { version = "0.8.6", features = ["runtime-tokio", "tls-native-tls"] }
serde_json = "1.0.143"
tokio = { version = "1.47.1", features = ["full", "rt-multi-thread", "signal"] }
tower-http = { version = "0.6.6", features = ["trace"] }
tracing = "0.1.41"

51
Dockerfile Normal file
View File

@@ -0,0 +1,51 @@
# Use the official Rust image as a base
FROM rust:bookworm as builder
# Set the working directory
WORKDIR /app
# Install system dependencies
RUN apt-get update && apt-get install -y \
pkg-config \
libssl-dev \
# Add any other dependencies required by your project
# For example, if you use postgres/mysql/sqlite, you might need libpq-dev, libmysqlclient-dev, libsqlite3-dev
&& rm -rf /var/lib/apt/lists/*
# Copy the Cargo.toml and Cargo.lock first to leverage Docker cache
# This layer only rebuilds if dependencies change
COPY Cargo.toml Cargo.lock ./
# Create a dummy src directory and main.rs to build dependencies
# This caches the dependency build
RUN mkdir -p src && echo "fn main() {println!(\"hello world\");}" > src/main.rs
# Build dependencies
RUN cargo build --release && rm -rf src
# Copy the actual source code
COPY . .
# Build the release binary
RUN cargo build --release
# --- Start a new stage for a smaller final image ---
FROM debian:bookworm-slim
# Set the working directory
WORKDIR /app
# Install runtime dependencies if any
# For example, if your Rust application dynamically links to OpenSSL, you might need libssl3
RUN apt-get update && apt-get install -y \
libssl3 \
# Add any other runtime dependencies here
&& rm -rf /var/lib/apt/lists/*
# Copy the built binary from the builder stage
COPY --from=builder /app/target/release/employee-tracking-backend .
# Expose the port your application listens on
EXPOSE 3000
# Set the entrypoint command to run your application
CMD ["./employee-tracking-backend"]

174
README.md
View File

@@ -0,0 +1,174 @@
# PureNotify Backend
This is the backend service for the PureNotify application, written in Rust. It's built to be a high-performance, reliable, and scalable foundation for sending notifications.
## 🚀 Features
- **Asynchronous:** Built with `tokio` and `axum` for non-blocking I/O and high concurrency.
- **Configurable:** Easily configure the application using environment variables.
- **Logging:** Integrated structured logging with `tracing` for better observability.
- **Graceful Shutdown:** Ensures the server shuts down cleanly without dropping active connections.
- **Health Check:** A dedicated endpoint to monitor the service's health.
## 🛠️ Technologies Used
- **[Rust](https://www.rust-lang.org/)**: The core programming language.
- **[Axum](https://github.com/tokio-rs/axum)**: A web application framework that focuses on ergonomics and modularity.
- **[Tokio](https://tokio.rs/)**: An asynchronous runtime for the Rust programming language.
- **[Serde](https://serde.rs/)**: A framework for serializing and deserializing Rust data structures efficiently.
- **[Dotenvy](https://github.com/dotenv-rs/dotenv)**: For loading environment variables from a `.env` file.
- **[Tracing](https://github.com/tokio-rs/tracing)**: A framework for instrumenting Rust programs to collect structured, event-based diagnostic information.
## ⚙️ Getting Started
Follow these instructions to get a copy of the project up and running on your local machine for development and testing purposes.
### Prerequisites
You need to have the Rust toolchain installed on your system. If you don't have it, you can install it from [rustup.rs](https://rustup.rs/).
```sh
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
```
### Installation & Running
1. **Clone the repository:**
```sh
git clone https://github.com/your-username/purenotify_backend.git
cd purenotify_backend
```
2. **Create a `.env` file:**
Copy the example environment file to create your own local configuration.
```sh
cp .env.example .env
```
You can modify the `.env` file to change the server's configuration.
3. **Build the project:**
```sh
cargo build
```
4. **Run the application database with Docker (Recommended):**
If you have Docker installed
5. **Run the application (Manual with Docker):**
First, start the MongoDB container:
```sh
docker run \
--name mongodb \
-p 27017:27017 \
-e MONGO_INITDB_ROOT_USERNAME=admin \
-e MONGO_INITDB_ROOT_PASSWORD=password123 \
-e MONGO_INITDB_DATABASE=purenotify \
-v mongodb_data:/data/db \
mongo:latest
```
(Note: The `purenotify` database name here should match `DATABASE_NAME` in your `.env` or `config.rs` for the backend to connect correctly. The `MONGODB_URI` for the backend would be `mongodb://127.0.0.1:27017` or `mongodb://localhost:27017`.)
Then, run the Rust application:
For development, you can run the project directly with `cargo run`:
```sh
cargo run
```
For a release build, run:
```sh
cargo run --release
```
The server will start, and you should see log output in your terminal indicating that it's running.
## 🔧 Configuration
The application is configured using environment variables. These can be set in a `.env` file in the project root or directly in your shell.
- `BIND_ADDRESS`: The IP address and port the server should listen on.
- **Default:** `127.0.0.1:3000`
- `RUST_LOG`: Controls the log level for the application.
- **Example:** `RUST_LOG=info,purenotify_backend=debug` will set the default log level to `info` and the log level for this crate to `debug`.
- **Default:** Reads from the environment; if not set, logging may be minimal.
## API Endpoints
Here are the available API endpoints for the service.
### Health Check
- **Endpoint:** `/health`
- **Method:** `GET`
- **Description:** Used to verify that the service is running and healthy.
- **Success Response:**
- **Code:** `200 OK`
- **Content:** `{
"message": "health check successful",
"data": {},
"success": true,
"error": false
}`
#### Example Usage
You can use `curl` to check the health of the service:
```sh
curl http://127.0.0.1:3000/health
```
**Expected Output:**
```json
{
"message": "health check successful",
"data": {},
"success": true,
"error": false
}
```
## 📂 Project Structure
The project follows a standard Rust project layout. The main application logic is located in the `src/` directory.
```
src/
├── main.rs # Application entry point, server setup
├── config.rs # Configuration management
├── handlers/ # Business logic for handling requests
│ └── health/
│ └── health.rs
├── routes/ # API route definitions
│ └── health/
│ └── health.rs
└── utils/ # Utility functions and shared modules
```
- `main.rs`: Initializes the server, logging, configuration, and wires up the routes.
- `config.rs`: Defines the `Config` struct and handles loading configuration from the environment.
- `handlers/`: Contains the core logic for each API endpoint. Each handler is responsible for processing a request and returning a response.
- `routes/`: Defines the Axum `Router` for different parts of the application. These modules map URL paths to their corresponding handlers.
- `utils/`: A place for helper functions or modules that are used across different parts of the application.
## 🤝 Contributing
Contributions are welcome! If you'd like to contribute, please fork the repository and use a feature branch. Pull requests are warmly welcome.
1. Fork the repository.
2. Create your feature branch (`git checkout -b feature/fooBar`).
3. Commit your changes (`git commit -am 'Add some fooBar'`).
4. Push to the branch (`git push origin feature/fooBar`).
5. Create a new Pull Request.
## 📄 License
This project is licensed under the MIT License - see the [LICENSE.md](LICENSE.md) file for details.

38
docker-compose.yml Normal file
View File

@@ -0,0 +1,38 @@
version: "3.8"
services:
purenotify_backend:
build:
context: .
dockerfile: Dockerfile
ports:
- "3000:3000"
environment:
# These should match the defaults or your specific configuration in config.rs
BIND_ADDRESS: "0.0.0.0:3000"
MONGODB_URI: "mongodb://mongodb:27017"
DATABASE_NAME: "purenotify"
RUST_LOG: "info,tower_http=debug,mongodb=debug"
depends_on:
- mongodb
# Optional: If you want to enable the no-auth feature for local development
# command: cargo run --features "no-auth"
mongodb:
image: mongo:6.0
ports:
- "27017:27017"
volumes:
- mongodb_data:/data/db
environment:
MONGO_INITDB_ROOT_USERNAME: admin
MONGO_INITDB_ROOT_PASSWORD: password123
MONGO_INITDB_DATABASE: purenotify
# Optional: MongoDB authentication (highly recommended for production)
# MONGO_INITDB_ROOT_USERNAME: your_mongo_username
# MONGO_INITDB_ROOT_PASSWORD: your_mongo_password
# MONGODB_REPLICA_SET_NAME: rs0 # Uncomment for replica set
volumes:
mongodb_data:

View File

@@ -6,10 +6,11 @@ use std::str::FromStr;
use tracing::error;
#[derive(Debug)]
#[derive(Debug, Clone)]
pub struct Config {
pub bind_address: SocketAddr,
pub database_url: Option<String>,
pub mongodb_uri: String,
pub database_name: String,
}
impl Config {
@@ -20,17 +21,20 @@ impl Config {
let bind_address = SocketAddr::from_str(&bind_address_str)
.map_err(|e| format!("Invalid BIND_ADDRESS: {}", e))?;
#[cfg(feature = "no-auth")]
if bind_address.ip() != std::net::IpAddr::from([127, 0, 0, 1]) {
error!("In no-auth mode, BIND_ADDRESS must be 127.0.0.1");
return Err("In no-auth mode, BIND_ADDRESS must be 127.0.0.1".to_string());
}
let database_url = env::var("DATABASE_URL").ok();
let mongodb_uri =
env::var("MONGODB_URI").unwrap_or_else(|_| "mongodb://localhost:27017".to_string());
let database_name = env::var("DATABASE_NAME").unwrap_or_else(|_| "purenotify".to_string());
Ok(Self {
bind_address,
database_url,
mongodb_uri,
database_name,
})
}
}

View File

@@ -0,0 +1,20 @@
// src/handlers/health/health.rs
use axum::Json;
use axum::http::StatusCode;
use axum::response::IntoResponse;
use serde_json::json;
pub async fn health() -> impl IntoResponse {
(
StatusCode::OK,
Json(json!(
{
"message": "health check successful",
"data": {},
"success": true,
"error": false,
}
)),
)
}

View File

@@ -0,0 +1,3 @@
// src/handlers/health/mod.rs
pub mod health;

4
src/handlers/mod.rs Normal file
View File

@@ -0,0 +1,4 @@
// src/handlers/mod.rs
pub mod health;
pub mod user;

4
src/handlers/user/mod.rs Normal file
View File

@@ -0,0 +1,4 @@
// src/handlers/user/mod.rs
pub mod register;
pub mod user;

View File

@@ -0,0 +1,40 @@
// src/handlers/register/register.rs
use axum::Json;
use axum::http::StatusCode;
use axum::response::IntoResponse;
use serde::Deserialize;
use serde_json::json;
#[derive(Debug, Deserialize)]
pub struct RegisterPayload {
pub email: String,
pub password: String,
}
impl RegisterPayload {
pub fn new(email: String, password: String) -> Self {
RegisterPayload { email, password }
}
}
pub async fn register(Json(_payload): Json<RegisterPayload>) -> impl IntoResponse {
// TODO: Implement user registration logic using the user repository in ./src/mongodb/repositories/user
(
StatusCode::OK,
Json(json!(
{
"message": "new user registered",
"data": {
"user": {
"email" : _payload.email,
"password": _payload.password,
}
},
"success": true,
"error": false,
}
)),
)
}

48
src/handlers/user/user.rs Normal file
View File

@@ -0,0 +1,48 @@
// src/handlers/user/user.rs
use axum::Json;
use axum::http::StatusCode;
use axum::response::IntoResponse;
use serde_json::json;
pub async fn user() -> impl IntoResponse {
(
StatusCode::OK,
Json(json!(
{
"message": "health check successful",
"data": {
"id": "usr_123456789",
"username": "john_doe",
"email": "john.doe@example.com",
"first_name": "John",
"last_name": "Doe",
"role": "user",
"is_active": true,
"created_at": "2024-01-15T10:30:00Z",
"updated_at": "2024-08-15T14:20:00Z",
"profile": {
"avatar_url": "https://api.example.com/avatars/john_doe.png",
"bio": "Software developer passionate about Rust",
"location": "San Francisco, CA",
"website": "https://johndoe.dev"
},
"preferences": {
"theme": "dark",
"language": "en",
"notifications_enabled": true,
"email_verified": true
},
"stats": {
"total_posts": 42,
"total_comments": 156,
"total_likes": 523,
"account_age_days": 213
}
},
"success": true,
"error": false,
}
)),
)
}

View File

@@ -1,10 +0,0 @@
// src/health.rs
use axum::Json;
use axum::http::StatusCode;
use axum::response::IntoResponse;
use serde_json::json;
pub async fn health() -> impl IntoResponse {
(StatusCode::OK, Json(json!({ "status": "ok" })))
}

View File

@@ -1,9 +1,8 @@
// src/main.rs
use std::net::SocketAddr;
use std::process::exit;
use std::{process::exit, sync::Arc};
use axum::{Router, routing::get};
use axum::Router;
use dotenvy::dotenv;
use tokio::signal;
use tower_http::trace::TraceLayer;
@@ -11,41 +10,102 @@ use tracing::{error, info};
use tracing_subscriber::{EnvFilter, fmt, prelude::*};
mod config;
mod health;
mod handlers;
mod mongo; // local module wrapping the Mongo client
mod routes;
use ::mongodb::Database; // external crate (absolute path avoids name clash)
use config::Config;
use mongo::MongoDb; // your wrapper
// Shared application state for online mode
pub struct AppState {
pub db: Database,
pub config: Config,
}
#[tokio::main]
async fn main() {
// Load environment variables from .env file
// Load .env early
dotenv().ok();
// Initialize tracing
// Tracing with a safe fallback if RUST_LOG is unset
let env_filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info"));
tracing_subscriber::registry()
.with(fmt::layer())
.with(EnvFilter::from_env("RUST_LOG"))
.with(env_filter)
.init();
// Load config
let config = match Config::from_env() {
Ok(c) => c,
Err(e) => {
error!("Failed to load config: {}", e);
error!("Failed to load config: {e}");
exit(1);
}
};
#[cfg(feature = "no-auth")]
info!("NO-AUTH MODE ENABLED");
// Runtime OFFLINE switch: true if OFFLINE is 1/true/yes/on (case-insensitive)
let offline = std::env::var("OFFLINE")
.ok()
.map(|v| matches!(v.to_ascii_lowercase().as_str(), "1" | "true" | "yes" | "on"))
.unwrap_or(false);
if offline {
// Enforce loopback binding while offline
if !config.bind_address.ip().is_loopback() {
error!(
"OFFLINE=true requires binding to a loopback address (e.g., 127.0.0.1:<port> or [::1]:<port>), got {}",
config.bind_address
);
exit(1);
}
info!("OFFLINE mode enabled — not connecting to MongoDB");
info!("Server starting on {}", config.bind_address);
// Health-only, no state. Subrouter is typed to `()`.
let app = Router::new()
.nest("/health", routes::health::health::health_routes::<()>())
.layer(TraceLayer::new_for_http());
let listener = tokio::net::TcpListener::bind(config.bind_address)
.await
.unwrap();
axum::serve(listener, app)
.with_graceful_shutdown(shutdown_signal())
.await
.unwrap();
return;
}
// --- Online (DB-enabled) path ---
let mongo = match MongoDb::connect(&config).await {
Ok(db) => db,
Err(e) => {
error!("Failed to connect to MongoDB: {e}");
exit(1);
}
};
let shared_state = Arc::new(AppState {
db: mongo.database,
config: config.clone(),
});
info!("Server starting on {}", config.bind_address);
// Build the Axum router
let app = Router::new()
.route("/health", get(health::health))
// Build subrouters typed with the same state as the root
let health_router = routes::health::health::health_routes::<Arc<AppState>>();
let user_router = routes::user::user::user_routes::<Arc<AppState>>();
// Root router typed with state; set state once on the root
let app = Router::<Arc<AppState>>::new()
.nest("/health", health_router)
.nest("/user", user_router)
.with_state(shared_state)
.layer(TraceLayer::new_for_http());
// Run the server
let listener = tokio::net::TcpListener::bind(config.bind_address)
.await
.unwrap();

6
src/mongo/mod.rs Normal file
View File

@@ -0,0 +1,6 @@
// src/mongodb/mod.rs
pub mod models;
pub mod mongodb;
pub use mongodb::MongoDb;

5
src/mongo/models/mod.rs Normal file
View File

@@ -0,0 +1,5 @@
// src/mongodb/models/mod.rs
pub mod user;
// Re-exports can be added here when needed

98
src/mongo/models/user.rs Normal file
View File

@@ -0,0 +1,98 @@
// models/user.rs
use bson::oid::ObjectId;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct User {
#[serde(rename = "_id", skip_serializing_if = "Option::is_none")]
pub id: Option<ObjectId>,
pub username: String,
pub email: String,
pub first_name: String,
pub last_name: String,
pub age: u32,
pub is_active: bool,
pub phone_number: String,
pub password: String,
pub salt: String,
#[serde(default = "chrono::Utc::now")]
pub created_at: DateTime<Utc>,
#[serde(default = "chrono::Utc::now")]
pub updated_at: DateTime<Utc>,
pub last_login: Option<DateTime<Utc>>,
pub role: String,
pub profile: Option<Profile>,
pub preferences: Option<Preferences>,
pub stats: Option<Stats>,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct Profile {
pub avatar_url: String,
pub bio: String,
pub location: String,
pub website: String,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct Preferences {
pub theme: String,
pub language: String,
pub notifications_enabled: bool,
pub email_verified: bool,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct Stats {
pub total_posts: u32,
pub total_comments: u32,
pub total_likes: u32,
pub account_age_days: u32,
}
impl User {
pub fn new(username: String, email: String, password: String, salt: String) -> Self {
let now = chrono::Utc::now();
Self {
id: None,
username,
email,
first_name: String::new(),
last_name: String::new(),
age: 0,
is_active: true,
phone_number: String::new(),
password,
salt,
created_at: now,
updated_at: now,
last_login: None,
role: "user".to_string(),
profile: None,
preferences: None,
stats: None,
}
}
pub fn with_profile(mut self, profile: Profile) -> Self {
self.profile = Some(profile);
self
}
pub fn with_preferences(mut self, preferences: Preferences) -> Self {
self.preferences = Some(preferences);
self
}
pub fn with_stats(mut self, stats: Stats) -> Self {
self.stats = Some(stats);
self
}
pub fn update_last_login(&mut self) {
self.last_login = Some(chrono::Utc::now());
self.updated_at = chrono::Utc::now();
}
}

38
src/mongo/mongodb.rs Normal file
View File

@@ -0,0 +1,38 @@
use crate::config::Config;
use mongodb::options::{ClientOptions, ServerApi, ServerApiVersion};
use mongodb::{Client, Database};
pub struct MongoDb {
// pub client: Client,
pub database: Database,
}
impl MongoDb {
pub async fn connect(config: &Config) -> Result<Self, mongodb::error::Error> {
// Parse connection string from config
let mut client_options = ClientOptions::parse(&config.mongodb_uri).await?;
// Set the server API version (optional but recommended for MongoDB Atlas)
let server_api = ServerApi::builder().version(ServerApiVersion::V1).build();
client_options.server_api = Some(server_api);
// Optional: Set additional options
client_options.app_name = Some("PureNotify".to_string());
// Create client
let client = Client::with_options(client_options)?;
// Ping the server to verify connection
client
.database("admin")
.run_command(mongodb::bson::doc! {"ping": 1})
.await?;
println!("✅ Successfully connected to MongoDB!");
// Get database handle using the database_name from config
let database = client.database(&config.database_name);
Ok(MongoDb { database })
}
}

View File

@@ -0,0 +1,191 @@
use async_trait::async_trait;
use bson::oid::ObjectId;
use futures::TryStreamExt;
use mongodb::Collection;
use mongodb::bson::doc;
use mongodb::options::FindOptions;
use super::user_repository::{UserError, UserRepository};
use crate::models::user::User;
pub struct MongoUserRepository {
collection: Collection<User>,
}
impl MongoUserRepository {
pub fn new(collection: Collection<User>) -> Self {
Self { collection }
}
}
#[async_trait]
impl UserRepository for MongoUserRepository {
async fn create(&self, mut user: User) -> Result<User, UserError> {
// Validate required fields
if user.username.is_empty() {
return Err(UserError::ValidationError(
"Username is required".to_string(),
));
}
if user.email.is_empty() {
return Err(UserError::ValidationError("Email is required".to_string()));
}
// Check for existing users
if self.exists_by_username(user.username.clone()).await? {
return Err(UserError::DuplicateKey("username".to_string()));
}
if self.exists_by_email(user.email.clone()).await? {
return Err(UserError::DuplicateKey("email".to_string()));
}
// Set timestamps
let now = chrono::Utc::now();
user.created_at = now;
user.updated_at = now;
user.id = None; // Let MongoDB generate the ID
let result = self.collection.insert_one(&user, None).await?;
// Return the created user with the new ID
user.id = result.inserted_id.as_object_id();
Ok(user)
}
async fn get(&self, id: ObjectId) -> Result<User, UserError> {
let user = self.collection.find_one(doc! {"_id": id}, None).await?;
user.ok_or(UserError::NotFound)
}
async fn update(&self, id: ObjectId, mut user: User) -> Result<User, UserError> {
// Update the timestamp
user.updated_at = chrono::Utc::now();
user.id = Some(id);
let result = self
.collection
.replace_one(doc! {"_id": id}, &user, None)
.await?;
if result.matched_count == 0 {
return Err(UserError::NotFound);
}
Ok(user)
}
async fn delete(&self, id: ObjectId) -> Result<(), UserError> {
let result = self.collection.delete_one(doc! {"_id": id}, None).await?;
if result.deleted_count == 0 {
return Err(UserError::NotFound);
}
Ok(())
}
async fn list(&self, limit: Option<i64>, skip: Option<u64>) -> Result<Vec<User>, UserError> {
let find_options = FindOptions::builder().limit(limit).skip(skip).build();
let cursor = self.collection.find(None, find_options).await?;
let users: Vec<User> = cursor.try_collect().await?;
Ok(users)
}
async fn search(&self, query: String) -> Result<Vec<User>, UserError> {
// Use regex for partial matching or text search
let filter = doc! {
"$or": [
{"username": {"$regex": &query, "$options": "i"}},
{"email": {"$regex": &query, "$options": "i"}},
{"first_name": {"$regex": &query, "$options": "i"}},
{"last_name": {"$regex": &query, "$options": "i"}}
]
};
let cursor = self.collection.find(filter, None).await?;
let users: Vec<User> = cursor.try_collect().await?;
Ok(users)
}
async fn count(&self) -> Result<u64, UserError> {
let count = self.collection.count_documents(None, None).await?;
Ok(count)
}
async fn count_by_name(&self, name: String) -> Result<u64, UserError> {
let filter = doc! {
"$or": [
{"first_name": &name},
{"last_name": &name}
]
};
let count = self.collection.count_documents(filter, None).await?;
Ok(count)
}
async fn count_by_email(&self, email: String) -> Result<u64, UserError> {
let count = self
.collection
.count_documents(doc! {"email": email}, None)
.await?;
Ok(count)
}
async fn count_by_phone(&self, phone: String) -> Result<u64, UserError> {
let count = self
.collection
.count_documents(doc! {"phone_number": phone}, None)
.await?;
Ok(count)
}
async fn count_by_id(&self, id: ObjectId) -> Result<u64, UserError> {
let count = self
.collection
.count_documents(doc! {"_id": id}, None)
.await?;
Ok(count)
}
async fn find_by_email(&self, email: String) -> Result<Option<User>, UserError> {
let user = self
.collection
.find_one(doc! {"email": email}, None)
.await?;
Ok(user)
}
async fn find_by_username(&self, username: String) -> Result<Option<User>, UserError> {
let user = self
.collection
.find_one(doc! {"username": username}, None)
.await?;
Ok(user)
}
async fn exists_by_email(&self, email: String) -> Result<bool, UserError> {
let count = self.count_by_email(email).await?;
Ok(count > 0)
}
async fn exists_by_username(&self, username: String) -> Result<bool, UserError> {
let count = self
.collection
.count_documents(doc! {"username": username}, None)
.await?;
Ok(count > 0)
}
async fn get_active_users(&self) -> Result<Vec<User>, UserError> {
let cursor = self.collection.find(doc! {"is_active": true}, None).await?;
let users: Vec<User> = cursor.try_collect().await?;
Ok(users)
}
async fn get_users_by_role(&self, role: String) -> Result<Vec<User>, UserError> {
let cursor = self.collection.find(doc! {"role": role}, None).await?;
let users: Vec<User> = cursor.try_collect().await?;
Ok(users)
}
}

View File

@@ -0,0 +1,53 @@
use async_trait::async_trait;
use bson::oid::ObjectId;
use crate::models::user::User;
// Define custom error type
#[derive(Debug)]
pub enum UserError {
MongoError(mongodb::error::Error),
NotFound,
ValidationError(String),
DuplicateKey(String),
}
impl std::fmt::Display for UserError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
UserError::MongoError(e) => write!(f, "MongoDB error: {}", e),
UserError::NotFound => write!(f, "User not found"),
UserError::ValidationError(msg) => write!(f, "Validation error: {}", msg),
UserError::DuplicateKey(field) => write!(f, "Duplicate key error: {}", field),
}
}
}
impl std::error::Error for UserError {}
impl From<mongodb::error::Error> for UserError {
fn from(error: mongodb::error::Error) -> Self {
UserError::MongoError(error)
}
}
// Repository trait
#[async_trait]
pub trait UserRepository {
async fn create(&self, user: User) -> Result<User, UserError>;
async fn get(&self, id: ObjectId) -> Result<User, UserError>;
async fn update(&self, id: ObjectId, user: User) -> Result<User, UserError>;
async fn delete(&self, id: ObjectId) -> Result<(), UserError>;
async fn list(&self, limit: Option<i64>, skip: Option<u64>) -> Result<Vec<User>, UserError>;
async fn search(&self, query: String) -> Result<Vec<User>, UserError>;
async fn count(&self) -> Result<u64, UserError>;
async fn count_by_name(&self, name: String) -> Result<u64, UserError>;
async fn count_by_email(&self, email: String) -> Result<u64, UserError>;
async fn count_by_phone(&self, phone: String) -> Result<u64, UserError>;
async fn count_by_id(&self, id: ObjectId) -> Result<u64, UserError>;
async fn find_by_email(&self, email: String) -> Result<Option<User>, UserError>;
async fn find_by_username(&self, username: String) -> Result<Option<User>, UserError>;
async fn exists_by_email(&self, email: String) -> Result<bool, UserError>;
async fn exists_by_username(&self, username: String) -> Result<bool, UserError>;
async fn get_active_users(&self) -> Result<Vec<User>, UserError>;
async fn get_users_by_role(&self, role: String) -> Result<Vec<User>, UserError>;
}

View File

@@ -0,0 +1,11 @@
// src/routes/health/healh.rs
use axum::{Router, routing::get};
pub fn health_routes<S>() -> Router<S>
where
S: Clone + Send + Sync + 'static,
{
// keep your existing routes/handlers here
Router::new().route("/", get(crate::handlers::health::health::health))
}

4
src/routes/health/mod.rs Normal file
View File

@@ -0,0 +1,4 @@
// src/routes/health/mod.rs
pub mod health;

4
src/routes/mod.rs Normal file
View File

@@ -0,0 +1,4 @@
// src/routes/mod.rs
pub mod health;
pub mod user;

3
src/routes/user/mod.rs Normal file
View File

@@ -0,0 +1,3 @@
// src/routes/user/mod.rs
pub mod user;

16
src/routes/user/user.rs Normal file
View File

@@ -0,0 +1,16 @@
// src/routes/user/user.rs
use axum::{
Router,
routing::{get, post},
};
pub fn user_routes<S>() -> Router<S>
where
S: Clone + Send + Sync + 'static,
{
// keep your existing routes/handlers here
Router::new()
.route("/", get(crate::handlers::user::user::user))
.route("/register", post(crate::handlers::user::register::register))
}

203
src/utils/crypto/crypto.md Normal file
View File

@@ -0,0 +1,203 @@
# Crypto Utility Module (`crypto.rs`)
This document provides detailed documentation for the `CryptoUtils` module, a Rust implementation for essential cryptographic operations. The module offers functionalities for symmetric encryption/decryption and asymmetric key pair management.
## Table of Contents
1. [Overview](#overview)
2. [Dependencies](#dependencies)
3. [Error Handling](#error-handling)
4. [Core Structures](#core-structures)
- [`KeyPair`](#keypair)
5. [Static Properties](#static-properties)
- [`KEY`](#key)
- [`IV`](#iv)
6. [API Functions](#api-functions)
- [Symmetric Encryption](#symmetric-encryption)
- [`encrypt`](#encrypt)
- [`decrypt`](#decrypt)
- [Asymmetric Key Management](#asymmetric-key-management)
- [`generate_key_pair`](#generate_key_pair)
- [`save_keys_to_files`](#save_keys_to_files)
- [`load_keys_from_files`](#load_keys_from_files)
- [`init_keys`](#init_keys)
7. [Usage Examples](#usage-examples)
---
### Overview
The `CryptoUtils` module provides a set of static methods to perform common cryptographic tasks. It is designed to be a centralized utility for handling both symmetric (AES-256-CBC) and asymmetric (RSA-4096) cryptography.
### Dependencies
This module requires the following dependencies to be added to your `Cargo.toml`:
```toml
[dependencies]
openssl = "0.10"
sha2 = "0.10"
hex = "0.4"
once_cell = "1.19" # For lazy static initialization
```
### Error Handling
The module defines a custom `CryptoError` enum to handle various failure scenarios, providing clear and specific error information.
- `OpenSsl`: Wraps errors from the `openssl` crate.
- `Io`: For file system I/O errors (e.g., reading/writing keys).
- `Hex`: For errors during hex encoding/decoding.
- `Utf8`: For errors converting byte slices to UTF-8 strings.
- `Custom`: For other specific, custom error messages.
### Core Structures
#### `KeyPair`
A public struct that holds a pair of RSA keys.
- `private_key: String`: The PEM-encoded private key.
- `public_key: String`: The PEM-encoded public key.
### Static Properties
#### `KEY`
A statically initialized 32-byte array used as the secret key for AES-256-CBC encryption and decryption. It is derived by applying SHA-256 to a hardcoded salt phrase, ensuring a consistent key across the application.
#### `IV`
A 16-byte initialization vector used for the AES-256-CBC algorithm.
### API Functions
All functions are implemented as static methods on the `CryptoUtils` struct.
#### Symmetric Encryption
##### `encrypt`
`pub fn encrypt(secret: &str) -> Result<String, CryptoError>`
Encrypts a string slice using AES-256-CBC.
- **Parameters**:
- `secret`: The plaintext string to encrypt.
- **Returns**: A `Result` containing the hex-encoded ciphertext string or a `CryptoError`.
##### `decrypt`
`pub fn decrypt(encrypted_secret: &str) -> Result<String, CryptoError>`
Decrypts a hex-encoded ciphertext string using AES-256-CBC.
- **Parameters**:
- `encrypted_secret`: The hex-encoded ciphertext.
- **Returns**: A `Result` containing the decrypted plaintext string or a `CryptoError`.
#### Asymmetric Key Management
##### `generate_key_pair`
`pub fn generate_key_pair() -> Result<KeyPair, CryptoError>`
Generates a new 4096-bit RSA key pair.
- **Returns**: A `Result` containing a `KeyPair` struct with the new PEM-encoded keys or a `CryptoError`.
##### `save_keys_to_files`
`pub fn save_keys_to_files(keys: &KeyPair, directory: &Path) -> Result<(), CryptoError>`
Saves a `KeyPair` to the specified directory in two files: `private.pem` and `public.pem`.
- **Parameters**:
- `keys`: A reference to the `KeyPair` to save.
- `directory`: The path to the directory where the keys will be saved.
##### `load_keys_from_files`
`pub fn load_keys_from_files(directory: &Path) -> Result<KeyPair, CryptoError>`
Loads an RSA key pair from `private.pem` and `public.pem` files in a given directory.
- **Parameters**:
- `directory`: The path to the directory containing the key files.
- **Returns**: A `Result` containing the loaded `KeyPair` or a `CryptoError`.
##### `init_keys`
`pub fn init_keys() -> Result<KeyPair, CryptoError>`
A convenience function that initializes the RSA key pair for the application. It first checks if the keys exist in the default `./keys` directory.
- If the keys exist, it loads them.
- If they do not exist, it generates a new pair and saves them to the `./keys` directory.
- **Returns**: A `Result` containing the initialized `KeyPair` or a `CryptoError`.
### Usage Examples
#### Example 1: AES Encryption and Decryption
```rust
use your_project::utils::crypto::crypto::CryptoUtils;
fn main() {
let secret_message = "This is a highly confidential message.";
// Encrypt the message
match CryptoUtils::encrypt(secret_message) {
Ok(encrypted) => {
println!("Original: {}", secret_message);
println!("Encrypted: {}", encrypted);
// Decrypt the message
match CryptoUtils::decrypt(&encrypted) {
Ok(decrypted) => {
println!("Decrypted: {}", decrypted);
assert_eq!(secret_message, decrypted);
},
Err(e) => eprintln!("Decryption failed: {}", e),
}
},
Err(e) => eprintln!("Encryption failed: {}", e),
}
}
```
#### Example 2: RSA Key Pair Initialization and Management
This example demonstrates how to ensure RSA keys are available for the application.
```rust
use your_project::utils::crypto::crypto::CryptoUtils;
use std::fs;
use std::path::Path;
fn main() {
// Clean up previous keys for demonstration purposes
if Path::new("keys").exists() {
fs::remove_dir_all("keys").unwrap();
}
println!("Attempting to initialize keys...");
// Use init_keys to either generate or load keys
match CryptoUtils::init_keys() {
Ok(keys) => {
println!("Keys initialized successfully.");
println!("Public Key (first 50 chars): {}...", &keys.public_key[..50]);
// Calling it again should now load the existing keys
println!("\nCalling init_keys again...");
let loaded_keys = CryptoUtils::init_keys().unwrap();
assert_eq!(keys.public_key, loaded_keys.public_key);
println!("Keys loaded successfully from files.");
},
Err(e) => {
eprintln!("Failed to initialize keys: {}", e);
}
}
}
```

167
src/utils/crypto/crypto.rs Normal file
View File

@@ -0,0 +1,167 @@
use once_cell::sync::Lazy;
use openssl::error::ErrorStack;
use openssl::pkey::PKey;
use openssl::rsa::{Padding, Rsa};
use openssl::symm::{Cipher, Crypter, Mode};
use sha2::{Digest, Sha256};
use std::fs;
use std::io;
use std::path::{Path, PathBuf};
// --- Error Handling ---
#[derive(Debug)]
pub enum CryptoError {
OpenSsl(ErrorStack),
Io(io::Error),
Hex(hex::FromHexError),
Utf8(std::string::FromUtf8Error),
Custom(String),
}
impl From<ErrorStack> for CryptoError {
fn from(err: ErrorStack) -> Self {
CryptoError::OpenSsl(err)
}
}
impl From<io::Error> for CryptoError {
fn from(err: io::Error) -> Self {
CryptoError::Io(err)
}
}
impl From<hex::FromHexError> for CryptoError {
fn from(err: hex::FromHexError) -> Self {
CryptoError::Hex(err)
}
}
impl From<std::string::FromUtf8Error> for CryptoError {
fn from(err: std::string::FromUtf8Error) -> Self {
CryptoError::Utf8(err)
}
}
impl std::fmt::Display for CryptoError {
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
match self {
CryptoError::OpenSsl(e) => write!(f, "OpenSSL error: {}", e),
CryptoError::Io(e) => write!(f, "IO error: {}", e),
CryptoError::Hex(e) => write!(f, "Hex decoding error: {}", e),
CryptoError::Utf8(e) => write!(f, "UTF-8 conversion error: {}", e),
CryptoError::Custom(s) => write!(f, "Crypto error: {}", s),
}
}
}
impl std::error::Error for CryptoError {}
// --- KeyPair Structure ---
#[derive(Debug, Clone)]
pub struct KeyPair {
pub private_key: String,
pub public_key: String,
}
// --- Crypto Utility ---
pub struct CryptoUtils;
static KEY: Lazy<[u8; 32]> = Lazy::new(|| {
let mut hasher = Sha256::new();
hasher.update(b"z&R*3mN@wS5gY!8c*P#L5bQm&8wT3vNxE!UW4ex7HJKLfghRT");
hasher.finalize().into()
});
const IV: &[u8; 16] = b"6234567890123456";
impl CryptoUtils {
/// Encrypts a string using AES-256-CBC.
pub fn encrypt(secret: &str) -> Result<String, CryptoError> {
let cipher = Cipher::aes_256_cbc();
let data = secret.as_bytes();
let mut encrypter = Crypter::new(cipher, Mode::Encrypt, &KEY, Some(IV))?;
encrypter.pad(true);
let mut encrypted = vec![0; data.len() + cipher.block_size()];
let count = encrypter.update(data, &mut encrypted)?;
let rest = encrypter.finalize(&mut encrypted[count..])?;
encrypted.truncate(count + rest);
Ok(hex::encode(encrypted))
}
/// Decrypts a string using AES-256-CBC.
pub fn decrypt(encrypted_secret: &str) -> Result<String, CryptoError> {
let cipher = Cipher::aes_256_cbc();
let data = hex::decode(encrypted_secret)?;
let mut decrypter = Crypter::new(cipher, Mode::Decrypt, &KEY, Some(IV))?;
decrypter.pad(true);
let mut decrypted = vec![0; data.len() + cipher.block_size()];
let count = decrypter.update(&data, &mut decrypted)?;
let rest = decrypter.finalize(&mut decrypted[count..])?;
decrypted.truncate(count + rest);
Ok(String::from_utf8(decrypted)?)
}
/// Generates a 4096-bit RSA key pair in PEM format.
pub fn generate_key_pair() -> Result<KeyPair, CryptoError> {
let rsa = Rsa::generate(4096)?;
let pkey = PKey::from_rsa(rsa)?;
let private_key = pkey
.private_key_to_pem_pkcs8()?
.iter()
.map(|&c| c as char)
.collect();
let public_key = pkey
.public_key_to_pem()?
.iter()
.map(|&c| c as char)
.collect();
Ok(KeyPair {
private_key,
public_key,
})
}
/// Saves the given KeyPair to files in the specified directory.
pub fn save_keys_to_files(keys: &KeyPair, directory: &Path) -> Result<(), CryptoError> {
fs::create_dir_all(directory)?;
fs::write(directory.join("private.pem"), &keys.private_key)?;
fs::write(directory.join("public.pem"), &keys.public_key)?;
Ok(())
}
/// Loads a KeyPair from files in the specified directory.
pub fn load_keys_from_files(directory: &Path) -> Result<KeyPair, CryptoError> {
let private_key = fs::read_to_string(directory.join("private.pem"))?;
let public_key = fs::read_to_string(directory.join("public.pem"))?;
Ok(KeyPair {
private_key,
public_key,
})
}
/// Initializes RSA key pair.
/// If keys exist in the default 'keys' directory, they are loaded.
/// Otherwise, new keys are generated and saved.
pub fn init_keys() -> Result<KeyPair, CryptoError> {
let key_path = PathBuf::from("keys");
if key_path.join("private.pem").exists() && key_path.join("public.pem").exists() {
Self::load_keys_from_files(&key_path)
} else {
let keys = Self::generate_key_pair()?;
Self::save_keys_to_files(&keys, &key_path)?;
Ok(keys)
}
}
}

186
src/utils/jwt/jwt.md Normal file
View File

@@ -0,0 +1,186 @@
# JWT Utility Module Documentation
## Overview
The `JWTUtils` module provides a comprehensive suite of tools for creating, signing, verifying, and managing JSON Web Tokens (JWTs) in Rust. It is designed to work seamlessly with the `CryptoUtils` module for RSA key management. This implementation handles JWTs manually using the `openssl` crate for cryptographic operations, avoiding external JWT-specific libraries.
The module supports standard claims like `exp` (expiration time), `iat` (issued at), and `iss` (issuer), and allows for custom payloads.
## Dependencies
To use this module, ensure the following dependencies are included in your `Cargo.toml` file:
```toml
[dependencies]
openssl = "0.10"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
chrono = "0.4"
base64 = "0.21"
```
## Core Components
### `JWTError` Enum
A custom error type that consolidates all potential failures within the module, including issues from `openssl`, `serde_json`, `base64`, and invalid token logic.
- `OpenSsl(openssl::error::ErrorStack)`: An error from the OpenSSL library.
- `SerdeJson(serde_json::Error)`: An error during JSON serialization or deserialization.
- `Base64(base64::DecodeError)`: An error during Base64 decoding.
- `Crypto(String)`: An error related to cryptographic key loading from `CryptoUtils`.
- `InvalidTokenFormat(String)`: The token string is malformed (e.g., wrong number of segments).
- `Validation(String)`: The token failed a validation check (e.g., signature invalid, expired).
- `Custom(String)`: A generic error for other specific issues.
### `JWTOptions` Struct
Defines the configurable options for creating a JWT.
- `algorithm: String`: The signing algorithm (defaults to `"RS256"`).
- `expires_in: i64`: The token's lifetime in seconds. Defaults to `3600` (1 hour) or the value of the `JWT_EXPIRES_IN` environment variable.
- `issuer: String`: The issuer of the token. Defaults to an empty string or the value of the `JWT_ISSUER` environment variable.
### `JWTUtils` Struct
The main struct for handling JWT operations. An instance of `JWTUtils` is typically created to generate a new token.
- `payload: Value`: The custom payload for the JWT, represented as a `serde_json::Value`.
- `private_key: String`: The PEM-encoded RSA private key for signing.
- `public_key: String`: The PEM-encoded RSA public key for verification.
- `options: JWTOptions`: The configuration options for the token.
## Instantiation
### `new(payload: Value, private_key: Option<String>, public_key: Option<String>, options: Option<JWTOptions>) -> Result<Self, JWTError>`
Creates a new `JWTUtils` instance.
- **payload**: The custom data to include in the token.
- **private_key / public_key**: Optional RSA keys. If not provided, the constructor will attempt to load them from the default `keys/` directory using `CryptoUtils::load_keys_from_files()`.
- **options**: Optional `JWTOptions`. If not provided, default values will be used.
## Instance Methods
### `create_token(&self) -> Result<String, JWTError>`
Generates and signs a complete JWT string. The process involves:
1. Creating the JWT header (`{"alg": "RS256", "typ": "JWT"}`).
2. Processing the payload by adding standard claims (`iat`, `exp`, `iss`).
3. Base64URL-encoding the header and payload.
4. Creating a signature by signing the encoded header and payload with the RSA private key.
5. Combining the three parts into the final `header.payload.signature` format.
### `verify(&self, token: &str) -> bool`
Verifies the signature of a given JWT using the public key stored in the `JWTUtils` instance.
- **Returns**: `true` if the signature is valid, `false` otherwise.
- **Note**: This method **only** checks the signature. It does not validate the expiration time or other claims. For comprehensive validation, use `decode_and_verify`.
## Static Methods
### `decode(token: &str) -> Result<(Value, Value), JWTError>`
Decodes a JWT string into its header and payload components without verifying the signature.
- **Returns**: A tuple `(header, payload)` where both elements are of type `serde_json::Value`.
- **Use Case**: Useful for inspecting token data when the signature's validity is not a concern (e.g., for logging or preliminary checks).
### `is_expired(token: &str) -> bool`
Checks if a token has expired. It decodes the token and compares the `exp` claim to the current UTC time.
- **Returns**: `true` if the token is expired, has no `exp` claim, or is malformed. `false` otherwise.
### `decode_and_verify(token: &str) -> Result<(Value, Value), JWTError>`
A comprehensive function that performs all necessary validations on a token:
1. Verifies the token's signature using the public key loaded from the default `keys/` directory.
2. Checks if the token has expired.
3. If both checks pass, it decodes and returns the header and payload.
- **Returns**: `Ok((header, payload))` on success, or a `JWTError` if validation fails for any reason.
### `refresh_token(old_token: &str) -> Result<String, JWTError>`
Generates a new token based on the payload of an old token. The `iat` and `exp` claims are stripped from the original payload and replaced with new ones.
- **Note**: This function loads the RSA keys from the `keys/` directory to sign the new token.
### `validate_claims(token: &str, required_claims: &[&str]) -> bool`
Checks for the presence of specific keys in the token's payload.
- **required_claims**: A slice of strings representing the keys that must be present.
- **Returns**: `true` if all required claims exist, `false` otherwise.
## Usage Examples
### Example 1: Creating a JWT
```rust
use serde_json::json;
use your_project::utils::jwt::JWTUtils;
fn create_new_token() {
// The custom data for the token
let payload = json!({
"user_id": "12345",
"roles": ["admin", "user"]
});
// Keys can be provided directly or loaded automatically from the 'keys/' directory
// If None, the constructor will try to load them from files.
let jwt_instance = JWTUtils::new(payload, None, None, None).unwrap();
match jwt_instance.create_token() {
Ok(token) => println!("Generated Token: {}", token),
Err(e) => eprintln!("Error creating token: {}", e),
}
}
```
### Example 2: Verifying and Decoding a JWT
```rust
use your_project::utils::jwt::JWTUtils;
fn validate_and_read_token(token: &str) {
match JWTUtils::decode_and_verify(token) {
Ok((header, payload)) => {
println!("Token is valid!");
println!("Header: {:?}", header);
println!("Payload: {:?}", payload);
},
Err(e) => {
eprintln!("Token validation failed: {}", e);
}
}
// You can also check for specific claims
if JWTUtils::validate_claims(token, &["user_id", "roles"]) {
println!("All required claims are present.");
}
}
```
### Example 3: Refreshing a Token
```rust
use your_project::utils::jwt::JWTUtils;
fn refresh_existing_token(old_token: &str) {
match JWTUtils::refresh_token(old_token) {
Ok(new_token) => {
println!("Token refreshed successfully!");
println!("New Token: {}", new_token);
},
Err(e) => {
eprintln!("Failed to refresh token: {}", e);
}
}
}
```

320
src/utils/jwt/jwt.rs Normal file
View File

@@ -0,0 +1,320 @@
// Add the following dependencies to your Cargo.toml file:
// openssl = "0.10"
// serde = { version = "1.0", features = ["derive"] }
// serde_json = "1.0"
// chrono = "0.4"
// base64 = "0.21"
use crate::utils::crypto::crypto::CryptoUtils;
use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
use chrono::{Duration, Utc};
use openssl::{
hash::MessageDigest,
pkey::{PKey, Private},
rsa::Rsa,
sign::{Signer, Verifier},
};
use serde::{Deserialize, Serialize};
use serde_json::{Value, from_str, from_value, to_string};
use std::collections::BTreeMap;
use std::env;
// --- Error Handling ---
#[derive(Debug)]
pub enum JWTError {
OpenSsl(openssl::error::ErrorStack),
SerdeJson(serde_json::Error),
Base64(base64::DecodeError),
Crypto(String),
InvalidTokenFormat(String),
Validation(String),
Custom(String),
}
impl std::fmt::Display for JWTError {
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
match self {
JWTError::OpenSsl(e) => write!(f, "OpenSSL error: {}", e),
JWTError::SerdeJson(e) => write!(f, "JSON serialization error: {}", e),
JWTError::Base64(e) => write!(f, "Base64 decoding error: {}", e),
JWTError::Crypto(s) => write!(f, "Crypto error: {}", s),
JWTError::InvalidTokenFormat(s) => write!(f, "Invalid token format: {}", s),
JWTError::Validation(s) => write!(f, "Token validation failed: {}", s),
JWTError::Custom(s) => write!(f, "JWT error: {}", s),
}
}
}
impl std::error::Error for JWTError {}
impl From<openssl::error::ErrorStack> for JWTError {
fn from(err: openssl::error::ErrorStack) -> JWTError {
JWTError::OpenSsl(err)
}
}
impl From<serde_json::Error> for JWTError {
fn from(err: serde_json::Error) -> JWTError {
JWTError::SerdeJson(err)
}
}
impl From<base64::DecodeError> for JWTError {
fn from(err: base64::DecodeError) -> JWTError {
JWTError::Base64(err)
}
}
// --- Structures ---
#[derive(Debug, Serialize, Deserialize, Clone)]
struct JWTHeader {
alg: String,
typ: String,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct JWTOptions {
pub algorithm: String,
pub expires_in: i64, // seconds
pub issuer: String,
}
impl Default for JWTOptions {
fn default() -> Self {
let expires_in_str = env::var("JWT_EXPIRES_IN").unwrap_or_else(|_| "3600".to_string());
let expires_in = expires_in_str.parse::<i64>().unwrap_or(3600);
let issuer = env::var("JWT_ISSUER").unwrap_or_default();
JWTOptions {
algorithm: "RS256".to_string(),
expires_in,
issuer,
}
}
}
pub struct JWTUtils {
payload: Value,
private_key: String,
public_key: String,
options: JWTOptions,
}
// --- Helper Functions ---
fn base64url_encode<T: AsRef<[u8]>>(input: T) -> String {
URL_SAFE_NO_PAD.encode(input)
}
fn base64url_decode(input: &str) -> Result<Vec<u8>, base64::DecodeError> {
URL_SAFE_NO_PAD.decode(input)
}
// --- Implementation ---
impl JWTUtils {
pub fn new(
payload: Value,
private_key: Option<String>,
public_key: Option<String>,
options: Option<JWTOptions>,
) -> Result<Self, JWTError> {
let opts = options.unwrap_or_default();
let (priv_key, pub_key) = match (private_key, public_key) {
(Some(priv_k), Some(pub_k)) => (priv_k, pub_k),
(priv_k, pub_k) => {
let keys = CryptoUtils::load_keys_from_files("keys")
.map_err(|e| JWTError::Crypto(e.to_string()))?;
(
priv_k.unwrap_or(keys.private_key),
pub_k.unwrap_or(keys.public_key),
)
}
};
Ok(JWTUtils {
payload,
private_key: priv_key,
public_key: pub_key,
options: opts,
})
}
/// Create JWT header
fn create_header(&self) -> JWTHeader {
JWTHeader {
alg: self.options.algorithm.clone(),
typ: "JWT".to_string(),
}
}
/// Process payload with standard claims
fn process_payload(&self) -> Result<String, JWTError> {
let mut payload_obj = self
.payload
.as_object()
.ok_or_else(|| JWTError::Custom("Payload must be a JSON object".to_string()))?
.clone();
let now = Utc::now();
let iat = now.timestamp();
let exp = (now + Duration::seconds(self.options.expires_in)).timestamp();
payload_obj.insert("iat".to_string(), iat.into());
payload_obj.insert("exp".to_string(), exp.into());
payload_obj.insert("iss".to_string(), self.options.issuer.clone().into());
Ok(to_string(&payload_obj)?)
}
/// Sign the JWT components
fn sign(&self, header_base64: &str, payload_base64: &str) -> Result<String, JWTError> {
let signature_input = format!("{}.{}", header_base64, payload_base64);
let keypair = Rsa::private_key_from_pem(self.private_key.as_bytes())?;
let pkey = PKey::from_rsa(keypair)?;
let mut signer = Signer::new(MessageDigest::sha256(), &pkey)?;
signer.update(signature_input.as_bytes())?;
let signature = signer.sign_to_vec()?;
Ok(base64url_encode(&signature))
}
/// Create complete JWT
pub fn create_token(&self) -> Result<String, JWTError> {
let header = self.create_header();
let processed_payload = self.process_payload()?;
let header_base64 = base64url_encode(to_string(&header)?);
let payload_base64 = base64url_encode(processed_payload);
let signature = self.sign(&header_base64, &payload_base64)?;
Ok(format!(
"{}.{}.{}",
header_base64, payload_base64, signature
))
}
/// Verify JWT token signature
pub fn verify(&self, token: &str) -> bool {
let parts: Vec<&str> = token.split('.').collect();
if parts.len() != 3 {
return false;
}
let signature_input = format!("{}.{}", parts[0], parts[1]);
let signature = match base64url_decode(parts[2]) {
Ok(s) => s,
Err(_) => return false,
};
let key = match PKey::public_key_from_pem(self.public_key.as_bytes()) {
Ok(k) => k,
Err(_) => return false,
};
let mut verifier = match Verifier::new(MessageDigest::sha256(), &key) {
Ok(v) => v,
Err(_) => return false,
};
verifier.update(signature_input.as_bytes()).unwrap();
verifier.verify(&signature).unwrap_or(false)
}
/// Decode JWT token without verification
pub fn decode(token: &str) -> Result<(Value, Value), JWTError> {
let parts: Vec<&str> = token.split('.').collect();
if parts.len() < 2 {
return Err(JWTError::InvalidTokenFormat(
"Token must have at least two parts".to_string(),
));
}
let header_json = String::from_utf8(base64url_decode(parts[0])?)
.map_err(|_| JWTError::InvalidTokenFormat("Header is not valid UTF-8".to_string()))?;
let payload_json = String::from_utf8(base64url_decode(parts[1])?)
.map_err(|_| JWTError::InvalidTokenFormat("Payload is not valid UTF-8".to_string()))?;
let header: Value = from_str(&header_json)?;
let payload: Value = from_str(&payload_json)?;
Ok((header, payload))
}
/// Check if token is expired
pub fn is_expired(token: &str) -> bool {
match Self::decode(token) {
Ok((_, payload)) => {
if let Some(exp) = payload.get("exp").and_then(|v| v.as_i64()) {
let now = Utc::now().timestamp();
exp < now
} else {
true // No expiration claim, consider it expired for safety
}
}
Err(_) => true, // Invalid token, consider it expired
}
}
/// A combined decode and verify function
pub fn decode_and_verify(token: &str) -> Result<(Value, Value), JWTError> {
let jwt = JWTUtils::new(Value::Null, None, None, None)?;
if !jwt.verify(token) {
return Err(JWTError::Validation(
"Signature verification failed".to_string(),
));
}
if Self::is_expired(token) {
return Err(JWTError::Validation("Token has expired".to_string()));
}
Self::decode(token)
}
/// Refresh a token
pub fn refresh_token(old_token: &str) -> Result<String, JWTError> {
let (_, payload_val) = Self::decode(old_token)?;
let mut payload_obj = payload_val
.as_object()
.ok_or_else(|| JWTError::Custom("Payload is not an object".to_string()))?
.clone();
payload_obj.remove("exp");
payload_obj.remove("iat");
// The keys must be loaded from files for this static method
let keys = CryptoUtils::load_keys_from_files("keys")
.map_err(|e| JWTError::Crypto(e.to_string()))?;
let jwt = JWTUtils::new(
Value::Object(payload_obj),
Some(keys.private_key),
Some(keys.public_key),
None,
)?;
jwt.create_token()
}
/// Validate that specific claims are present in the token
pub fn validate_claims(token: &str, required_claims: &[&str]) -> bool {
match Self::decode(token) {
Ok((_, payload)) => {
if let Some(payload_obj) = payload.as_object() {
required_claims
.iter()
.all(|claim| payload_obj.contains_key(*claim))
} else {
false
}
}
Err(_) => false,
}
}
}

View File

@@ -0,0 +1,83 @@
use sha2::{Digest, Sha256};
use rand::Rng;
use regex::Regex;
pub struct PasswordUtils;
impl PasswordUtils {
pub fn hash_password(password: &str) -> String {
let mut hasher = Sha256::new();
hasher.update(password.as_bytes());
format!("{:x}", hasher.finalize())
}
pub fn compare_password(password: &str, hash: &str) -> bool {
Self::hash_password(password) == *hash
}
pub fn generate_salt() -> String {
let salt: [u8; 16] = rand::thread_rng().gen();
hex::encode(salt)
}
pub fn hash_password_with_salt(password: &str, salt: &str) -> String {
let mut hasher = Sha256::new();
hasher.update((password.to_owned() + salt).as_bytes());
format!("{:x}", hasher.finalize())
}
pub fn compare_password_with_salt(password: &str, hash: &str, salt: &str) -> bool {
Self::hash_password_with_salt(password, salt) == *hash
}
pub fn generate_password_reset_token() -> String {
let token: [u8; 32] = rand::thread_rng().gen();
hex::encode(token)
}
// This method in the JS was incorrect (verify_password_reset_token was comparing a hash to itself)
// A proper verification would involve hashing the provided token and comparing it to a stored hash.
// For now, I'll just return true, implying a successful generation and that the token is "valid" on its own.
// In a real application, you'd store the hashed token in the database and compare it during verification.
pub fn verify_password_reset_token(_token: &str) -> bool {
// In a real application, you would hash the token provided and compare it to a stored hash.
// For demonstration, we'll just return true.
true
}
pub fn hash_password_with_salt_and_pepper(password: &str, salt: &str, pepper: &str) -> String {
let mut hasher = Sha256::new();
hasher.update((password.to_owned() + salt + pepper).as_bytes());
format!("{:x}", hasher.finalize())
}
pub fn compare_password_with_salt_and_pepper(password: &str, hash: &str, salt: &str, pepper: &str) -> bool {
Self::hash_password_with_salt_and_pepper(password, salt, pepper) == *hash
}
pub fn check_password_strength(password: &str) -> bool {
let min_length = 8;
let has_upper_case = Regex::new(r"[A-Z]").unwrap();
let has_lower_case = Regex::new(r"[a-z]").unwrap();
let has_numbers = Regex::new(r"\d").unwrap();
let has_special_chars = Regex::new(r"[!@#$%^&*]").unwrap();
password.len() >= min_length
&& has_upper_case.is_match(password)
&& has_lower_case.is_match(password)
&& has_numbers.is_match(password)
&& has_special_chars.is_match(password)
}
pub fn generate_password(length: usize) -> String {
const CHARSET: &[u8] = b"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*()";
let mut rng = rand::thread_rng();
let password: String = (0..length)
.map(|_| {
let idx = rng.gen_range(0..CHARSET.len());
CHARSET[idx] as char
})
.collect();
password
}
}