CaptchaService.php 5.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174
  1. <?php
  2. namespace App\Services\Account;
  3. use App\Cache\CaptchaCache;
  4. use App\Consts\ErrorConst;
  5. use App\Exceptions\ApiException;
  6. use App\Libs\Utils;
  7. class CaptchaService
  8. {
  9. /**
  10. * 生成验证码:返回随机 key 与 base64 图片
  11. *
  12. * 答案只保存在服务端(Redis),返回给前端的仅是一个随机 key。
  13. *
  14. * @return array
  15. */
  16. public function generate(): array
  17. {
  18. $code = $this->randomCode((int)config('captcha.length', 4));
  19. $key = md5(uniqid((string)mt_rand(), true));
  20. CaptchaCache::set($key, $code, (int)config('captcha.ttl', 300));
  21. return [
  22. 'key' => $key,
  23. 'image' => 'data:image/png;base64,' . base64_encode($this->render($code)),
  24. ];
  25. }
  26. /**
  27. * 校验验证码
  28. *
  29. * 无论校验成功或失败,答案都会被消费掉(不复用),避免重放与暴力试错。
  30. *
  31. * @param string $key 验证码 key
  32. * @param string $code 用户输入的验证码
  33. * @return void
  34. * @throws ApiException
  35. */
  36. public function verify(string $key, string $code): void
  37. {
  38. $key = trim($key);
  39. $code = trim($code);
  40. if ($key === '' || $code === '') {
  41. Utils::throwError(ErrorConst::CAPTCHA_VERIFY_ERROR);
  42. }
  43. // 一次性取出,不存在或已过期都返回空
  44. $expected = CaptchaCache::pull($key);
  45. if ($expected === '') {
  46. Utils::throwError(ErrorConst::CAPTCHA_VERIFY_ERROR);
  47. }
  48. $input = config('captcha.case_insensitive', true) ? strtolower($code) : $code;
  49. if (!hash_equals($expected, $input)) {
  50. Utils::throwError(ErrorConst::CAPTCHA_VERIFY_ERROR);
  51. }
  52. }
  53. /**
  54. * 生成随机验证码
  55. *
  56. * @param int $length
  57. * @return string
  58. */
  59. private function randomCode(int $length): string
  60. {
  61. $charset = (string)config('captcha.charset', 'ABCDEFGHJKMNPQRSTUVWXY3456789');
  62. $max = strlen($charset) - 1;
  63. $code = '';
  64. for ($i = 0; $i < $length; $i++) {
  65. $code .= $charset[mt_rand(0, $max)];
  66. }
  67. return $code;
  68. }
  69. /**
  70. * 渲染验证码图片,返回 PNG 二进制内容
  71. *
  72. * 线上 GD 未启用 FreeType,imagettftext 不可用,这里使用内置字体 imagestring;
  73. * 内置字体最大仅 5 号(约 9x15),因此先按 1/2 尺寸绘制再整体放大。
  74. *
  75. * @param string $code
  76. * @return string
  77. */
  78. private function render(string $code): string
  79. {
  80. $width = max(60, (int)config('captcha.width', 132));
  81. $height = max(24, (int)config('captcha.height', 48));
  82. $scale = 2;
  83. $fw = (int)($width / $scale);
  84. $fh = (int)($height / $scale);
  85. $img = imagecreatetruecolor($fw, $fh);
  86. imagefill($img, 0, 0, imagecolorallocate($img, 245, 247, 250));
  87. $font = 5;
  88. $charH = imagefontheight($font);
  89. $len = strlen($code);
  90. $step = (int)(($fw - 4) / max(1, $len));
  91. for ($i = 0; $i < $len; $i++) {
  92. $color = imagecolorallocate($img, mt_rand(20, 110), mt_rand(20, 110), mt_rand(20, 110));
  93. $x = 2 + $i * $step + mt_rand(0, 2);
  94. $y = max(0, (int)(($fh - $charH) / 2) + mt_rand(-3, 3));
  95. imagestring($img, $font, $x, $y, $code[$i], $color);
  96. }
  97. $this->drawNoise($img, $fw, $fh);
  98. // 放大输出(点阵放大后带锯齿感,对简单 OCR 也有一定干扰)
  99. $out = imagecreatetruecolor($width, $height);
  100. imagecopyresized($out, $img, 0, 0, 0, 0, $width, $height, $fw, $fh);
  101. imagedestroy($img);
  102. // 放大后再叠加干扰线,弱化点阵规律
  103. for ($i = 0; $i < 3; $i++) {
  104. imageline(
  105. $out,
  106. mt_rand(0, $width),
  107. mt_rand(0, $height),
  108. mt_rand(0, $width),
  109. mt_rand(0, $height),
  110. imagecolorallocate($out, mt_rand(150, 215), mt_rand(150, 215), mt_rand(150, 215))
  111. );
  112. }
  113. ob_start();
  114. imagepng($out);
  115. imagedestroy($out);
  116. return (string)ob_get_clean();
  117. }
  118. /**
  119. * 绘制干扰线与噪点
  120. *
  121. * @param resource $img
  122. * @param int $w
  123. * @param int $h
  124. * @return void
  125. */
  126. private function drawNoise($img, int $w, int $h): void
  127. {
  128. for ($i = 0; $i < 4; $i++) {
  129. imageline(
  130. $img,
  131. mt_rand(0, $w),
  132. mt_rand(0, $h),
  133. mt_rand(0, $w),
  134. mt_rand(0, $h),
  135. imagecolorallocate($img, mt_rand(160, 220), mt_rand(160, 220), mt_rand(160, 220))
  136. );
  137. }
  138. for ($i = 0; $i < 60; $i++) {
  139. imagesetpixel(
  140. $img,
  141. mt_rand(0, max(0, $w - 1)),
  142. mt_rand(0, max(0, $h - 1)),
  143. imagecolorallocate($img, mt_rand(120, 200), mt_rand(120, 200), mt_rand(120, 200))
  144. );
  145. }
  146. }
  147. }