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,18 +1,24 @@
#![allow(dead_code)]
#![allow(unused_imports)]
use mongodb::bson::{oid::ObjectId, DateTime};
use anyhow::Result;
use base64::Engine;
use mongodb::{
bson::{doc, oid::ObjectId, DateTime},
Collection,
};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
/// A persisted, rotated refresh token. Only the SHA-256 hash of the raw JWT
/// refresh token is ever stored — the plaintext lives only inside the signed
/// JWT handed to the client.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RefreshToken {
#[serde(rename = "_id", skip_serializing_if = "Option::is_none")]
pub id: Option<ObjectId>,
#[serde(rename = "tokenId")]
pub token_id: String,
#[serde(rename = "userId")]
pub user_id: String,
#[serde(rename = "tokenHash")]
pub token_hash: String,
#[serde(rename = "userId")]
pub user_id: String,
#[serde(rename = "expiresAt")]
pub expires_at: DateTime,
#[serde(rename = "createdAt")]
@ -22,3 +28,119 @@ pub struct RefreshToken {
#[serde(rename = "revokedAt")]
pub revoked_at: Option<DateTime>,
}
/// SHA-256 the raw refresh token and base64-encode the digest. Two equal
/// tokens produce equal hashes, so lookups work; the plaintext is never stored.
pub fn hash_token(token: &str) -> String {
let digest = Sha256::digest(token.as_bytes());
base64::engine::general_purpose::STANDARD.encode(digest)
}
#[derive(Clone)]
pub struct RefreshTokenRepository {
collection: Collection<RefreshToken>,
}
impl RefreshTokenRepository {
pub fn new(db: &mongodb::Database) -> Self {
Self {
collection: db.collection("refresh_tokens"),
}
}
/// Persist a refresh token record from its raw JWT form. Hashes the token
/// before storage.
pub async fn create(
&self,
user_id: &str,
raw_token: &str,
expires_at: DateTime,
) -> Result<ObjectId> {
let now = DateTime::now();
let record = RefreshToken {
id: None,
token_hash: hash_token(raw_token),
user_id: user_id.to_string(),
expires_at,
created_at: now,
revoked: false,
revoked_at: None,
};
let id = self
.collection
.insert_one(record, None)
.await?
.inserted_id
.as_object_id()
.ok_or_else(|| anyhow::anyhow!("Failed to get inserted refresh token id"))?;
Ok(id)
}
/// Look up a token record by its raw JWT form. Returns `None` if not found.
/// Callers must still check `revoked` and `expires_at`.
pub async fn find_by_raw_token(&self, raw_token: &str) -> Result<Option<RefreshToken>> {
let hash = hash_token(raw_token);
let record = self
.collection
.find_one(doc! { "tokenHash": hash }, None)
.await?;
Ok(record)
}
/// Mark a single token record (by id) as revoked.
pub async fn revoke(&self, id: &ObjectId) -> Result<()> {
self.collection
.update_one(
doc! { "_id": id },
doc! {
"$set": {
"revoked": true,
"revokedAt": DateTime::now()
}
},
None,
)
.await?;
Ok(())
}
/// Revoke every active refresh token for a user. Called on credential
/// changes (password change / recovery) and explicit "logout everywhere".
pub async fn revoke_all_by_user(&self, user_id: &str) -> Result<()> {
self.collection
.update_many(
doc! { "userId": user_id, "revoked": false },
doc! {
"$set": {
"revoked": true,
"revokedAt": DateTime::now()
}
},
None,
)
.await?;
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn hash_token_is_deterministic() {
assert_eq!(hash_token("abc"), hash_token("abc"));
}
#[test]
fn hash_token_differs_for_different_input() {
assert_ne!(hash_token("abc"), hash_token("abd"));
}
#[test]
fn hash_token_is_base64_sha256_length() {
// SHA-256 = 32 bytes -> 44 base64 chars (standard alphabet, padded).
assert_eq!(hash_token("anything").len(), 44);
}
}