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:
parent
46695ae2a0
commit
7ba78a31fb
17 changed files with 950 additions and 128 deletions
|
|
@ -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(())
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue