Skip to content

isszz/rotate-captcha

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Rotate captcha

旋转图片角度验证码, 使用 PHP 生成验证图片(gd 或者 imagick) 用于旋转验证,可用于各种框架

前端已经支持原生JSjqueryvue2uniapp版本, 持续更新, 可放心使用

暂未实现react版,有能力的朋友参考现有版自行实现下哦
已知uniapp打包微信小程序(IOS有卡顿bug希望有能力的可以修复下,我没有设备无法复现问题)

若发现bug, 或更好的建议, 还请issue反馈

Stable Version Total Downloads License

更新

  • 2021-09-10 新增

    • 新增原生JS版本, 优化部分代码
  • 2021-09-16 新增

    • 增加存储驱动功能可使用session,cache,cookie驱动
    • 验证方式改为token交换,利于vue,react,app等调用
    • 加密方式更改为AES
  • 2021-09-17 新增

    • 新增输出格式设置,可设置webp,生成图片更小,清晰度更高且支持透明底色
  • 2021-09-19 更新

    • 移除thinkphp6的依赖,可在其他框架增加少量代码使用啦
  • 2021-09-20 更新

    • token存储增加了前缀
    • 新增Redis存储驱动,不依赖框架,支持redis即可
  • 2021-09-22 更新

    • 新增uniapp版,暂时兼容PC版有BUG
  • 2021-09-23 更新

    • 新增vue版,基于vue2,未测试vue3
  • 2021-09-24 更新

    • 修复uniapp小程序安卓真机卡顿问题(ios貌似还是有问题, 因为没设备测试, 暂时无法修复- -...)
  • 2021-09-25 更新

    • vue版增加了touch事件的支持, 兼容h5
  • 2021-09-26 更新

    • vue版改为canvas
  • 2021-10-07 更新

    • 修复Imagick方式旋转角度问题
    • 修复旧的存储方式逻辑bug,隔月无法找到相同角度图片
    • 新增图片存储开关,存储后,生成相同角度图片时,可以二次找回,无需再次生成
    • 启用存储生成图片时,可以设置存储图片深度,storeImage设置true1时存储为角度文件夹,设置2时根据角度生成2个文件夹,大于2时生成3个文件夹
    • 未启用存储生成图片时,每次图片访问后会清理存储图片的目录内所有文件,删除当前访问生成验证码图片
  • 2021-10-20 更新

    • 将语言改到为配置项
  • 2022-01-05 更新

    • 增加facade注释
    • 移除助手类的rotate_captcha_img方法使用rotate_captcha_output代替,用法和\isszz\captcha\rotate\facade\Captcha::output方法相同,返回数组[$mime, $image],生成图片的mime类型和图片内容
  • 2022-09-12 更新

    • 增加非TP6验证说明
    • 修复原生JS事件处理问题(感谢 笨笨天才 的issue)
    • 修改说明中X-CaptchaToken大写linux拿不到的问题, 应该拿的时候用X-Captchatoken(感谢 笨笨天才 的issue)

安装

composer require isszz/rotate-captcha -vvv

演示图

image

Ctrl+鼠标左键, 查看演示视频

点击查看视频演示

配置说明

<?php

return [
	'lang' => 'zh-cn', // 默认语言
	'size' => 350, // 生成图片尺寸
	'expire' => 300, // 生成验证有效期
	'salt' => '%%*$*$#$~#$^[email protected]^&*$#$~',
	'outputType' => 'webp', // 输出类型, png, jpg, webp, 建议使用webp, png文件较大, jpg不支持透明
	'storeImage' => true, // 是否存储生成的图片, 如果保存, 也可以设置存储深度, true或1是角度文件夹, 2根据角度生成2个文件夹, 大于2则根据角度生成3个文件夹
	'handle' => 'gd', // 服务器支持imagick时, 建议使用imagick
	'earea' => 10, // 容错率
	'gd' => [
		'quality' => 80,
		'bgcolor' => '', // 底色, #fff
	],
	'imagick' => [
		'quality' => 80,
		'bgcolor' => '', // 底色, white
	],
	// Redis存储驱动需要的配置
	'redis' => [
		'host'       => '127.0.0.1',
		'port'       => 6379,
		'password'   => '',
		'select'     => 0,
		'timeout'    => 0,
		'expire'     => 0,
		'persistent' => false,
		'prefix'     => 'captcha_',
	],
	// token存储驱动,默认为thinkphp6,需要其他的可以参考下面实现
	// 'store' => isszz\captcha\rotate\store\CacheStore::class, // cache token存储驱动,基于thinkphp6
	// 'store' => isszz\captcha\rotate\store\CookieStore::class, // cookie token存储驱动,基于thinkphp6
	// 'store' => isszz\captcha\rotate\store\SessionStore::class, // session token存储驱动,基于thinkphp6
	'store' => isszz\captcha\rotate\store\RedisStore::class, // redis token存储驱动,需支持redis,不依赖框架
];

PHP部分使用

tp6中使用

<?php
declare (strict_types = 1);

namespace app\common\controller;

use isszz\captcha\rotate\CaptchaException;
use isszz\captcha\rotate\facade\Captcha as RotateCaptcha;
use isszz\captcha\rotate\support\File;


use think\Response;
use think\Request;

class Captcha
{
	/**
	 * 生成验证码图片和相关信息
	 */
	public function rotate(Request $request)
	{
		// 用于测试, 这部分, 可以自己整个素材库, 去数据库, 或者缓存下来总之很灵活
		/*$list = [
			'1.png',
			'2.png',
			'1.jpg',
			'2.jpg',
			'3.jpg',
			'4.jpg',
			'5.jpg',
			'6.jpg',
			'7.jpg',
			'8.jpg',
			'9.jpg',
			'10.jpg',
			'11.jpg',
			'12.jpg',
			'13.jpg',
		];

		// upload_path 需要自己写一个

		// 随机拿一个图片
		$key = array_rand($list, 1);
		if(isset($list[$key])) {
			// 从素材存放目录拿一个图
			$image = upload_path('captcha_mtl') . $list[$key];  
		}*/

		// 说明: upload_path方法为自定义方法,更具自己使用的框架获取你存储素材的根目录即可
		// 例如在tp6框架可以这么写:
		/*
		function upload_path(string $path = ''): string
		{
			return public_path(DIRECTORY_SEPARATOR .'uploads' . DIRECTORY_SEPARATOR . ($path ? ltrim($path, DIRECTORY_SEPARATOR) : $path));
		}
		*/

		// 新增: 从指定目录随机读取文件,这个方法不知道效率如何,基于FilesystemIterator类实现
		$image = File::make(upload_path('captcha_mtl'))->rand();

		// 生成验证码需要的图片
		// setLang设置语言
		// $rotateCaptcha = RotateCaptcha::setLang('zh-cn');
		// $rotateCaptcha->create(...);
		$data = RotateCaptcha::create(
			$image,
			upload_path('captcha') // 用于存储生成图片的目录
		)->get(260); // 260为最终生成的图片尺寸

		if(!$data) {
			$this->result(1, 'error');
		}
		// $data['str']是图片的path加密, 用于前端交换验证码图片
		// 这里前端不涉及拿到角度, 都是去后端验证
		// 可以使用header传递token为X-Captchatoken
		$this->result(0, 'success', ['str' => $data['str']], ['X-Captchatoken' => $data['token']]);
	}
	
	/**
	 * 验证
	 */
	public function verify(Request $request)
	{
		$angle = $request->get('angle');
		// 优先从header获取token
		$token = $request->header('X-Captchatoken') ?: $request->get('token');

		if(empty($token) || empty($angle)) {
			$this->result(1, 'error');
		}

		try {
			if(RotateCaptcha::check($token, $angle) === true) {
				$this->result(0, 'success');
			}
		} catch(CaptchaException $e) {
			$this->result(1, $e->getMessage());
		}

		$this->result(1, 'error');
	}

	/**
	 * 通过前端传递str字段给后端叫唤图片显示到前端
	 */
	public function img(Request $request)
	{
		$str = $request->get('id');

		[$mime, $image] = RotateCaptcha::output($str, upload_path('captcha'));

		if(empty($image)) {
			return '';
		}

		return response($image, 200, [
			'Cache-Control' => 'private, no-cache, no-store, must-revalidate',
			'Content-Type' => $mime,
			'Content-Length' => strlen($image)
		]);
	}

	/**
	 * 返回json
	 */
	public function result(int|string $code = 0, string $msg = 'success', array|string $data, array $header = [])
	{
		$result = [
			'code' => $code,
			'data' => $data,
			'msg' => lang($msg) ?: ($code > 0 ? 'error' : 'success'),
		];

		$response = Response::create($result, 'json')->code(200);

		if(!empty($header)) {
			$response = $response->header($header);
		}
		throw new \think\exception\HttpResponseException($response);
	}
}

非thinkphp6框架, 可以参考如下

<?php
use isszz\captcha\rotate\support\File;
use isszz\captcha\rotate\facade\Captcha;
use isszz\captcha\rotate\CaptchaException;

use think\facade\Config;

// 这里用到的Config用自己框架的配置类
class CaptchaConfig extends \isszz\captcha\rotate\Config
{
    public function get(string $name, string $defaultValue = null): mixed
    {
        return Config::get($name, $defaultValue);
    }

    public function put(string $name, array|string $data): bool
    {
        return Config::set($name, $data);
    }

    public function forget(string $name): bool
    {
        return Config::delete($name);
    }
}

/*
$list = [
	'1.png',
	'2.png',
	'1.jpg',
	'2.jpg',
	'3.jpg',
	'4.jpg',
	'5.jpg',
	'6.jpg',
	'7.jpg',
	'8.jpg',
	'9.jpg',
	'10.jpg',
	'11.jpg',
	'12.jpg',
	'13.jpg',
];

$key = array_rand($list, 1);

if(isset($list[$key])) {
	$image = $list[$key];
}

$image = path('upload') . 'captcha_mtl' . DIRECTORY_SEPARATOR . $image;
*/

// 新增: 从指定目录随机读取文件,这个方法不知道效率如何,基于FilesystemIterator类实现
$image = File::make(path('upload') . 'captcha_mtl' . DIRECTORY_SEPARATOR)->rand();

$data = Captcha::configDrive(\CaptchaConfig::class)->create($image, path('upload') . 'captcha' . DIRECTORY_SEPARATOR)->get(260);

header('Content-Type:application/json; charset=utf-8');

if($data) {
	// 可以使用header传递token
	header('X-CaptchaToken: '. $data['token']);
	echo json_encode([
		'code' => 0,
		'msg' => 'success',
		'data' => [/*'token' => $data['token'], */'str' => $data['str']],
	]);
}
 
echo json_encode([
	'code' => 1,
	'msg' => 'error',
	'data' => null,
]);

非thinkphp6框架,验证

<?php

use isszz\captcha\rotate\support\request\Request;
use isszz\captcha\rotate\CaptchaException;
use isszz\captcha\rotate\facade\Captcha;

// 下面Config配置类自行实现
class CaptchaConfig extends \isszz\captcha\rotate\Config
{
	public function get(string $name, string $defaultValue = null): mixed
	{
		return \Config::get($name, $defaultValue);
	}

	public function put(string $name, array|string $data): bool
	{
		return \Config::put($name, $data);
	}

	public function forget(string $name): bool
	{
		return \Config::forget($name);
	}
}

$angle = $_GET['angle'] ?? '';

// 优先从header获取token
$token = Request::header('X-Captchatoken') ?: $_GET['token'] ?? '';

if(empty($token) || empty($angle)) {
	exit(json_encode(['code' => 1, 'msg' => 'error']));
}

try {
	if(Captcha::configDrive(\CaptchaConfig::class)->setLang('zh-cn')->check($token, $angle) === true) {
		exit(json_encode(['code' => 0, 'msg' => 'success']));
	}
} catch(CaptchaException $e) {
	exit(json_encode(['code' => 1, 'msg' => $e->getMessage()]));
}

exit(json_encode(['code' => 1, 'msg' => 'error']));

非thinkphp6框架,输出图片

<?php

use isszz\captcha\rotate\facade\Captcha;

$id = $_GET['id'] ?? null;

if(empty($id)) {
	exit('');
}

[$mime, $image] = Captcha::output($id, upload_path('captcha'));

if(empty($image)) {
	exit('');
}

header('Cache-Control: private, no-cache, no-store, must-revalidate');
// header('Content-Disposition: inline; filename=captcha_' . $id . '.' . str_replace('image/', '.', $mime));
header('Content-type: '. $mime);
header('Content-Length: '. strlen($image));
echo $image;

关于配置驱动和自定义存储驱动说明

use isszz\captcha\rotate\Store;
use isszz\captcha\rotate\Config;

// 配置获取驱动,需要基于\isszz\captcha\rotate\Config实现如下方法:
class CaptchaConfig extends Config
{
	public function get(string $name, string $defaultValue = null): mixed
	{
		// 获取配置
		return Config::get($name, $defaultValue);
	}

	public function put(string $name, array|string $data): bool
	{
		// 存储配置 - 暂时无用
		return Config::put($name, $data);
	}

	public function forget(string $name): bool
	{
		// 删除配置 - 暂时无用
		return Config::forget($name);
	}
}

// 自定义存储驱动,需要基于\isszz\captcha\rotate\Store实现如下方法:
// 更多方式参考\isszz\captcha\rotate\store\文件夹内示例,只要能存储token怎么存自由发挥哈
// 这里大家只需要实现, 验证token是否存在(当然此处可以省略,获取后判断也是一样), 获取token, 和删除token, 存储token
class CaptchaSessionStore extends Store
{
	public function get(string $token): array
	{
		// 检测token是否存在
		if(!Session::has($token)) {
			return [];
		}
		
		// 获取token内容
		$payload = Session::get($token);

		if(empty($payload)) {
			return [];
		}

		// 解析token内容
		$payload = $this->encrypter->decrypt($payload);

		if(empty($payload)) {
			return [];
		}

		// 删除token
		Session::forget($token);

		// 返回解析后的token
		return json_decode($payload, true);
	}

	public function put(?int $degrees): string
	{
		[$token, $payload] = $this->buildPayload($degrees);

		// 存储token, 并设置token过期时间ttl
		Session::put($token, $payload, $this->ttl);

		return $token;
	}
}

前端配置项

options = {
	theme: '#07f', // 验证码主色调
	title: '安全验证',
	desc: '拖动滑块,使图片角度为正',
	width: 305, // 验证界面的宽度
	successClose: 1500, // 验证成功后页面关闭时间
	timerProgressBar: !0, // 验证成功后关闭时是否显示进度条
	timerProgressBarColor: '#07f', // 进度条颜色
	url: {
		info: '/captcha', // 获取验证码信息
		check: '/captcha/check', // 验证
		img: '/captcha/img', // 交换图片
	},
	init: function (captcha) {}, // 初始化
	success: function () {}, // 验证成功
	fail: function () {}, // 验证失败
	complete: function (state) {}, // 触发验证, 不管成功与否
	close: function (state) {}, // 关闭验证码窗口并返回验证状态state
};

前端使用

原生JS版本的使用方式

// .J__captcha__是输出验证码的容器
// 方式1
let myCaptcha = document.querySelectorAll('.J__captcha__').item(0).captcha({
	// 验证成功时显示
	timerProgressBar: !0, // 是否启用进度条
	timerProgressBarColor: '#07f', // 进度条颜色
	url: {
		create: '/common/captcha/rotate', // 获取验证码信息
		check: '/common/captcha/verify', // 验证
		img: '/common/captcha/img', // 交换旋转图
	},
	// 初始化回调
	init: function (captcha) {
		// console.log(captcha);
	},
	// 验证成回调
	success: function() {
		console.log('Captcha state:success');
	},
	// 验证失败回调
	fail: function() {
		console.log('Captcha state:fail');
	},
	// 触发验证回调, 不管成功与否
	complete: function(state) {
		console.log('Captcha complete, state:', state);
	},
	// 验证码触发关闭(验证成功后需要关闭)
	close: function(state) {
		modal.close(); // 关闭你的modal
		console.log('Captcha close, state:', state);
	}
});
// 方式2
let myCaptcha = new Captcha(document.querySelectorAll('.J__captcha__').item(0), {
	// options同上...
});

jquery版本的使用方式

// .J__captcha__是输出验证码的容器
modal.element.find('.J__captcha__').captcha({
	// options同上...
});
// 获取Captcha实例
// var captcha = modal.element.find('.J__captcha__').data('captcha');
// console.log(captcha.state());

vue使用示例

<template>
	<div id="app">
		<div @click="openCaptcha">
			<img alt="Vue logo" src="./assets/logo.png">
			<div>打开验证码</div>
		</div>
		<RoutateCaptcha :options="captchaOptions" v-if="captchaShow" @close="captchaShow = false" @init="captchaInit" @complete="captchaComplete" @success="captchaSuccess" @fail="captchaFail"></RoutateCaptcha>
	</div>
</template>

<script>
// 组件路径, 在这里引入. 组件位置:js/vue/RoutateCaptcha
import RoutateCaptcha from './components/RoutateCaptcha/index.vue'

export default {
	name: 'captchaDemo',
	components: {
		RoutateCaptcha
	},
	data() {
		return {
			captchaShow: false,
			captchaOptions: {
				theme: '#07f',
				title: '安全验证',
				desc: '拖动滑块,使图片角度为正',
				successClose: 1500, // 验证成功后页面关闭时间
				timerProgressBar: true, // 验证成功后关闭时是否显示进度条
				timerProgressBarColor: 'rgba(0, 0, 0, 0.2)',
				request: {
					// 获取验证码信息
					info: (callback) => {
						this.getJSON('http://vmd.co/common/captcha/rotate', null, function(res, xhr) {
							if(xhr.status != 200) {
								alert('系统出错:'+ res.statusCode +',请关闭重试!')
								return false
							}
							// 第二个参数传递从header中获取的token, 如果嫌麻烦, 可以在res内返回token
							callback(res, xhr.getResponseHeader('X-CaptchaToken'))
						})
					},
					// 验证, angle用户旋转角度
					check: (angle, token, callback) => {
						// 将token设置好, 数据验证时传递给后端
						// 当然这里的数据请求只作为参考, 实际使用中以你的数据请求组件方式为准
						this.token = token
						this.getJSON('http://vmd.co/common/captcha/verify', {angle: angle}, function(res, xhr) {
							if(xhr.status != 200) {
								alert('系统出错:'+ res.statusCode +',请关闭重试!')
								return false
							}
							callback(res, xhr)
						})
					},
					// 交换图片
					img: (id) => {
						return 'http://vmd.co/common/captcha/img?id=' + id
					},
				},
			},
		}
	},
	methods: {
		openCaptcha() {
			this.captchaShow = true
		},
		captchaInit(captcha) {

		},
		captchaSuccess() {
			console.log('captcha success')
		},
		captchaFail() {
			console.log('caotcha fail')
		},
		captchaComplete(state) {
			// console.log('caotcha complete state: ' + state)
		},
		// ajax请求, 这里只做演示, 请使用自己项目中的
		getJSON(url, data, callback) {
			const _this = this;
			let params = '';
			if(data && typeof data == 'object') {
				params = Object.keys(data).map(function(key) {
					return encodeURIComponent(key) + '=' + encodeURIComponent(data[key]);
				}).join('&');

				url = url + ((url.indexOf('?') == -1 ? '?' : '&') + params);
			}

			let xhr, formData = null;
			xhr = new XMLHttpRequest();
			xhr.withCredentials = false;

			xhr.open('GET', url);
			xhr.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded');
			if(_this.token) {
				xhr.setRequestHeader('X-CaptchaToken', _this.token);
			}
			xhr.onload = function() {
				if (xhr.status != 200) {
					return;
				}

				try {
					let res = JSON.parse(xhr.responseText) || null;
					if (!res) {
						return;
					}
					callback(res, xhr);
				} catch(e) {
					return;
				}
			};

			xhr.send(formData);
		}
	}
}
</script>

<style>
html, body {
	margin: 0;
	padding: 0;
	width: 100%;
	height: 100%;
}
#app {
  font-family: 'Avenir', Helvetica, Arial, sans-serif;
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
  text-align: center;
  color: #2c3e50;
  display: flex;
  flex-direction: column;
  justify-content: center;
  align-content: center;
  align-items: center;
  width: 100%;
  height: 100%;
  margin-top: 0;
}
</style>

在uniapp中使用

组件位置:js/vue/uniapp放在uniapp项目的uni_modules目录或者去官方插件中心搜索 isszz-captcha

<template >
	<view>
		<rotate-captcha :options="captchaOptions" v-if="captchaShow" @init="captchaInit" @close="captchaShow = false" @complete="captchaComplete" @success="captchaSuccess" @fail="captchaFail"></rotate-captcha>
	</view>
</template>
<script>
	export default {
		data() {
			return {
				captchaShow: false,
				captchaOptions: {
					theme: '#07f',
					title: '安全验证',
					desc: '拖动滑块,使图片角度为正',
					successClose: 1500, // 验证成功后页面关闭时间
					timerProgressBar: true, // 验证成功后关闭时是否显示进度条
					timerProgressBarColor: 'rgba(0, 0, 0, 0.2)',
					url: {
						info: 'http://cfyun.cc/common/captcha/rotate', // 获取验证码信息
						check: 'http://cfyun.cc/common/captcha/verify', // 验证
						img: 'http://cfyun.cc/common/captcha/img', // 交换图片
					},	
				},
			}
		},
		created: function () {
			// 显示验证码
			this.captchaShow = true
		},
		methods: {
			captchaInit(captcha) {
				// console.log(captcha)
			},
			captchaSuccess() {
				// console.log('captcha success')
			},
			captchaFail() {
				// console.log('caotcha fail')
			},
			captchaComplete(state) {
				// console.log('caotcha complete state: ' + state)
			},
		}
	}
</script>