Skip to main content

koprogo_api/infrastructure/web/handlers/
two_factor_handlers.rs

1use crate::application::dto::{
2    Disable2FADto, Enable2FADto, RegenerateBackupCodesDto, Verify2FADto,
3};
4use crate::infrastructure::web::classification_erreurs;
5use crate::infrastructure::web::middleware::AuthenticatedUser;
6use crate::infrastructure::web::AppState;
7use actix_web::{web, HttpResponse};
8
9/// Setup 2FA for a user (returns QR code + backup codes)
10///
11/// This endpoint initiates 2FA setup by generating a TOTP secret, QR code, and backup codes.
12/// The user must then verify a TOTP code via `POST /2fa/enable` to activate 2FA.
13///
14/// # Security
15/// - User must be authenticated
16/// - Secret is only returned once during setup
17/// - Backup codes are only shown once (user must save them)
18///
19/// # Returns
20/// - 200 OK: Setup successful with QR code and backup codes
21/// - 400 Bad Request: 2FA already enabled
22/// - 401 Unauthorized: Not authenticated
23/// - 500 Internal Server Error: Setup failed
24///
25/// # Example Response
26/// ```json
27/// {
28///   "secret": "JBSWY3DPEHPK3PXP...",
29///   "qr_code_data_url": "data:image/png;base64,...",
30///   "backup_codes": ["ABCD-EFGH", "IJKL-MNOP", ...],
31///   "issuer": "KoproGo",
32///   "account_name": "user@example.com"
33/// }
34/// ```
35pub async fn setup_2fa(auth: AuthenticatedUser, state: web::Data<AppState>) -> HttpResponse {
36    let organization_id = match auth.organization_id {
37        Some(id) => id,
38        None => {
39            return HttpResponse::BadRequest().json(serde_json::json!({
40                "error": "Organization ID is required"
41            }))
42        }
43    };
44
45    match state
46        .two_factor_use_cases
47        .setup_2fa(auth.user_id, organization_id)
48        .await
49    {
50        Ok(response) => HttpResponse::Ok().json(response),
51        Err(e) if e.contains("already enabled") => {
52            HttpResponse::BadRequest().json(serde_json::json!({
53                "error": e
54            }))
55        }
56        Err(e) => {
57            log::error!("Failed to setup 2FA for user: {}", "internal error");
58            let _ = e; // error details intentionally not logged (may contain sensitive data)
59            HttpResponse::InternalServerError().json(serde_json::json!({
60                "error": "Failed to setup 2FA"
61            }))
62        }
63    }
64}
65
66/// Enable 2FA after verifying TOTP code
67///
68/// After setup, the user must verify their TOTP code from their authenticator app
69/// to enable 2FA. This confirms they have successfully saved the secret.
70///
71/// # Security
72/// - User must be authenticated
73/// - Requires valid 6-digit TOTP code
74/// - Failed attempts are logged for security monitoring
75///
76/// # Request Body
77/// ```json
78/// {
79///   "totp_code": "123456"
80/// }
81/// ```
82///
83/// # Returns
84/// - 200 OK: 2FA successfully enabled
85/// - 400 Bad Request: Invalid TOTP code or already enabled
86/// - 401 Unauthorized: Not authenticated
87/// - 500 Internal Server Error: Enable failed
88pub async fn enable_2fa(
89    auth: AuthenticatedUser,
90    dto: web::Json<Enable2FADto>,
91    state: web::Data<AppState>,
92) -> HttpResponse {
93    let organization_id = match auth.organization_id {
94        Some(id) => id,
95        None => {
96            return HttpResponse::BadRequest().json(serde_json::json!({
97                "error": "Organization ID is required"
98            }))
99        }
100    };
101
102    match state
103        .two_factor_use_cases
104        .enable_2fa(auth.user_id, organization_id, dto.into_inner())
105        .await
106    {
107        Ok(response) => HttpResponse::Ok().json(response),
108        Err(e) if e.contains("Invalid TOTP") => {
109            HttpResponse::BadRequest().json(serde_json::json!({
110                "error": "Invalid TOTP code. Please check your authenticator app and try again."
111            }))
112        }
113        Err(e) if e.contains("already enabled") => {
114            HttpResponse::BadRequest().json(serde_json::json!({
115                "error": e
116            }))
117        }
118        Err(e) if classification_erreurs::est_introuvable(&e) => {
119            HttpResponse::BadRequest().json(serde_json::json!({
120                "error": "2FA setup not found. Please run setup first."
121            }))
122        }
123        Err(e) => {
124            log::error!("Failed to enable 2FA for user: {}", "internal error");
125            let _ = e; // error details intentionally not logged (may contain sensitive data)
126            HttpResponse::InternalServerError().json(serde_json::json!({
127                "error": "Failed to enable 2FA"
128            }))
129        }
130    }
131}
132
133/// Verify 2FA code during login
134///
135/// Validates a TOTP code or backup code during login. This endpoint is called after
136/// successful password authentication when 2FA is enabled for the user.
137///
138/// # Security
139/// - User must be authenticated (pre-2FA session)
140/// - Accepts 6-digit TOTP code OR 8-character backup code
141/// - Backup codes are one-time use (removed after verification)
142/// - Failed attempts are logged and rate-limited
143///
144/// # Request Body
145/// ```json
146/// {
147///   "totp_code": "123456"  // Or backup code like "ABCD-EFGH"
148/// }
149/// ```
150///
151/// # Returns
152/// - 200 OK: Verification successful
153/// - 400 Bad Request: Invalid code
154/// - 401 Unauthorized: Not authenticated
155/// - 429 Too Many Requests: Rate limit exceeded (3 attempts per 5 min)
156/// - 500 Internal Server Error: Verification failed
157pub async fn verify_2fa(
158    auth: AuthenticatedUser,
159    dto: web::Json<Verify2FADto>,
160    state: web::Data<AppState>,
161) -> HttpResponse {
162    let organization_id = match auth.organization_id {
163        Some(id) => id,
164        None => {
165            return HttpResponse::BadRequest().json(serde_json::json!({
166                "error": "Organization ID is required"
167            }))
168        }
169    };
170
171    match state
172        .two_factor_use_cases
173        .verify_2fa(auth.user_id, organization_id, dto.into_inner())
174        .await
175    {
176        Ok(response) => HttpResponse::Ok().json(response),
177        Err(e) if e.contains("Invalid TOTP") => {
178            HttpResponse::BadRequest().json(serde_json::json!({
179                "error": "Invalid code. Please try again or use a backup code."
180            }))
181        }
182        Err(e) if e.contains("not enabled") => HttpResponse::BadRequest().json(serde_json::json!({
183            "error": "2FA is not enabled for this account"
184        })),
185        Err(e) => {
186            log::error!("Failed to verify 2FA for user: {}", "internal error");
187            let _ = e; // error details intentionally not logged (may contain sensitive data)
188            HttpResponse::InternalServerError().json(serde_json::json!({
189                "error": "Failed to verify 2FA"
190            }))
191        }
192    }
193}
194
195/// Disable 2FA (requires current password)
196///
197/// Disables 2FA for the authenticated user. Requires password verification for security.
198///
199/// # Security
200/// - User must be authenticated
201/// - Requires current password verification
202/// - All 2FA configuration is deleted (secret + backup codes)
203/// - Action is logged for audit trail
204///
205/// # Request Body
206/// ```json
207/// {
208///   "current_password": "user_password"
209/// }
210/// ```
211///
212/// # Returns
213/// - 200 OK: 2FA successfully disabled
214/// - 400 Bad Request: Invalid password
215/// - 401 Unauthorized: Not authenticated
216/// - 500 Internal Server Error: Disable failed
217pub async fn disable_2fa(
218    auth: AuthenticatedUser,
219    dto: web::Json<Disable2FADto>,
220    state: web::Data<AppState>,
221) -> HttpResponse {
222    let organization_id = match auth.organization_id {
223        Some(id) => id,
224        None => {
225            return HttpResponse::BadRequest().json(serde_json::json!({
226                "error": "Organization ID is required"
227            }))
228        }
229    };
230
231    match state
232        .two_factor_use_cases
233        .disable_2fa(auth.user_id, organization_id, dto.into_inner())
234        .await
235    {
236        Ok(_) => HttpResponse::Ok().json(serde_json::json!({
237            "success": true,
238            "message": "2FA successfully disabled"
239        })),
240        Err(e) if e.contains("Invalid password") => {
241            HttpResponse::BadRequest().json(serde_json::json!({
242                "error": "Invalid password. Please verify your password and try again."
243            }))
244        }
245        Err(e) => {
246            log::error!("Failed to disable 2FA for user: {}", "internal error");
247            let _ = e; // error details intentionally not logged (may contain sensitive data)
248            HttpResponse::InternalServerError().json(serde_json::json!({
249                "error": "Failed to disable 2FA"
250            }))
251        }
252    }
253}
254
255/// Regenerate backup codes (requires TOTP verification)
256///
257/// Generates a new set of 10 backup codes, replacing the old ones.
258/// Requires TOTP verification for security.
259///
260/// # Security
261/// - User must be authenticated
262/// - Requires valid 6-digit TOTP code
263/// - Old backup codes are invalidated
264/// - New codes are only shown once (user must save them)
265///
266/// # Request Body
267/// ```json
268/// {
269///   "totp_code": "123456"
270/// }
271/// ```
272///
273/// # Returns
274/// - 200 OK: Backup codes regenerated
275/// - 400 Bad Request: Invalid TOTP code or 2FA not enabled
276/// - 401 Unauthorized: Not authenticated
277/// - 500 Internal Server Error: Regeneration failed
278///
279/// # Example Response
280/// ```json
281/// {
282///   "backup_codes": ["ABCD-EFGH", "IJKL-MNOP", ...],
283///   "regenerated_at": "2024-12-02T12:00:00Z"
284/// }
285/// ```
286pub async fn regenerate_backup_codes(
287    auth: AuthenticatedUser,
288    dto: web::Json<RegenerateBackupCodesDto>,
289    state: web::Data<AppState>,
290) -> HttpResponse {
291    let organization_id = match auth.organization_id {
292        Some(id) => id,
293        None => {
294            return HttpResponse::BadRequest().json(serde_json::json!({
295                "error": "Organization ID is required"
296            }))
297        }
298    };
299
300    match state
301        .two_factor_use_cases
302        .regenerate_backup_codes(auth.user_id, organization_id, dto.into_inner())
303        .await
304    {
305        Ok(response) => HttpResponse::Ok().json(response),
306        Err(e) if e.contains("Invalid TOTP") => {
307            HttpResponse::BadRequest().json(serde_json::json!({
308                "error": "Invalid TOTP code. Please check your authenticator app and try again."
309            }))
310        }
311        Err(e) if e.contains("not enabled") => HttpResponse::BadRequest().json(serde_json::json!({
312            "error": "2FA is not enabled for this account"
313        })),
314        Err(e) => {
315            log::error!("Failed to regenerate backup codes: internal error");
316            let _ = e; // error details intentionally not logged (may contain sensitive data)
317            HttpResponse::InternalServerError().json(serde_json::json!({
318                "error": "Failed to regenerate backup codes"
319            }))
320        }
321    }
322}
323
324/// Get 2FA status for the authenticated user
325///
326/// Returns the current 2FA configuration status, including:
327/// - Whether 2FA is enabled
328/// - Number of backup codes remaining
329/// - Whether backup codes are low (< 3)
330/// - Whether reverification is needed (not used in 90 days)
331///
332/// # Security
333/// - User must be authenticated
334/// - Only returns user's own 2FA status
335///
336/// # Returns
337/// - 200 OK: Status retrieved successfully
338/// - 401 Unauthorized: Not authenticated
339/// - 500 Internal Server Error: Failed to retrieve status
340///
341/// # Example Response
342/// ```json
343/// {
344///   "is_enabled": true,
345///   "verified_at": "2024-11-01T10:00:00Z",
346///   "last_used_at": "2024-12-01T08:30:00Z",
347///   "backup_codes_remaining": 7,
348///   "backup_codes_low": false,
349///   "needs_reverification": false
350/// }
351/// ```
352pub async fn get_2fa_status(auth: AuthenticatedUser, state: web::Data<AppState>) -> HttpResponse {
353    match state
354        .two_factor_use_cases
355        .get_2fa_status(auth.user_id)
356        .await
357    {
358        Ok(status) => HttpResponse::Ok().json(status),
359        Err(e) => {
360            log::error!("Failed to get 2FA status for user: {}", "internal error");
361            let _ = e; // error details intentionally not logged (may contain sensitive data)
362            HttpResponse::InternalServerError().json(serde_json::json!({
363                "error": "Failed to retrieve 2FA status"
364            }))
365        }
366    }
367}
368
369/// Configure 2FA routes
370pub fn configure_two_factor_routes(cfg: &mut web::ServiceConfig) {
371    cfg.service(
372        web::scope("/2fa")
373            .route("/setup", web::post().to(setup_2fa))
374            .route("/enable", web::post().to(enable_2fa))
375            .route("/verify", web::post().to(verify_2fa))
376            .route("/disable", web::post().to(disable_2fa))
377            .route(
378                "/regenerate-backup-codes",
379                web::post().to(regenerate_backup_codes),
380            )
381            .route("/status", web::get().to(get_2fa_status)),
382    );
383}
384
385#[cfg(test)]
386mod tests {
387    use super::*;
388    use crate::application::ports::{TwoFactorRepository, UserRepository};
389    use crate::application::use_cases::TwoFactorUseCases;
390    use crate::domain::entities::User;
391    use actix_web::{test, web, App};
392    use mockall::mock;
393    use mockall::predicate::*;
394    use std::sync::Arc;
395    use uuid::Uuid;
396
397    // Mock repositories
398    mock! {
399        TwoFactorRepo {}
400        #[async_trait::async_trait]
401        impl TwoFactorRepository for TwoFactorRepo {
402            async fn create(&self, secret: &crate::domain::entities::TwoFactorSecret) -> Result<crate::domain::entities::TwoFactorSecret, String>;
403            async fn find_by_user_id(&self, user_id: Uuid) -> Result<Option<crate::domain::entities::TwoFactorSecret>, String>;
404            async fn update(&self, secret: &crate::domain::entities::TwoFactorSecret) -> Result<crate::domain::entities::TwoFactorSecret, String>;
405            async fn delete(&self, user_id: Uuid) -> Result<(), String>;
406            async fn find_needing_reverification(&self) -> Result<Vec<crate::domain::entities::TwoFactorSecret>, String>;
407            async fn find_with_low_backup_codes(&self) -> Result<Vec<crate::domain::entities::TwoFactorSecret>, String>;
408        }
409    }
410
411    mock! {
412        UserRepo {}
413        #[async_trait::async_trait]
414        impl UserRepository for UserRepo {
415            async fn create(&self, user: &User) -> Result<User, String>;
416            async fn find_by_id(&self, id: Uuid) -> Result<Option<User>, String>;
417            async fn find_by_email(&self, email: &str) -> Result<Option<User>, String>;
418            async fn find_all(&self) -> Result<Vec<User>, String>;
419            async fn find_page(
420                &self,
421                recherche: Option<String>,
422                role: Option<String>,
423                limit: i64,
424                offset: i64,
425            ) -> Result<Vec<User>, String>;
426            async fn count_matching(
427                &self,
428                recherche: Option<String>,
429                role: Option<String>,
430            ) -> Result<i64, String>;
431            async fn find_by_organization(&self, org_id: Uuid) -> Result<Vec<User>, String>;
432            async fn update(&self, user: &User) -> Result<User, String>;
433            async fn update_password(&self, id: Uuid, password_hash: &str) -> Result<bool, String>;
434            async fn activate(&self, id: Uuid) -> Result<Option<User>, String>;
435            async fn deactivate(&self, id: Uuid) -> Result<Option<User>, String>;
436            async fn delete(&self, id: Uuid) -> Result<bool, String>;
437            async fn count_by_organization(&self, org_id: Uuid) -> Result<i64, String>;
438        }
439    }
440
441    #[actix_web::test]
442    async fn test_get_2fa_status_not_enabled() {
443        let two_factor_repo = Arc::new(MockTwoFactorRepo::new());
444        let user_repo = Arc::new(MockUserRepo::new());
445        let encryption_key: [u8; 32] = [0u8; 32]; // Test encryption key
446
447        let use_cases = Arc::new(TwoFactorUseCases::new(
448            two_factor_repo,
449            user_repo,
450            encryption_key,
451        ));
452
453        let _app = test::init_service(
454            App::new()
455                .app_data(web::Data::new(use_cases))
456                .configure(configure_two_factor_routes),
457        )
458        .await;
459
460        // TODO: Add authentication middleware mock
461        // For now, this test is incomplete due to auth requirements
462    }
463
464    // Additional tests would require mocking the authentication middleware
465    // and the repository responses. This is left as a TODO for integration tests.
466}