Replace captcha package with own implementation

This commit is contained in:
2026-04-20 21:36:02 +02:00
parent 361b511c3e
commit 8ae9eaaadb
17 changed files with 212 additions and 156 deletions

View File

@@ -5,7 +5,7 @@ namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Models\Downloads; use App\Models\Downloads;
use App\Models\Episode; use App\Models\Episode;
use GrantHolle\Altcha\Rules\ValidAltcha; use App\Rules\ValidCaptcha;
use Illuminate\Http\Request; use Illuminate\Http\Request;
class DownloadApiController extends Controller class DownloadApiController extends Controller
@@ -17,7 +17,7 @@ class DownloadApiController extends Controller
{ {
$validated = $request->validate([ $validated = $request->validate([
'episode_id' => ['required'], 'episode_id' => ['required'],
'captcha' => ['required', new ValidAltcha], 'captcha' => ['required', new ValidCaptcha],
]); ]);
$episode = Episode::where('id', $request->input('episode_id')) $episode = Episode::where('id', $request->input('episode_id'))

View File

@@ -0,0 +1,30 @@
<?php
namespace App\Http\Controllers\Auth;
use AltchaOrg\Altcha\Algorithm\Pbkdf2;
use AltchaOrg\Altcha\Altcha;
use AltchaOrg\Altcha\CreateChallengeOptions;
use App\Http\Controllers\Controller;
class CaptchaController extends Controller
{
public function create(): array
{
$pbkdf2 = new Pbkdf2;
$altcha = new Altcha(
hmacSignatureSecret: config('captcha.hmac_key'),
);
// Create challenge
$challenge = $altcha->createChallenge(new CreateChallengeOptions(
algorithm: $pbkdf2,
cost: 5000,
counter: random_int(5000, 10000),
expiresAt: time() + 600,
));
return get_object_vars($challenge);
}
}

View File

@@ -4,7 +4,7 @@ namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Models\User; use App\Models\User;
use GrantHolle\Altcha\Rules\ValidAltcha; use App\Rules\ValidCaptcha;
use Illuminate\Auth\Events\Registered; use Illuminate\Auth\Events\Registered;
use Illuminate\Http\RedirectResponse; use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
@@ -25,7 +25,7 @@ class RegisteredUserController extends Controller
'name' => ['required', 'string', 'max:255'], 'name' => ['required', 'string', 'max:255'],
'email' => ['required', 'string', 'lowercase', 'email', 'max:255', 'unique:'.User::class], 'email' => ['required', 'string', 'lowercase', 'email', 'max:255', 'unique:'.User::class],
'password' => ['required', 'confirmed', Rules\Password::defaults()], 'password' => ['required', 'confirmed', Rules\Password::defaults()],
'altcha' => ['required', new ValidAltcha], 'altcha' => ['required', new ValidCaptcha],
]); ]);
$user = User::create([ $user = User::create([

View File

@@ -3,7 +3,7 @@
namespace App\Http\Controllers; namespace App\Http\Controllers;
use App\Models\Contact; use App\Models\Contact;
use GrantHolle\Altcha\Rules\ValidAltcha; use App\Rules\ValidCaptcha;
use Illuminate\Http\Request; use Illuminate\Http\Request;
class ContactController extends Controller class ContactController extends Controller
@@ -26,7 +26,7 @@ class ContactController extends Controller
'email' => 'required|max:50', 'email' => 'required|max:50',
'message' => 'required|max:1000', 'message' => 'required|max:1000',
'subject' => 'required|max:50', 'subject' => 'required|max:50',
'altcha' => ['required', new ValidAltcha], 'altcha' => ['required', new ValidCaptcha],
]); ]);
$contact = new Contact; $contact = new Contact;

View File

@@ -2,7 +2,7 @@
namespace App\Http\Requests\Auth; namespace App\Http\Requests\Auth;
use GrantHolle\Altcha\Rules\ValidAltcha; use App\Rules\ValidCaptcha;
use Illuminate\Auth\Events\Lockout; use Illuminate\Auth\Events\Lockout;
use Illuminate\Foundation\Http\FormRequest; use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
@@ -30,7 +30,7 @@ class LoginRequest extends FormRequest
return [ return [
'email' => ['required', 'string', 'email'], 'email' => ['required', 'string', 'email'],
'password' => ['required', 'string'], 'password' => ['required', 'string'],
'altcha' => ['required', new ValidAltcha], 'altcha' => ['required', new ValidCaptcha],
]; ];
} }

129
app/Rules/ValidCaptcha.php Normal file
View File

@@ -0,0 +1,129 @@
<?php
namespace App\Rules;
use AltchaOrg\Altcha\Algorithm\Pbkdf2;
use AltchaOrg\Altcha\Altcha;
use AltchaOrg\Altcha\Challenge;
use AltchaOrg\Altcha\ChallengeParameters;
use AltchaOrg\Altcha\Payload;
use AltchaOrg\Altcha\Solution;
use AltchaOrg\Altcha\VerifySolutionOptions;
use Closure;
use Illuminate\Contracts\Validation\ValidationRule;
/**
* Validation rule to verify captcha solution.
*/
class ValidCaptcha implements ValidationRule
{
/**
* Altcha instance.
*/
protected Altcha $altcha;
/**
* Pbkdf2 algorithm instance.
*/
protected Pbkdf2 $pbkdf2;
/**
* Class constructor.
*/
public function __construct()
{
$this->pbkdf2 = new Pbkdf2;
$this->altcha = new Altcha(
hmacSignatureSecret: config('captcha.hmac_key'),
);
}
/**
* Parse payload and return the decoded data as an array.
*/
private function parsePayload(string $value): ?array
{
$decoded = base64_decode($value, true);
if ($decoded === false) {
return null;
}
$payload = json_decode($decoded, true);
if (! is_array($payload)) {
return null;
}
return $payload;
}
/**
* Verify if payload has required fields.
*/
private function verifyFields(array $payload): bool
{
if (! isset($payload['challenge'], $payload['solution'])) {
return false;
}
if (! is_array($payload['challenge']) || ! is_array($payload['solution'])) {
return false;
}
return true;
}
/**
* Create a Challenge object from challenge data.
*/
private function createChallenge(array $challengeData): Challenge
{
return new Challenge(
ChallengeParameters::fromArray($challengeData['parameters'] ?? []),
$challengeData['signature'] ?? null,
);
}
/**
* Create a Solution object from solution data.
*/
private function createSolution(array $solutionData): Solution
{
return new Solution(
counter: (int) ($solutionData['counter'] ?? 0),
derivedKey: (string) ($solutionData['derivedKey'] ?? ''),
);
}
/**
* Run the validation rule.
*
* @param \Closure(string, ?string=): \Illuminate\Translation\PotentiallyTranslatedString $fail
*/
public function validate(string $attribute, mixed $value, Closure $fail): void
{
$payload = $this->parsePayload($value);
if (! $payload) {
$fail('Invalid captcha.');
return;
}
if (! $this->verifyFields($payload)) {
$fail('Invalid captcha.');
return;
}
$challenge = $this->createChallenge($payload['challenge']);
$solution = $this->createSolution($payload['solution']);
$result = $this->altcha->verifySolution(new VerifySolutionOptions(
algorithm: $this->pbkdf2,
payload: new Payload($challenge, $solution),
));
if (! $result->verified) {
$fail('Invalid captcha.');
}
}
}

View File

@@ -9,7 +9,7 @@
"license": "MIT", "license": "MIT",
"require": { "require": {
"php": "^8.2", "php": "^8.2",
"grantholle/laravel-altcha": "^2.1", "altcha-org/altcha": "^2.0",
"guzzlehttp/guzzle": "^7.8.1", "guzzlehttp/guzzle": "^7.8.1",
"hisorange/browser-detect": "^5.0", "hisorange/browser-detect": "^5.0",
"http-interop/http-factory-guzzle": "^1.2", "http-interop/http-factory-guzzle": "^1.2",

96
composer.lock generated
View File

@@ -4,24 +4,23 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically" "This file is @generated automatically"
], ],
"content-hash": "3400111a6254560d548639295422875c", "content-hash": "a33ae97980a40b1a7c55bec75b10d64f",
"packages": [ "packages": [
{ {
"name": "altcha-org/altcha", "name": "altcha-org/altcha",
"version": "v1.3.1", "version": "v2.0.0",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/altcha-org/altcha-lib-php.git", "url": "https://github.com/altcha-org/altcha-lib-php.git",
"reference": "9e9e70c864a9db960d071c77c778be0c9ff1a4d0" "reference": "8fc4a698b159824d7c1d729dc08e5c0687b494a9"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/altcha-org/altcha-lib-php/zipball/9e9e70c864a9db960d071c77c778be0c9ff1a4d0", "url": "https://api.github.com/repos/altcha-org/altcha-lib-php/zipball/8fc4a698b159824d7c1d729dc08e5c0687b494a9",
"reference": "9e9e70c864a9db960d071c77c778be0c9ff1a4d0", "reference": "8fc4a698b159824d7c1d729dc08e5c0687b494a9",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
"ext-json": "*",
"php": ">=8.2" "php": ">=8.2"
}, },
"require-dev": { "require-dev": {
@@ -29,7 +28,7 @@
"phpstan/extension-installer": "^1.4", "phpstan/extension-installer": "^1.4",
"phpstan/phpstan": "^2.1", "phpstan/phpstan": "^2.1",
"phpstan/phpstan-phpunit": "^2.0", "phpstan/phpstan-phpunit": "^2.0",
"phpunit/phpunit": "^11.5" "phpunit/phpunit": "^11.5.50"
}, },
"type": "library", "type": "library",
"autoload": { "autoload": {
@@ -47,11 +46,12 @@
"email": "536331+ovx@users.noreply.github.com" "email": "536331+ovx@users.noreply.github.com"
} }
], ],
"description": "A lightweight PHP library for creating and verifying ALTCHA challenges.",
"support": { "support": {
"issues": "https://github.com/altcha-org/altcha-lib-php/issues", "issues": "https://github.com/altcha-org/altcha-lib-php/issues",
"source": "https://github.com/altcha-org/altcha-lib-php/tree/v1.3.1" "source": "https://github.com/altcha-org/altcha-lib-php/tree/v2.0.0"
}, },
"time": "2025-12-13T10:03:53+00:00" "time": "2026-04-07T11:35:25+00:00"
}, },
{ {
"name": "brick/math", "name": "brick/math",
@@ -823,82 +823,6 @@
], ],
"time": "2025-12-27T19:43:20+00:00" "time": "2025-12-27T19:43:20+00:00"
}, },
{
"name": "grantholle/laravel-altcha",
"version": "2.1.1",
"source": {
"type": "git",
"url": "https://github.com/grantholle/laravel-altcha.git",
"reference": "c0dcc6d0805e8640d46709e5f8d05c7c65b2687c"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/grantholle/laravel-altcha/zipball/c0dcc6d0805e8640d46709e5f8d05c7c65b2687c",
"reference": "c0dcc6d0805e8640d46709e5f8d05c7c65b2687c",
"shasum": ""
},
"require": {
"altcha-org/altcha": "^1.3.1",
"illuminate/contracts": "^10.0|^11.0|^12.0",
"php": "^8.2",
"spatie/laravel-package-tools": "^1.14.0"
},
"require-dev": {
"laravel/pint": "^1.0",
"nunomaduro/collision": "^8.1.1||^7.10.0",
"orchestra/testbench": "^10.0.0||^9.0.0||^8.22.0",
"pestphp/pest": "^3.0||^2.0",
"pestphp/pest-plugin-arch": "^3.0||^2.0",
"pestphp/pest-plugin-laravel": "^3.0||^2.0",
"spatie/laravel-ray": "^1.26"
},
"type": "library",
"extra": {
"laravel": {
"aliases": {
"Altcha": "GrantHolle\\Altcha\\Facades\\Altcha"
},
"providers": [
"GrantHolle\\Altcha\\AltchaServiceProvider"
]
}
},
"autoload": {
"psr-4": {
"GrantHolle\\Altcha\\": "src/",
"GrantHolle\\Altcha\\Database\\Factories\\": "database/factories/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Grant Holle",
"email": "hollegrant@gmail.com",
"role": "Developer"
}
],
"description": "A Laravel server implementation for Altcha.",
"homepage": "https://github.com/grantholle/laravel-altcha",
"keywords": [
"Grant Holle",
"laravel",
"laravel-altcha"
],
"support": {
"issues": "https://github.com/grantholle/laravel-altcha/issues",
"source": "https://github.com/grantholle/laravel-altcha/tree/2.1.1"
},
"funding": [
{
"url": "https://github.com/Grant Holle",
"type": "github"
}
],
"time": "2025-12-16T03:39:06+00:00"
},
{ {
"name": "guzzlehttp/guzzle", "name": "guzzlehttp/guzzle",
"version": "7.10.0", "version": "7.10.0",
@@ -11584,5 +11508,5 @@
"php": "^8.2" "php": "^8.2"
}, },
"platform-dev": {}, "platform-dev": {},
"plugin-api-version": "2.6.0" "plugin-api-version": "2.9.0"
} }

9
config/captcha.php Normal file
View File

@@ -0,0 +1,9 @@
<?php
/**
* Altcha Captcha System
*/
return [
'hmac_key' => env('ALTCHA_HMAC_KEY'),
];

39
package-lock.json generated
View File

@@ -4,12 +4,11 @@
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "hstream",
"dependencies": { "dependencies": {
"@fortawesome/fontawesome-free": "^6.5.1", "@fortawesome/fontawesome-free": "^6.5.1",
"@jellyfin/libass-wasm": "^4.1.1", "@jellyfin/libass-wasm": "^4.1.1",
"@yaireo/tagify": "^4.21.2", "@yaireo/tagify": "^4.21.2",
"altcha": "^2.3.0", "altcha": "^3.0.0",
"chart.js": "^4.5.0", "chart.js": "^4.5.0",
"dashjs": "^5.0.0", "dashjs": "^5.0.0",
"hammerjs": "^2.0.8", "hammerjs": "^2.0.8",
@@ -42,12 +41,6 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/@altcha/crypto": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/@altcha/crypto/-/crypto-0.0.1.tgz",
"integrity": "sha512-qZMdnoD3lAyvfSUMNtC2adRi666Pxdcw9zqfMU5qBOaJWqpN9K+eqQGWqeiKDMqL0SF+EytNG4kR/Pr/99GJ6g==",
"license": "MIT"
},
"node_modules/@esbuild/aix-ppc64": { "node_modules/@esbuild/aix-ppc64": {
"version": "0.27.2", "version": "0.27.2",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.2.tgz", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.2.tgz",
@@ -1209,30 +1202,14 @@
} }
}, },
"node_modules/altcha": { "node_modules/altcha": {
"version": "2.3.0", "version": "3.0.4",
"resolved": "https://registry.npmjs.org/altcha/-/altcha-2.3.0.tgz", "resolved": "https://registry.npmjs.org/altcha/-/altcha-3.0.4.tgz",
"integrity": "sha512-vl8I0dQvSQB7/Mx09XuWZ1+LdSP7vEda6OLbg9kUQ2ZO2LT7MzgUyLK7Iips+GAV6c0ntVcS1XWOqhEPpwbDhQ==", "integrity": "sha512-TA1N0vnKR7aI/usDN2y3dPDcg9DdsDQKvRuCwApMtw3kJHjxH7NRtt+hSV8NZ01tkfHqXEJOtCYRFr8sxw8Fbg==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@altcha/crypto": "^0.0.1" "hash-wasm": "^4.12.0"
},
"optionalDependencies": {
"@rollup/rollup-linux-x64-gnu": "4.18.0"
} }
}, },
"node_modules/altcha/node_modules/@rollup/rollup-linux-x64-gnu": {
"version": "4.18.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.18.0.tgz",
"integrity": "sha512-xuglR2rBVHA5UsI8h8UbX4VJ470PtGCf5Vpswh7p2ukaqBGFTnsfzxUBetoWBWymHMxbIG0Cmx7Y9qDZzr648w==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/any-promise": { "node_modules/any-promise": {
"version": "1.3.0", "version": "1.3.0",
"resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz",
@@ -2004,6 +1981,12 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/hash-wasm": {
"version": "4.12.0",
"resolved": "https://registry.npmjs.org/hash-wasm/-/hash-wasm-4.12.0.tgz",
"integrity": "sha512-+/2B2rYLb48I/evdOIhP+K/DD2ca2fgBjp6O+GBEnCDk2e4rpeXIK8GvIyRPjTezgmWn9gmKwkQjjx6BtqDHVQ==",
"license": "MIT"
},
"node_modules/hasown": { "node_modules/hasown": {
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",

View File

@@ -20,7 +20,7 @@
"@fortawesome/fontawesome-free": "^6.5.1", "@fortawesome/fontawesome-free": "^6.5.1",
"@jellyfin/libass-wasm": "^4.1.1", "@jellyfin/libass-wasm": "^4.1.1",
"@yaireo/tagify": "^4.21.2", "@yaireo/tagify": "^4.21.2",
"altcha": "^2.3.0", "altcha": "^3.0.0",
"chart.js": "^4.5.0", "chart.js": "^4.5.0",
"dashjs": "^5.0.0", "dashjs": "^5.0.0",
"hammerjs": "^2.0.8", "hammerjs": "^2.0.8",

View File

@@ -126,29 +126,5 @@ input:checked~.dot {
/* Captcha */ /* Captcha */
:root { :root {
--altcha-border-width: 1px; color-scheme: light dark;
--altcha-border-radius: 0.375rem;
--altcha-color-base: #333;
--altcha-color-border: #a0a0a0;
--altcha-color-text: #fff;
--altcha-color-border-focus: currentColor;
--altcha-color-error-text: #f23939;
--altcha-color-footer-bg: #141414;
--altcha-max-width: 260px;
} }
.altcha-footer {
border-bottom-left-radius: 0.375rem;
border-bottom-right-radius: 0.375rem;
}
input[type="checkbox"] {
background-color: #ffffff;
border-color: #a0a0a0;
color: rgb(225,29,72);
}
input[type="checkbox"]:checked {
background-color: rgb(225,29,72);
box-shadow: 0 0 0 0px #fff, 0 0 0 calc(2px + 0px) rgba(246, 59, 118, 0.5), 0 0 #0000;
}

View File

@@ -14,6 +14,7 @@ import {
// Captcha // Captcha
import 'altcha'; import 'altcha';
import "altcha/themes/cupcake.css";
// import Alpine from 'alpinejs'; // import Alpine from 'alpinejs';

View File

@@ -69,8 +69,8 @@
</label> </label>
</div> </div>
<div class="block"> <div class="block pt-3 w-3/4 mx-auto">
<altcha-widget id="captcha" floating challengeurl="/altcha-challenge"></altcha-widget> <altcha-widget id="captcha" theme="cupcake" challenge="/altcha-challenge"></altcha-widget>
<x-input-error :messages="$errors->get('altcha')" class="mt-2" /> <x-input-error :messages="$errors->get('altcha')" class="mt-2" />
</div> </div>
@@ -132,8 +132,8 @@
<x-input-error :messages="$errors->get('password_confirmation')" class="mt-2" /> <x-input-error :messages="$errors->get('password_confirmation')" class="mt-2" />
</div> </div>
<div class="block"> <div class="block pt-3 w-3/4 mx-auto">
<altcha-widget id="captcha" floating challengeurl="/altcha-challenge"></altcha-widget> <altcha-widget id="captcha" theme="cupcake" challenge="/altcha-challenge"></altcha-widget>
<x-input-error :messages="$errors->get('altcha')" class="mt-2" /> <x-input-error :messages="$errors->get('altcha')" class="mt-2" />
</div> </div>

View File

@@ -33,7 +33,7 @@
<x-input-error class="mt-2" :messages="$errors->get('message')" /> <x-input-error class="mt-2" :messages="$errors->get('message')" />
</div> </div>
<altcha-widget id="captcha" floating challengeurl="/altcha-challenge"></altcha-widget> <altcha-widget id="captcha" theme="cupcake" challenge="/altcha-challenge"></altcha-widget>
<div class="flex items-center gap-4"> <div class="flex items-center gap-4">
<x-primary-button>{{ __('Submit') }}</x-primary-button> <x-primary-button>{{ __('Submit') }}</x-primary-button>

View File

@@ -7,8 +7,8 @@
</p> </p>
<p id="message" class="text-red-600"> <p id="message" class="text-red-600">
</p> </p>
<div class="flex pt-2"> <div class="block pt-2">
<altcha-widget id="altcha" challengeurl="/altcha-challenge"></altcha-widget> <altcha-widget id="altcha" theme="cupcake" challenge="/altcha-challenge"></altcha-widget>
</div> </div>
<br> <br>
<p class="text-gray-800 dark:text-gray-200 text-sm"> <p class="text-gray-800 dark:text-gray-200 text-sm">

View File

@@ -1,6 +1,7 @@
<?php <?php
use App\Http\Controllers\Auth\AuthenticatedSessionController; use App\Http\Controllers\Auth\AuthenticatedSessionController;
use App\Http\Controllers\Auth\CaptchaController;
use App\Http\Controllers\Auth\ConfirmablePasswordController; use App\Http\Controllers\Auth\ConfirmablePasswordController;
use App\Http\Controllers\Auth\DiscordAuthController; use App\Http\Controllers\Auth\DiscordAuthController;
use App\Http\Controllers\Auth\EmailVerificationNotificationController; use App\Http\Controllers\Auth\EmailVerificationNotificationController;
@@ -38,6 +39,9 @@ Route::middleware('guest')->group(function () {
->name('discord.login'); ->name('discord.login');
Route::get('/auth/discord/callback', [DiscordAuthController::class, 'callback']); Route::get('/auth/discord/callback', [DiscordAuthController::class, 'callback']);
// Captcha
Route::get('/altcha-challenge', [CaptchaController::class, 'create']);
}); });
Route::middleware('auth')->group(function () { Route::middleware('auth')->group(function () {