*/
private const PREFIXES = [
self::SCOPE_ADMIN => 'admin_captcha:',
self::SCOPE_PLAYER => 'player_captcha:',
];
private const TTL_SECONDS = 120;
/** 剔除易混淆字符 */
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(
'',
$w,
$h,
$w,
$h,
$bg,
$lines,
$texts,
);
}
}