|
@@ -0,0 +1,174 @@
|
|
|
|
|
+<?php
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+namespace App\Services\Account;
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+use App\Cache\CaptchaCache;
|
|
|
|
|
+use App\Consts\ErrorConst;
|
|
|
|
|
+use App\Exceptions\ApiException;
|
|
|
|
|
+use App\Libs\Utils;
|
|
|
|
|
+
|
|
|
|
|
+class CaptchaService
|
|
|
|
|
+{
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 生成验证码:返回随机 key 与 base64 图片
|
|
|
|
|
+ *
|
|
|
|
|
+ * 答案只保存在服务端(Redis),返回给前端的仅是一个随机 key。
|
|
|
|
|
+ *
|
|
|
|
|
+ * @return array
|
|
|
|
|
+ */
|
|
|
|
|
+ public function generate(): array
|
|
|
|
|
+ {
|
|
|
|
|
+ $code = $this->randomCode((int)config('captcha.length', 4));
|
|
|
|
|
+ $key = md5(uniqid((string)mt_rand(), true));
|
|
|
|
|
+
|
|
|
|
|
+ CaptchaCache::set($key, $code, (int)config('captcha.ttl', 300));
|
|
|
|
|
+
|
|
|
|
|
+ return [
|
|
|
|
|
+ 'key' => $key,
|
|
|
|
|
+ 'image' => 'data:image/png;base64,' . base64_encode($this->render($code)),
|
|
|
|
|
+ ];
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 校验验证码
|
|
|
|
|
+ *
|
|
|
|
|
+ * 无论校验成功或失败,答案都会被消费掉(不复用),避免重放与暴力试错。
|
|
|
|
|
+ *
|
|
|
|
|
+ * @param string $key 验证码 key
|
|
|
|
|
+ * @param string $code 用户输入的验证码
|
|
|
|
|
+ * @return void
|
|
|
|
|
+ * @throws ApiException
|
|
|
|
|
+ */
|
|
|
|
|
+ public function verify(string $key, string $code): void
|
|
|
|
|
+ {
|
|
|
|
|
+ $key = trim($key);
|
|
|
|
|
+ $code = trim($code);
|
|
|
|
|
+
|
|
|
|
|
+ if ($key === '' || $code === '') {
|
|
|
|
|
+ Utils::throwError(ErrorConst::CAPTCHA_VERIFY_ERROR);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 一次性取出,不存在或已过期都返回空
|
|
|
|
|
+ $expected = CaptchaCache::pull($key);
|
|
|
|
|
+ if ($expected === '') {
|
|
|
|
|
+ Utils::throwError(ErrorConst::CAPTCHA_VERIFY_ERROR);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ $input = config('captcha.case_insensitive', true) ? strtolower($code) : $code;
|
|
|
|
|
+ if (!hash_equals($expected, $input)) {
|
|
|
|
|
+ Utils::throwError(ErrorConst::CAPTCHA_VERIFY_ERROR);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 生成随机验证码
|
|
|
|
|
+ *
|
|
|
|
|
+ * @param int $length
|
|
|
|
|
+ * @return string
|
|
|
|
|
+ */
|
|
|
|
|
+ private function randomCode(int $length): string
|
|
|
|
|
+ {
|
|
|
|
|
+ $charset = (string)config('captcha.charset', 'ABCDEFGHJKMNPQRSTUVWXY3456789');
|
|
|
|
|
+ $max = strlen($charset) - 1;
|
|
|
|
|
+
|
|
|
|
|
+ $code = '';
|
|
|
|
|
+ for ($i = 0; $i < $length; $i++) {
|
|
|
|
|
+ $code .= $charset[mt_rand(0, $max)];
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ return $code;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 渲染验证码图片,返回 PNG 二进制内容
|
|
|
|
|
+ *
|
|
|
|
|
+ * 线上 GD 未启用 FreeType,imagettftext 不可用,这里使用内置字体 imagestring;
|
|
|
|
|
+ * 内置字体最大仅 5 号(约 9x15),因此先按 1/2 尺寸绘制再整体放大。
|
|
|
|
|
+ *
|
|
|
|
|
+ * @param string $code
|
|
|
|
|
+ * @return string
|
|
|
|
|
+ */
|
|
|
|
|
+ private function render(string $code): string
|
|
|
|
|
+ {
|
|
|
|
|
+ $width = max(60, (int)config('captcha.width', 132));
|
|
|
|
|
+ $height = max(24, (int)config('captcha.height', 48));
|
|
|
|
|
+
|
|
|
|
|
+ $scale = 2;
|
|
|
|
|
+ $fw = (int)($width / $scale);
|
|
|
|
|
+ $fh = (int)($height / $scale);
|
|
|
|
|
+
|
|
|
|
|
+ $img = imagecreatetruecolor($fw, $fh);
|
|
|
|
|
+ imagefill($img, 0, 0, imagecolorallocate($img, 245, 247, 250));
|
|
|
|
|
+
|
|
|
|
|
+ $font = 5;
|
|
|
|
|
+ $charH = imagefontheight($font);
|
|
|
|
|
+ $len = strlen($code);
|
|
|
|
|
+ $step = (int)(($fw - 4) / max(1, $len));
|
|
|
|
|
+
|
|
|
|
|
+ for ($i = 0; $i < $len; $i++) {
|
|
|
|
|
+ $color = imagecolorallocate($img, mt_rand(20, 110), mt_rand(20, 110), mt_rand(20, 110));
|
|
|
|
|
+ $x = 2 + $i * $step + mt_rand(0, 2);
|
|
|
|
|
+ $y = max(0, (int)(($fh - $charH) / 2) + mt_rand(-3, 3));
|
|
|
|
|
+
|
|
|
|
|
+ imagestring($img, $font, $x, $y, $code[$i], $color);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ $this->drawNoise($img, $fw, $fh);
|
|
|
|
|
+
|
|
|
|
|
+ // 放大输出(点阵放大后带锯齿感,对简单 OCR 也有一定干扰)
|
|
|
|
|
+ $out = imagecreatetruecolor($width, $height);
|
|
|
|
|
+ imagecopyresized($out, $img, 0, 0, 0, 0, $width, $height, $fw, $fh);
|
|
|
|
|
+ imagedestroy($img);
|
|
|
|
|
+
|
|
|
|
|
+ // 放大后再叠加干扰线,弱化点阵规律
|
|
|
|
|
+ for ($i = 0; $i < 3; $i++) {
|
|
|
|
|
+ imageline(
|
|
|
|
|
+ $out,
|
|
|
|
|
+ mt_rand(0, $width),
|
|
|
|
|
+ mt_rand(0, $height),
|
|
|
|
|
+ mt_rand(0, $width),
|
|
|
|
|
+ mt_rand(0, $height),
|
|
|
|
|
+ imagecolorallocate($out, mt_rand(150, 215), mt_rand(150, 215), mt_rand(150, 215))
|
|
|
|
|
+ );
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ ob_start();
|
|
|
|
|
+ imagepng($out);
|
|
|
|
|
+ imagedestroy($out);
|
|
|
|
|
+
|
|
|
|
|
+ return (string)ob_get_clean();
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 绘制干扰线与噪点
|
|
|
|
|
+ *
|
|
|
|
|
+ * @param resource $img
|
|
|
|
|
+ * @param int $w
|
|
|
|
|
+ * @param int $h
|
|
|
|
|
+ * @return void
|
|
|
|
|
+ */
|
|
|
|
|
+ private function drawNoise($img, int $w, int $h): void
|
|
|
|
|
+ {
|
|
|
|
|
+ for ($i = 0; $i < 4; $i++) {
|
|
|
|
|
+ imageline(
|
|
|
|
|
+ $img,
|
|
|
|
|
+ mt_rand(0, $w),
|
|
|
|
|
+ mt_rand(0, $h),
|
|
|
|
|
+ mt_rand(0, $w),
|
|
|
|
|
+ mt_rand(0, $h),
|
|
|
|
|
+ imagecolorallocate($img, mt_rand(160, 220), mt_rand(160, 220), mt_rand(160, 220))
|
|
|
|
|
+ );
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ for ($i = 0; $i < 60; $i++) {
|
|
|
|
|
+ imagesetpixel(
|
|
|
|
|
+ $img,
|
|
|
|
|
+ mt_rand(0, max(0, $w - 1)),
|
|
|
|
|
+ mt_rand(0, max(0, $h - 1)),
|
|
|
|
|
+ imagecolorallocate($img, mt_rand(120, 200), mt_rand(120, 200), mt_rand(120, 200))
|
|
|
|
|
+ );
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+}
|