Skip to content

Commit

Permalink
feat: math captcha added
Browse files Browse the repository at this point in the history
  • Loading branch information
hazzeldorn committed Jul 18, 2024
1 parent 7051226 commit cf768e9
Show file tree
Hide file tree
Showing 4 changed files with 192 additions and 0 deletions.
31 changes: 31 additions & 0 deletions docs/field-types/captcha.html
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,37 @@ <h2>Google ReCaptcha V3</h2>
<p>With every user request, reCAPTCHA v3 returns a value between 0 and 1, indicating the probability that the request comes from a bot. If the value is close to 0, it is probably a bot; if it is close to 1, it is more likely that it is a human. The default score of <code>0.4</code> may be customized using the <code>min_score</code> argument.</p>
<br>

<h2>Math Captcha</h2>
<p>Let users solve a simple math puzzle to verify if they are human.<br>
You can choose between a text or an image captcha. The image captcha is recommended because only more sophisticated bots have the ability to read text from images.
</p>
<pre><code class="language-php">$form->addField('my-captcha', 'math-captcha', [
'label' => "What is &lt;challenge&gt;?", // &lt;challenge&gt; will be replaced with the actual math question
'secret' => 'YOUR_SECRET_KEY', // secret salt for captcha (generate a random string)
'min' => 1, // optionally override the minimum number for the math puzzle
'max' => 100, // optionally override the maximum number for the math puzzle
'use_image' => true, // use an image instead of text (recommended)
'font_path' => '...', // -- optionally change the font (when using an image)
'font_size' => 24, // -- optionally override font size (when using an image)
'color' => [255, 0, 0], // -- optionally override the text color using [R, G, B] (when using an image)
]);
</code></pre>

<p>If you use the image based puzzle, you might want to add some CSS to shrink the image and align it with the label text.</p>
<pre><code class="language-css">.form-wrap .img-puzzle {
position: relative;
display: inline-block;
height: 0.8em;
transform: translateY(0.1em); /* vertical alignment */
}

.form-wrap .img-puzzle img {
height: 100%;
}
</code></pre>
<br><br>


<h2>Honeypot</h2>

<p>
Expand Down
158 changes: 158 additions & 0 deletions src/HazzelForms/Field/Captcha/MathCaptcha.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
<?php

namespace HazzelForms\Field\Captcha;

use HazzelForms\Field\Field as Field;

class MathCaptcha extends Captcha {
protected $fieldType = 'math-captcha';
protected $num1;
protected $num2;
protected $operator;
protected $answer;
protected $secret;
protected $useImage;
protected $fontPath;
protected $fontSize;
protected $color;
protected $ttl;

public function __construct($formName, $fieldName, $args = []) {
parent::__construct($formName, $fieldName, $args);

$min = $args['min'] ?? 1; // default to 1 if not specified
$max = $args['max'] ?? 25; // default to 25 if not specified
$this->secret = $args['secret'];
$this->ttl = isset($args['ttl']) && is_int($args['ttl']) ? $args['ttl'] : 10; // default to 10 minutes if not specified
$this->useImage = ($args['use_image'] && extension_loaded('gd')) ?? false;
$this->fontPath = $args['font_path'] ?? realpath(__DIR__ . '/../../assets/font/OpenSans-Regular.ttf');
$this->fontSize = $args['font_size'] ?? 24;
$this->color = $args['color'] ?? [0, 0, 0];

// Generate two random numbers and a random operator
$this->num1 = rand($min, $max);
$this->num2 = rand($min, $max);
$this->operator = rand(0, 1) ? '+' : '-';

if ($this->operator == '-' && $this->num2 > $this->num1) {
// Swap numbers to ensure a non-negative result
[$this->num1, $this->num2] = [$this->num2, $this->num1];
}

$this->answer = $this->operator == '+' ? ($this->num1 + $this->num2) : ($this->num1 - $this->num2);
}

// override label method to allow for custom label
public function returnLabel() {
// Get the challenge string (either image or text representation)
$challengeString = sprintf('%d %s %d', $this->num1, $this->operator, $this->num2);
$challenge = $this->useImage ? $this->generateImage($challengeString) : $challengeString;

// If the label contains the placeholder <challenge>, replace it
if (strpos($this->label, '<challenge>') !== false) {
$replacedLabel = str_replace('<challenge>', $challenge, $this->label);
return sprintf('<label for="%1$s-%2$s"><span class="label">%3$s</span></label>', $this->formName, $this->fieldSlug, $replacedLabel);
}

// If no placeholder is found or if label is not set, revert to the default behavior
return parent::returnLabel();
}

public function returnField() {
$currentTimestamp = date('Y-m-d H:i'); // Capture current timestamp

return sprintf(
'<input type="text" name="%1$s[%2$s]" id="%1$s-%2$s" class="%4$s" />
<input type="hidden" name="%1$s--math-verify" value="%3$s" />',
$this->formName,
$this->fieldSlug,
md5($this->answer . $this->secret . $currentTimestamp),
$this->classlist
);
}

// Build error message as html
public function returnError($lang) {
if (!empty($this->error)) {
return sprintf('<span class="error-msg">%1$s</span>', $lang->getMessage('submit', 'invalid_captcha'));
}
}

// Build error placeholder
public function returnErrorPlaceholder() {
return sprintf('<span class="error-msg" id="error--%1$s-%2$s"></span>', $this->formName, $this->fieldSlug);
}

// Get error message
public function getErrorMessage($lang) {
if (!empty($this->error)) {
return $lang->getMessage('submit', 'invalid_captcha');
}
}

protected function generateImage($text) {
$textBoundingBox = imagettfbbox($this->fontSize, 0, $this->fontPath, $text);

// Determine the width and height of the text box
$textWidth = $textBoundingBox[2] - $textBoundingBox[0];
$textHeight = $textBoundingBox[1] - $textBoundingBox[7];

// Add some padding around the text
$padding = 2;
$imageWidth = $textWidth + ($padding * 2);
$imageHeight = $textHeight + ($padding * 2);

// Create a true color image with transparency
$image = imagecreatetruecolor($imageWidth, $imageHeight);

// Enable alpha blending and save the alpha channel
imagesavealpha($image, true);
$backgroundColor = imagecolorallocatealpha($image, 0, 0, 0, 127);
imagefill($image, 0, 0, $backgroundColor);

// Allocate the color for the text (white)
$textColor = imagecolorallocate($image, $this->color[0], $this->color[1], $this->color[2]);

// Align the text at the bottom of the image
imagettftext($image, $this->fontSize, 0, $padding, $imageHeight - $padding, $textColor, $this->fontPath, $text);

ob_start();
imagepng($image);
$data = ob_get_contents();
ob_end_clean();

imagedestroy($image);

return '<span class="img-puzzle"><img src="data:image/png;base64,' . base64_encode($data) . '" alt="Math puzzle" /></span>';
}


public function validate() {
$oneValid = false;
$storedAnswer = $_POST[$this->formName . '--math-verify'] ?? '';

// loop over possible timestamps from last 10 minutes
for ($i = 0; $i < $this->ttl; $i++) {
$timestamp = date('Y-m-d H:i', strtotime('-' . $i . ' minutes')); // Capture current timestamp

// check if the answer is correct for any of the timestamps
if (md5($this->fieldValue . $this->secret . $timestamp) === $storedAnswer) {
$oneValid = true;
break;
}
}

if ($oneValid === false || $storedAnswer === '') {
$this->error = 'invalid';
} else {
$this->fieldValue = 'ok';
}

$this->validated = true;
return $this->isValid();
}

public function setValue($value) {
$this->fieldValue = $value;
}
}
3 changes: 3 additions & 0 deletions src/HazzelForms/HazzelForm.php
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,9 @@ public function addField($fieldName, $type = 'text', $args = []) {
case 'recaptcha-v3':
$this->fields->$fieldName = new Field\Captcha\RecaptchaV3($fieldName, $this->formName, $args);
break;
case 'math-captcha':
$this->fields->$fieldName = new Field\Captcha\MathCaptcha($fieldName, $this->formName, $args);
break;
case 'honeypot':
$this->fields->$fieldName = new Field\Captcha\HoneyPot($fieldName, $this->formName, $args);
break;
Expand Down
Binary file added src/HazzelForms/assets/font/OpenSans-Regular.ttf
Binary file not shown.

0 comments on commit cf768e9

Please sign in to comment.