pending_email_change.rs
171 lignes · 5741 octets
use chrono::{DateTime, Utc}; use sea_orm::entity::prelude::*; use serde::{Deserialize, Serialize}; use uuid::Uuid; /// PendingEmailChange entity model. /// /// Holds an email-change request that has **not** been applied yet. Until the /// confirmation is submitted, `users.email` keeps its current — verified — /// value: the account is never left pointing at an unproven address. /// /// # Security features /// /// - Two independent SHA-256 hashes: `token_hash` (confirmation, mailed to the /// new address) and `report_token_hash` (abuse report, mailed to the current /// address). Plain tokens are never stored. /// - Expiration: reuses `EMAIL_VALIDATION_EXPIRATION_HOURS` (24 h by default). /// - Single use: `used_at` is set when the change is applied. /// - Cascade delete: rows are removed when the user is deleted. /// /// # Request lifecycle /// /// 1. **Creation**: two UUID v4 tokens are generated, hashed and stored with an /// expiration; the plain tokens are only ever sent by email. /// 2. **Confirmation**: the owner of `new_email` submits the confirmation form, /// `users.email` is written and `used_at` is set. /// 3. **Report**: the owner of the *current* address may flag the request — /// `reported_at` is set, but the change is never blocked. /// 4. **Cleanup**: rows expired for longer than the retention window are /// hard-deleted (the durable audit trail lives in `audit_logs`). #[derive(Clone, Debug, Serialize, Deserialize, DeriveEntityModel)] #[sea_orm(table_name = "pending_email_changes")] pub struct Model { /// Unique identifier for the request. #[sea_orm(primary_key, auto_increment = false)] pub id: Uuid, /// User this request belongs to. #[sea_orm(indexed)] pub user_id: Uuid, /// Candidate address. Written to `users.email` only on confirmation. pub new_email: String, /// SHA-256 hash of the confirmation token (sent to `new_email`). #[sea_orm(unique, indexed)] pub token_hash: String, /// SHA-256 hash of the report token (sent to the current address). #[sea_orm(unique, indexed)] pub report_token_hash: String, /// Expiration timestamp. #[sea_orm(indexed)] pub expires_at: DateTime<Utc>, /// Set when the change has been applied. `None` while pending. pub used_at: Option<DateTime<Utc>>, /// Set when the current address owner flagged the request as suspicious. /// Informational only — it never blocks the change. pub reported_at: Option<DateTime<Utc>>, /// Creation timestamp. pub created_at: DateTime<Utc>, } /// SeaORM relations for the PendingEmailChange entity. #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] pub enum Relation { /// Relationship to User entity. #[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` once the expiration timestamp has passed. pub fn is_expired(&self) -> bool { Utc::now() > self.expires_at } /// Returns `true` once the change has been applied. pub fn is_used(&self) -> bool { self.used_at.is_some() } /// Returns `true` while the request can still be confirmed. pub fn is_pending(&self) -> bool { !self.is_used() && !self.is_expired() } /// Returns `true` once the current address owner flagged the request. /// /// Purely informational: a reported request stays confirmable (see /// [`EmailChangeService::report_suspicious`](crate::services::email_change_service::EmailChangeService::report_suspicious)). pub fn is_reported(&self) -> bool { self.reported_at.is_some() } } #[cfg(test)] mod tests { use super::*; use chrono::Duration; /// Build a model whose `expires_at` / `used_at` are set by the caller. fn model(expires_in_hours: i64, used: bool) -> Model { let now = Utc::now(); Model { id: Uuid::new_v4(), user_id: Uuid::new_v4(), new_email: "candidate@example.test".to_string(), token_hash: "a".repeat(64), report_token_hash: "b".repeat(64), expires_at: now + Duration::hours(expires_in_hours), used_at: if used { Some(now) } else { None }, reported_at: None, created_at: now, } } #[test] fn truth_table_pending_used_expired() { // fresh + unused → pending let fresh = model(24, false); assert!(!fresh.is_expired()); assert!(!fresh.is_used()); assert!(fresh.is_pending()); // fresh + used → not pending let used = model(24, true); assert!(!used.is_expired()); assert!(used.is_used()); assert!(!used.is_pending()); // expired + unused → not pending let expired = model(-1, false); assert!(expired.is_expired()); assert!(!expired.is_used()); assert!(!expired.is_pending()); // expired + used → not pending let both = model(-1, true); assert!(both.is_expired()); assert!(both.is_used()); assert!(!both.is_pending()); } #[test] fn reported_flag_does_not_affect_pending() { let mut reported = model(24, false); assert!(!reported.is_reported()); reported.reported_at = Some(Utc::now()); assert!(reported.is_reported()); // RFC 0041: a report alerts, it never blocks. assert!(reported.is_pending()); } }
GitRust