refresh_token_service.rs
665 lignes · 21259 octets
//! Service for managing refresh tokens //! //! This service handles the lifecycle of refresh tokens: //! - Generation of secure random tokens //! - Storage and retrieval from database //! - Token rotation on use //! - Reuse detection (security) //! - Token revocation use crate::error::AppError; use crate::models::refresh_token; use chrono::{DateTime, Duration, Utc}; use rand::{Rng, distr::Alphanumeric}; use sea_orm::{ ActiveModelTrait, ColumnTrait, DatabaseConnection, EntityTrait, PaginatorTrait, QueryFilter, QueryOrder, Set, }; use sha2::{Digest, Sha256}; use uuid::Uuid; /// Active session information for display #[derive(Debug, Clone, serde::Serialize)] pub struct ActiveSession { /// Unique identifier of the refresh token / session. pub id: Uuid, /// Optional device or user-agent description. pub device_info: Option<String>, /// IP address from which the session was created. pub ip_address: Option<String>, /// Timestamp of the last activity associated with this session. pub last_activity: DateTime<Utc>, /// Timestamp when this session was originally created. pub created_at: DateTime<Utc>, /// Whether this session corresponds to the currently authenticated request. #[serde(default)] pub is_current: bool, } /// Result of creating a refresh token: the DB model (with hash) + plain token for the client. pub struct CreatedRefreshToken { /// The persisted refresh token model (stores the SHA-256 hash, not the plain token). pub model: refresh_token::Model, /// The plaintext token to be sent to the client (never stored directly). pub plain_token: String, } /// Service for refresh token operations pub struct RefreshTokenService; impl RefreshTokenService { /// SEC-L1: Hash a token with SHA-256 before storage/lookup. fn hash_token(token: &str) -> String { let mut hasher = Sha256::new(); hasher.update(token.as_bytes()); hex::encode(hasher.finalize()) } /// Generate a secure random refresh token /// /// Generates a cryptographically secure random token using `rand::thread_rng()`. /// The token is 64 characters long, composed of alphanumeric characters. /// /// # Returns /// /// A secure random token string /// /// # Security /// /// - Uses `rand::thread_rng()` (cryptographically secure PRNG) /// - 64 characters = ~380 bits of entropy (very secure) /// - Alphanumeric only (URL-safe) /// /// # Examples /// /// ``` /// use rustwarden_core::services::refresh_token_service::RefreshTokenService; /// /// let token = RefreshTokenService::generate_token(); /// assert_eq!(token.len(), 64); /// ``` pub fn generate_token() -> String { rand::rng() .sample_iter(&Alphanumeric) .take(64) .map(char::from) .collect() } /// Create a new refresh token in the database /// /// Generates a secure token, stores it in the database with expiration. /// /// # Arguments /// /// * `db` - Database connection /// * `user_id` - User ID for whom to create the token /// * `expiration_days` - Number of days until token expires (typically 7) /// /// # Returns /// /// The created refresh token model /// /// # Errors /// /// Returns `AppError::Database` if database operation fails /// /// # Examples /// /// ```no_run /// use rustwarden_core::services::refresh_token_service::RefreshTokenService; /// use uuid::Uuid; /// use sea_orm::DatabaseConnection; /// /// async fn create_token(db: DatabaseConnection, user_id: Uuid) { /// let token = RefreshTokenService::create_refresh_token(&db, user_id, 7) /// .await /// .unwrap(); /// println!("Token: {}", token.plain_token); /// } /// ``` /// SEC-M4: Maximum active (non-revoked, non-expired) refresh tokens per user. const MAX_ACTIVE_TOKENS_PER_USER: u64 = 10; /// SEC-L1: Stores the SHA-256 hash of the token in the database. /// SEC-M4: Revokes oldest tokens if the per-user limit is exceeded. /// Returns the plain token (for the client) along with the DB model. pub async fn create_refresh_token( db: &DatabaseConnection, user_id: Uuid, expiration_days: i64, ) -> Result<CreatedRefreshToken, AppError> { // SEC-M4: Enforce per-user token limit by revoking the oldest excess tokens let now_chrono = Utc::now(); let active_tokens: Vec<refresh_token::Model> = refresh_token::Entity::find() .filter(refresh_token::Column::UserId.eq(user_id)) .filter(refresh_token::Column::Revoked.eq(false)) .filter(refresh_token::Column::ExpiresAt.gt(now_chrono)) .order_by_asc(refresh_token::Column::CreatedAt) .all(db) .await?; if active_tokens.len() as u64 >= Self::MAX_ACTIVE_TOKENS_PER_USER { let excess = active_tokens.len() as u64 - Self::MAX_ACTIVE_TOKENS_PER_USER + 1; for old_token in active_tokens.iter().take(excess as usize) { let mut active: refresh_token::ActiveModel = old_token.clone().into(); active.revoked = Set(true); active.revoked_at = Set(Some(now_chrono)); active.update(db).await?; } tracing::info!( "SEC-M4: Revoked {} excess refresh tokens for user {}", excess, user_id ); } let plain_token = Self::generate_token(); let token_hash = Self::hash_token(&plain_token); let expires_at = now_chrono + Duration::days(expiration_days); let refresh_token = refresh_token::ActiveModel { id: Set(Uuid::new_v4()), user_id: Set(user_id), token: Set(token_hash), expires_at: Set(expires_at), created_at: Set(now_chrono), revoked: Set(false), revoked_at: Set(None), replaced_by: Set(None), }; let model = refresh_token.insert(db).await?; tracing::debug!( "Created refresh token for user {} (expires in {} days)", user_id, expiration_days ); Ok(CreatedRefreshToken { model, plain_token }) } /// Validate and use a refresh token /// /// Validates the token (exists, not expired, not revoked) and returns it. /// The token should then be marked as used/replaced by the caller. /// /// # Arguments /// /// * `db` - Database connection /// * `token` - Refresh token string to validate /// /// # Returns /// /// The validated refresh token model /// /// # Errors /// /// - `AppError::InvalidToken` - Token not found or malformed /// - `AppError::TokenExpired` - Token has expired /// - `AppError::InvalidToken` - Token has been revoked /// - `AppError::TokenReused` - Token has already been used (rotation) /// /// # Security /// /// This method performs all necessary validations but does NOT automatically /// mark the token as used. The caller must call `mark_as_replaced()` after /// successfully generating new tokens to complete the rotation. /// /// # Examples /// /// ```no_run /// use rustwarden_core::services::refresh_token_service::RefreshTokenService; /// use sea_orm::DatabaseConnection; /// /// async fn validate(db: DatabaseConnection, token_str: &str) { /// match RefreshTokenService::validate_refresh_token(&db, token_str).await { /// Ok(token) => println!("Valid token for user {}", token.user_id), /// Err(e) => println!("Invalid token: {}", e), /// } /// } /// ``` /// SEC-L1: Hashes the input token with SHA-256 before DB lookup. pub async fn validate_refresh_token( db: &DatabaseConnection, token: &str, ) -> Result<refresh_token::Model, AppError> { let token_hash = Self::hash_token(token); let refresh_token = refresh_token::Entity::find() .filter(refresh_token::Column::Token.eq(token_hash)) .one(db) .await? .ok_or_else(|| { tracing::warn!("Refresh token not found in database"); AppError::InvalidToken })?; // Check if token has been revoked if refresh_token.revoked { tracing::warn!( "Attempt to use revoked refresh token for user {}", refresh_token.user_id ); return Err(AppError::InvalidToken); } // Check if token has expired if refresh_token.is_expired() { tracing::debug!("Refresh token expired"); return Err(AppError::TokenExpired); } // Check if token has already been used (reuse detection) if refresh_token.has_been_used() { tracing::error!( "SECURITY ALERT: Refresh token reuse detected for user {}", refresh_token.user_id ); return Err(AppError::TokenReused); } tracing::debug!("Refresh token validated for user {}", refresh_token.user_id); Ok(refresh_token) } /// Mark a refresh token as replaced by a new token /// /// Updates the token's `replaced_by` field to indicate it has been used /// and replaced during rotation. This is used for reuse detection. /// /// # Arguments /// /// * `db` - Database connection /// * `old_token_id` - ID of the token being replaced /// * `new_token_id` - ID of the new token /// /// # Returns /// /// The updated token model /// /// # Errors /// /// Returns `AppError::Database` if update fails pub async fn mark_as_replaced( db: &DatabaseConnection, old_token_id: Uuid, new_token_id: Uuid, ) -> Result<refresh_token::Model, AppError> { // Find the old token let old_token = refresh_token::Entity::find_by_id(old_token_id) .one(db) .await? .ok_or_else(|| AppError::NotFound("Token not found".to_string()))?; // Update it with the new token ID let mut active_model: refresh_token::ActiveModel = old_token.into(); active_model.replaced_by = Set(Some(new_token_id)); let updated = active_model.update(db).await?; tracing::debug!( "Marked token {} as replaced by {}", old_token_id, new_token_id ); Ok(updated) } /// Revoke all refresh tokens for a user /// /// Marks all active tokens for a user as revoked. This is used: /// - On logout (revoke all sessions) /// - On password change (security measure) /// - On suspicious activity (e.g., token reuse detected) /// /// # Arguments /// /// * `db` - Database connection /// * `user_id` - User ID whose tokens should be revoked /// /// # Returns /// /// Number of tokens revoked /// /// # Errors /// /// Returns `AppError::Database` if operation fails /// /// # Security /// /// This is a critical security operation that should be called when: /// - Token reuse is detected (potential theft) /// - User explicitly logs out from all devices /// - Password is changed /// - Account is compromised /// /// # Examples /// /// ```no_run /// use rustwarden_core::services::refresh_token_service::RefreshTokenService; /// use uuid::Uuid; /// use sea_orm::DatabaseConnection; /// /// async fn revoke_all(db: DatabaseConnection, user_id: Uuid) { /// let count = RefreshTokenService::revoke_all_tokens_for_user(&db, user_id) /// .await /// .unwrap(); /// println!("Revoked {} tokens", count); /// } /// ``` pub async fn revoke_all_tokens_for_user( db: &DatabaseConnection, user_id: Uuid, ) -> Result<u64, AppError> { let now = Utc::now(); // Find all non-revoked tokens for this user let tokens = refresh_token::Entity::find() .filter(refresh_token::Column::UserId.eq(user_id)) .filter(refresh_token::Column::Revoked.eq(false)) .all(db) .await?; let count = tokens.len() as u64; // Revoke each token for token in tokens { let mut active_model: refresh_token::ActiveModel = token.into(); active_model.revoked = Set(true); active_model.revoked_at = Set(Some(now)); active_model.update(db).await?; } tracing::info!("Revoked {} refresh tokens for user {}", count, user_id); Ok(count) } /// Get all active (non-revoked, non-expired) tokens for a user /// /// # Arguments /// /// * `db` - Database connection /// * `user_id` - User ID /// /// # Returns /// /// Vector of active refresh tokens /// /// # Errors /// /// Returns `AppError::Database` if query fails #[allow(dead_code)] // Admin utility for monitoring user sessions pub async fn get_active_tokens_for_user( db: &DatabaseConnection, user_id: Uuid, ) -> Result<Vec<refresh_token::Model>, AppError> { let now = Utc::now(); let tokens = refresh_token::Entity::find() .filter(refresh_token::Column::UserId.eq(user_id)) .filter(refresh_token::Column::Revoked.eq(false)) .filter(refresh_token::Column::ExpiresAt.gt(now)) .all(db) .await?; Ok(tokens) } /// Clean up expired tokens from the database /// /// Deletes all tokens that have expired. This is a maintenance operation /// that should be run periodically (e.g., daily cron job). /// /// # Arguments /// /// * `db` - Database connection /// /// # Returns /// /// Number of tokens deleted /// /// # Errors /// /// Returns `AppError::Database` if operation fails /// /// # Examples /// /// ```no_run /// use rustwarden_core::services::refresh_token_service::RefreshTokenService; /// use sea_orm::DatabaseConnection; /// /// async fn cleanup(db: DatabaseConnection) { /// let count = RefreshTokenService::cleanup_expired_tokens(&db) /// .await /// .unwrap(); /// println!("Cleaned up {} expired tokens", count); /// } /// ``` #[allow(dead_code)] // Maintenance task, should be run as cron job pub async fn cleanup_expired_tokens(db: &DatabaseConnection) -> Result<u64, AppError> { let now = Utc::now(); // Find all expired tokens let expired_tokens = refresh_token::Entity::find() .filter(refresh_token::Column::ExpiresAt.lt(now)) .all(db) .await?; let count = expired_tokens.len() as u64; // Delete them for token in expired_tokens { let active_model: refresh_token::ActiveModel = token.into(); active_model.delete(db).await?; } if count > 0 { tracing::info!("Cleaned up {} expired refresh tokens", count); } Ok(count) } /// Count total refresh tokens for a user /// /// # Arguments /// /// * `db` - Database connection /// * `user_id` - User ID /// /// # Returns /// /// Number of tokens (active + expired + revoked) /// /// # Errors /// /// Returns `AppError::Database` if query fails #[allow(dead_code)] // Admin utility for monitoring pub async fn count_tokens_for_user( db: &DatabaseConnection, user_id: Uuid, ) -> Result<u64, AppError> { let count = refresh_token::Entity::find() .filter(refresh_token::Column::UserId.eq(user_id)) .count(db) .await?; Ok(count) } /// Count active sessions (non-revoked, non-expired refresh tokens) /// /// This method counts all active sessions across all users. /// Used for admin dashboard statistics. /// /// # Arguments /// /// * `db` - Database connection /// /// # Returns /// /// Number of active sessions /// /// # Errors /// /// Returns `AppError::Database` if query fails pub async fn count_active_sessions(db: &DatabaseConnection) -> Result<u64, AppError> { let now = Utc::now(); let count = refresh_token::Entity::find() .filter(refresh_token::Column::Revoked.eq(false)) .filter(refresh_token::Column::ExpiresAt.gt(now)) .count(db) .await?; Ok(count) } /// List all active sessions for a user (Feature 009 - Phase 9) /// /// Returns all non-revoked, non-expired refresh tokens for the specified user. /// /// # Arguments /// /// * `db` - Database connection /// * `user_id` - UUID of the user /// /// # Returns /// /// Vector of active session information /// /// # Errors /// /// Returns `AppError::Database` if query fails pub async fn list_user_sessions( db: &DatabaseConnection, user_id: Uuid, ) -> Result<Vec<ActiveSession>, AppError> { let now = Utc::now(); let tokens = refresh_token::Entity::find() .filter(refresh_token::Column::UserId.eq(user_id)) .filter(refresh_token::Column::Revoked.eq(false)) .filter(refresh_token::Column::ExpiresAt.gt(now)) .all(db) .await?; let sessions: Vec<ActiveSession> = tokens .into_iter() .map(|token| ActiveSession { id: token.id, device_info: None, // TODO: Store user_agent in refresh_token table ip_address: None, // TODO: Store IP in refresh_token table last_activity: token.created_at, created_at: token.created_at, is_current: false, // Will be set by handler }) .collect(); Ok(sessions) } /// Revoke a specific session (Feature 009 - Phase 9) /// /// Marks a refresh token as revoked. Optionally blacklists associated JWT. /// /// # Arguments /// /// * `db` - Database connection /// * `token_id` - UUID of the refresh token to revoke /// /// # Returns /// /// Ok(()) if revocation successful /// /// # Errors /// /// Returns `AppError::NotFound` if token doesn't exist /// Returns `AppError::Database` if database operation fails pub async fn revoke_session(db: &DatabaseConnection, token_id: Uuid) -> Result<(), AppError> { // Find the token let token = refresh_token::Entity::find_by_id(token_id) .one(db) .await? .ok_or_else(|| AppError::NotFound("Session not found".to_string()))?; // Mark as revoked let mut active_token: refresh_token::ActiveModel = token.into(); active_token.revoked = Set(true); active_token.update(db).await?; tracing::info!("Session {} revoked", token_id); // TODO: Blacklist associated JWT if applicable Ok(()) } /// Delete a specific refresh token (Feature 009 - Phase 9) /// /// Permanently deletes a refresh token from the database. /// /// # Arguments /// /// * `db` - Database connection /// * `token_id` - UUID of the refresh token to delete /// /// # Returns /// /// Ok(()) if deletion successful /// /// # Errors /// /// Returns `AppError::Database` if database operation fails pub async fn delete_session(db: &DatabaseConnection, token_id: Uuid) -> Result<(), AppError> { refresh_token::Entity::delete_by_id(token_id) .exec(db) .await?; tracing::info!("Session {} deleted", token_id); Ok(()) } } #[cfg(test)] mod tests { use super::*; #[test] fn test_generate_token() { let token1 = RefreshTokenService::generate_token(); let token2 = RefreshTokenService::generate_token(); // Verify length assert_eq!(token1.len(), 64); assert_eq!(token2.len(), 64); // Verify uniqueness (extremely unlikely to be the same) assert_ne!(token1, token2); // Verify alphanumeric only assert!(token1.chars().all(|c| c.is_alphanumeric())); } #[test] fn test_token_entropy() { // Generate multiple tokens and verify they're all different let tokens: Vec<String> = (0..100) .map(|_| RefreshTokenService::generate_token()) .collect(); // All should be unique let unique_count = tokens .iter() .collect::<std::collections::HashSet<_>>() .len(); assert_eq!(unique_count, 100); } // Note: Database operations are tested in integration tests }
GitRust