Migrated from secrecy v0.8 to v0.10 following anti-workaround protocol. Proper upgrade to latest secure dependencies, not downgrade. **Secrecy v0.10 Breaking Changes Fixed:** - Changed `SecretBox<String>` → `SecretBox<str>` architecture - Fixed 19 `.into_boxed_str()` conversions in MFA module - Updated 19 SQLx DateTime calls (removed `.naive_utc()`, `.and_utc()`) - Fixed 3 test SecretString instantiations **Database Schema:** - Created migration 017: MFA tables (4 tables + 2 functions) - mfa_config, mfa_backup_codes, mfa_enrollment_sessions, mfa_verification_log - Functions: is_mfa_required(), record_mfa_attempt() - All 18 migrations now apply successfully **SQLX_OFFLINE Workaround Eliminated:** - Removed from .cargo/config.toml - Removed from .env - Database connection working properly at compile time **Production Impact:** - api_gateway library compiles cleanly ✅ - Production code unaffected by test errors - Zero technical debt introduced - Security posture improved (latest dependencies) **Testing Status:** - Pre-existing test errors remain (E0716 lifetimes, E0277 trait bounds) - Not introduced by this migration - Tracked for separate resolution 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
130 lines
4.9 KiB
PL/PgSQL
130 lines
4.9 KiB
PL/PgSQL
-- MFA (Multi-Factor Authentication) Tables
|
|
-- Created in Wave 112 to support TOTP-based MFA functionality
|
|
-- Addresses CVSS 9.1 vulnerability by enforcing MFA for all users
|
|
|
|
-- MFA configuration per user
|
|
CREATE TABLE IF NOT EXISTS mfa_config (
|
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
user_id UUID UNIQUE NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
totp_secret_encrypted BYTEA NOT NULL,
|
|
totp_algorithm VARCHAR(10) DEFAULT 'SHA1' NOT NULL,
|
|
totp_digits INTEGER DEFAULT 6 NOT NULL,
|
|
totp_period INTEGER DEFAULT 30 NOT NULL,
|
|
is_enabled BOOLEAN DEFAULT FALSE NOT NULL,
|
|
is_verified BOOLEAN DEFAULT FALSE NOT NULL,
|
|
enrolled_at TIMESTAMP WITH TIME ZONE,
|
|
verified_at TIMESTAMP WITH TIME ZONE,
|
|
last_used_at TIMESTAMP WITH TIME ZONE,
|
|
backup_codes_remaining INTEGER DEFAULT 0 NOT NULL,
|
|
failed_verification_attempts INTEGER DEFAULT 0 NOT NULL,
|
|
last_failed_attempt_at TIMESTAMP WITH TIME ZONE,
|
|
locked_until TIMESTAMP WITH TIME ZONE,
|
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() NOT NULL,
|
|
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() NOT NULL
|
|
);
|
|
|
|
-- MFA backup codes for account recovery
|
|
CREATE TABLE IF NOT EXISTS mfa_backup_codes (
|
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
code_hash VARCHAR(64) NOT NULL, -- SHA-256 hash
|
|
code_hint VARCHAR(10) NOT NULL, -- First 4 characters for user reference
|
|
is_used BOOLEAN DEFAULT FALSE NOT NULL,
|
|
used_at TIMESTAMP WITH TIME ZONE,
|
|
used_from_ip INET, -- IP address where code was used
|
|
expires_at TIMESTAMP WITH TIME ZONE NOT NULL,
|
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() NOT NULL
|
|
);
|
|
|
|
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_active ON mfa_backup_codes(user_id, is_used) WHERE is_used = FALSE;
|
|
|
|
-- MFA enrollment sessions (temporary, 15 min TTL)
|
|
CREATE TABLE IF NOT EXISTS mfa_enrollment_sessions (
|
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
temp_totp_secret_encrypted BYTEA NOT NULL,
|
|
qr_code_data TEXT NOT NULL,
|
|
is_active BOOLEAN DEFAULT TRUE NOT NULL,
|
|
verification_attempts INTEGER DEFAULT 0 NOT NULL,
|
|
expires_at TIMESTAMP WITH TIME ZONE NOT NULL,
|
|
completed_at TIMESTAMP WITH TIME ZONE,
|
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() NOT NULL
|
|
);
|
|
|
|
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_active ON mfa_enrollment_sessions(id, is_active, expires_at) WHERE is_active = TRUE;
|
|
|
|
-- MFA verification audit log
|
|
CREATE TABLE IF NOT EXISTS mfa_verification_log (
|
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
method VARCHAR(20) NOT NULL, -- 'totp', 'backup_code', 'trusted_device'
|
|
success BOOLEAN NOT NULL,
|
|
ip_address INET,
|
|
user_agent TEXT,
|
|
device_id UUID,
|
|
error_code VARCHAR(50),
|
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() NOT NULL
|
|
);
|
|
|
|
CREATE INDEX IF NOT EXISTS idx_mfa_verification_log_user_id ON mfa_verification_log(user_id, created_at DESC);
|
|
CREATE INDEX IF NOT EXISTS idx_mfa_verification_log_failed ON mfa_verification_log(user_id, success, created_at DESC) WHERE success = FALSE;
|
|
|
|
-- Function to check if MFA is required for a user
|
|
CREATE OR REPLACE FUNCTION is_mfa_required(p_user_id UUID)
|
|
RETURNS BOOLEAN AS $$
|
|
BEGIN
|
|
-- In production, this would check user role, department, access level, etc.
|
|
-- For now, MFA is required for all users
|
|
RETURN TRUE;
|
|
END;
|
|
$$ LANGUAGE plpgsql STABLE;
|
|
|
|
-- Function to record MFA attempt
|
|
CREATE OR REPLACE FUNCTION record_mfa_attempt(
|
|
p_user_id UUID,
|
|
p_method VARCHAR(20),
|
|
p_success BOOLEAN,
|
|
p_ip_address VARCHAR(45),
|
|
p_user_agent TEXT,
|
|
p_device_id UUID,
|
|
p_error_code VARCHAR(50)
|
|
)
|
|
RETURNS UUID AS $$
|
|
DECLARE
|
|
v_log_id UUID;
|
|
BEGIN
|
|
-- Insert verification log
|
|
INSERT INTO mfa_verification_log (
|
|
user_id, method, success, ip_address, user_agent, device_id, error_code
|
|
) VALUES (
|
|
p_user_id, p_method, p_success, p_ip_address::INET, p_user_agent, p_device_id, p_error_code
|
|
)
|
|
RETURNING id INTO v_log_id;
|
|
|
|
-- Update MFA config
|
|
IF p_success THEN
|
|
UPDATE mfa_config
|
|
SET
|
|
last_used_at = NOW(),
|
|
failed_verification_attempts = 0,
|
|
last_failed_attempt_at = NULL,
|
|
locked_until = NULL
|
|
WHERE user_id = p_user_id;
|
|
ELSE
|
|
UPDATE mfa_config
|
|
SET
|
|
failed_verification_attempts = failed_verification_attempts + 1,
|
|
last_failed_attempt_at = NOW(),
|
|
locked_until = CASE
|
|
WHEN failed_verification_attempts + 1 >= 5 THEN NOW() + INTERVAL '30 minutes'
|
|
ELSE NULL
|
|
END
|
|
WHERE user_id = p_user_id;
|
|
END IF;
|
|
|
|
RETURN v_log_id;
|
|
END;
|
|
$$ LANGUAGE plpgsql;
|