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()); } }
GitRust