koprogo_api/infrastructure/web/handlers/
two_factor_handlers.rs

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