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

@ -70,6 +70,25 @@ async fn main() -> anyhow::Result<()> {
// Get the underlying MongoDB database for security services
let database = db.get_database();
// Ensure collections/indexes exist (best-effort; failures are logged, not
// fatal). Includes the refresh_tokens tokenHash-unique + expiresAt TTL
// indexes and the previously-never-run users.email unique index.
if let Err(e) = db::DatabaseInitializer::new(database.clone())
.initialize()
.await
{
tracing::warn!("Database index initialization skipped: {}", e);
}
// Short-TTL cache of token_version, so the JWT middleware can reject stale
// access tokens without a Mongo lookup per request.
let token_version_cache = std::sync::Arc::new(auth::TokenVersionCache::with_default_ttl());
// Persisted refresh tokens (hashed) for rotation/revocation.
let refresh_token_repo = std::sync::Arc::new(
models::refresh_token::RefreshTokenRepository::new(&database),
);
// Initialize security services (Phase 2.6)
let audit_logger = security::AuditLogger::new(&database);
let session_manager = security::SessionManager::new(&database);
@ -101,6 +120,8 @@ async fn main() -> anyhow::Result<()> {
health_stats_repo: Some(health_stats_repo),
mongo_client: None,
interaction_service: Some(interaction_service),
token_version_cache,
refresh_token_repo: Some(refresh_token_repo),
};
eprintln!("Building router with security middleware...");
@ -114,6 +135,8 @@ async fn main() -> anyhow::Result<()> {
.route("/ready", get(handlers::ready_check))
.route("/api/auth/register", post(handlers::register))
.route("/api/auth/login", post(handlers::login))
.route("/api/auth/refresh", post(handlers::refresh))
.route("/api/auth/logout", post(handlers::logout))
.route(
"/api/auth/recover-password",
post(handlers::recover_password),
@ -175,6 +198,11 @@ async fn main() -> anyhow::Result<()> {
.with_state(state)
.layer(
ServiceBuilder::new()
// Resolve the client IP once and stash it for handlers/middleware
// (audit logging, account-lockout forensics).
.layer(axum::middleware::from_fn(
middleware::client_ip_middleware,
))
// Add security headers first (applies to all responses)
.layer(axum::middleware::from_fn(
middleware::security_headers_middleware
@ -193,7 +221,14 @@ async fn main() -> anyhow::Result<()> {
eprintln!("Server listening on {}", &addr);
tracing::info!("Server listening on {}", &addr);
axum::serve(listener, app).await?;
// into_make_service_with_connect_info makes ConnectInfo<SocketAddr> available
// to the client_ip_middleware so we can fall back to the peer address when no
// proxy header (X-Forwarded-For / X-Real-IP) is present.
axum::serve(
listener,
app.into_make_service_with_connect_info::<std::net::SocketAddr>(),
)
.await?;
Ok(())
}