Address the four P0 security items from the project review: * token_version validation (#1): the JWT middleware now rejects access tokens whose token_version claim is stale (e.g. issued before a password change). A short-TTL (30s) in-memory cache (TokenVersionCache) avoids a Mongo lookup per request; credential changes invalidate the cache immediately on the handling instance. * Fail-fast config (#2): add APP_ENVIRONMENT (development|production). In production the server refuses to boot unless JWT_SECRET and ENCRYPTION_KEY are set to non-default values; development keeps the insecure defaults with a warning. * Real client IP in audit logs (#4): new client_ip middleware resolves the originating IP (X-Forwarded-For > X-Real-IP > ConnectInfo socket) and exposes it via a ClientIp extractor. All five hardcoded "0.0.0.0" audit calls are replaced, and the missing PasswordChanged audit event is added to change_password. axum::serve now uses into_make_service_with_connect_info. * Refresh token persistence (#5): refresh tokens are now stored hashed in MongoDB (RefreshTokenRepository) instead of an in-memory map lost on restart. Added /api/auth/refresh (with rotation + token_version check) and /api/auth/logout routes; register/login return a refresh_token; password change/recovery revoke all of a user's refresh tokens. JwtService now honors JwtConfig expiries instead of hardcoding 15min/30d, and the dead in-memory refresh store is removed. Also: wire up DatabaseInitializer (was never called), fix the refresh_tokens index to tokenHash + add an expiresAt TTL index, add sha2 dep. Rate limiting (#3) is deferred per scope; the stub remains. Verified: cargo fmt --check clean, cargo build/clippy --all-targets clean, 18 unit tests pass (9 new). Integration tests (tests/*) still need a live server — fixing them is tracked as P1. BREAKING CHANGE: AuthResponse now includes a refresh_token field.
157 lines
5.9 KiB
Rust
157 lines
5.9 KiB
Rust
#![allow(dead_code)]
|
|
use mongodb::{bson::doc, options::IndexOptions, Collection, IndexModel};
|
|
|
|
use anyhow::Result;
|
|
|
|
/// Creates required collections and indexes. Best-effort by design: index
|
|
/// creation failures are logged as warnings and do not abort startup, matching
|
|
/// the existing swallow-on-warning style. Safe to run repeatedly (idempotent).
|
|
pub struct DatabaseInitializer {
|
|
db: mongodb::Database,
|
|
}
|
|
|
|
impl DatabaseInitializer {
|
|
pub fn new(db: mongodb::Database) -> Self {
|
|
Self { db }
|
|
}
|
|
|
|
pub async fn initialize(&self) -> Result<()> {
|
|
println!("[MongoDB] Initializing database collections and indexes...");
|
|
|
|
// Create users collection and index
|
|
{
|
|
let collection: Collection<mongodb::bson::Document> = self.db.collection("users");
|
|
|
|
// Create email index using the builder pattern
|
|
let index = IndexModel::builder()
|
|
.keys(doc! { "email": 1 })
|
|
.options(IndexOptions::builder().unique(true).build())
|
|
.build();
|
|
|
|
match collection.create_index(index, None).await {
|
|
Ok(_) => println!("✓ Created index on users.email"),
|
|
Err(e) => println!("Warning: Failed to create index on users.email: {}", e),
|
|
}
|
|
}
|
|
|
|
// Create families collection and indexes
|
|
{
|
|
let collection: Collection<mongodb::bson::Document> = self.db.collection("families");
|
|
|
|
let index1 = IndexModel::builder().keys(doc! { "userId": 1 }).build();
|
|
|
|
let index2 = IndexModel::builder().keys(doc! { "familyId": 1 }).build();
|
|
|
|
match collection.create_index(index1, None).await {
|
|
Ok(_) => println!("✓ Created index on families.userId"),
|
|
Err(e) => println!("Warning: Failed to create index on families.userId: {}", e),
|
|
}
|
|
|
|
match collection.create_index(index2, None).await {
|
|
Ok(_) => println!("✓ Created index on families.familyId"),
|
|
Err(e) => println!(
|
|
"Warning: Failed to create index on families.familyId: {}",
|
|
e
|
|
),
|
|
}
|
|
}
|
|
|
|
// Create profiles collection and index
|
|
{
|
|
let collection: Collection<mongodb::bson::Document> = self.db.collection("profiles");
|
|
|
|
let index = IndexModel::builder().keys(doc! { "familyId": 1 }).build();
|
|
|
|
match collection.create_index(index, None).await {
|
|
Ok(_) => println!("✓ Created index on profiles.familyId"),
|
|
Err(e) => println!(
|
|
"Warning: Failed to create index on profiles.familyId: {}",
|
|
e
|
|
),
|
|
}
|
|
}
|
|
|
|
// Create health_data collection
|
|
{
|
|
let _collection: Collection<mongodb::bson::Document> =
|
|
self.db.collection("health_data");
|
|
println!("✓ Created health_data collection");
|
|
}
|
|
|
|
// Create lab_results collection
|
|
{
|
|
let _collection: Collection<mongodb::bson::Document> =
|
|
self.db.collection("lab_results");
|
|
println!("✓ Created lab_results collection");
|
|
}
|
|
|
|
// Create medications collection
|
|
{
|
|
let _collection: Collection<mongodb::bson::Document> =
|
|
self.db.collection("medications");
|
|
println!("✓ Created medications collection");
|
|
}
|
|
|
|
// Create appointments collection
|
|
{
|
|
let _collection: Collection<mongodb::bson::Document> =
|
|
self.db.collection("appointments");
|
|
println!("✓ Created appointments collection");
|
|
}
|
|
|
|
// Create shares collection and index
|
|
{
|
|
let collection: Collection<mongodb::bson::Document> = self.db.collection("shares");
|
|
|
|
let index = IndexModel::builder().keys(doc! { "familyId": 1 }).build();
|
|
|
|
match collection.create_index(index, None).await {
|
|
Ok(_) => println!("✓ Created index on shares.familyId"),
|
|
Err(e) => println!("Warning: Failed to create index on shares.familyId: {}", e),
|
|
}
|
|
}
|
|
|
|
// Create refresh_tokens collection and indexes.
|
|
// - unique index on tokenHash (the only thing we ever look tokens up by)
|
|
// - TTL index on expiresAt so expired tokens are auto-deleted by Mongo
|
|
{
|
|
let collection: Collection<mongodb::bson::Document> =
|
|
self.db.collection("refresh_tokens");
|
|
|
|
let unique_index = IndexModel::builder()
|
|
.keys(doc! { "tokenHash": 1 })
|
|
.options(IndexOptions::builder().unique(true).build())
|
|
.build();
|
|
|
|
// TTL: documents are deleted once their `expiresAt` timestamp passes.
|
|
// With expireAfterSeconds=0, Mongo removes each doc exactly when the
|
|
// datetime stored in `expiresAt` is reached.
|
|
let ttl_index = IndexModel::builder()
|
|
.keys(doc! { "expiresAt": 1 })
|
|
.options(
|
|
IndexOptions::builder()
|
|
.expire_after(std::time::Duration::from_secs(0))
|
|
.build(),
|
|
)
|
|
.build();
|
|
|
|
match collection.create_index(unique_index, None).await {
|
|
Ok(_) => println!("✓ Created unique index on refresh_tokens.tokenHash"),
|
|
Err(e) => println!(
|
|
"Warning: Failed to create index on refresh_tokens.tokenHash: {}",
|
|
e
|
|
),
|
|
}
|
|
match collection.create_index(ttl_index, None).await {
|
|
Ok(_) => println!("✓ Created TTL index on refresh_tokens.expiresAt"),
|
|
Err(e) => println!(
|
|
"Warning: Failed to create TTL index on refresh_tokens.expiresAt: {}",
|
|
e
|
|
),
|
|
}
|
|
}
|
|
|
|
println!("[MongoDB] Database initialization complete");
|
|
Ok(())
|
|
}
|
|
}
|