Copied from api_gateway, removed REST handlers (port 8080), added tonic-web + CORS for grpc-web browser access. Binary renamed: api-gateway → api Changes: - Package name: api-gateway → api - Deleted src/handlers/ (REST ML endpoints on port 8080) - Added tonic-web 0.13 + tower-http CORS layer - Server::builder().accept_http1(true) for grpc-web - CORS_ORIGINS env var (default http://localhost:5173) - Metrics server on port 9091 (axum) preserved - All 95 lib tests pass, 0 clippy warnings - Added services/api to workspace members Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
450 lines
13 KiB
Rust
450 lines
13 KiB
Rust
#![cfg(feature = "mfa")]
|
|
//! MFA Enrollment Integration Test
|
|
//!
|
|
//! Agent H3: Tests complete MFA enrollment flow including:
|
|
//! - QR code generation
|
|
//! - TOTP verification
|
|
//! - Backup code generation
|
|
//! - Account lockout after failed attempts
|
|
//! - Admin enforcement
|
|
|
|
use anyhow::Result;
|
|
use secrecy::ExposeSecret;
|
|
use sqlx::PgPool;
|
|
use uuid::Uuid;
|
|
|
|
use api::auth::mfa::MfaManager;
|
|
|
|
/// Helper to create test database connection
|
|
async fn setup_test_db() -> Result<PgPool> {
|
|
let database_url = std::env::var("DATABASE_URL").unwrap_or_else(|_| {
|
|
"postgresql://foxhunt:foxhunt_dev_password@localhost:5432/foxhunt".to_string()
|
|
});
|
|
|
|
let pool = PgPool::connect(&database_url).await?;
|
|
Ok(pool)
|
|
}
|
|
|
|
/// Helper to create test user with admin role
|
|
async fn create_test_admin_user(pool: &PgPool) -> Result<Uuid> {
|
|
let user_id = Uuid::new_v4();
|
|
let username = format!(
|
|
"test_admin_{}",
|
|
Uuid::new_v4().to_string().split('-').next().expect("INVARIANT: Iterator should have next element")
|
|
);
|
|
let email = format!("{}@test.local", username);
|
|
|
|
// Create user
|
|
sqlx::query(
|
|
r#"
|
|
INSERT INTO users (
|
|
id, username, email, password_hash, salt, must_change_password, active
|
|
) VALUES ($1, $2, $3, '$2b$12$test_hash', 'test_salt', FALSE, TRUE)
|
|
"#,
|
|
)
|
|
.bind(user_id)
|
|
.bind(&username)
|
|
.bind(&email)
|
|
.execute(pool)
|
|
.await?;
|
|
|
|
// Assign system_admin role
|
|
let admin_role_id = Uuid::parse_str("00000000-0000-0000-0000-000000000001")?;
|
|
sqlx::query("INSERT INTO user_roles (user_id, role_id, granted_by) VALUES ($1, $2, $1)")
|
|
.bind(user_id)
|
|
.bind(admin_role_id)
|
|
.execute(pool)
|
|
.await?;
|
|
|
|
Ok(user_id)
|
|
}
|
|
|
|
/// Helper to cleanup test user
|
|
async fn cleanup_test_user(pool: &PgPool, user_id: Uuid) -> Result<()> {
|
|
// Foreign key constraints will cascade delete related records
|
|
sqlx::query("DELETE FROM users WHERE id = $1")
|
|
.bind(user_id)
|
|
.execute(pool)
|
|
.await?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_mfa_enrollment_complete_flow() -> Result<()> {
|
|
let pool = setup_test_db().await?;
|
|
let user_id = create_test_admin_user(&pool).await?;
|
|
|
|
// Create MFA manager
|
|
let encryption_key = "test_encryption_key_32_bytes_long!!".to_string();
|
|
let mfa_manager = MfaManager::new(pool.clone(), encryption_key)?;
|
|
|
|
// Step 1: Start enrollment
|
|
let enrollment = mfa_manager
|
|
.start_enrollment(user_id, "Foxhunt Test", "test_user@test.local")
|
|
.await?;
|
|
|
|
println!("✓ MFA enrollment session created");
|
|
println!(" Session ID: {}", enrollment.session_id);
|
|
println!(" QR Code URI: {}", enrollment.qr_code_uri);
|
|
println!(" QR Code PNG: {} bytes", enrollment.qr_code_png.len());
|
|
println!(" Manual Entry Key: {}", enrollment.manual_entry_key);
|
|
|
|
assert!(
|
|
!enrollment.qr_code_uri.is_empty(),
|
|
"QR code URI should not be empty"
|
|
);
|
|
assert!(
|
|
!enrollment.qr_code_png.is_empty(),
|
|
"QR code PNG should not be empty"
|
|
);
|
|
assert!(
|
|
enrollment.qr_code_png.len() > 100,
|
|
"QR code PNG should be at least 100 bytes"
|
|
);
|
|
assert!(
|
|
enrollment.manual_entry_key.len() >= 16,
|
|
"TOTP secret should be at least 16 characters"
|
|
);
|
|
|
|
// Step 2: Generate valid TOTP code from secret
|
|
use totp_rs::{Algorithm, TOTP};
|
|
let totp = TOTP::new(
|
|
Algorithm::SHA1,
|
|
6,
|
|
1,
|
|
30,
|
|
enrollment.manual_entry_key.as_bytes().to_vec(),
|
|
)?;
|
|
|
|
let valid_code = totp.generate_current()?;
|
|
println!("✓ Generated TOTP code: {}", valid_code);
|
|
|
|
// Step 3: Complete enrollment with valid TOTP code
|
|
let backup_codes = mfa_manager
|
|
.complete_enrollment(enrollment.session_id, user_id, &valid_code)
|
|
.await?;
|
|
|
|
println!("✓ MFA enrollment completed");
|
|
println!(" Backup codes generated: {}", backup_codes.len());
|
|
|
|
assert_eq!(backup_codes.len(), 10, "Should generate 10 backup codes");
|
|
|
|
for (i, code) in backup_codes.iter().enumerate() {
|
|
println!(
|
|
" Backup Code {}: {} (hint: {})",
|
|
i + 1,
|
|
code.code.expose_secret(),
|
|
code.hint
|
|
);
|
|
assert!(
|
|
code.code.expose_secret().len() >= 8,
|
|
"Backup code should be at least 8 characters"
|
|
);
|
|
assert_eq!(code.hint.len(), 4, "Hint should be 4 characters");
|
|
}
|
|
|
|
// Step 4: Verify MFA is enabled
|
|
let config = mfa_manager.get_mfa_config(user_id).await?;
|
|
assert!(config.is_some(), "MFA config should exist");
|
|
|
|
let config = config.unwrap();
|
|
assert!(config.is_enabled, "MFA should be enabled");
|
|
assert!(config.is_verified, "MFA should be verified");
|
|
assert_eq!(
|
|
config.backup_codes_remaining, 10,
|
|
"Should have 10 backup codes"
|
|
);
|
|
|
|
println!("✓ MFA config verified");
|
|
println!(" Enabled: {}", config.is_enabled);
|
|
println!(" Verified: {}", config.is_verified);
|
|
println!(
|
|
" Backup codes remaining: {}",
|
|
config.backup_codes_remaining
|
|
);
|
|
|
|
// Cleanup
|
|
cleanup_test_user(&pool, user_id).await?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_mfa_totp_verification() -> Result<()> {
|
|
let pool = setup_test_db().await?;
|
|
let user_id = create_test_admin_user(&pool).await?;
|
|
|
|
let encryption_key = "test_encryption_key_32_bytes_long!!".to_string();
|
|
let mfa_manager = MfaManager::new(pool.clone(), encryption_key)?;
|
|
|
|
// Enroll user in MFA
|
|
let enrollment = mfa_manager
|
|
.start_enrollment(user_id, "Foxhunt Test", "test@test.local")
|
|
.await?;
|
|
|
|
use totp_rs::{Algorithm, TOTP};
|
|
let totp = TOTP::new(
|
|
Algorithm::SHA1,
|
|
6,
|
|
1,
|
|
30,
|
|
enrollment.manual_entry_key.as_bytes().to_vec(),
|
|
)?;
|
|
|
|
let valid_code = totp.generate_current()?;
|
|
let _backup_codes = mfa_manager
|
|
.complete_enrollment(enrollment.session_id, user_id, &valid_code)
|
|
.await?;
|
|
|
|
// Test valid TOTP verification
|
|
let new_code = totp.generate_current()?;
|
|
let is_valid = mfa_manager
|
|
.verify_totp(user_id, &new_code, Some("127.0.0.1".to_string()))
|
|
.await?;
|
|
|
|
assert!(is_valid, "Valid TOTP code should verify successfully");
|
|
println!("✓ Valid TOTP code verified");
|
|
|
|
// Test invalid TOTP code
|
|
let invalid_result = mfa_manager
|
|
.verify_totp(user_id, "000000", Some("127.0.0.1".to_string()))
|
|
.await;
|
|
|
|
assert!(
|
|
invalid_result.is_ok(),
|
|
"Invalid code should return Ok(false)"
|
|
);
|
|
assert!(
|
|
!invalid_result.unwrap(),
|
|
"Invalid TOTP code should not verify"
|
|
);
|
|
println!("✓ Invalid TOTP code rejected");
|
|
|
|
// Cleanup
|
|
cleanup_test_user(&pool, user_id).await?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_mfa_backup_code_recovery() -> Result<()> {
|
|
let pool = setup_test_db().await?;
|
|
let user_id = create_test_admin_user(&pool).await?;
|
|
|
|
let encryption_key = "test_encryption_key_32_bytes_long!!".to_string();
|
|
let mfa_manager = MfaManager::new(pool.clone(), encryption_key)?;
|
|
|
|
// Enroll user in MFA
|
|
let enrollment = mfa_manager
|
|
.start_enrollment(user_id, "Foxhunt Test", "test@test.local")
|
|
.await?;
|
|
|
|
use totp_rs::{Algorithm, TOTP};
|
|
let totp = TOTP::new(
|
|
Algorithm::SHA1,
|
|
6,
|
|
1,
|
|
30,
|
|
enrollment.manual_entry_key.as_bytes().to_vec(),
|
|
)?;
|
|
|
|
let valid_code = totp.generate_current()?;
|
|
let backup_codes = mfa_manager
|
|
.complete_enrollment(enrollment.session_id, user_id, &valid_code)
|
|
.await?;
|
|
|
|
// Test backup code usage
|
|
let first_backup_code = backup_codes[0].code.expose_secret();
|
|
let is_valid = mfa_manager
|
|
.verify_backup_code(user_id, first_backup_code, Some("127.0.0.1".to_string()))
|
|
.await?;
|
|
|
|
assert!(is_valid, "Valid backup code should verify successfully");
|
|
println!("✓ Backup code verified and consumed");
|
|
|
|
// Verify backup code count decreased
|
|
let status = mfa_manager.get_backup_codes_status(user_id).await?;
|
|
assert_eq!(status.remaining, 9, "Should have 9 remaining backup codes");
|
|
assert_eq!(status.used, 1, "Should have 1 used backup code");
|
|
println!("✓ Backup code status updated (9 remaining, 1 used)");
|
|
|
|
// Test reusing same backup code (should fail)
|
|
let is_valid = mfa_manager
|
|
.verify_backup_code(user_id, first_backup_code, Some("127.0.0.1".to_string()))
|
|
.await?;
|
|
|
|
assert!(!is_valid, "Used backup code should not verify again");
|
|
println!("✓ Reused backup code rejected");
|
|
|
|
// Cleanup
|
|
cleanup_test_user(&pool, user_id).await?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_mfa_account_lockout() -> Result<()> {
|
|
let pool = setup_test_db().await?;
|
|
let user_id = create_test_admin_user(&pool).await?;
|
|
|
|
let encryption_key = "test_encryption_key_32_bytes_long!!".to_string();
|
|
let mfa_manager = MfaManager::new(pool.clone(), encryption_key)?;
|
|
|
|
// Enroll user in MFA
|
|
let enrollment = mfa_manager
|
|
.start_enrollment(user_id, "Foxhunt Test", "test@test.local")
|
|
.await?;
|
|
|
|
use totp_rs::{Algorithm, TOTP};
|
|
let totp = TOTP::new(
|
|
Algorithm::SHA1,
|
|
6,
|
|
1,
|
|
30,
|
|
enrollment.manual_entry_key.as_bytes().to_vec(),
|
|
)?;
|
|
|
|
let valid_code = totp.generate_current()?;
|
|
let _backup_codes = mfa_manager
|
|
.complete_enrollment(enrollment.session_id, user_id, &valid_code)
|
|
.await?;
|
|
|
|
// Make 5 failed attempts to trigger lockout
|
|
for i in 1..=5 {
|
|
let result = mfa_manager
|
|
.verify_totp(user_id, "000000", Some("127.0.0.1".to_string()))
|
|
.await;
|
|
|
|
if i < 5 {
|
|
assert!(
|
|
result.is_ok(),
|
|
"Failed attempt {} should not lock account yet",
|
|
i
|
|
);
|
|
println!("✓ Failed attempt {} recorded", i);
|
|
}
|
|
}
|
|
|
|
// Check if account is locked
|
|
let is_locked = mfa_manager.is_mfa_locked(user_id).await?;
|
|
assert!(
|
|
is_locked,
|
|
"Account should be locked after 5 failed attempts"
|
|
);
|
|
println!("✓ Account locked after 5 failed attempts");
|
|
|
|
// Verify locked account cannot authenticate even with valid code
|
|
let new_code = totp.generate_current()?;
|
|
let locked_result = mfa_manager
|
|
.verify_totp(user_id, &new_code, Some("127.0.0.1".to_string()))
|
|
.await;
|
|
|
|
assert!(
|
|
locked_result.is_err(),
|
|
"Locked account should not allow authentication"
|
|
);
|
|
assert!(
|
|
locked_result.unwrap_err().to_string().contains("locked"),
|
|
"Error should mention account lock"
|
|
);
|
|
println!("✓ Locked account rejected valid TOTP code");
|
|
|
|
// Cleanup
|
|
cleanup_test_user(&pool, user_id).await?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_mfa_admin_enforcement() -> Result<()> {
|
|
let pool = setup_test_db().await?;
|
|
let user_id = create_test_admin_user(&pool).await?;
|
|
|
|
// Test 1: Verify MFA is required for admin user
|
|
let is_required: bool = sqlx::query_scalar("SELECT is_mfa_required($1)")
|
|
.bind(user_id)
|
|
.fetch_one(&pool)
|
|
.await?;
|
|
|
|
assert!(is_required, "MFA should be required for admin user");
|
|
println!("✓ MFA requirement detected for admin user");
|
|
|
|
// Test 2: Verify session creation is blocked without MFA
|
|
let session_id = Uuid::new_v4();
|
|
let token_hash = format!("test_token_{}", Uuid::new_v4());
|
|
|
|
let result = sqlx::query(
|
|
r#"
|
|
INSERT INTO sessions (
|
|
id, token_hash, user_id, expires_at, client_ip, session_type
|
|
) VALUES ($1, $2, $3, NOW() + INTERVAL '1 hour', '127.0.0.1'::inet, 'test')
|
|
"#,
|
|
)
|
|
.bind(session_id)
|
|
.bind(token_hash)
|
|
.bind(user_id)
|
|
.execute(&pool)
|
|
.await;
|
|
|
|
assert!(result.is_err(), "Session creation should fail without MFA");
|
|
let error_msg = result.unwrap_err().to_string();
|
|
assert!(
|
|
error_msg.contains("MFA_REQUIRED"),
|
|
"Error should indicate MFA requirement"
|
|
);
|
|
println!("✓ Session creation blocked without MFA enrollment");
|
|
|
|
// Test 3: Enroll in MFA and verify session creation succeeds
|
|
let encryption_key = "test_encryption_key_32_bytes_long!!".to_string();
|
|
let mfa_manager = MfaManager::new(pool.clone(), encryption_key)?;
|
|
|
|
let enrollment = mfa_manager
|
|
.start_enrollment(user_id, "Foxhunt Test", "test@test.local")
|
|
.await?;
|
|
|
|
use totp_rs::{Algorithm, TOTP};
|
|
let totp = TOTP::new(
|
|
Algorithm::SHA1,
|
|
6,
|
|
1,
|
|
30,
|
|
enrollment.manual_entry_key.as_bytes().to_vec(),
|
|
)?;
|
|
|
|
let valid_code = totp.generate_current()?;
|
|
let _backup_codes = mfa_manager
|
|
.complete_enrollment(enrollment.session_id, user_id, &valid_code)
|
|
.await?;
|
|
|
|
println!("✓ MFA enrollment completed");
|
|
|
|
// Now session creation should succeed
|
|
let session_id = Uuid::new_v4();
|
|
let token_hash = format!("test_token_{}", Uuid::new_v4());
|
|
|
|
let result = sqlx::query(
|
|
r#"
|
|
INSERT INTO sessions (
|
|
id, token_hash, user_id, expires_at, client_ip, session_type
|
|
) VALUES ($1, $2, $3, NOW() + INTERVAL '1 hour', '127.0.0.1'::inet, 'test')
|
|
"#,
|
|
)
|
|
.bind(session_id)
|
|
.bind(token_hash)
|
|
.bind(user_id)
|
|
.execute(&pool)
|
|
.await;
|
|
|
|
assert!(
|
|
result.is_ok(),
|
|
"Session creation should succeed with MFA enrolled"
|
|
);
|
|
println!("✓ Session creation allowed after MFA enrollment");
|
|
|
|
// Cleanup
|
|
cleanup_test_user(&pool, user_id).await?;
|
|
|
|
Ok(())
|
|
}
|