112 lines
3.5 KiB
PHP
112 lines
3.5 KiB
PHP
<?php
|
|
|
|
namespace App\Services;
|
|
|
|
use Illuminate\Support\Facades\Http;
|
|
use Illuminate\Support\Facades\Log;
|
|
use Throwable;
|
|
|
|
class RecaptchaV3
|
|
{
|
|
/**
|
|
* Google reCAPTCHA v3 server-side verify
|
|
*
|
|
* @return array Google response array
|
|
*/
|
|
public function verify(string $token, string $expectedAction, ?string $ip = null): array
|
|
{
|
|
$secret = (string) config('services.recaptcha.secret');
|
|
|
|
// secret 없으면 무조건 실패(운영 안전)
|
|
if ($secret === '') {
|
|
if ($this->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']);
|
|
}
|
|
}
|