personal_token_service.rs
582 lignes · 19297 octets
//! Personal access token service. //! //! Generic over a user-defined `TokenScope` so each consumer app keeps a //! type-safe vocabulary (e.g. gitrust → `RepoScope`, PasterMan → `WallScope`) //! while sharing the hash/verify/revoke/cleanup plumbing. //! //! # Security //! //! - Token plaintext is returned once from `PersonalTokenService::create` //! and never persisted. Only the SHA-256 digest is stored. //! - `PersonalTokenService::verify` uses a constant-time comparison on the //! hash (via `subtle`) to reject timing oracles. //! - Expired and revoked tokens refuse authentication without leaking the //! reason (uniform `AppError::InvalidToken`). //! //! # Example //! //! ```rust,ignore //! use rustwarden_core::services::personal_token_service::{ //! PersonalTokenService, TokenScope, //! }; //! //! #[derive(Clone, Debug)] //! enum RepoScope { //! Read, //! Write, //! } //! //! impl TokenScope for RepoScope { //! fn as_str(&self) -> &str { //! match self { //! RepoScope::Read => "read:repo", //! RepoScope::Write => "write:repo", //! } //! } //! fn parse(s: &str) -> Option<Self> { //! match s { //! "read:repo" => Some(Self::Read), //! "write:repo" => Some(Self::Write), //! _ => None, //! } //! } //! } //! ``` use crate::error::AppError; use crate::models::personal_access_token; use chrono::{DateTime, Utc}; use rand::RngCore; use sea_orm::{ ActiveModelTrait, ColumnTrait, DatabaseConnection, EntityTrait, NotSet, QueryFilter, Set, }; use sha2::{Digest, Sha256}; use std::marker::PhantomData; use subtle::ConstantTimeEq; use uuid::Uuid; /// Scope vocabulary implemented by each consumer app. /// /// Scopes are stored as their `as_str()` representation in the DB; a token /// created with an unknown-to-the-app scope string is silently dropped from /// the parsed list returned by [`PersonalTokenService::verify`], so rolling /// back a scope definition does not invalidate prior tokens outright — they /// just lose the removed capability. pub trait TokenScope: Sized + Clone + Send + Sync + 'static { /// Canonical string representation (e.g. `"read:repo"`). fn as_str(&self) -> &str; /// Parse back from the canonical string. Returns `None` for unknown values. fn parse(s: &str) -> Option<Self>; } /// Personal access token service parameterised by a scope vocabulary. /// /// All methods are associated fns (no state) — the `PhantomData<S>` only /// binds the scope type for the trait impl. Construct as /// `PersonalTokenService::<MyScope>::create(...)` or keep a type alias. pub struct PersonalTokenService<S: TokenScope> { _phantom: PhantomData<S>, } impl<S: TokenScope> PersonalTokenService<S> { /// Create a new PAT and return the (model, plaintext) pair. /// /// The plaintext is shown once to the user and MUST NOT be persisted /// elsewhere by the caller — only its SHA-256 digest lives in the DB. /// /// # Errors /// /// - `AppError::Validation` if `name` is empty. /// - `AppError::Database` on insertion failure (FK violation, unique /// constraint on `(user_id, name)` or `token_hash`). pub async fn create( db: &DatabaseConnection, user_id: Uuid, name: &str, scopes: &[S], expires_at: Option<DateTime<Utc>>, ) -> Result<(personal_access_token::Model, String), AppError> { if name.trim().is_empty() { return Err(AppError::Validation( "Token name cannot be empty".to_string(), )); } let plain = generate_token_plain(); let token_hash = hash_token(&plain); let scope_strings: Vec<String> = scopes.iter().map(|s| s.as_str().to_string()).collect(); let active = personal_access_token::ActiveModel { id: Set(Uuid::new_v4()), user_id: Set(user_id), name: Set(name.to_string()), token_hash: Set(token_hash), scopes: Set(scope_strings), expires_at: Set(expires_at), last_used_at: NotSet, revoked_at: NotSet, created_at: Set(Utc::now()), }; let model = active.insert(db).await?; Ok((model, plain)) } /// Verify a plaintext token and return the record plus parsed scopes. /// /// Touches `last_used_at` on success (best-effort; update failure only /// logs a warning). Rejects tokens that are revoked, expired, or whose /// hash does not match anything in the DB — always with /// [`AppError::InvalidToken`] to avoid leaking which condition failed. pub async fn verify( db: &DatabaseConnection, token_clear: &str, ) -> Result<(personal_access_token::Model, Vec<S>), AppError> { if token_clear.is_empty() { return Err(AppError::InvalidToken); } let candidate_hash = hash_token(token_clear); let row = personal_access_token::Entity::find() .filter(personal_access_token::Column::TokenHash.eq(&candidate_hash)) .one(db) .await? .ok_or(AppError::InvalidToken)?; // Constant-time re-check — the SQL lookup already matched on the hash, // but this guards against DB-side comparison being variable-time. if !bool::from(row.token_hash.as_bytes().ct_eq(candidate_hash.as_bytes())) { return Err(AppError::InvalidToken); } if !row.is_active() { return Err(AppError::InvalidToken); } // Best-effort last_used_at update (don't block auth on slow write). let id = row.id; let mut touched: personal_access_token::ActiveModel = row.clone().into(); touched.last_used_at = Set(Some(Utc::now())); if let Err(e) = touched.update(db).await { tracing::warn!(token_id = %id, "PAT last_used_at update failed: {}", e); } let scopes: Vec<S> = row.scopes.iter().filter_map(|s| S::parse(s)).collect(); Ok((row, scopes)) } /// List all non-revoked PATs for a user (most recent first). pub async fn list_for_user( db: &DatabaseConnection, user_id: Uuid, ) -> Result<Vec<personal_access_token::Model>, AppError> { use sea_orm::QueryOrder; let rows = personal_access_token::Entity::find() .filter(personal_access_token::Column::UserId.eq(user_id)) .filter(personal_access_token::Column::RevokedAt.is_null()) .order_by_desc(personal_access_token::Column::CreatedAt) .all(db) .await?; Ok(rows) } /// Revoke a PAT by id. Idempotent: revoking an already-revoked token is /// a no-op. Refuses if the token does not belong to `user_id` (ownership /// enforcement — otherwise an attacker knowing the UUID could revoke /// another user's token). pub async fn revoke( db: &DatabaseConnection, token_id: Uuid, user_id: Uuid, ) -> Result<(), AppError> { let row = personal_access_token::Entity::find_by_id(token_id) .one(db) .await? .ok_or_else(|| AppError::NotFound(format!("PAT {token_id}")))?; if row.user_id != user_id { return Err(AppError::Forbidden); } if row.revoked_at.is_some() { return Ok(()); // idempotent } let mut active: personal_access_token::ActiveModel = row.into(); active.revoked_at = Set(Some(Utc::now())); active.update(db).await?; Ok(()) } /// Background cleanup: hard-delete tokens that are both expired AND /// revoked (either condition alone is kept for audit window). Returns /// the number of rows removed. pub async fn cleanup_expired(db: &DatabaseConnection) -> Result<u64, AppError> { let now = Utc::now(); let result = personal_access_token::Entity::delete_many() .filter( sea_orm::Condition::all() .add(personal_access_token::Column::ExpiresAt.is_not_null()) .add(personal_access_token::Column::ExpiresAt.lt(now)) .add(personal_access_token::Column::RevokedAt.is_not_null()), ) .exec(db) .await?; Ok(result.rows_affected) } } /// Generate a fresh 32-byte token encoded as 64 hex chars (URL-safe). fn generate_token_plain() -> String { let mut bytes = [0u8; 32]; rand::rng().fill_bytes(&mut bytes); hex::encode(bytes) } /// SHA-256 hex of the plaintext token — deterministic indexing key. fn hash_token(plain: &str) -> String { let mut hasher = Sha256::new(); hasher.update(plain.as_bytes()); hex::encode(hasher.finalize()) } #[cfg(test)] mod tests { use super::*; use crate::services::password_service::PasswordService; use crate::services::user_service::UserService; use chrono::Duration; /// Minimal scope type exercising the generic bounds. #[derive(Clone, Debug, PartialEq, Eq)] enum TestScope { Read, Write, } impl TokenScope for TestScope { fn as_str(&self) -> &str { match self { Self::Read => "test:read", Self::Write => "test:write", } } fn parse(s: &str) -> Option<Self> { match s { "test:read" => Some(Self::Read), "test:write" => Some(Self::Write), _ => None, } } } /// Per-test connection. A shared `OnceCell<DatabaseConnection>` cannot be /// reused across `#[tokio::test]` calls because each creates its own /// runtime; the pool captured by the first test outlives it and later /// tests see "Tokio 1.x context being shutdown". Re-connecting per test /// costs ~10ms on localhost Postgres — worth the isolation. async fn test_db() -> DatabaseConnection { dotenvy::dotenv().ok(); let url = std::env::var("DATABASE_URL") .expect("DATABASE_URL required for personal_token_service tests"); sea_orm::Database::connect(&url).await.unwrap() } /// Isolates each test with a unique user. Avoids polluting the dev DB. async fn fresh_user(db: &DatabaseConnection) -> Uuid { let unique = Uuid::new_v4().simple().to_string(); let hash = PasswordService::hash_password("NotReallyUsed123!") .await .unwrap(); let user = UserService::create_user( db, format!("pat-test-{}", &unique[..12]), format!("pat_test_{}@example.com", &unique[..12]), hash, false, ) .await .unwrap(); user.id } #[tokio::test] async fn create_returns_plaintext_and_persists_hash_only() { let db = test_db().await; let user_id = fresh_user(&db).await; let (record, plain) = PersonalTokenService::<TestScope>::create( &db, user_id, "ci-bot", &[TestScope::Read], None, ) .await .unwrap(); assert_eq!(plain.len(), 64, "token should be 32 bytes hex"); assert_ne!( record.token_hash, plain, "DB must store hash, not plaintext" ); assert_eq!(record.token_hash.len(), 64, "SHA-256 hex is 64 chars"); assert_eq!(record.scopes, vec!["test:read".to_string()]); assert!(record.expires_at.is_none()); assert!(record.last_used_at.is_none()); assert!(record.revoked_at.is_none()); } #[tokio::test] async fn create_rejects_empty_name() { let db = test_db().await; let user_id = fresh_user(&db).await; let err = PersonalTokenService::<TestScope>::create(&db, user_id, " ", &[], None) .await .unwrap_err(); matches!(err, AppError::Validation(_)); } #[tokio::test] async fn verify_accepts_valid_token_and_updates_last_used_at() { let db = test_db().await; let user_id = fresh_user(&db).await; let (record, plain) = PersonalTokenService::<TestScope>::create( &db, user_id, "ci-verify", &[TestScope::Read, TestScope::Write], None, ) .await .unwrap(); let (verified, scopes) = PersonalTokenService::<TestScope>::verify(&db, &plain) .await .unwrap(); assert_eq!(verified.id, record.id); assert_eq!( scopes, vec![TestScope::Read, TestScope::Write], "scopes must round-trip through parse()" ); // Reload to assert last_used_at was actually persisted. let fresh = personal_access_token::Entity::find_by_id(record.id) .one(&db) .await .unwrap() .unwrap(); assert!( fresh.last_used_at.is_some(), "last_used_at must be set after verify()" ); } #[tokio::test] async fn verify_rejects_unknown_token() { let db = test_db().await; let err = PersonalTokenService::<TestScope>::verify(&db, "deadbeefdeadbeefdeadbeefdeadbeef") .await .unwrap_err(); assert!(matches!(err, AppError::InvalidToken)); } #[tokio::test] async fn verify_rejects_revoked_token() { let db = test_db().await; let user_id = fresh_user(&db).await; let (record, plain) = PersonalTokenService::<TestScope>::create( &db, user_id, "ci-revoke", &[TestScope::Read], None, ) .await .unwrap(); PersonalTokenService::<TestScope>::revoke(&db, record.id, user_id) .await .unwrap(); let err = PersonalTokenService::<TestScope>::verify(&db, &plain) .await .unwrap_err(); assert!(matches!(err, AppError::InvalidToken)); } #[tokio::test] async fn verify_rejects_expired_token() { let db = test_db().await; let user_id = fresh_user(&db).await; let (_record, plain) = PersonalTokenService::<TestScope>::create( &db, user_id, "ci-expired", &[TestScope::Read], Some(Utc::now() - Duration::seconds(1)), ) .await .unwrap(); let err = PersonalTokenService::<TestScope>::verify(&db, &plain) .await .unwrap_err(); assert!(matches!(err, AppError::InvalidToken)); } #[tokio::test] async fn list_for_user_returns_only_active_tokens() { let db = test_db().await; let user_id = fresh_user(&db).await; let (t1, _) = PersonalTokenService::<TestScope>::create(&db, user_id, "t1", &[TestScope::Read], None) .await .unwrap(); let (t2, _) = PersonalTokenService::<TestScope>::create( &db, user_id, "t2", &[TestScope::Write], None, ) .await .unwrap(); PersonalTokenService::<TestScope>::revoke(&db, t2.id, user_id) .await .unwrap(); let listed = PersonalTokenService::<TestScope>::list_for_user(&db, user_id) .await .unwrap(); assert_eq!(listed.len(), 1, "revoked token must not appear in list"); assert_eq!(listed[0].id, t1.id); } #[tokio::test] async fn revoke_refuses_other_users_tokens() { let db = test_db().await; let owner = fresh_user(&db).await; let attacker = fresh_user(&db).await; let (record, _) = PersonalTokenService::<TestScope>::create( &db, owner, "owned", &[TestScope::Read], None, ) .await .unwrap(); let err = PersonalTokenService::<TestScope>::revoke(&db, record.id, attacker) .await .unwrap_err(); assert!(matches!(err, AppError::Forbidden)); // Token must still be usable — cross-user revoke must not have fired. let reloaded = personal_access_token::Entity::find_by_id(record.id) .one(&db) .await .unwrap() .unwrap(); assert!(reloaded.revoked_at.is_none()); } #[tokio::test] async fn revoke_is_idempotent() { let db = test_db().await; let user_id = fresh_user(&db).await; let (record, _) = PersonalTokenService::<TestScope>::create( &db, user_id, "idempotent", &[TestScope::Read], None, ) .await .unwrap(); PersonalTokenService::<TestScope>::revoke(&db, record.id, user_id) .await .unwrap(); PersonalTokenService::<TestScope>::revoke(&db, record.id, user_id) .await .expect("second revoke must be a no-op"); } #[tokio::test] async fn cleanup_expired_removes_only_expired_and_revoked_rows() { let db = test_db().await; let user_id = fresh_user(&db).await; // 1. Expired + revoked → should be deleted. let (expired_revoked, _) = PersonalTokenService::<TestScope>::create( &db, user_id, "expired-revoked", &[TestScope::Read], Some(Utc::now() - Duration::hours(1)), ) .await .unwrap(); PersonalTokenService::<TestScope>::revoke(&db, expired_revoked.id, user_id) .await .unwrap(); // 2. Expired only (no revoke) → keep for audit. let (expired_only, _) = PersonalTokenService::<TestScope>::create( &db, user_id, "expired-only", &[TestScope::Read], Some(Utc::now() - Duration::hours(1)), ) .await .unwrap(); // 3. Active → must stay. let (active, _) = PersonalTokenService::<TestScope>::create( &db, user_id, "active", &[TestScope::Read], None, ) .await .unwrap(); let removed = PersonalTokenService::<TestScope>::cleanup_expired(&db) .await .unwrap(); assert!(removed >= 1); assert!( personal_access_token::Entity::find_by_id(expired_revoked.id) .one(&db) .await .unwrap() .is_none(), "expired+revoked must be deleted" ); assert!( personal_access_token::Entity::find_by_id(expired_only.id) .one(&db) .await .unwrap() .is_some(), "expired-only must be kept for audit" ); assert!( personal_access_token::Entity::find_by_id(active.id) .one(&db) .await .unwrap() .is_some(), "active token must not be touched" ); } }
GitRust