isDev()) { Log::warning('[recaptcha] secret missing', [ 'expected_action' => $expectedAction, ]); } return [ 'success' => false, 'error-codes' => ['missing-input-secret'], ]; } try { $resp = Http::asForm() ->timeout(3) // 너무 길게 잡지 말기 ->connectTimeout(2) ->retry(1, 200) // 네트워크 순간 오류 대비(1회만) ->post('https://www.google.com/recaptcha/api/siteverify', [ 'secret' => $secret, 'response' => $token, 'remoteip' => $ip, ]); $data = $resp->json(); if (!is_array($data)) { if ($this->isDev()) { Log::warning('[recaptcha] invalid json', [ 'status' => $resp->status(), 'body_snippet' => mb_substr((string)$resp->body(), 0, 200), 'expected_action' => $expectedAction, ]); } return [ 'success' => false, 'error-codes' => ['invalid-json'], ]; } // (선택) 개발환경에서만 핵심만 로깅 (token/secret 절대 로그 금지) if ($this->isDev()) { Log::info('[recaptcha] verify ok', [ 'expected_action' => $expectedAction, 'success' => $data['success'] ?? null, 'score' => $data['score'] ?? null, 'action' => $data['action'] ?? null, 'hostname' => $data['hostname'] ?? null, 'error_codes' => $data['error-codes'] ?? null, ]); } return $data; } catch (Throwable $e) { if ($this->isDev()) { Log::error('[recaptcha] verify exception', [ 'expected_action' => $expectedAction, 'message' => $e->getMessage(), ]); } return [ 'success' => false, 'error-codes' => ['http-exception'], ]; } } /** * 정책 판단: success + action 일치 + score >= min_score */ public function isPass(array $data, string $expectedAction): bool { if (!($data['success'] ?? false)) return false; // action은 v3에서 꼭 맞춰주는 게 좋음 if (($data['action'] ?? '') !== $expectedAction) return false; $min = (float) config('services.recaptcha.min_score', 0.5); $score = (float) ($data['score'] ?? 0); return $score >= $min; } private function isDev(): bool { // 네 환경명에 맞춰 추가 가능: staging 등 return app()->environment(['local', 'development', 'staging']); } }