Skip to content
Open
Show file tree
Hide file tree
Changes from 27 commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
0b2b78c
start: proof of work captcha feature.
crhallberg Jul 21, 2025
b3b6c80
feat: session id used to generate consistent secrets.
crhallberg Jul 22, 2025
6fc0381
fix: alignment of captcha with comment.
crhallberg Jul 22, 2025
356676b
fix: send less information back with form, since we have a better sta…
crhallberg Jul 22, 2025
35a4318
doc: copyright 2
crhallberg Jul 22, 2025
5a6f033
chore: qa-js-and-scss
crhallberg Jul 22, 2025
843c2ea
chore: php-cs-fixer
crhallberg Jul 22, 2025
a21c1e2
doc: rename CAPTCHA
crhallberg Jul 22, 2025
1d3336e
Update module/VuFind/src/VuFind/Captcha/PoW.php
crhallberg Jul 22, 2025
5746da2
doc: update copyright
crhallberg Jul 22, 2025
136e3cb
doc: copyright
crhallberg Jul 22, 2025
04fe429
feat: add Altcha as an option.
crhallberg Jul 28, 2025
6b5884f
fix: woke up in the middle of the night in a cold sweat with the abso…
crhallberg Jul 28, 2025
8ff7271
refactor: pass Altcha instance from Factory.
crhallberg Jul 31, 2025
761e780
rm: unused file since vendor supplies WebComponent.
crhallberg Jul 31, 2025
c97bfba
fix: remove postinstall.
crhallberg Jul 31, 2025
8dc7d17
doc: add Altcha LICENSE.
crhallberg Jul 31, 2025
62ca158
remove home-grown pow solution.
crhallberg Aug 6, 2025
d06cb50
fix: escape challenge JSON.
crhallberg Aug 6, 2025
7f1b88a
Update config/vufind/config.ini
crhallberg Aug 6, 2025
182f5bd
docs: fix parameter types.
crhallberg Aug 6, 2025
a9f9003
style: phpcs
crhallberg Aug 6, 2025
a358335
Merge branch 'pow-captcha' of https://github.com/crhallberg/vufind in…
crhallberg Aug 6, 2025
d6972fa
Merge branch 'dev' into pow-captcha
crhallberg Sep 9, 2025
fb11bf4
Merge branch 'dev' into pow-captcha
demiankatz Dec 4, 2025
de5dae0
Fix outdated license text.
demiankatz Dec 4, 2025
826c5a9
Handle garbled input data more robustly.
demiankatz Dec 4, 2025
2e485c6
php-cs-fixer
demiankatz Dec 4, 2025
22e93c6
Update module/VuFind/src/VuFind/Captcha/AltchaFactory.php
crhallberg May 5, 2026
8723bef
Merge branch 'dev' into pow-captcha
demiankatz May 6, 2026
e61d683
Style fixes.
demiankatz May 6, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@
"require": {
"php": ">=8.2",
"ahand/mobileesp": "dev-master",
"altcha-org/altcha": "^1.1",
"browscap/browscap-php": "^7.2",
"cap60552/php-sip2": "1.0.0",
"colinmollenhour/credis": "1.17.0",
Expand Down
517 changes: 291 additions & 226 deletions composer.lock

Large diffs are not rendered by default.

19 changes: 19 additions & 0 deletions config/vufind/config.ini
Original file line number Diff line number Diff line change
Expand Up @@ -2557,6 +2557,7 @@ validateHierarchySequences = true
; your instance.
;[Captcha]
; Valid type values:
; - altcha (proof of work bot deterrent)
; - dumb (ask the user to reverse a random string; only recommended for testing)
; - image (generate a local image for the user to interpret)
; - interval (allow an action after a specified time has elapsed from session start
Expand All @@ -2575,6 +2576,24 @@ validateHierarchySequences = true
; form-by-form basis with the useCaptcha setting in FeedbackForms.yaml.
;forms = *


; Altcha
;altcha_secret = "secret hmac hey that should be a long hash"

; Altcha Challenge Options
; @link https://github.com/altcha-org/altcha-lib-php/blob/main/src/ChallengeOptions.php
; - Hashing algorithm to use (`SHA-1`, `SHA-256`, `SHA-512`, default: `SHA-256`).
;altcha_algorithm = 'SHA-256'
; - Maximum number for the random number generator (default: 1000000)
;altcha_max_number = 1000000
; - Length of the random salt (default: 12 bytes).
;altcha_salt_len = 12
; - Optional interval to generate expiration time for the challenge.
; MUST be valid string for DateInterval::createFromDateString.
;altcha_expires_interval =
; - Optional URL-encoded query parameters.
;altcha_params =

Comment thread
crhallberg marked this conversation as resolved.
; Image options, see:
; https://docs.laminas.dev/laminas-captcha/adapters/#laminascaptchaimage
;image_length = 8
Expand Down
5 changes: 4 additions & 1 deletion config/vufind/contentsecuritypolicy.ini
Original file line number Diff line number Diff line change
Expand Up @@ -44,8 +44,11 @@ script-src[] = "https:"
connect-src[] = "'self'"
; If you are using Google Analytics, uncomment the line below
;connect-src[] = "https://*.google-analytics.com"
; worker-src required for jsTree with browsers that don't support 'strict-dynamic' (e.g. Safari):
; worker-src (permission to load web worker JS files)
; (blob) required for jsTree with browsers that don't support 'strict-dynamic' (e.g. Safari):
worker-src[] = "blob:"
; (self) required for proof of work (PoW) Captcha web wworker
worker-src[] = "'self'"
style-src[] = "'self'"
style-src[] = "'unsafe-inline'"
img-src[] = "'self'"
Expand Down
111 changes: 111 additions & 0 deletions module/VuFind/src/VuFind/Captcha/Altcha.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
<?php

/**
* Altcha proof-of-work CAPTCHA.
*
* PHP version 8
*
* Copyright (C) Villanova University 2025.
*
* This program is free software; you can redistribute it and/or modify
* it under the terms of the GNU General Public License version 2,
* as published by the Free Software Foundation.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program; if not, see
* <https://www.gnu.org/licenses/>.
*
* @category VuFind
* @package CAPTCHA
* @author Chris Hallberg <challber@villanova.edu>
* @license http://opensource.org/licenses/gpl-2.0.php GNU General Public License
* @link https://vufind.org Main Page
*/

namespace VuFind\Captcha;

use AltchaOrg\Altcha\ChallengeOptions;
use AltchaOrg\Altcha\Hasher\Algorithm;
use Laminas\Mvc\Controller\Plugin\Params;

/**
* Altcha proof-of-work CAPTCHA.
*
* @category VuFind
* @package CAPTCHA
* @author Chris Hallberg <challber@villanova.edu>
* @license http://opensource.org/licenses/gpl-2.0.php GNU General Public License
* @link https://vufind.org/wiki/development Wiki
*/
class Altcha extends AbstractBase
{
/**
* Constructor
*
* @param \AltchaOrg\Altcha\Altcha $altcha Required HMAC key for challenge calculation/solution verification.
* @param Algorithm $algorithm Hashing algorithm to use (SHA-1, SHA-256, SHA-512, default: SHA-256).
* @param int $maxNumber Maximum number for the random number generator (default: 1,000,000)
* @param null|\DateTimeInterface $expires Optional expiration time for the challenge.
* @param array $params Optional URL-encoded query parameters.
* @param int<1, max> $saltLength Length of the random salt (default: 12 bytes).
*/
public function __construct(
protected \AltchaOrg\Altcha\Altcha $altcha,
// Options for creation of a new challenge
protected Algorithm $algorithm = Algorithm::SHA256,
protected int $maxNumber = 1000000,
protected ?\DateTimeInterface $expires = null,
protected array $params = [],
protected int $saltLength = 12,
) {
}

/**
* Get list of URLs with JS dependencies to load for the active CAPTCHA type.
*
* @return array
*/
public function getJsIncludes(): array
{
return ['vendor/altcha.js', 'vendor/altcha-i18n.js'];
}

/**
* Generate challenge
*
* @return Challenge
*/
public function getChallenge()
{
$options = new ChallengeOptions(
algorithm: $this->algorithm,
maxNumber: $this->maxNumber,
expires: $this->expires,
params: $this->params,
saltLength: $this->saltLength,
);

return json_encode($this->altcha->createChallenge($options));
}

/**
* Pull the captcha field from controller params and check them for accuracy
* We pull from the form in case config changed since challenge was sent
*
* @param Params $params Controller params
*
* @return bool
*/
public function verify(Params $params): bool
{
$encoded = $params->fromPost('altcha', null);
$json = base64_decode($encoded);
$payload = json_decode($json, true);
return is_array($payload) ? $this->altcha->verifySolution($payload, checkExpires: true) : false;
}
}
105 changes: 105 additions & 0 deletions module/VuFind/src/VuFind/Captcha/AltchaFactory.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
<?php

/**
* Factory for Altcha proof-of-work CAPTCHA module.
*
* PHP version 8
*
* Copyright (C) Villanova University 2025.
*
* This program is free software; you can redistribute it and/or modify
* it under the terms of the GNU General Public License version 2,
* as published by the Free Software Foundation.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program; if not, see
* <https://www.gnu.org/licenses/>.
*
* @category VuFind
* @package CAPTCHA
* @author Chris Hallberg <challber@villanova.edu>
* @license http://opensource.org/licenses/gpl-2.0.php GNU General Public License
* @link https://vufind.org/wiki/development Wiki
*/

namespace VuFind\Captcha;

use AltchaOrg\Altcha\Hasher\Algorithm;
use Laminas\ServiceManager\Exception\ServiceNotCreatedException;
use Laminas\ServiceManager\Exception\ServiceNotFoundException;
use Laminas\ServiceManager\Factory\FactoryInterface;
use Psr\Container\ContainerExceptionInterface as ContainerException;
use Psr\Container\ContainerInterface;

/**
* Altcha proof-of-work CAPTCHA factory.
*
* @category VuFind
* @package CAPTCHA
* @author Chris Hallberg <challber@villanova.edu>
* @license http://opensource.org/licenses/gpl-2.0.php GNU General Public License
* @link https://vufind.org/wiki/development Wiki
*/
class AltchaFactory implements FactoryInterface
{
/**
* Create an object
*
* @param ContainerInterface $container Service manager
* @param string $requestedName Service being created
* @param null|array $options Extra options (optional)
*
* @return object
*
* @throws ServiceNotFoundException if unable to resolve the service.
* @throws ServiceNotCreatedException if an exception is raised when
* creating a service.
* @throws ContainerException&\Throwable if any other error occurs
*/
public function __invoke(
ContainerInterface $container,
$requestedName,
?array $options = null
) {
if (!empty($options)) {
throw new \Exception('Unexpected options passed to factory.');
}

$config = $container
->get(\VuFind\Config\PluginManager::class)
->get('config');
Comment on lines +74 to +75
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

\VuFind\Config\PluginManager has been deprecated. This should become:

Suggested change
->get(\VuFind\Config\PluginManager::class)
->get('config');
->get(\VuFind\Config\ConfigManager::class)
->getConfigArray('config');


$secret = $config->Captcha->altcha_secret ?? null;

if (empty($secret)) {
throw new \Exception('Secret key needed for Altcha. See config.ini.');
}

$algorithm = Algorithm::from($config->Captcha->altcha_algorithm ?? 'SHA-256');
Comment thread
crhallberg marked this conversation as resolved.
Outdated
$maxNumber = $config->Captcha->altcha_max_number ?? 100000;
$saltLength = $config->Captcha->altcha_salt_len ?? 12;
$expiresInterval = $config->Captcha->altcha_expires_interval ?? null;
$params = $config->Captcha->altcha_params ?? [];
Comment thread
crhallberg marked this conversation as resolved.
Outdated

$expires = !empty($expiresInterval)
? (new \DateTimeImmutable())->add(new \DateInterval($expiresInterval))
: null;

$altcha = new \AltchaOrg\Altcha\Altcha($secret);

return new $requestedName(
$altcha,
// challenge options
$algorithm,
$maxNumber,
$expires,
$params,
$saltLength,
);
}
}
2 changes: 2 additions & 0 deletions module/VuFind/src/VuFind/Captcha/PluginManager.php
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ class PluginManager extends \VuFind\ServiceManager\AbstractPluginManager
* @var array
*/
protected $aliases = [
'altcha' => Altcha::class,
'demo' => Demo::class,
'dumb' => Dumb::class,
'image' => Image::class,
Expand All @@ -61,6 +62,7 @@ class PluginManager extends \VuFind\ServiceManager\AbstractPluginManager
* @var array
*/
protected $factories = [
Altcha::class => AltchaFactory::class,
Demo::class => InvokableFactory::class,
Dumb::class => DumbFactory::class,
Image::class => ImageFactory::class,
Expand Down
1 change: 1 addition & 0 deletions module/VuFind/src/VuFind/Controller/Plugin/Captcha.php
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,7 @@ public function verify(): bool
$errorMessage = $captcha->getErrorMessage();
}
} catch (\Exception $e) {
error_log($e);
$captchaPassed = false;
$errorMessage = $this->translate('captcha_technical_difficulties');
}
Expand Down
2 changes: 1 addition & 1 deletion themes/bootstrap5/templates/Helpers/captcha.phtml
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
<?php if (count($this->captchas) == 1):?>
<?php if ($captchaHtml = $this->captcha()->getHtmlForCaptcha($this->captchas[0])): ?>
<label class="form-label"><?=$this->transEsc('captcha_label_single')?></label>
<p><?=$captchaHtml?></p>
<div><?=$captchaHtml?></div>
Comment thread
demiankatz marked this conversation as resolved.
<?php endif; ?>
<?php else:?>
<label class="form-label"><?=$this->transEsc('captcha_label_multiple')?></label>
Expand Down
2 changes: 1 addition & 1 deletion themes/bootstrap5/templates/RecordTab/usercomments.phtml
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,10 @@
<?=$this->component('star-rating', ['ratingData' => $ratingData, 'allowClear' => 0 === $ratingData['count'] || $this->accountCapabilities()->isRatingRemovalAllowed()]);?>
</div>
<?php endif; ?>
<div class="clearfix"></div>
<?php if ($this->tab->isCaptchaActive()): ?>
<?=$this->captcha()->html(true, false) ?>
<?php endif; ?>
<div class="clearfix"></div>
<input class="btn btn-primary" type="submit" value="<?=$this->transEscAttr('Add your comment')?>">
<div class="loading-spinner js-loading-spinner hidden"><?=$this->icon('spinner') ?> <?=$this->translate('loading_ellipsis')?></div>
</div>
Expand Down
21 changes: 21 additions & 0 deletions themes/root/js/vendor/altcha-LICENSE.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
MIT License

Copyright (c) 2023 Daniel Regeci, BAU Software s.r.o.

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
1 change: 1 addition & 0 deletions themes/root/js/vendor/altcha-i18n.js

Large diffs are not rendered by default.

4 changes: 4 additions & 0 deletions themes/root/js/vendor/altcha.js

Large diffs are not rendered by default.

11 changes: 11 additions & 0 deletions themes/root/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"name": "vufind-root",
"description": "Dependencies for the root theme",
"scripts": {
"installDeps": "npm install --no-audit && node tools/copyDependencies.mjs",
"updateDeps": "npm update && node tools/copyDependencies.mjs"
},
"dependencies": {
"altcha": "^2.1.0"
}
}
1 change: 1 addition & 0 deletions themes/root/templates/Captcha/altcha.phtml
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
<altcha-widget challengejson='<?=$this->escapeHtmlAttr($this->captcha->getChallenge()) ?>'></altcha-widget>
24 changes: 24 additions & 0 deletions themes/root/tools/copyDependencies.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { copyFile, mkdir } from 'node:fs/promises';

await mkdir('js/vendor', { recursive: true });

let buildDepsOnly = false;
process.argv.forEach(arg => {
if (arg === '--only-build-deps') {
buildDepsOnly = true;
}
});

console.log('Copying dependencies...');

if (buildDepsOnly) {
console.log('Done copying build dependencies.');
process.exit();
}

// Altcha
await copyFile('node_modules/altcha/LICENSE.txt', 'js/vendor/altcha-LICENSE.txt');
await copyFile('node_modules/altcha/dist/altcha.umd.cjs', 'js/vendor/altcha.js');
await copyFile('node_modules/altcha/dist_i18n/all.umd.cjs', 'js/vendor/altcha-i18n.js');
Comment thread
crhallberg marked this conversation as resolved.

console.log('Done copying dependencies.');
Loading