*/ private const PREFIXES = [ self::SCOPE_ADMIN => 'admin_captcha:', self::SCOPE_PLAYER => 'player_captcha:', ]; private const TTL_SECONDS = 300; /** 剔除易混淆字符 */ private const CHARSET = '23456789abcdefghjkmpqrstuvwxyz'; /** * @return array{captcha_key: string, image_svg: string, image_base64: string} */ public function create(string $scope = self::SCOPE_ADMIN): array { $code = $this->randomCode(); $key = (string) Str::uuid(); Cache::put( $this->prefix($scope).$key, $this->digest($code), now()->addSeconds(self::TTL_SECONDS), ); $svg = $this->renderSvg($code); return [ 'captcha_key' => $key, 'image_svg' => $svg, 'image_base64' => base64_encode($svg), ]; } public function verify(?string $captchaKey, ?string $captchaInput, string $scope = self::SCOPE_ADMIN): bool { // E2E 旁路:仅在 LOTTERY_E2E=true 时放行,约定 captcha_code == LOTTERY_E2E_BYPASS // 视为通过。生产环境不启用,对应变量在 .env.example 留空。 if ($captchaInput !== null && trim($captchaInput) === 'LOTTERY_E2E_BYPASS' && (bool) env('LOTTERY_E2E', false)) { // 顺便消费掉 key,避免同一 key 被多次复用 if ($captchaKey !== null && $captchaKey !== '') { Cache::pull($this->prefix($scope).$captchaKey); } return true; } if ($captchaKey === null || $captchaKey === '' || $captchaInput === null || trim($captchaInput) === '') { return false; } $digest = Cache::pull($this->prefix($scope).$captchaKey); if ($digest === null) { return false; } $guess = strtolower(trim($captchaInput)); return hash_equals($digest, $this->digest($guess)); } private function prefix(string $scope): string { $prefix = self::PREFIXES[$scope] ?? null; if ($prefix === null) { throw new \InvalidArgumentException('invalid_captcha_scope'); } return $prefix; } private function digest(string $normalizedCode): string { return hash_hmac( 'sha256', $normalizedCode, (string) config('app.key'), ); } private function randomCode(int $length = 4): string { $out = ''; $max = strlen(self::CHARSET) - 1; for ($i = 0; $i < $length; $i++) { $out .= self::CHARSET[random_int(0, $max)]; } return $out; } private function renderSvg(string $code): string { $w = 160; $h = 48; $bg = '#fafafa'; $escape = static fn (string $c): string => htmlspecialchars($c, ENT_XML1 | ENT_QUOTES); $lines = ''; for ($i = 0; $i < 4; $i++) { $x1 = random_int(0, $w); $y1 = random_int(0, $h); $x2 = random_int(0, $w); $y2 = random_int(0, $h); $stroke = sprintf('#%06x', random_int(0x9_00_000, 0xBF_BF_BF)); $lines .= sprintf( '', $x1, $y1, $x2, $y2, $stroke, ); } $chars = str_split($code); $texts = ''; $x = 18; foreach ($chars as $ch) { $rot = random_int(-20, 20); $dy = random_int(-3, 3); $y = 32 + $dy; $fill = sprintf('#%06x', random_int(0x20_20_20, 0x3F_3F_3F)); $texts .= sprintf( '%s', $x, $y, $fill, $rot, $x, $y, $escape($ch), ); $x += 34; } return sprintf( '%s%s', $w, $h, $w, $h, $bg, $lines, $texts, ); } }