'invalid_phone_e164'], 400); } if (!in_array($purpose, ['register', 'login'], true)) { api_json(['error' => 'invalid_purpose'], 400); } if ($deviceUuid !== null && (!is_string($deviceUuid) || !preg_match('/^[0-9a-f-]{36}$/i', $deviceUuid))) { api_json(['error' => 'invalid_device_uuid'], 400); } try { AppConfig::loadEnvironment(); if (getenv('OTP_ENABLED') !== 'true') { api_json(['error' => 'otp_provider_not_configured'], 503); } $hashKey = AppConfig::required('OTP_HASH_KEY'); if (strlen($hashKey) < 32) { throw new RuntimeException('OTP_HASH_KEY must be at least 32 bytes'); } $jwtKey = AppConfig::required('JWT_SIGNING_KEY'); if (strlen($jwtKey) < 32) { throw new RuntimeException('JWT_SIGNING_KEY must be at least 32 bytes'); } $db = Database::getInstance(); $connection = $db->getConnection(); $connection->begin_transaction(); $rateBuckets = [ ['phone', hash_hmac('sha256', 'phone:' . $phone, $hashKey), 5, 3600], ['ip', hash_hmac('sha256', 'ip:' . ($_SERVER['REMOTE_ADDR'] ?? 'unknown'), $hashKey), 20, 3600], ]; if ($deviceUuid !== null) { $rateBuckets[] = ['device', hash_hmac('sha256', 'device:' . $deviceUuid, $hashKey), 10, 3600]; } foreach ($rateBuckets as [$bucketType, $digest, $limit, $windowSeconds]) { $stmt = $db->prepare('INSERT INTO auth_rate_limit_buckets (bucket_digest, bucket_type, window_started_at, request_count) VALUES (?, ?, UTC_TIMESTAMP(), 1) ON DUPLICATE KEY UPDATE request_count = IF(window_started_at < UTC_TIMESTAMP() - INTERVAL ? SECOND, 1, request_count + 1), window_started_at = IF(window_started_at < UTC_TIMESTAMP() - INTERVAL ? SECOND, UTC_TIMESTAMP(), window_started_at)'); $stmt->bind_param('ssii', $digest, $bucketType, $windowSeconds, $windowSeconds); $stmt->execute(); $stmt->close(); $check = $db->prepare('SELECT request_count FROM auth_rate_limit_buckets WHERE bucket_digest = ?'); $check->bind_param('s', $digest); $check->execute(); $count = (int) $check->get_result()->fetch_assoc()['request_count']; $check->close(); if ($count > $limit) { $connection->rollback(); api_json(['error' => 'rate_limited'], 429); } } $code = (string) random_int(100000, 999999); $challengeUuid = sprintf('%04x%04x-%04x-4%03x-%04x-%04x%04x%04x', random_int(0, 0xffff), random_int(0, 0xffff), random_int(0, 0xffff), random_int(0, 0xfff), random_int(0, 0x3fff) | 0x8000, random_int(0, 0xffff), random_int(0, 0xffff), random_int(0, 0xffff)); $codeDigest = hash_hmac('sha256', $challengeUuid . '|' . $phone . '|' . $code, $hashKey); $ipDigest = hash_hmac('sha256', 'ip:' . ($_SERVER['REMOTE_ADDR'] ?? 'unknown'), $hashKey); $insert = $db->prepare('INSERT INTO otp_challenges (challenge_uuid, phone_e164, purpose, code_digest, request_ip_digest, device_uuid, expires_at) VALUES (?, ?, ?, ?, ?, ?, UTC_TIMESTAMP() + INTERVAL 5 MINUTE)'); $insert->bind_param('ssssss', $challengeUuid, $phone, $purpose, $codeDigest, $ipDigest, $deviceUuid); $insert->execute(); $insert->close(); $connection->commit(); (new ConfiguredHttpOtpProvider())->send($phone, $code); api_json(['challenge_id' => $challengeUuid, 'expires_in_seconds' => 300]); } catch (Throwable $exception) { if (isset($connection) && $connection instanceof mysqli && $connection->errno === 0) { try { $connection->rollback(); } catch (Throwable $ignored) {} } error_log('OTP request failed: ' . $exception->getMessage()); api_json(['error' => 'otp_delivery_failed'], 503); }