personal_access_token.rs 158 lignes · 4575 octets
use chrono::{DateTime, Utc};
use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};
use uuid::Uuid;

/// Personal access token (long-lived API credential).
///
/// Companion of [`refresh_token`](super::refresh_token) — whereas refresh
/// tokens rotate on every use, PATs are static secrets consumed unchanged by
/// protocols that cannot handle rotation (git push over HTTPS, webhook ingest,
/// third-party integrations).
///
/// # Storage
///
/// Only the SHA-256 hash of the token is persisted. The plaintext is returned
/// once from [`crate::services::personal_token_service::PersonalTokenService::create`]
/// and never stored anywhere in the framework.
///
/// # Lifecycle
///
/// 1. **Creation**: a random 32-byte token is generated, shown once to the user,
///    its SHA-256 hash is stored with requested scopes.
/// 2. **Usage**: every `verify()` call updates `last_used_at` for UX (list
///    tokens, show "last used N hours ago").
/// 3. **Revocation**: `revoked_at` is set; the row is kept for audit.
/// 4. **Expiration**: optional `expires_at` — `None` means permanent.
#[derive(Clone, Debug, Serialize, Deserialize, DeriveEntityModel)]
#[sea_orm(table_name = "personal_access_tokens")]
pub struct Model {
    /// Unique identifier for the token record.
    #[sea_orm(primary_key)]
    pub id: Uuid,

    /// Owner of the token. Cascade-deleted with the user.
    pub user_id: Uuid,

    /// Human-friendly name chosen by the user (unique per user).
    pub name: String,

    /// SHA-256 hex digest of the plaintext token (64 chars).
    #[sea_orm(unique)]
    pub token_hash: String,

    /// Requested scopes (app-specific strings, e.g. `"read:repo"`).
    pub scopes: Vec<String>,

    /// Optional expiration. `None` = permanent token.
    #[sea_orm(nullable)]
    pub expires_at: Option<DateTime<Utc>>,

    /// Last time this token was used for authentication (for UX).
    #[sea_orm(nullable)]
    pub last_used_at: Option<DateTime<Utc>>,

    /// When the token was revoked (if applicable). A revoked token is kept
    /// for audit but refuses authentication.
    #[sea_orm(nullable)]
    pub revoked_at: Option<DateTime<Utc>>,

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

/// SeaORM relations for the PersonalAccessToken entity.
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
    /// The user who owns this personal access token.
    #[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 {
    /// Returns true if the token has a set expiration date in the past.
    pub fn is_expired(&self) -> bool {
        self.expires_at.is_some_and(|t| Utc::now() > t)
    }

    /// Returns true if the token has been explicitly revoked.
    pub fn is_revoked(&self) -> bool {
        self.revoked_at.is_some()
    }

    /// Returns true if the token is neither expired nor revoked.
    pub fn is_active(&self) -> bool {
        !self.is_expired() && !self.is_revoked()
    }
}

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

    fn base_model() -> Model {
        Model {
            id: Uuid::new_v4(),
            user_id: Uuid::new_v4(),
            name: "test".into(),
            token_hash: "a".repeat(64),
            scopes: vec![],
            expires_at: None,
            last_used_at: None,
            revoked_at: None,
            created_at: Utc::now(),
        }
    }

    #[test]
    fn permanent_token_never_expires() {
        assert!(!base_model().is_expired());
    }

    #[test]
    fn expires_at_in_past_is_expired() {
        let m = Model {
            expires_at: Some(Utc::now() - Duration::hours(1)),
            ..base_model()
        };
        assert!(m.is_expired());
    }

    #[test]
    fn expires_at_in_future_is_not_expired() {
        let m = Model {
            expires_at: Some(Utc::now() + Duration::hours(1)),
            ..base_model()
        };
        assert!(!m.is_expired());
    }

    #[test]
    fn revoked_token_is_inactive() {
        let m = Model {
            revoked_at: Some(Utc::now()),
            ..base_model()
        };
        assert!(m.is_revoked());
        assert!(!m.is_active());
    }

    #[test]
    fn fresh_permanent_token_is_active() {
        assert!(base_model().is_active());
    }
}