koprogo_api/infrastructure/web/handlers/
two_factor_handlers.rs

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