Skip to content

solutionforest/laravel-math-captcha

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

8 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Laravel Math CAPTCHA

License: MIT PHP Version Laravel Version

A simple, self-hosted math-based CAPTCHA package for Laravel with image generation to prevent AI/OCR bypass. No external API dependencies required.

Note: This package was generated with the assistance of AI (Claude by Anthropic).

Table of Contents

Screenshot

CAPTCHA Example

Example of a generated math CAPTCHA image with noise and distortion

Features

  • Math-based CAPTCHA - Generates simple arithmetic problems (addition, subtraction, multiplication)
  • Image Generation - Renders CAPTCHA as PNG image with anti-OCR measures:
    • Random noise lines and dots
    • Character position variations
    • Multiple text colors
    • Curved distortion line
  • Self-hosted - No external API calls, no third-party dependencies
  • Configurable - Customize difficulty, appearance, and behavior
  • Laravel Integration - Service provider auto-discovery, Facade support, validation rule
  • One-time Use - Each CAPTCHA token is invalidated after verification attempt
  • Cache-based Storage - Uses Laravel's cache system for token storage

Requirements

  • PHP 8.2 or higher
  • Laravel 11.x or 12.x
  • GD extension (for image generation)

Installation

Install the package via Composer:

composer require solution-forest/laravel-math-captcha

The package will automatically register its service provider via Laravel's package auto-discovery.

Publish Configuration (Optional)

To customize the CAPTCHA settings, publish the configuration file:

php artisan vendor:publish --tag=math-captcha-config

This will create a config/math-captcha.php file in your application.

Quick Start

  1. Install the package:

    composer require solution-forest/laravel-math-captcha
  2. Add CAPTCHA to your form (frontend):

    <img id="captcha-image" src="" alt="CAPTCHA">
    <button type="button" onclick="refreshCaptcha()">Refresh</button>
    <input type="hidden" name="captcha_token" id="captcha-token">
    <input type="text" name="captcha_answer" placeholder="Enter the answer">
    
    <script>
    async function refreshCaptcha() {
        const response = await fetch('/captcha');
        const data = await response.json();
        document.getElementById('captcha-image').src = data.image;
        document.getElementById('captcha-token').value = data.token;
    }
    refreshCaptcha(); // Load initial CAPTCHA
    </script>
  3. Verify in your controller (backend):

    use SolutionForest\MathCaptcha\Contracts\CaptchaGenerator;
    
    public function store(Request $request, CaptchaGenerator $captcha)
    {
        if (!$captcha->verify($request->captcha_token, $request->captcha_answer)) {
            return back()->withErrors(['captcha' => 'Incorrect answer']);
        }
    
        // Process form...
    }

Usage

Generate a CAPTCHA

The package automatically registers a route at /captcha (GET) that returns JSON:

{
    "token": "abc123def456...",
    "image": "data:image/png;base64,iVBORw0KGgo..."
}
  • token - A unique 32-character string to identify this CAPTCHA challenge
  • image - Base64-encoded PNG image data URI that can be used directly in <img src="">

Verify a CAPTCHA

Send the token and user's answer to your backend for verification. The verification:

  • Returns true if the answer is correct
  • Returns false if the answer is wrong or the token is invalid/expired
  • Invalidates the token after the first verification attempt (one-time use)

Using the Facade

use SolutionForest\MathCaptcha\Facades\MathCaptcha;

// Generate a new CAPTCHA
$captcha = MathCaptcha::generate();
// Returns: [
//     'token' => 'abc123...',
//     'image' => 'data:image/png;base64,...'
// ]

// Verify an answer
$isValid = MathCaptcha::verify($token, $userAnswer);
// Returns: true or false

Using Dependency Injection

use SolutionForest\MathCaptcha\Contracts\CaptchaGenerator;

class ContactController extends Controller
{
    public function store(Request $request, CaptchaGenerator $captcha)
    {
        // Verify the CAPTCHA
        $isValid = $captcha->verify(
            $request->input('captcha_token'),
            $request->input('captcha_answer')
        );

        if (!$isValid) {
            return back()->withErrors(['captcha' => 'Invalid CAPTCHA answer. Please try again.']);
        }

        // CAPTCHA is valid, process the form...
    }
}

Using the Validation Rule

For cleaner validation, use the built-in validation rule:

use SolutionForest\MathCaptcha\Rules\ValidCaptcha;

public function store(Request $request)
{
    $request->validate([
        'name' => 'required|string',
        'email' => 'required|email',
        'captcha_answer' => ['required', new ValidCaptcha()],
    ]);

    // Validation passed, process the form...
}

The rule automatically looks for captcha_token and captcha_answer fields. You can customize the field names:

new ValidCaptcha(
    tokenField: 'my_captcha_token',
    answerField: 'my_captcha_answer'
)

Frontend Integration

Livewire Example

Livewire Component (app/Livewire/ContactForm.php):

<?php

namespace App\Livewire;

use Livewire\Component;
use SolutionForest\MathCaptcha\Contracts\CaptchaGenerator;

class ContactForm extends Component
{
    public string $name = '';
    public string $email = '';
    public string $phone = '';
    public string $message = '';
    public string $captchaToken = '';
    public string $captchaImage = '';
    public string $captchaAnswer = '';

    public function mount(): void
    {
        $this->refreshCaptcha();
    }

    public function refreshCaptcha(): void
    {
        $captcha = app(CaptchaGenerator::class)->generate();
        $this->captchaToken = $captcha['token'];
        $this->captchaImage = $captcha['image'];
        $this->captchaAnswer = '';
    }

    public function submit(CaptchaGenerator $captcha): void
    {
        $this->validate([
            'name' => 'required|string|max:255',
            'email' => 'required|email',
            'phone' => 'required|string',
            'captchaAnswer' => 'required',
        ]);

        // Verify CAPTCHA
        if (!$captcha->verify($this->captchaToken, $this->captchaAnswer)) {
            $this->addError('captchaAnswer', 'Incorrect answer. Please try again.');
            $this->refreshCaptcha();
            return;
        }

        // Process form submission...

        session()->flash('success', 'Your message has been sent!');
        $this->reset(['name', 'email', 'phone', 'message', 'captchaAnswer']);
        $this->refreshCaptcha();
    }

    public function render()
    {
        return view('livewire.contact-form');
    }
}

Blade View (resources/views/livewire/contact-form.blade.php):

<form wire:submit="submit">
    @if (session('success'))
        <div class="alert alert-success">{{ session('success') }}</div>
    @endif

    <div>
        <label for="name">Name</label>
        <input type="text" id="name" wire:model="name">
        @error('name') <span class="error">{{ $message }}</span> @enderror
    </div>

    <div>
        <label for="email">Email</label>
        <input type="email" id="email" wire:model="email">
        @error('email') <span class="error">{{ $message }}</span> @enderror
    </div>

    <div>
        <label for="phone">Phone</label>
        <input type="tel" id="phone" wire:model="phone">
        @error('phone') <span class="error">{{ $message }}</span> @enderror
    </div>

    <div>
        <label for="message">Message</label>
        <textarea id="message" wire:model="message"></textarea>
    </div>

    <div>
        <label>Security Check</label>
        <div class="captcha-container">
            <img src="{{ $captchaImage }}" alt="CAPTCHA">
            <button type="button" wire:click="refreshCaptcha" wire:loading.attr="disabled">
                <span wire:loading.remove wire:target="refreshCaptcha">Refresh</span>
                <span wire:loading wire:target="refreshCaptcha">Loading...</span>
            </button>
        </div>
        <input
            type="text"
            wire:model="captchaAnswer"
            placeholder="Enter the answer"
            inputmode="numeric"
        >
        @error('captchaAnswer') <span class="error">{{ $message }}</span> @enderror
    </div>

    <button type="submit" wire:loading.attr="disabled">
        <span wire:loading.remove>Submit</span>
        <span wire:loading>Sending...</span>
    </button>
</form>

React Example

import { useState, useEffect, useCallback } from 'react';

interface CaptchaData {
    token: string;
    image: string;
}

function ContactForm() {
    const [captcha, setCaptcha] = useState<CaptchaData | null>(null);
    const [answer, setAnswer] = useState('');
    const [isLoading, setIsLoading] = useState(false);

    const fetchCaptcha = useCallback(async () => {
        setIsLoading(true);
        try {
            const response = await fetch('/captcha');
            const data = await response.json();
            setCaptcha(data);
            setAnswer('');
        } catch (error) {
            console.error('Failed to fetch CAPTCHA:', error);
        } finally {
            setIsLoading(false);
        }
    }, []);

    useEffect(() => {
        fetchCaptcha();
    }, [fetchCaptcha]);

    const handleSubmit = async (e: React.FormEvent) => {
        e.preventDefault();

        const formData = new FormData(e.target as HTMLFormElement);
        formData.append('captcha_token', captcha?.token || '');
        formData.append('captcha_answer', answer);

        // Submit form...
    };

    return (
        <form onSubmit={handleSubmit}>
            {/* Other form fields... */}

            <div className="captcha-container">
                <div className="captcha-image">
                    {isLoading ? (
                        <span>Loading...</span>
                    ) : (
                        <img
                            src={captcha?.image}
                            alt="CAPTCHA"
                            draggable={false}
                        />
                    )}
                </div>
                <button type="button" onClick={fetchCaptcha} disabled={isLoading}>
                    Refresh
                </button>
            </div>

            <input
                type="text"
                value={answer}
                onChange={(e) => setAnswer(e.target.value)}
                placeholder="Enter the answer"
                inputMode="numeric"
                required
            />

            <button type="submit">Submit</button>
        </form>
    );
}

Vue Example

<template>
    <form @submit.prevent="handleSubmit">
        <!-- Other form fields... -->

        <div class="captcha-container">
            <div class="captcha-image">
                <span v-if="isLoading">Loading...</span>
                <img v-else :src="captcha?.image" alt="CAPTCHA" />
            </div>
            <button type="button" @click="fetchCaptcha" :disabled="isLoading">
                Refresh
            </button>
        </div>

        <input
            v-model="answer"
            type="text"
            placeholder="Enter the answer"
            inputmode="numeric"
            required
        />

        <button type="submit">Submit</button>
    </form>
</template>

<script setup>
import { ref, onMounted } from 'vue';

const captcha = ref(null);
const answer = ref('');
const isLoading = ref(false);

async function fetchCaptcha() {
    isLoading.value = true;
    try {
        const response = await fetch('/captcha');
        captcha.value = await response.json();
        answer.value = '';
    } catch (error) {
        console.error('Failed to fetch CAPTCHA:', error);
    } finally {
        isLoading.value = false;
    }
}

async function handleSubmit() {
    const formData = {
        // Other form data...
        captcha_token: captcha.value?.token,
        captcha_answer: answer.value,
    };

    // Submit form...
}

onMounted(fetchCaptcha);
</script>

Vanilla JavaScript Example

<form id="contact-form">
    <!-- Other form fields... -->

    <div class="captcha-container">
        <img id="captcha-image" src="" alt="CAPTCHA">
        <button type="button" id="refresh-captcha">Refresh</button>
    </div>

    <input type="hidden" name="captcha_token" id="captcha-token">
    <input
        type="text"
        name="captcha_answer"
        id="captcha-answer"
        placeholder="Enter the answer"
        inputmode="numeric"
        required
    >

    <button type="submit">Submit</button>
</form>

<script>
document.addEventListener('DOMContentLoaded', function() {
    const captchaImage = document.getElementById('captcha-image');
    const captchaToken = document.getElementById('captcha-token');
    const captchaAnswer = document.getElementById('captcha-answer');
    const refreshButton = document.getElementById('refresh-captcha');

    async function fetchCaptcha() {
        try {
            const response = await fetch('/captcha');
            const data = await response.json();

            captchaImage.src = data.image;
            captchaToken.value = data.token;
            captchaAnswer.value = '';
        } catch (error) {
            console.error('Failed to fetch CAPTCHA:', error);
        }
    }

    refreshButton.addEventListener('click', fetchCaptcha);

    // Load initial CAPTCHA
    fetchCaptcha();
});
</script>

Configuration

After publishing the configuration file, you can customize the following options in config/math-captcha.php:

Image Settings

// Image dimensions in pixels
'width' => 200,
'height' => 60,

// Built-in GD font size (1-5, where 5 is largest)
'font_size' => 5,

Math Operations

// How long a CAPTCHA token remains valid (in minutes)
'ttl' => 10,

// Math operators to use: '+' (addition), '-' (subtraction), '*' (multiplication)
'operators' => ['+', '-', '*'],

// Number ranges for each operator to keep problems reasonable
// For subtraction, num1 is always >= num2 to avoid negative answers
'ranges' => [
    '+' => ['min1' => 1, 'max1' => 50, 'min2' => 1, 'max2' => 50],  // Results: 2-100
    '-' => ['min1' => 20, 'max1' => 50, 'min2' => 1, 'max2' => 20], // Results: 0-49
    '*' => ['min1' => 2, 'max1' => 12, 'min2' => 2, 'max2' => 9],   // Results: 4-108
],

Visual Customization

// Background color [R, G, B]
'background_color' => [245, 245, 247],

// Text colors (randomly selected per character) [R, G, B]
'text_colors' => [
    [30, 30, 50],   // Dark blue-gray
    [50, 30, 80],   // Purple-gray
    [30, 60, 60],   // Teal-gray
],

// Noise element colors [R, G, B]
'noise_colors' => [
    [200, 200, 210],
    [180, 190, 200],
    [210, 200, 190],
],

// Number of noise lines to draw
'noise_lines' => 8,

// Number of noise dots to draw
'noise_dots' => 100,

Route Configuration

'route' => [
    // Set to false to disable automatic route registration
    'enabled' => true,

    // The URI for the CAPTCHA endpoint
    'uri' => '/captcha',

    // Route name for URL generation: route('captcha.generate')
    'name' => 'captcha.generate',

    // Middleware to apply to the route
    'middleware' => ['web'],
],

Cache Settings

// Prefix for cache keys (useful if you have multiple apps sharing cache)
'cache_prefix' => 'math_captcha',

API Reference

CaptchaGenerator Interface

interface CaptchaGenerator
{
    /**
     * Generate a new CAPTCHA challenge.
     *
     * @return array{token: string, image: string}
     */
    public function generate(): array;

    /**
     * Verify a CAPTCHA answer.
     *
     * @param string $token The CAPTCHA token
     * @param int|string $answer The user's answer
     * @return bool True if correct, false otherwise
     */
    public function verify(string $token, int|string $answer): bool;
}

ValidCaptcha Rule

use SolutionForest\MathCaptcha\Rules\ValidCaptcha;

// Default field names
new ValidCaptcha();

// Custom field names
new ValidCaptcha(
    tokenField: 'my_token_field',
    answerField: 'my_answer_field'
);

Security Considerations

  1. One-time Use: Each CAPTCHA token is invalidated after the first verification attempt, preventing replay attacks.

  2. Token Expiration: Tokens expire after a configurable time (default: 10 minutes), limiting the window for brute-force attacks.

  3. Rate Limiting: Consider adding rate limiting to your CAPTCHA endpoint to prevent abuse:

    // In your route configuration or middleware
    Route::middleware(['throttle:60,1'])->get('/captcha', ...);
  4. HTTPS: Always serve your application over HTTPS to prevent CAPTCHA images and tokens from being intercepted.

  5. Cache Security: CAPTCHA answers are stored in your application's cache. Ensure your cache driver is properly secured.

Troubleshooting

CAPTCHA Image Shows Strange Characters

Problem: The image displays garbled characters like "A" instead of math symbols.

Solution: This package uses PHP's built-in GD fonts which only support ASCII characters. The operators are displayed as:

  • Addition: +
  • Subtraction: -
  • Multiplication: x (lowercase x)

GD Extension Not Found

Problem: Error "Call to undefined function imagecreatetruecolor()"

Solution: Install the GD extension:

# Ubuntu/Debian
sudo apt-get install php-gd

# macOS with Homebrew
brew install php-gd

# Then restart your web server

CAPTCHA Always Fails Verification

Problem: Correct answers are rejected.

Possible Causes:

  1. Token expired (default: 10 minutes)
  2. Token already used (one-time use)
  3. Cache not working properly

Solution: Check your cache configuration and ensure the cache driver is working correctly.

Route Not Found

Problem: 404 error when accessing /captcha

Solution:

  1. Clear route cache: php artisan route:clear
  2. Check if route registration is enabled in config
  3. Verify the package is properly installed: composer dump-autoload

Contributing

Contributions are welcome! Please feel free to submit a Pull Request.

  1. Fork the repository
  2. Create your feature branch (git checkout -b feature/amazing-feature)
  3. Commit your changes (git commit -m 'Add some amazing feature')
  4. Push to the branch (git push origin feature/amazing-feature)
  5. Open a Pull Request

License

This package is open-sourced software licensed under the MIT License.


Made with AI assistance by Solution Forest

About

No description, website, or topics provided.

Resources

License

Stars

Watchers

Forks

Packages

No packages published

Contributors 2

  •  
  •  

Languages