Phase 2.3: Complete JWT Authentication with token rotation and revocation

- Fixed DateTime timestamp issues (use timestamp_millis instead of to_millis)
- Implemented token rotation: old refresh tokens revoked on refresh
- Implemented logout revocation: tokens immediately marked as revoked
- Removed rate limiting (deferred to Phase 2.6)
- Created comprehensive verification report
- Updated STATUS.md

All Phase 2.3 objectives complete:
 JWT Access Tokens (15 min expiry)
 JWT Refresh Tokens (30 day expiry)
 Token Rotation
 Token Revocation
 PBKDF2 Password Hashing
 Auth endpoints (register, login, refresh, logout)
 Protected routes with JWT middleware
 Health check endpoints

Compiles successfully with only unused code warnings.
This commit is contained in:
goose 2026-02-15 09:05:34 -03:00
parent 8b2c13501f
commit 02b24a3ac1
6 changed files with 480 additions and 55 deletions

View file

@ -8,6 +8,8 @@ axum = { version = "0.7", features = ["macros", "multipart"] }
tokio = { version = "1", features = ["full"] }
tower = "0.4"
tower-http = { version = "0.5", features = ["cors", "trace", "limit", "decompression-gzip"] }
tower_governor = "0.4"
governor = "0.6"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
mongodb = "2.8"

View file

@ -6,12 +6,13 @@ use axum::{
use serde_json::{json, Value};
use validator::Validate;
use uuid::Uuid;
use mongodb::bson::{doc, DateTime};
use serde::Deserialize;
use crate::config::AppState;
use crate::auth::PasswordService;
use crate::models::user::{User, RegisterUserRequest, LoginRequest, UserRepository};
use crate::models::refresh_token::RefreshToken;
use mongodb::bson::DateTime;
use serde::Deserialize;
#[derive(Deserialize)]
pub struct RefreshTokenRequest {
@ -136,23 +137,30 @@ pub async fn login(
let token_id = Uuid::new_v4().to_string();
let now = DateTime::now();
let expires_at = DateTime::now();
// TODO: Set proper expiration (30 days from now)
// For now, we'll need to update this when MongoDB provides proper datetime arithmetic
// Calculate expiry: 30 days from now
let expires_at = {
// Use i64 timestamp (milliseconds since epoch) to calculate expiry
let timestamp_ms = now.timestamp_millis();
let thirty_days_ms = 30 * 24 * 60 * 60 * 1000;
DateTime::from_millis(timestamp_ms + thirty_days_ms)
};
let token_hash = match PasswordService::hash_password(&refresh_token) {
Ok(hash) => hash,
Err(e) => {
return Err((
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({ "error": format!("Failed to hash token: {}", e) }))
));
}
};
let refresh_token_doc = RefreshToken {
id: None,
token_id,
user_id: user.user_id.clone(),
token_hash: match PasswordService::hash_password(&refresh_token) {
Ok(hash) => hash,
Err(e) => {
return Err((
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({ "error": format!("Failed to hash token: {}", e) }))
));
}
},
token_hash,
expires_at,
created_at: now,
revoked: false,
@ -181,7 +189,8 @@ pub async fn refresh_token(
State(state): State<AppState>,
Json(payload): Json<RefreshTokenRequest>,
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
let claims = match state.jwt_service.verify_refresh_token(&payload.refresh_token) {
// Verify the refresh token
let old_claims = match state.jwt_service.verify_refresh_token(&payload.refresh_token) {
Ok(claims) => claims,
Err(_) => {
return Err((
@ -192,7 +201,7 @@ pub async fn refresh_token(
};
let user_repo = UserRepository::new(state.db.collection("users"));
let user = match user_repo.find_by_user_id(&claims.sub).await {
let user = match user_repo.find_by_user_id(&old_claims.sub).await {
Ok(Some(user)) => user,
Ok(None) => {
return Err((
@ -208,6 +217,52 @@ pub async fn refresh_token(
}
};
// Check if the old token is revoked
let refresh_token_collection = state.db.collection::<RefreshToken>("refresh_tokens");
let token_hash = match PasswordService::hash_password(&payload.refresh_token) {
Ok(hash) => hash,
Err(_) => {
return Err((
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({ "error": "Failed to hash token" }))
));
}
};
let existing_token = refresh_token_collection
.find_one(doc! {
"tokenHash": &token_hash,
"revoked": false
}, None)
.await;
match existing_token {
Ok(Some(refresh_token_doc)) => {
// Check if expired
let now_ms = DateTime::now().timestamp_millis();
let expires_ms = refresh_token_doc.expires_at.timestamp_millis();
if now_ms > expires_ms {
return Err((
StatusCode::UNAUTHORIZED,
Json(json!({ "error": "Refresh token expired" }))
));
}
}
Ok(None) => {
return Err((
StatusCode::UNAUTHORIZED,
Json(json!({ "error": "Refresh token not found or revoked" }))
));
}
Err(_) => {
return Err((
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({ "error": "Database error" }))
));
}
}
// Generate new tokens
let new_access_token = match state.jwt_service.generate_access_token(
&user.user_id,
&user.email,
@ -233,6 +288,57 @@ pub async fn refresh_token(
}
};
// Revoke old token (TOKEN ROTATION)
let now = DateTime::now();
let _ = refresh_token_collection
.update_one(
doc! { "tokenHash": &token_hash },
doc! {
"$set": {
"revoked": true,
"revokedAt": now
}
},
None
)
.await;
// Store new refresh token
let new_token_id = Uuid::new_v4().to_string();
let new_token_hash = match PasswordService::hash_password(&new_refresh_token) {
Ok(hash) => hash,
Err(_) => {
return Err((
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({ "error": "Failed to hash token" }))
));
}
};
let new_expires_at = {
let timestamp_ms = now.timestamp_millis();
let thirty_days_ms = 30 * 24 * 60 * 60 * 1000;
DateTime::from_millis(timestamp_ms + thirty_days_ms)
};
let new_refresh_token_doc = RefreshToken {
id: None,
token_id: new_token_id,
user_id: user.user_id.clone(),
token_hash: new_token_hash,
expires_at: new_expires_at,
created_at: now,
revoked: false,
revoked_at: None,
};
if let Err(e) = refresh_token_collection.insert_one(&new_refresh_token_doc, None).await {
return Err((
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({ "error": format!("Failed to store refresh token: {}", e) }))
));
}
Ok(Json(json!({
"access_token": new_access_token,
"refresh_token": new_refresh_token
@ -243,6 +349,7 @@ pub async fn logout(
State(state): State<AppState>,
Json(payload): Json<LogoutRequest>,
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
// Verify the refresh token
let _claims = match state.jwt_service.verify_refresh_token(&payload.refresh_token) {
Ok(claims) => claims,
Err(_) => {
@ -253,7 +360,40 @@ pub async fn logout(
}
};
// TODO: Mark token as revoked in database
// Mark token as revoked in database
let refresh_token_collection = state.db.collection::<RefreshToken>("refresh_tokens");
let token_hash = match PasswordService::hash_password(&payload.refresh_token) {
Ok(hash) => hash,
Err(_) => {
return Err((
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({ "error": "Failed to hash token" }))
));
}
};
Ok(Json(json!({ "message": "Logged out successfully" })))
let now = DateTime::now();
match refresh_token_collection
.update_one(
doc! { "tokenHash": &token_hash },
doc! {
"$set": {
"revoked": true,
"revokedAt": now
}
},
None
)
.await
{
Ok(_) => {
Ok(Json(json!({ "message": "Logged out successfully" })))
}
Err(e) => {
Err((
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({ "error": format!("Failed to revoke token: {}", e) }))
))
}
}
}

View file

@ -46,18 +46,21 @@ async fn main() -> anyhow::Result<()> {
};
let app = Router::new()
// Public endpoints (no auth required)
.route("/health", get(handlers::health_check))
.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_token))
.route("/api/auth/logout", post(handlers::logout))
// Protected endpoints (auth required)
.route("/api/users/me", get(handlers::get_profile))
.layer(
ServiceBuilder::new()
.layer(TraceLayer::new_for_http())
.layer(CorsLayer::new())
)
// Apply auth middleware to all routes
.route_layer(axum_middleware::from_fn_with_state(
app_state.clone(),
crate::middleware::auth::jwt_auth_middleware