refresh_token.rs 191 lignes · 5376 octets
use chrono::{DateTime, Utc};
use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};
use uuid::Uuid;

/// RefreshToken entity model
///
/// Represents a refresh token in the authentication system.
/// Refresh tokens allow users to obtain new JWT access tokens without re-authentication.
///
/// # Security Features
///
/// - Token rotation: Each use generates a new token and revokes the old one
/// - Reuse detection: Using an already-used token revokes all tokens for that user
/// - Expiration: Tokens have a configurable expiration (default 7 days)
/// - Revocation: Tokens can be manually revoked (e.g., on logout)
///
/// # Token Lifecycle
///
/// 1. **Creation**: Token generated at login, stored with hash
/// 2. **Usage**: Token used to refresh JWT, replaced by new token
/// 3. **Revocation**: Token marked as revoked, cannot be used
/// 4. **Expiration**: Token expires after configured duration
#[derive(Clone, Debug, Serialize, Deserialize, DeriveEntityModel)]
#[sea_orm(table_name = "refresh_token")]
pub struct Model {
    /// Unique identifier for the refresh token
    #[sea_orm(primary_key)]
    pub id: Uuid,

    /// User ID this token belongs to
    pub user_id: Uuid,

    /// The refresh token value (stored as-is, should be generated securely)
    /// In production, consider hashing this value for extra security
    #[sea_orm(unique)]
    pub token: String,

    /// Expiration timestamp
    pub expires_at: DateTime<Utc>,

    /// Creation timestamp
    pub created_at: DateTime<Utc>,

    /// Whether the token has been revoked
    #[sea_orm(default_value = "false")]
    pub revoked: bool,

    /// When the token was revoked (if applicable)
    #[sea_orm(nullable)]
    pub revoked_at: Option<DateTime<Utc>>,

    /// ID of the token that replaced this one (for rotation tracking)
    #[sea_orm(nullable)]
    pub replaced_by: Option<Uuid>,
}

/// SeaORM relations for the RefreshToken entity.
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
    /// The user this refresh token belongs to.
    #[sea_orm(
        belongs_to = "super::user::Entity",
        from = "Column::UserId",
        to = "super::user::Column::Id",
        on_delete = "Cascade"
    )]
    User,
}

impl Related<super::user::Entity> for Entity {
    fn to() -> RelationDef {
        Relation::User.def()
    }
}

impl ActiveModelBehavior for ActiveModel {}

impl Model {
    /// Check if the token is expired
    ///
    /// # Returns
    ///
    /// `true` if the token has expired, `false` otherwise
    pub fn is_expired(&self) -> bool {
        Utc::now() > self.expires_at
    }

    /// Check if the token is valid (not expired and not revoked)
    ///
    /// # Returns
    ///
    /// `true` if the token is valid and can be used, `false` otherwise
    #[allow(dead_code)] // Utility method, used in tests
    pub fn is_valid(&self) -> bool {
        !self.is_expired() && !self.revoked
    }

    /// Check if the token has been used (replaced by another token)
    ///
    /// # Returns
    ///
    /// `true` if the token has been replaced, indicating potential reuse attack
    pub fn has_been_used(&self) -> bool {
        self.replaced_by.is_some()
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use chrono::Duration;

    #[test]
    fn test_is_expired() {
        // Non-expired token
        let token = Model {
            id: Uuid::new_v4(),
            user_id: Uuid::new_v4(),
            token: "test_token".to_string(),
            expires_at: Utc::now() + Duration::days(7),
            created_at: Utc::now(),
            revoked: false,
            revoked_at: None,
            replaced_by: None,
        };
        assert!(!token.is_expired());

        // Expired token
        let expired_token = Model {
            expires_at: Utc::now() - Duration::days(1),
            ..token
        };
        assert!(expired_token.is_expired());
    }

    #[test]
    fn test_is_valid() {
        let base_token = Model {
            id: Uuid::new_v4(),
            user_id: Uuid::new_v4(),
            token: "test_token".to_string(),
            expires_at: Utc::now() + Duration::days(7),
            created_at: Utc::now(),
            revoked: false,
            revoked_at: None,
            replaced_by: None,
        };

        // Valid token
        assert!(base_token.is_valid());

        // Revoked token
        let revoked_token = Model {
            revoked: true,
            revoked_at: Some(Utc::now()),
            ..base_token.clone()
        };
        assert!(!revoked_token.is_valid());

        // Expired token
        let expired_token = Model {
            expires_at: Utc::now() - Duration::days(1),
            ..base_token
        };
        assert!(!expired_token.is_valid());
    }

    #[test]
    fn test_has_been_used() {
        let token = Model {
            id: Uuid::new_v4(),
            user_id: Uuid::new_v4(),
            token: "test_token".to_string(),
            expires_at: Utc::now() + Duration::days(7),
            created_at: Utc::now(),
            revoked: false,
            revoked_at: None,
            replaced_by: None,
        };

        assert!(!token.has_been_used());

        let used_token = Model {
            replaced_by: Some(Uuid::new_v4()),
            ..token
        };

        assert!(used_token.has_been_used());
    }
}