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:
parent
8b2c13501f
commit
02b24a3ac1
6 changed files with 480 additions and 55 deletions
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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) }))
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue