-- Migration 017: Multi-Factor Authentication (TOTP) Implementation -- CRITICAL SECURITY: TOTP-based MFA for financial system compliance -- CVSS 9.1 vulnerability remediation: Implement mandatory MFA for all users -- Enable required extensions CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; CREATE EXTENSION IF NOT EXISTS "pgcrypto"; -- ============================================================================ -- MFA Configuration Table -- ============================================================================ CREATE TABLE IF NOT EXISTS mfa_config ( id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), user_id UUID NOT NULL UNIQUE REFERENCES users(id) ON DELETE CASCADE, -- TOTP Configuration (RFC 6238) totp_secret_encrypted BYTEA NOT NULL, -- Encrypted TOTP secret (Base32) totp_algorithm VARCHAR(10) NOT NULL DEFAULT 'SHA1', -- SHA1, SHA256, SHA512 totp_digits INTEGER NOT NULL DEFAULT 6, -- 6 or 8 digits totp_period INTEGER NOT NULL DEFAULT 30, -- Time step in seconds (usually 30) -- MFA Status is_enabled BOOLEAN NOT NULL DEFAULT false, is_verified BOOLEAN NOT NULL DEFAULT false, -- True after first successful verification -- Enrollment enrolled_at TIMESTAMP, verified_at TIMESTAMP, last_used_at TIMESTAMP, -- Recovery and Security backup_codes_remaining INTEGER NOT NULL DEFAULT 10, failed_verification_attempts INTEGER NOT NULL DEFAULT 0, last_failed_attempt_at TIMESTAMP, locked_until TIMESTAMP, -- Device Trust (optional future enhancement) trusted_device_ids JSONB DEFAULT '[]'::jsonb, -- Audit fields created_at TIMESTAMP NOT NULL DEFAULT NOW(), updated_at TIMESTAMP NOT NULL DEFAULT NOW(), created_by VARCHAR(255), updated_by VARCHAR(255), -- Constraints CONSTRAINT mfa_config_totp_algorithm_check CHECK (totp_algorithm IN ('SHA1', 'SHA256', 'SHA512')), CONSTRAINT mfa_config_totp_digits_check CHECK (totp_digits IN (6, 8)), CONSTRAINT mfa_config_totp_period_check CHECK (totp_period > 0 AND totp_period <= 60), CONSTRAINT mfa_config_backup_codes_check CHECK (backup_codes_remaining >= 0 AND backup_codes_remaining <= 20) ); -- ============================================================================ -- MFA Backup Codes Table -- ============================================================================ CREATE TABLE IF NOT EXISTS mfa_backup_codes ( id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, -- Code storage (hashed for security) code_hash VARCHAR(64) NOT NULL UNIQUE, -- SHA-256 hash of the backup code code_hint VARCHAR(10) NOT NULL, -- First 4 chars for user reference (e.g., "A3F2...") -- Usage tracking is_used BOOLEAN NOT NULL DEFAULT false, used_at TIMESTAMP, used_from_ip INET, -- Expiration expires_at TIMESTAMP NOT NULL, -- Audit created_at TIMESTAMP NOT NULL DEFAULT NOW(), -- Constraints CONSTRAINT mfa_backup_codes_expiration_check CHECK (expires_at > created_at) ); -- ============================================================================ -- MFA Verification Attempts Log -- ============================================================================ CREATE TABLE IF NOT EXISTS mfa_verification_log ( id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, -- Verification details verification_method VARCHAR(50) NOT NULL, -- 'totp', 'backup_code', 'trusted_device' success BOOLEAN NOT NULL, -- Context ip_address INET, user_agent TEXT, location_data JSONB, -- Optional geolocation data -- TOTP-specific totp_code VARCHAR(8), -- Not stored in plaintext for failed attempts totp_drift INTEGER, -- Time drift in periods (for analysis) -- Error information error_code VARCHAR(100), error_message TEXT, -- Timestamp created_at TIMESTAMP NOT NULL DEFAULT NOW(), -- Constraints CONSTRAINT mfa_verification_log_method_check CHECK (verification_method IN ('totp', 'backup_code', 'trusted_device', 'recovery')) ); -- ============================================================================ -- MFA Enrollment Sessions (temporary during setup) -- ============================================================================ CREATE TABLE IF NOT EXISTS mfa_enrollment_sessions ( id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, -- Temporary TOTP secret (during enrollment) temp_totp_secret_encrypted BYTEA NOT NULL, qr_code_data TEXT NOT NULL, -- QR code URI (otpauth://) -- Session management is_active BOOLEAN NOT NULL DEFAULT true, expires_at TIMESTAMP NOT NULL, completed_at TIMESTAMP, -- Verification verification_attempts INTEGER NOT NULL DEFAULT 0, max_verification_attempts INTEGER NOT NULL DEFAULT 3, -- Created timestamp created_at TIMESTAMP NOT NULL DEFAULT NOW(), -- Constraints CONSTRAINT mfa_enrollment_expiration_check CHECK (expires_at > created_at), CONSTRAINT mfa_enrollment_attempts_check CHECK (verification_attempts >= 0) ); -- ============================================================================ -- Indexes for Performance -- ============================================================================ CREATE INDEX IF NOT EXISTS idx_mfa_config_user_id ON mfa_config(user_id); CREATE INDEX IF NOT EXISTS idx_mfa_config_is_enabled ON mfa_config(is_enabled); CREATE INDEX IF NOT EXISTS idx_mfa_config_last_used_at ON mfa_config(last_used_at); CREATE INDEX IF NOT EXISTS idx_mfa_backup_codes_user_id ON mfa_backup_codes(user_id); CREATE INDEX IF NOT EXISTS idx_mfa_backup_codes_code_hash ON mfa_backup_codes(code_hash); CREATE INDEX IF NOT EXISTS idx_mfa_backup_codes_is_used ON mfa_backup_codes(is_used); CREATE INDEX IF NOT EXISTS idx_mfa_backup_codes_expires_at ON mfa_backup_codes(expires_at); CREATE INDEX IF NOT EXISTS idx_mfa_verification_log_user_id ON mfa_verification_log(user_id); CREATE INDEX IF NOT EXISTS idx_mfa_verification_log_created_at ON mfa_verification_log(created_at); CREATE INDEX IF NOT EXISTS idx_mfa_verification_log_success ON mfa_verification_log(success); CREATE INDEX IF NOT EXISTS idx_mfa_verification_log_ip_address ON mfa_verification_log(ip_address); CREATE INDEX IF NOT EXISTS idx_mfa_enrollment_sessions_user_id ON mfa_enrollment_sessions(user_id); CREATE INDEX IF NOT EXISTS idx_mfa_enrollment_sessions_is_active ON mfa_enrollment_sessions(is_active); CREATE INDEX IF NOT EXISTS idx_mfa_enrollment_sessions_expires_at ON mfa_enrollment_sessions(expires_at); -- ============================================================================ -- Encryption Key Management Functions -- ============================================================================ -- Function to encrypt TOTP secrets using application encryption key -- In production, this should use a proper key management service (KMS) CREATE OR REPLACE FUNCTION encrypt_totp_secret( plaintext_secret TEXT, encryption_key TEXT DEFAULT current_setting('app.encryption_key', true) ) RETURNS BYTEA AS $$ BEGIN -- Use pgcrypto for AES-256 encryption -- In production, encryption_key should be from Vault/KMS RETURN pgp_sym_encrypt(plaintext_secret, encryption_key, 'cipher-algo=aes256'); END; $$ LANGUAGE plpgsql SECURITY DEFINER; -- Function to decrypt TOTP secrets CREATE OR REPLACE FUNCTION decrypt_totp_secret( encrypted_secret BYTEA, encryption_key TEXT DEFAULT current_setting('app.encryption_key', true) ) RETURNS TEXT AS $$ BEGIN RETURN pgp_sym_decrypt(encrypted_secret, encryption_key); EXCEPTION WHEN OTHERS THEN -- Log decryption failures RAISE EXCEPTION 'TOTP secret decryption failed: %', SQLERRM; END; $$ LANGUAGE plpgsql SECURITY DEFINER; -- ============================================================================ -- MFA Management Functions -- ============================================================================ -- Function to hash backup codes securely CREATE OR REPLACE FUNCTION hash_backup_code(backup_code TEXT) RETURNS VARCHAR(64) AS $$ BEGIN RETURN encode(digest(backup_code, 'sha256'), 'hex'); END; $$ LANGUAGE plpgsql IMMUTABLE; -- Function to validate backup code CREATE OR REPLACE FUNCTION validate_backup_code( p_user_id UUID, p_backup_code TEXT, p_ip_address INET DEFAULT NULL ) RETURNS BOOLEAN AS $$ DECLARE v_code_hash VARCHAR(64); v_code_id UUID; v_is_valid BOOLEAN := false; BEGIN v_code_hash := hash_backup_code(p_backup_code); -- Check if backup code exists and is valid SELECT id INTO v_code_id FROM mfa_backup_codes WHERE user_id = p_user_id AND code_hash = v_code_hash AND is_used = false AND expires_at > NOW(); IF v_code_id IS NOT NULL THEN -- Mark code as used UPDATE mfa_backup_codes SET is_used = true, used_at = NOW(), used_from_ip = p_ip_address WHERE id = v_code_id; -- Decrement backup codes remaining UPDATE mfa_config SET backup_codes_remaining = backup_codes_remaining - 1, last_used_at = NOW() WHERE user_id = p_user_id; v_is_valid := true; -- Log successful verification INSERT INTO mfa_verification_log ( user_id, verification_method, success, ip_address ) VALUES ( p_user_id, 'backup_code', true, p_ip_address ); ELSE -- Log failed verification INSERT INTO mfa_verification_log ( user_id, verification_method, success, ip_address, error_code ) VALUES ( p_user_id, 'backup_code', false, p_ip_address, 'INVALID_BACKUP_CODE' ); END IF; RETURN v_is_valid; END; $$ LANGUAGE plpgsql; -- Function to check if MFA is required for user CREATE OR REPLACE FUNCTION is_mfa_required(p_user_id UUID) RETURNS BOOLEAN AS $$ DECLARE v_user_role VARCHAR(50); v_mfa_enabled BOOLEAN := false; BEGIN -- Get user role SELECT role INTO v_user_role FROM users WHERE id = p_user_id; -- Check if MFA is enabled for user SELECT is_enabled INTO v_mfa_enabled FROM mfa_config WHERE user_id = p_user_id; -- MFA is required for all privileged roles in financial systems -- Also required if user has MFA enabled (cannot disable once enabled) RETURN (v_user_role IN ('admin', 'trader', 'risk_manager', 'compliance_officer')) OR COALESCE(v_mfa_enabled, false); END; $$ LANGUAGE plpgsql; -- Function to record MFA verification attempt CREATE OR REPLACE FUNCTION record_mfa_attempt( p_user_id UUID, p_method VARCHAR(50), p_success BOOLEAN, p_ip_address INET DEFAULT NULL, p_user_agent TEXT DEFAULT NULL, p_totp_drift INTEGER DEFAULT NULL, p_error_code VARCHAR(100) DEFAULT NULL ) RETURNS UUID AS $$ DECLARE v_log_id UUID; BEGIN INSERT INTO mfa_verification_log ( user_id, verification_method, success, ip_address, user_agent, totp_drift, error_code ) VALUES ( p_user_id, p_method, p_success, p_ip_address, p_user_agent, p_totp_drift, p_error_code ) RETURNING id INTO v_log_id; -- Update MFA config based on success/failure IF p_success THEN UPDATE mfa_config SET last_used_at = NOW(), failed_verification_attempts = 0, locked_until = NULL WHERE user_id = p_user_id; ELSE -- Increment failed attempts UPDATE mfa_config SET failed_verification_attempts = failed_verification_attempts + 1, last_failed_attempt_at = NOW() WHERE user_id = p_user_id; -- Lock account after 5 failed attempts (15 minutes lockout) UPDATE mfa_config SET locked_until = NOW() + INTERVAL '15 minutes' WHERE user_id = p_user_id AND failed_verification_attempts >= 5 AND locked_until IS NULL; END IF; RETURN v_log_id; END; $$ LANGUAGE plpgsql; -- Function to clean up expired MFA data CREATE OR REPLACE FUNCTION cleanup_expired_mfa_data() RETURNS INTEGER AS $$ DECLARE cleanup_count INTEGER := 0; BEGIN -- Delete expired backup codes DELETE FROM mfa_backup_codes WHERE expires_at < NOW() OR (is_used = true AND used_at < NOW() - INTERVAL '90 days'); GET DIAGNOSTICS cleanup_count = ROW_COUNT; -- Delete old verification logs (keep 1 year) DELETE FROM mfa_verification_log WHERE created_at < NOW() - INTERVAL '1 year'; -- Delete expired enrollment sessions DELETE FROM mfa_enrollment_sessions WHERE expires_at < NOW() OR (completed_at IS NOT NULL AND completed_at < NOW() - INTERVAL '7 days'); -- Clear expired account locks UPDATE mfa_config SET locked_until = NULL WHERE locked_until < NOW(); RETURN cleanup_count; END; $$ LANGUAGE plpgsql; -- ============================================================================ -- Triggers -- ============================================================================ CREATE TRIGGER update_mfa_config_updated_at BEFORE UPDATE ON mfa_config FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); -- ============================================================================ -- Row Level Security (RLS) -- ============================================================================ ALTER TABLE mfa_config ENABLE ROW LEVEL SECURITY; ALTER TABLE mfa_backup_codes ENABLE ROW LEVEL SECURITY; ALTER TABLE mfa_verification_log ENABLE ROW LEVEL SECURITY; ALTER TABLE mfa_enrollment_sessions ENABLE ROW LEVEL SECURITY; -- Users can only access their own MFA data CREATE POLICY mfa_config_self_access ON mfa_config FOR ALL TO foxhunt_user USING (user_id = current_setting('app.current_user_id')::uuid OR current_setting('app.current_user_role') = 'admin'); CREATE POLICY mfa_backup_codes_self_access ON mfa_backup_codes FOR ALL TO foxhunt_user USING (user_id = current_setting('app.current_user_id')::uuid OR current_setting('app.current_user_role') = 'admin'); CREATE POLICY mfa_verification_log_self_access ON mfa_verification_log FOR SELECT TO foxhunt_user USING (user_id = current_setting('app.current_user_id')::uuid OR current_setting('app.current_user_role') = 'admin'); CREATE POLICY mfa_enrollment_sessions_self_access ON mfa_enrollment_sessions FOR ALL TO foxhunt_user USING (user_id = current_setting('app.current_user_id')::uuid); -- ============================================================================ -- Grants -- ============================================================================ GRANT SELECT, INSERT, UPDATE ON mfa_config TO foxhunt_user; GRANT SELECT, INSERT, UPDATE ON mfa_backup_codes TO foxhunt_user; GRANT SELECT, INSERT ON mfa_verification_log TO foxhunt_user; GRANT SELECT, INSERT, UPDATE, DELETE ON mfa_enrollment_sessions TO foxhunt_user; -- ============================================================================ -- Comments -- ============================================================================ COMMENT ON TABLE mfa_config IS 'Multi-factor authentication configuration (TOTP) for users'; COMMENT ON TABLE mfa_backup_codes IS 'One-time backup codes for MFA recovery'; COMMENT ON TABLE mfa_verification_log IS 'Audit log for MFA verification attempts'; COMMENT ON TABLE mfa_enrollment_sessions IS 'Temporary sessions during MFA enrollment'; COMMENT ON FUNCTION encrypt_totp_secret IS 'Encrypt TOTP secrets using AES-256'; COMMENT ON FUNCTION decrypt_totp_secret IS 'Decrypt TOTP secrets for verification'; COMMENT ON FUNCTION hash_backup_code IS 'Hash backup codes using SHA-256'; COMMENT ON FUNCTION validate_backup_code IS 'Validate and consume a backup code'; COMMENT ON FUNCTION is_mfa_required IS 'Check if MFA is required for a user'; COMMENT ON FUNCTION record_mfa_attempt IS 'Record MFA verification attempt with security tracking'; COMMENT ON FUNCTION cleanup_expired_mfa_data IS 'Clean up expired MFA sessions and codes';