fix(backend): P0 security hardening pass

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.
This commit is contained in:
goose 2026-06-27 13:12:19 -03:00
parent 46695ae2a0
commit 7ba78a31fb
17 changed files with 950 additions and 128 deletions

View file

@ -1,35 +1,31 @@
#![allow(dead_code)]
use mongodb::{bson::doc, Client, Collection, IndexModel};
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 {
client: Client,
db_name: String,
db: mongodb::Database,
}
impl DatabaseInitializer {
pub fn new(client: Client, db_name: String) -> Self {
Self { client, db_name }
pub fn new(db: mongodb::Database) -> Self {
Self { db }
}
pub async fn initialize(&self) -> Result<()> {
let db = self.client.database(&self.db_name);
println!("[MongoDB] Initializing database collections and indexes...");
// Create users collection and index
{
let collection: Collection<mongodb::bson::Document> = db.collection("users");
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(
mongodb::options::IndexOptions::builder()
.unique(true)
.build(),
)
.options(IndexOptions::builder().unique(true).build())
.build();
match collection.create_index(index, None).await {
@ -40,7 +36,7 @@ impl DatabaseInitializer {
// Create families collection and indexes
{
let collection: Collection<mongodb::bson::Document> = db.collection("families");
let collection: Collection<mongodb::bson::Document> = self.db.collection("families");
let index1 = IndexModel::builder().keys(doc! { "userId": 1 }).build();
@ -62,7 +58,7 @@ impl DatabaseInitializer {
// Create profiles collection and index
{
let collection: Collection<mongodb::bson::Document> = db.collection("profiles");
let collection: Collection<mongodb::bson::Document> = self.db.collection("profiles");
let index = IndexModel::builder().keys(doc! { "familyId": 1 }).build();
@ -77,31 +73,35 @@ impl DatabaseInitializer {
// Create health_data collection
{
let _collection: Collection<mongodb::bson::Document> = db.collection("health_data");
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> = db.collection("lab_results");
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> = db.collection("medications");
let _collection: Collection<mongodb::bson::Document> =
self.db.collection("medications");
println!("✓ Created medications collection");
}
// Create appointments collection
{
let _collection: Collection<mongodb::bson::Document> = db.collection("appointments");
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> = db.collection("shares");
let collection: Collection<mongodb::bson::Document> = self.db.collection("shares");
let index = IndexModel::builder().keys(doc! { "familyId": 1 }).build();
@ -111,23 +111,41 @@ impl DatabaseInitializer {
}
}
// Create refresh_tokens collection and index
// 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> = db.collection("refresh_tokens");
let collection: Collection<mongodb::bson::Document> =
self.db.collection("refresh_tokens");
let index = IndexModel::builder()
.keys(doc! { "token": 1 })
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(
mongodb::options::IndexOptions::builder()
.unique(true)
IndexOptions::builder()
.expire_after(std::time::Duration::from_secs(0))
.build(),
)
.build();
match collection.create_index(index, None).await {
Ok(_) => println!("✓ Created index on refresh_tokens.token"),
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.token: {}",
"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
),
}