koprogo_api/domain/entities/
two_factor_secret.rs

1use chrono::{DateTime, Utc};
2use serde::{Deserialize, Serialize};
3use uuid::Uuid;
4
5/// Two-factor authentication secret for TOTP (Time-based One-Time Password)
6/// Stores encrypted TOTP secret and backup codes for account recovery
7#[derive(Clone, Serialize, Deserialize, PartialEq)]
8pub struct TwoFactorSecret {
9    pub id: Uuid,
10    pub user_id: Uuid,
11    pub secret_encrypted: String, // Base32-encoded TOTP secret (AES-256 encrypted at application level)
12    pub backup_codes_encrypted: Vec<String>, // 10 backup codes (bcrypt hashed)
13    pub is_enabled: bool,
14    pub verified_at: Option<DateTime<Utc>>, // First successful verification
15    pub last_used_at: Option<DateTime<Utc>>, // Last successful TOTP verification
16    pub created_at: DateTime<Utc>,
17    pub updated_at: DateTime<Utc>,
18}
19
20impl TwoFactorSecret {
21    /// Create a new 2FA secret (not yet enabled)
22    pub fn new(user_id: Uuid, secret_encrypted: String) -> Result<Self, String> {
23        // Validate secret is non-empty
24        if secret_encrypted.trim().is_empty() {
25            return Err("Secret cannot be empty".to_string());
26        }
27
28        // Generate 10 backup codes (will be encrypted by caller)
29        let backup_codes = Self::generate_backup_codes_placeholders();
30
31        Ok(Self {
32            id: Uuid::new_v4(),
33            user_id,
34            secret_encrypted,
35            backup_codes_encrypted: backup_codes,
36            is_enabled: false,
37            verified_at: None,
38            last_used_at: None,
39            created_at: Utc::now(),
40            updated_at: Utc::now(),
41        })
42    }
43
44    /// Set backup codes (encrypted/hashed by caller)
45    pub fn with_backup_codes(
46        mut self,
47        backup_codes_encrypted: Vec<String>,
48    ) -> Result<Self, String> {
49        if backup_codes_encrypted.len() != 10 {
50            return Err("Must provide exactly 10 backup codes".to_string());
51        }
52
53        self.backup_codes_encrypted = backup_codes_encrypted;
54        Ok(self)
55    }
56
57    /// Enable 2FA after successful verification
58    pub fn enable(&mut self) -> Result<(), String> {
59        if self.is_enabled {
60            return Err("2FA is already enabled".to_string());
61        }
62
63        self.is_enabled = true;
64        self.verified_at = Some(Utc::now());
65        self.updated_at = Utc::now();
66
67        Ok(())
68    }
69
70    /// Disable 2FA
71    pub fn disable(&mut self) {
72        self.is_enabled = false;
73        self.updated_at = Utc::now();
74    }
75
76    /// Mark TOTP as used (update last_used_at)
77    pub fn mark_used(&mut self) {
78        self.last_used_at = Some(Utc::now());
79        self.updated_at = Utc::now();
80    }
81
82    /// Regenerate backup codes (caller must hash/encrypt new codes)
83    pub fn regenerate_backup_codes(
84        &mut self,
85        new_backup_codes_encrypted: Vec<String>,
86    ) -> Result<(), String> {
87        if new_backup_codes_encrypted.len() != 10 {
88            return Err("Must provide exactly 10 backup codes".to_string());
89        }
90
91        self.backup_codes_encrypted = new_backup_codes_encrypted;
92        self.updated_at = Utc::now();
93
94        Ok(())
95    }
96
97    /// Remove a used backup code (caller must identify which code was used)
98    pub fn remove_backup_code(&mut self, code_index: usize) -> Result<(), String> {
99        if code_index >= self.backup_codes_encrypted.len() {
100            return Err("Invalid backup code index".to_string());
101        }
102
103        self.backup_codes_encrypted.remove(code_index);
104        self.updated_at = Utc::now();
105
106        Ok(())
107    }
108
109    /// Check if backup codes are exhausted (< 3 remaining)
110    pub fn backup_codes_low(&self) -> bool {
111        self.backup_codes_encrypted.len() < 3
112    }
113
114    /// Check if 2FA needs re-verification (not used in 90 days)
115    pub fn needs_reverification(&self) -> bool {
116        if !self.is_enabled {
117            return false;
118        }
119
120        match self.last_used_at {
121            Some(last_used) => {
122                let days_since_use = (Utc::now() - last_used).num_days();
123                days_since_use > 90
124            }
125            None => false, // Never used yet
126        }
127    }
128
129    /// Generate placeholder backup codes (will be replaced with real codes)
130    fn generate_backup_codes_placeholders() -> Vec<String> {
131        vec!["placeholder".to_string(); 10]
132    }
133}
134
135/// Custom Debug implementation that redacts sensitive cryptographic fields
136/// to prevent accidental logging of TOTP secrets and backup codes.
137impl std::fmt::Debug for TwoFactorSecret {
138    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
139        f.debug_struct("TwoFactorSecret")
140            .field("id", &self.id)
141            .field("user_id", &self.user_id)
142            .field("secret_encrypted", &"[REDACTED]")
143            .field(
144                "backup_codes_encrypted",
145                &format!("[REDACTED × {}]", self.backup_codes_encrypted.len()),
146            )
147            .field("is_enabled", &self.is_enabled)
148            .field("verified_at", &self.verified_at)
149            .field("last_used_at", &self.last_used_at)
150            .field("created_at", &self.created_at)
151            .field("updated_at", &self.updated_at)
152            .finish()
153    }
154}
155
156#[cfg(test)]
157mod tests {
158    use super::*;
159
160    fn sample_user_id() -> Uuid {
161        Uuid::parse_str("550e8400-e29b-41d4-a716-446655440000").unwrap()
162    }
163
164    #[test]
165    fn test_create_two_factor_secret_success() {
166        let secret = TwoFactorSecret::new(
167            sample_user_id(),
168            "JBSWY3DPEHPK3PXP".to_string(), // Base32 encoded secret
169        );
170
171        assert!(secret.is_ok());
172        let s = secret.unwrap();
173        assert_eq!(s.user_id, sample_user_id());
174        assert_eq!(s.secret_encrypted, "JBSWY3DPEHPK3PXP");
175        assert!(!s.is_enabled);
176        assert!(s.verified_at.is_none());
177        assert_eq!(s.backup_codes_encrypted.len(), 10);
178    }
179
180    #[test]
181    fn test_create_two_factor_secret_empty() {
182        let secret = TwoFactorSecret::new(sample_user_id(), "".to_string());
183
184        assert!(secret.is_err());
185        assert!(secret.unwrap_err().contains("Secret cannot be empty"));
186    }
187
188    #[test]
189    fn test_with_backup_codes() {
190        let codes: Vec<String> = (0..10).map(|i| format!("CODE{}", i)).collect();
191
192        let secret = TwoFactorSecret::new(sample_user_id(), "SECRET123".to_string())
193            .unwrap()
194            .with_backup_codes(codes.clone());
195
196        assert!(secret.is_ok());
197        let s = secret.unwrap();
198        assert_eq!(s.backup_codes_encrypted.len(), 10);
199        assert_eq!(s.backup_codes_encrypted[0], "CODE0");
200    }
201
202    #[test]
203    fn test_with_backup_codes_invalid_count() {
204        let codes: Vec<String> = vec!["CODE1".to_string(), "CODE2".to_string()]; // Only 2 codes
205
206        let secret = TwoFactorSecret::new(sample_user_id(), "SECRET123".to_string())
207            .unwrap()
208            .with_backup_codes(codes);
209
210        assert!(secret.is_err());
211        assert!(secret
212            .unwrap_err()
213            .contains("Must provide exactly 10 backup codes"));
214    }
215
216    #[test]
217    fn test_enable_2fa() {
218        let mut secret = TwoFactorSecret::new(sample_user_id(), "SECRET123".to_string()).unwrap();
219
220        assert!(!secret.is_enabled);
221        assert!(secret.verified_at.is_none());
222
223        let result = secret.enable();
224        assert!(result.is_ok());
225        assert!(secret.is_enabled);
226        assert!(secret.verified_at.is_some());
227        assert!(secret.verified_at.unwrap() <= Utc::now());
228    }
229
230    #[test]
231    fn test_enable_2fa_already_enabled() {
232        let mut secret = TwoFactorSecret::new(sample_user_id(), "SECRET123".to_string()).unwrap();
233
234        secret.enable().unwrap();
235
236        let result = secret.enable();
237        assert!(result.is_err());
238        assert!(result.unwrap_err().contains("already enabled"));
239    }
240
241    #[test]
242    fn test_disable_2fa() {
243        let mut secret = TwoFactorSecret::new(sample_user_id(), "SECRET123".to_string()).unwrap();
244
245        secret.enable().unwrap();
246        assert!(secret.is_enabled);
247
248        secret.disable();
249        assert!(!secret.is_enabled);
250    }
251
252    #[test]
253    fn test_mark_used() {
254        let mut secret = TwoFactorSecret::new(sample_user_id(), "SECRET123".to_string()).unwrap();
255
256        assert!(secret.last_used_at.is_none());
257
258        secret.mark_used();
259        assert!(secret.last_used_at.is_some());
260        assert!(secret.last_used_at.unwrap() <= Utc::now());
261    }
262
263    #[test]
264    fn test_regenerate_backup_codes() {
265        let mut secret = TwoFactorSecret::new(sample_user_id(), "SECRET123".to_string()).unwrap();
266
267        let new_codes: Vec<String> = (0..10).map(|i| format!("NEWCODE{}", i)).collect();
268
269        let result = secret.regenerate_backup_codes(new_codes.clone());
270        assert!(result.is_ok());
271        assert_eq!(secret.backup_codes_encrypted.len(), 10);
272        assert_eq!(secret.backup_codes_encrypted[0], "NEWCODE0");
273    }
274
275    #[test]
276    fn test_regenerate_backup_codes_invalid_count() {
277        let mut secret = TwoFactorSecret::new(sample_user_id(), "SECRET123".to_string()).unwrap();
278
279        let new_codes: Vec<String> = vec!["CODE1".to_string()]; // Only 1 code
280
281        let result = secret.regenerate_backup_codes(new_codes);
282        assert!(result.is_err());
283    }
284
285    #[test]
286    fn test_remove_backup_code() {
287        let codes: Vec<String> = (0..10).map(|i| format!("CODE{}", i)).collect();
288        let mut secret = TwoFactorSecret::new(sample_user_id(), "SECRET123".to_string())
289            .unwrap()
290            .with_backup_codes(codes)
291            .unwrap();
292
293        assert_eq!(secret.backup_codes_encrypted.len(), 10);
294
295        let result = secret.remove_backup_code(0);
296        assert!(result.is_ok());
297        assert_eq!(secret.backup_codes_encrypted.len(), 9);
298        assert_eq!(secret.backup_codes_encrypted[0], "CODE1"); // CODE0 removed, CODE1 is now first
299    }
300
301    #[test]
302    fn test_remove_backup_code_invalid_index() {
303        let mut secret = TwoFactorSecret::new(sample_user_id(), "SECRET123".to_string()).unwrap();
304
305        let result = secret.remove_backup_code(20); // Invalid index
306        assert!(result.is_err());
307        assert!(result.unwrap_err().contains("Invalid backup code index"));
308    }
309
310    #[test]
311    fn test_backup_codes_low() {
312        let codes: Vec<String> = (0..10).map(|i| format!("CODE{}", i)).collect();
313        let mut secret = TwoFactorSecret::new(sample_user_id(), "SECRET123".to_string())
314            .unwrap()
315            .with_backup_codes(codes)
316            .unwrap();
317
318        assert!(!secret.backup_codes_low());
319
320        // Remove 8 codes (leaving 2)
321        for _ in 0..8 {
322            secret.remove_backup_code(0).unwrap();
323        }
324
325        assert_eq!(secret.backup_codes_encrypted.len(), 2);
326        assert!(secret.backup_codes_low());
327    }
328
329    #[test]
330    fn test_needs_reverification() {
331        let mut secret = TwoFactorSecret::new(sample_user_id(), "SECRET123".to_string()).unwrap();
332
333        // Not enabled
334        assert!(!secret.needs_reverification());
335
336        // Enable
337        secret.enable().unwrap();
338        assert!(!secret.needs_reverification()); // Never used yet
339
340        // Mark as used recently
341        secret.mark_used();
342        assert!(!secret.needs_reverification());
343
344        // Simulate 91 days ago (needs reverification)
345        secret.last_used_at = Some(Utc::now() - chrono::Duration::days(91));
346        assert!(secret.needs_reverification());
347    }
348}