db = $database; } else { $this->db = $database->getConnection(); } } /** * Enable 2FA for a user * * @param int $userId User ID * @return array Array containing success status and data (secret, QR code URL) */ public function enable($userId) { try { // Check if 2FA is already enabled $stmt = $this->db->prepare('SELECT enabled FROM user_2fa WHERE user_id = ?'); $stmt->execute([$userId]); $existing = $stmt->fetch(PDO::FETCH_ASSOC); if ($existing && $existing['enabled']) { return ['success' => false, 'message' => '2FA is already enabled']; } // Generate secret key $secret = $this->generateSecret(); // Get user's username for the QR code $stmt = $this->db->prepare('SELECT username FROM users WHERE id = ?'); $stmt->execute([$userId]); $user = $stmt->fetch(PDO::FETCH_ASSOC); // Generate backup codes $backupCodes = $this->generateBackupCodes(); // Store in database $this->db->beginTransaction(); $stmt = $this->db->prepare(' INSERT INTO user_2fa (user_id, secret_key, backup_codes, enabled, created_at) VALUES (?, ?, ?, 0, NOW()) ON DUPLICATE KEY UPDATE secret_key = VALUES(secret_key), backup_codes = VALUES(backup_codes), enabled = VALUES(enabled), created_at = VALUES(created_at) '); $stmt->execute([ $userId, $secret, json_encode($backupCodes) ]); $this->db->commit(); // Generate otpauth URL for QR code $otpauthUrl = $this->generateOtpauthUrl($user['username'], $secret); return [ 'success' => true, 'data' => [ 'secret' => $secret, 'otpauthUrl' => $otpauthUrl, 'backupCodes' => $backupCodes ] ]; } catch (Exception $e) { if ($this->db->inTransaction()) { $this->db->rollBack(); } return ['success' => false, 'message' => $e->getMessage()]; } } /** * Verify a 2FA code * * @param int $userId User ID * @param string $code The verification code * @return bool True if verified, false otherwise */ public function verify($userId, $code) { try { // Get user's 2FA settings $stmt = $this->db->prepare(' SELECT secret_key, backup_codes, enabled FROM user_2fa WHERE user_id = ? '); $stmt->execute([$userId]); $tfa = $stmt->fetch(PDO::FETCH_ASSOC); if (!$tfa || !$tfa['enabled']) { return false; } // Check if it's a backup code if ($this->verifyBackupCode($userId, $code)) { return true; } // Verify TOTP code $currentTime = time(); // Check current and adjacent time steps for ($timeStep = -1; $timeStep <= 1; $timeStep++) { $checkTime = $currentTime + ($timeStep * $this->period); if ($this->generateCode($tfa['secret_key'], $checkTime) === $code) { // Update last used timestamp $stmt = $this->db->prepare(' UPDATE user_2fa SET last_used = NOW() WHERE user_id = ? '); $stmt->execute([$userId]); return true; } } return false; } catch (Exception $e) { error_log('2FA verification error: ' . $e->getMessage()); return false; } } /** * Generate a TOTP code for a given secret and time * * @param string $secret The secret key * @param int $time Current Unix timestamp * @return string Generated code */ private function generateCode($secret, $time) { $timeStep = floor($time / $this->period); $timeHex = str_pad(dechex($timeStep), 16, '0', STR_PAD_LEFT); // Convert hex time to binary $timeBin = ''; for ($i = 0; $i < strlen($timeHex); $i += 2) { $timeBin .= chr(hexdec(substr($timeHex, $i, 2))); } // Get binary secret $secretBin = $this->base32Decode($secret); // Calculate HMAC $hash = hash_hmac($this->algorithm, $timeBin, $secretBin, true); // Get offset $offset = ord($hash[strlen($hash) - 1]) & 0xF; // Generate 4-byte code $code = ( ((ord($hash[$offset]) & 0x7F) << 24) | ((ord($hash[$offset + 1]) & 0xFF) << 16) | ((ord($hash[$offset + 2]) & 0xFF) << 8) | (ord($hash[$offset + 3]) & 0xFF) ) % pow(10, $this->digits); return str_pad($code, $this->digits, '0', STR_PAD_LEFT); } /** * Generate a random secret key * * @return string Base32 encoded secret */ private function generateSecret() { $random = random_bytes($this->secretLength); return $this->base32Encode($random); } /** * Base32 encode data * * @param string $data Data to encode * @return string Base32 encoded string */ private function base32Encode($data) { $alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'; $binary = ''; $encoded = ''; // Convert to binary for ($i = 0; $i < strlen($data); $i++) { $binary .= str_pad(decbin(ord($data[$i])), 8, '0', STR_PAD_LEFT); } // Process 5 bits at a time for ($i = 0; $i < strlen($binary); $i += 5) { $chunk = substr($binary . '0000', $i, 5); $encoded .= $alphabet[bindec($chunk)]; } return $encoded; } /** * Base32 decode data * * @param string $data Base32 encoded string * @return string Decoded data */ private function base32Decode($data) { $alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'; $binary = ''; $decoded = ''; // Convert to binary for ($i = 0; $i < strlen($data); $i++) { $position = strpos($alphabet, $data[$i]); if ($position === false) continue; $binary .= str_pad(decbin($position), 5, '0', STR_PAD_LEFT); } // Process 8 bits at a time for ($i = 0; $i + 7 < strlen($binary); $i += 8) { $chunk = substr($binary, $i, 8); $decoded .= chr(bindec($chunk)); } return $decoded; } /** * Generate otpauth URL for QR codes * * @param string $username Username * @param string $secret Secret key * @return string otpauth URL */ private function generateOtpauthUrl($username, $secret) { return sprintf( 'otpauth://totp/%s:%s?secret=%s&issuer=%s&algorithm=%s&digits=%d&period=%d', urlencode($this->issuer), urlencode($username), $secret, urlencode($this->issuer), strtoupper($this->algorithm), $this->digits, $this->period ); } /** * Generate backup codes * * @param int $count Number of backup codes to generate * @return array Array of backup codes */ private function generateBackupCodes($count = 8) { $codes = []; for ($i = 0; $i < $count; $i++) { $codes[] = bin2hex(random_bytes(4)); } return $codes; } /** * Verify a backup code * * @param int $userId User ID * @param string $code The backup code to verify * @return bool True if verified, false otherwise */ private function verifyBackupCode($userId, $code) { try { $stmt = $this->db->prepare('SELECT backup_codes FROM user_2fa WHERE user_id = ?'); $stmt->execute([$userId]); $result = $stmt->fetch(PDO::FETCH_ASSOC); if (!$result) { return false; } $backupCodes = json_decode($result['backup_codes'], true); // Check if the code exists and hasn't been used $codeIndex = array_search($code, $backupCodes); if ($codeIndex !== false) { // Remove the used code unset($backupCodes[$codeIndex]); $backupCodes = array_values($backupCodes); // Update backup codes in database $stmt = $this->db->prepare(' UPDATE user_2fa SET backup_codes = ? WHERE user_id = ? '); $stmt->execute([json_encode($backupCodes), $userId]); return true; } return false; } catch (Exception $e) { error_log('Backup code verification error: ' . $e->getMessage()); return false; } } /** * Disable 2FA for a user * * @param int $userId User ID * @return bool True if disabled successfully */ public function disable($userId) { try { $stmt = $this->db->prepare(' UPDATE user_2fa SET enabled = 0, secret_key = NULL, backup_codes = NULL WHERE user_id = ? '); return $stmt->execute([$userId]); } catch (Exception $e) { error_log('2FA disable error: ' . $e->getMessage()); return false; } } /** * Check if 2FA is enabled for a user * * @param int $userId User ID * @return bool True if enabled */ public function isEnabled($userId) { try { $stmt = $this->db->prepare('SELECT enabled FROM user_2fa WHERE user_id = ?'); $stmt->execute([$userId]); $result = $stmt->fetch(PDO::FETCH_ASSOC); return $result && $result['enabled']; } catch (Exception $e) { error_log('2FA status check error: ' . $e->getMessage()); return false; } } }