PHP + Captcha - автоматизируем защиту форм
2015-05-28 в 01:05 PHP Captcha
Одна из нетривиальных задач в реализации надежного и комфортного в поддержке веб-проекта – защита и ограничние принимаемых от пользователей данных.
Часто для этого применяется механизм защиты с помошью captcha (каптчи) [Completely Automated Public Turing test to tell Computers and Humans Apart]. Изначально задача каптчи – это защита от ввода данных с помощью компьюетра с использование специально обученного скрипта. И в большинстве случаев – это достаточно надежный механизм. Механизму сто лет в обед, поэтому статья, конечно посвящена не написанию очередног генератора каптч.
Здесь я попробую решить попутную, достаточно занудную задачу – как определить, что к нам начал "стучаться" робот, а не обычный человек. Ведь мы не хотим лишний раз создавать неудобство своим пользователям и заставлять разгадывать каптчи. Итак, попытка создать универсальный простой механизм для защиты определенных мест в своем PHP-проекте, будь то форма аутентификации или форма обратной связи.
Подготовка
1. Для начала, берем удобный генератор каптч, для этого я просто иду на Packagist.org, и ввожу в поиск "Captcha". Беру самый популярный пакет, читаю документацию – все ОК, беру.
2. Ставим composer, если еще нет. Идем в консоль, создаем директорию проекта и пишем в ней:
curl -sS https://getcomposer.org/installer | php
./composer.phar require gregwar/captcha:1.1
3. Далее создаем описание нашего проекта в появившемся файле composer.json:
{
"name" : "jakulov/captcha-protector",
"type": "captcha",
"description": "Universal captcha protector",
"keywords": ["captcha", "spam", "bot", "brute-force"],
"homepage": "https://github.com/jakulov/captcha-protector",
"license": "MIT",
"authors": [
{
"name": "Yakov Akulov",
"email": "[email protected]",
"homepage": "http://jakulov.ru/"
}
],
"require": {
"php": ">=5.4.0",
"gregwar/captcha": "dev-master"
},
"autoload": {
"psr-4": {
"CaptchaProtector\\": "src/CaptchaProtector"
}
}
}
4. Теперь создаем директорию для исходного кода библиотеки: src/CaptchaProtector. В ней заводим класс Protector в файле Protector.php.
<?php
namespace CaptchaProtector;
use Gregwar\Captcha\CaptchaBuilder;
/**
* Class Protector
* @package CaptchaProtector
*/
class Protector
{
/** @var string Директория для хранения временных данных */
protected $storageDir;
/** @var bool Флаг необходмости показа каптчи */
protected $needCaptcha = false;
/** @var bool Флаг того, что каптча показана пользователю */
protected $captchaShown = false;
/** @var int Лимит запросов от одного пользователя в защищенную зону */
protected $requestLimit = 0;
/** @var int Лимит времени, в который действуюет лимит на кол-во запросов (сек) */
protected $timeLimit; // 0 - по времени не ограничивается
/** @var int Счетчик запросов пользователя в защищенную зону */
protected $requestCount = 0;
/** @var CaptchaBuilder Инкапсулированный объект генератора каптчи */
protected $captchaBuilder;
/** @var string Кука, в которой будем хранить идентификатор показанной каптчи */
public $captchaCookieName = 'captcha_protector';
/** @var int Частота запуска (% запросов) сборщика мусора, для очистки временной директории */
public $gcFrequency = 10;
/** @var string Именование защищаемой зоны (обычно HTTP_METHOD + URI) */
protected $request;
/** @var int Время жизни информации о пользователи (сек) */
public $clientInfoExpires = 3600;
/** @var int Время жизни информации о показанной куке (сек) */
public $captchaInfoExpires = 3600;
/**
* @param string $storageDir
* @param int $requestLimit
* @param int|null $timeLimit
*/
public function __construct($storageDir = '/tmp', $requestLimit = 3, $timeLimit = null)
{
$this->storageDir = $storageDir .'/captcha_protector';
$this->requestLimit = $requestLimit;
$this->timeLimit = $timeLimit;
}
}
Реализация логики защиты
5. Пока наш класс "защитник" ничего не делает, это просто каркас. Теперь добавим метод, который мы будем вызывать, когда хотим защитить тот или иной запрос с помощью каптчи. ВАЖНО – остальные функции протектора не будут работать до вызова метода protect
<php
/**
* @param null|string $request Имя защищаемой зоны
* @return $this
*/
public function protect($request = null)
{
if($request === null) {
// если имя не задано, формируем его на основе
$uri = $_SERVER['REQUEST_METHOD'] . '/ ' . $_SERVER['REQUEST_URI'];
$request = explode('?', $uri)[0];
}
if(!$request) {
// имя защищаемой зоны не может быть пустым
throw new \RuntimeException('Unable to protect empty request: '. var_export($request, true));
}
$this->request = $request; // сохраняем имя для последующего использования
$clientIp = $this->getClientIp(); // получаем максимально реальный ip пользования
$info = $this->processClientInfo($this->getClientInfo($clientIp)); // собираем информацию о посетителе
$this->setClientInfo($clientIp, $info); // сохраняем обновленную информацию о пользователе
// если превышен лимит запросов, ставим флаг, что нужна каптча
$this->needCaptcha = $this->requestCount > $this->requestLimit;
if($this->needCaptcha) {
// флаг показа каптчи ставится только если это не первое достижение лимита запросов
$this->captchaShown = $this->requestCount - $this->requestLimit === 1 ? false : true;
}
return $this;
}
6. Реализуем функции получения IP клиента и сбора информации о запросах клиента.
<php
/**
* Честно спи жена со stackoverflow
* @return string
*/
public function getClientIp()
{
$client = isset($_SERVER['HTTP_CLIENT_IP']) ? $_SERVER['HTTP_CLIENT_IP'] : '';
$forward = isset($_SERVER['HTTP_X_FORWARDED_FOR']) ? $_SERVER['HTTP_X_FORWARDED_FOR'] : '';
$remote = $_SERVER['REMOTE_ADDR'];
if(filter_var($client, FILTER_VALIDATE_IP)) {
$ip = $client;
}
elseif(filter_var($forward, FILTER_VALIDATE_IP)) {
$ip = $forward;
}
else {
$ip = $remote;
}
return $ip;
}
/**
* Когда имеем ip можем загрузить информацию о клиенте
* @param $ip
* @return array
*/
public function getClientInfo($ip)
{
$file = $this->getClientFilename($ip); // получаем имя файла, в котором храним данные
if(!file_exists($file)) {
if(!is_dir(dirname($file))) {
if(!mkdir(dirname($file), 0777, true)) {
throw new \RuntimeException('Unable to create storage dir: '. $this->storageDir);
}
}
// если файла еще нет - создаем новый с пустым массивом данных
file_put_contents($file, json_encode([]));
}
// данные храним в json, работаем с массивом
$info = file_get_contents($file);
$json = json_decode($info, true);
return $json ? $json : [];
}
/**
* Процесс защиты
* @param $info информация о запросах клиента
* @return mixed
*/
protected function processClientInfo($info)
{
if($this->timeLimit !== null) {
// если импользовано ограничение на кол-во запросо в период времени
if(!isset($info[$this->request])) {
// если запросов еще не было в эту зону - создаем массив данных
$info[$this->request] = [
time()
];
}
else {
// иначе добавляем новый элемент
$info[$this->request][] = time();
}
// данные о запросах клиента сохраняем в виде массива UNIX-таймстампов времени запроса
// так удобно получить кол-во запросов на установленный период времени
$actualRequests = [];
$actualTime = time() - $this->timeLimit;
foreach($info[$this->request] as $time) {
if($time > $actualTime) {
$this->requestCount++; // в счетчик запросов идут только запросы за заданные период времени
$actualRequests[] = $time;
}
}
// не актуальные по времени запросы удаляются из истории, оставляем актульальные
$info[$this->request] = $actualRequests;
}
else {
// если ограничения по времени нет, то все проще
if(!isset($info[$this->request])) {
$info[$this->request] = 1;
}
else {
$info[$this->request] += 1;
}
// просто инкрементируем счетчик запросов и все
$this->requestCount = $info[$this->request];
}
return $info;
}
7. По сути, основная логика защиты уже реализована - осталось добавить несколько вспомогательных методов
<php
/**
* Сохранение обработанной информации о запросах клиента
* @param $ip
* @param array $info
* @return int
*/
public function setClientInfo($ip, $info = [])
{
if(mt_rand(0, 100) < $this->gcFrequency) {
$this->runGC();
}
return file_put_contents($this->getClientFilename($ip), json_encode($info));
}
/**
* Получаем контент каптчи для показа картинки
* @param $width ширина картинки
* @param $height высота
* @param null $phrase текст на картинке (можно не задавать - генерится сам)
* @param int $quality - качество jpeg)
* @return string
*/
public function getCaptcha($width, $height, $phrase = null, $quality = 90)
{
if($phrase) {
$this->getCaptchaBuilder()->setPhrase($phrase);
}
$content = $this->getCaptchaBuilder()->build($width, $height)->inline($quality);
$this->saveCaptchaRequest();
return $content;
}
/**
* Сохраняем информацию о показанной каптче
* @return int
*/
protected function saveCaptchaRequest()
{
// получаем имя файла, в котором будем хранить показанную каптчу
$file = $this->getCaptchaRequestFile($this->getCaptchaRequestUid());
// getCaptchaRequestUid - выдает uid пользователя, сохраненный в куке (если нет куки - генерит новый)
if(!is_dir(dirname($file))) {
mkdir(dirname($file), 0777, true);
}
return file_put_contents($file, $this->getCaptchaBuilder()->getPhrase());
}
/**
* Определяем была ли решена каптча
* @param $userInput то, что ввел пользователь
* @return bool
*/
public function isCaptchaResolved($userInput)
{
// читам сохраненную фразу из файла
$file = $this->getCaptchaRequestFile($this->getCaptchaRequestUid());
if(file_exists($file) && trim($userInput)) {
// и сравниваем с вводом пользователя
if ($userInput == file_get_contents($file)) {
unlink($file);
return true;
}
}
return false;
}
Полный код класса смотрите на GitHub https://github.com/jakulov/captcha-protector
Применяем на практике
8. Как это все работает? Довольно просто, главно, что разработчику не нужно заботится о муторных процедурах проверки. Просто используюем пару функций из готового класса и все – твой проект неплохо защищен.
Теперь создаем директорию app, в ней файл test.php для тестирования написанного кода. В простейшем применении это будет выглять вот так:
<php
require_once __DIR__ . '/../vendor/autoload.php'; // погружаем автозагрузчик классов
$successLogin = $processLogin = false; // флаги: аутентификация не пройдена, проводить не надо
$error = ''; // сообщение об ошибке
// инициализируем протектор
$protector = new \CaptchaProtector\Protector(__DIR__ .'/../var', 2);
$login = $password = 'test'; // "правильные" логин и пароль
if($_SERVER['REQUEST_METHOD'] === 'POST') { // форма отправлена
$processLogin = true; // будем проводить аутентификацию
$protector->protect('test'); // запускаем "защиту" формы
if($protector->isNeedCaptcha() && $protector->isCaptchaShown()) {
// если защитник говорит, что нужна капча и капча была показана
// выполняем проверку правильности введенной капчи
if($protector->isCaptchaResolved(isset($_POST['captcha']) ? $_POST['captcha'] : '')) {
// если капча разгадана - будем проводить аутентификацию
$processLogin = true;
}
else {
$processLogin = false;
$error = 'Invalid text from picture';
}
}
if($processLogin) {
// типа аутентификация
if($_POST['login'] === $login && $_POST['password'] === $password) {
$protector->forgive('test'); // метод позволяет удалить историю запросов этого пользователя в защищаемую зону
$successLogin = true;
}
else {
$error = 'Invalid login or password';
}
}
}
ob_start(); // ставим буфер вывода, т.к. протектор будет использовать setcookie,
// и не сможет это сделать, если мы начнем выводить контент раньше этого
// далее все понятно - обычная форма. В которой с помощью протектора мы можем вывести капчу.
?>
<h1>Test form example.</h1>
<p>Form will require captcha after 3 incorrect login attempts.</p>
<p><small>Correct login and password: "test:test"</small></p>
<?php if($successLogin):?>
<p>You've logged in successfully!</p>
<p><a href="./test.php">Logout</a></p>
<?php else:?>
<form method="post">
<?php if($error):?><div style="color: red;"><?php echo $error?></div><?php endif?>
<label>Login: <br><input required type="text" name="login" value="<?php echo ($_POST ? $_POST['login'] : '')?>"></label><br>
<label>Password: <br><input required type="password" name="password" value="<?php echo ($_POST ? $_POST['password'] : '')?>"></label><br>
<?php if($protector->isNeedCaptcha()):?>
<img src="<?php echo $protector->getCaptcha(150, 50)?>"><br>
<label>Captcha: <br><input type="text" name="captcha" value="" placeholder="Enter text from image above" required></label>
<?php endif?>
<br><br>
<input type="submit" value="Log In">
</form>
<?php endif?>
Запусить данные пример легко. Просто переходим в директорию app нашего проекта. В коносли запускаем PHP-сервер: php -S localhost:8000
. Затем идем по ссылке http://localhost:8000/test.php. Тестируем пример.
Автор Yakov Akulov
Комментарии (2) написать
Yakov Akulov
Проект написал только вчера ночью.
В планах добавить реализацию функции обновления каптчи без перезагрузки страницы.
А также возможности заменить реализацию генерации и проверки каптчи с помощью интерфейса на другую реализацию, например для использования ReCaptcha или чего-то еще.
Ответить
Комментарий удален
Написать комментарий: