aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorJan-Hendrik Willms <tleilax+studip@gmail.com>2024-05-08 12:56:34 +0000
committerJan-Hendrik Willms <tleilax+studip@gmail.com>2024-05-08 12:56:34 +0000
commit79a1564005f300ea9ee23a2769756e2e12001f96 (patch)
treebdc67106dc370788efd9d725b46f2d08935540b1
parentb56bb5506f46ae8f6ed249902cfcad2524c1f2af (diff)
introduce altcha/captcha, fixes #4113
Closes #4113 Merge request studip/studip!2965
-rw-r--r--app/controllers/accessibility/forms.php3
-rw-r--r--app/controllers/captcha.php12
-rw-r--r--db/migrations/6.0.2_captcha_by_altcha.php39
-rw-r--r--lib/classes/forms/Captcha.php29
-rw-r--r--lib/classes/forms/CaptchaInput.php38
-rw-r--r--lib/classes/forms/Form.php8
-rw-r--r--lib/classes/forms/Part.php2
-rw-r--r--lib/cronjobs/garbage_collector.class.php3
-rw-r--r--lib/models/CaptchaChallenge.php88
-rw-r--r--package-lock.json7
-rw-r--r--package.json1
-rw-r--r--resources/vue/base-components.js1
-rw-r--r--resources/vue/components/form_inputs/CaptchaInput.vue70
-rw-r--r--templates/forms/form.php6
14 files changed, 301 insertions, 6 deletions
diff --git a/app/controllers/accessibility/forms.php b/app/controllers/accessibility/forms.php
index 476f2fe..e240a6c 100644
--- a/app/controllers/accessibility/forms.php
+++ b/app/controllers/accessibility/forms.php
@@ -146,6 +146,9 @@ class Accessibility_FormsController extends StudipController
}
$this->form->addPart($personal_data_part);
+
+ $this->form->addPart(new \Studip\Forms\Captcha());
+
$this->form->setSaveButtonText(_('Barriere melden'));
$this->form->setSaveButtonName('report');
$this->form->setURL($this->report_barrierURL());
diff --git a/app/controllers/captcha.php b/app/controllers/captcha.php
new file mode 100644
index 0000000..37bac47
--- /dev/null
+++ b/app/controllers/captcha.php
@@ -0,0 +1,12 @@
+<?php
+final class CaptchaController extends StudipController
+{
+ public function challenge_action(): void
+ {
+ $this->response->add_header(
+ 'Expires',
+ gmdate('D, d M Y H:i:s', time() + CaptchaChallenge::CHALLENGE_EXPIRATION) . ' GMT'
+ );
+ $this->render_json(CaptchaChallenge::createNewChallenge());
+ }
+}
diff --git a/db/migrations/6.0.2_captcha_by_altcha.php b/db/migrations/6.0.2_captcha_by_altcha.php
new file mode 100644
index 0000000..3749c71
--- /dev/null
+++ b/db/migrations/6.0.2_captcha_by_altcha.php
@@ -0,0 +1,39 @@
+<?php
+return new class extends Migration
+{
+ public function description(): string
+ {
+ return 'Creates a config entry for the key used for captchas and '
+ . 'db storage for solved challenges.';
+ }
+
+ protected function up(): void
+ {
+ $query = "INSERT INTO `config` (`field`, `value`, `type`, `range`, `mkdate`, `chdate`, `description`)
+ VALUES ('CAPTCHA_KEY', '', 'string', 'global', UNIX_TIMESTAMP(), UNIX_TIMESTAMP(), ?)";
+ DBManager::get()->execute($query, [
+ 'Speichert den für Captchas verwendeten Schlüssel (Wert leeren, um einen neuen zu generieren)',
+ ]);
+
+ $query = "CREATE TABLE `captcha_challenges` (
+ `challenge_id` int(11) NOT NULL AUTO_INCREMENT,
+ `salt` CHAR(32) COLLATE `latin1_bin` NOT NULL,
+ `number` INT(11) UNSIGNED NOT NULL,
+ `mkdate` INT(11) UNSIGNED NOT NULL,
+ PRIMARY KEY (`challenge_id`)
+ )";
+ DBManager::get()->exec($query);
+ }
+
+ protected function down(): void
+ {
+ $query = "DROP TABLE `captcha_challenges`";
+ DBManager::get()->exec($query);
+
+ $query = "DELETE `config`, `config_values`
+ FROM `config`
+ LEFT JOIN `config_values` USING (`field`)
+ WHERE `field` = 'CAPTCHA_KEY'";
+ DBManager::get()->exec($query);
+ }
+};
diff --git a/lib/classes/forms/Captcha.php b/lib/classes/forms/Captcha.php
new file mode 100644
index 0000000..c01b702
--- /dev/null
+++ b/lib/classes/forms/Captcha.php
@@ -0,0 +1,29 @@
+<?php
+
+namespace Studip\Forms;
+
+use CaptchaChallenge;
+
+/**
+ * The Text class represents a part of a form that displays a captcha.
+ */
+class Captcha extends Fieldset
+{
+ private CaptchaInput $captcha_input;
+
+ public function __construct()
+ {
+ parent::__construct(_('Bitte bestätigen Sie, dass Sie kein Roboter sind'));
+
+ $captchaInput = new CaptchaInput('altcha', $this->legend, null);
+ $captchaInput->setStoringFunction(function (string $payload) {
+ $json = CaptchaChallenge::decodePayload($payload);
+
+ CaptchaChallenge::create([
+ 'salt' => $json['salt'],
+ 'number' => $json['number'],
+ ]);
+ });
+ $this->addInput($captchaInput);
+ }
+}
diff --git a/lib/classes/forms/CaptchaInput.php b/lib/classes/forms/CaptchaInput.php
new file mode 100644
index 0000000..6476f87
--- /dev/null
+++ b/lib/classes/forms/CaptchaInput.php
@@ -0,0 +1,38 @@
+<?php
+
+namespace Studip\Forms;
+
+use CaptchaChallenge;
+use URLHelper;
+
+/**
+ * The Text class represents a part of a form that displays a captcha.
+ */
+final class CaptchaInput extends Input
+{
+ public function hasValidation(): bool
+ {
+ return true;
+ }
+
+ public function getValidationCallback(): callable
+ {
+ return fn($value) => \CaptchaChallenge::validatePayload($value);
+ }
+
+ public function render(): string
+ {
+ return sprintf(
+ '<captcha-input challenge-url="%s" v-model="%s" auto="onload"></captcha-input>',
+ URLHelper::getLink('dispatch.php/captcha/challenge', [], true),
+ htmlReady($this->name)
+ );
+ }
+
+ public function renderWithCondition(): string
+ {
+ return $this->render();
+ }
+
+
+}
diff --git a/lib/classes/forms/Form.php b/lib/classes/forms/Form.php
index fa0422e..8977a37 100644
--- a/lib/classes/forms/Form.php
+++ b/lib/classes/forms/Form.php
@@ -309,7 +309,7 @@ class Form extends Part
//verify the user input:
$output = [];
foreach ($this->getAllInputs() as $input) {
- if ($input->validate) {
+ if ($input->hasValidation()) {
$callback = $input->getValidationCallback();
$value = $this->getStorableValueFromRequest($input);
$valid = $callback($value, $input);
@@ -317,7 +317,7 @@ class Form extends Part
$output[$input->getName()] = [
'name' => $input->getName(),
'label' => $input->getTitle(),
- 'error' => $callback($value, $input)
+ 'error' => $valid,
];
}
}
@@ -396,7 +396,7 @@ class Form extends Part
$stored = 0;
foreach ($this->getAllInputs() as $input) {
- if ($input->validate) {
+ if ($input->hasValidation()) {
$callback = $input->getValidationCallback();
$value = $this->getStorableValueFromRequest($input);
$valid = $callback($value, $input);
@@ -450,7 +450,7 @@ class Form extends Part
/**
* Returns all the Part objects like Fieldsets as an array.
- * @return array
+ * @return Part[]
*/
public function getParts() : array
{
diff --git a/lib/classes/forms/Part.php b/lib/classes/forms/Part.php
index fdca8f5..779cab7 100644
--- a/lib/classes/forms/Part.php
+++ b/lib/classes/forms/Part.php
@@ -139,7 +139,7 @@ abstract class Part
/**
* Recursively returns all Input elements attached to this Part object or any child Parts.
- * @return array
+ * @return Input[]
*/
public function getAllInputs()
{
diff --git a/lib/cronjobs/garbage_collector.class.php b/lib/cronjobs/garbage_collector.class.php
index 13d3029..e426cf3 100644
--- a/lib/cronjobs/garbage_collector.class.php
+++ b/lib/cronjobs/garbage_collector.class.php
@@ -195,5 +195,8 @@ class GarbageCollectorJob extends CronJob
'mkdate < UNIX_TIMESTAMP() - ?',
[TFASecret::getGreatestValidityDuration()]
);
+
+ // Remove expired solved captcha challenges
+ CaptchaChallenge::gc();
}
}
diff --git a/lib/models/CaptchaChallenge.php b/lib/models/CaptchaChallenge.php
new file mode 100644
index 0000000..446985d
--- /dev/null
+++ b/lib/models/CaptchaChallenge.php
@@ -0,0 +1,88 @@
+<?php
+final class CaptchaChallenge extends SimpleORMap
+{
+ public const ALGORITHM = 'SHA-256';
+ public const CHALLENGE_EXPIRATION = 5 * 60;
+
+ protected static function configure($config = [])
+ {
+ $config['db_table'] = 'captcha_challenges';
+
+ parent::configure($config);
+ }
+
+ protected static function getKey(): string
+ {
+ $key = Config::get()->CAPTCHA_KEY;
+ if ($key === '') {
+ $key = bin2hex(random_bytes(32));
+ Config::get()->store('CAPTCHA_KEY', $key);
+ }
+ return $key;
+ }
+
+ public static function createChallenge(string $salt, int $number): array
+ {
+ $algorithm = 'sha256';
+ $challenge = hash($algorithm, $salt . $number);
+ $signature = hash_hmac($algorithm, $challenge, self::getKey());
+
+ return [
+ 'algorithm' => self::ALGORITHM,
+ 'challenge' => $challenge,
+ 'salt' => $salt,
+ 'signature' => $signature,
+ ];
+ }
+
+ public static function createNewChallenge(): array
+ {
+ do {
+ $salt = time() . '-' . bin2hex(random_bytes(12));
+ $number = random_int(1e3, 1e5);
+ } while (self::countBySql('salt = ? AND number = ?', [$salt, $number]) > 0);
+
+ return self::createChallenge($salt, $number);
+ }
+
+ public static function decodePayload(string $payload): array|null
+ {
+ return json_decode(base64_decode($payload), true);
+ }
+
+ public static function validatePayload(string $payload): string|bool
+ {
+ $json = self::decodePayload($payload);
+
+ if ($json === null) {
+ return _('Sie haben nicht bestätigt, dass Sie kein Roboter sind');
+ }
+
+ $time = explode('-', $json['salt'])[0];
+ if ($time < time() - self::CHALLENGE_EXPIRATION) {
+ return _('Die Challenge ist abgelaufen');
+ }
+
+ // Replay?
+ if (\CaptchaChallenge::countBySql('salt = ? AND number = ?', [$json['salt'], $json['number']]) > 0) {
+ return _('Nicht schummeln!');
+ }
+
+ $check = self::createChallenge($json['salt'], $json['number']);
+
+ if (
+ $json['algorithm'] !== $check['algorithm']
+ || $json['challenge'] !== $check['challenge']
+ || $json['signature'] !== $check['signature']
+ ) {
+ return _('Sie sind scheinbar ein Roboter...');
+ }
+
+ return true;
+ }
+
+ public static function gc(): void
+ {
+ self::deleteBySQL("mkdate < UNIX_TIMESTAMP() - ?", [self::CHALLENGE_EXPIRATION]);
+ }
+}
diff --git a/package-lock.json b/package-lock.json
index f808530..cef443d 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -61,6 +61,7 @@
"@types/jqueryui": "^1.12.16",
"@types/lodash": "^4.14.191",
"@vue/eslint-config-typescript": "^12.0.0",
+ "altcha": "^0.3.2",
"autoprefixer": "^10.2.5",
"axios": "^0.21.0",
"babel-loader": "^8.2.1",
@@ -4902,6 +4903,12 @@
"integrity": "sha512-0FcBfdcmaumGPQ0qPn7Q5qTgz/ooXgIyp1rf8ik5bGX8mpE2YHjC0P/eyQvxu1GURYQgq9ozf2mteQ5ZD9YiyQ==",
"dev": true
},
+ "node_modules/altcha": {
+ "version": "0.3.2",
+ "resolved": "https://registry.npmjs.org/altcha/-/altcha-0.3.2.tgz",
+ "integrity": "sha512-5UQP/fwgdlxfhgr4GADoPyMzHWTmDuWq3OloQlZsmUl3C/8+0huWdXW5S8FraA6GWK8iEwbG/2IR4TehLTY9cQ==",
+ "dev": true
+ },
"node_modules/ansi-colors": {
"version": "4.1.3",
"resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.3.tgz",
diff --git a/package.json b/package.json
index fef26f2..3d382e5 100644
--- a/package.json
+++ b/package.json
@@ -71,6 +71,7 @@
"@types/jqueryui": "^1.12.16",
"@types/lodash": "^4.14.191",
"@vue/eslint-config-typescript": "^12.0.0",
+ "altcha": "^0.3.2",
"autoprefixer": "^10.2.5",
"axios": "^0.21.0",
"babel-loader": "^8.2.1",
diff --git a/resources/vue/base-components.js b/resources/vue/base-components.js
index 65b467b..5d11daa 100644
--- a/resources/vue/base-components.js
+++ b/resources/vue/base-components.js
@@ -1,4 +1,5 @@
const BaseComponents = {
+ CaptchaInput: () => import('./components/form_inputs/CaptchaInput.vue'),
CalendarPermissionsTable: () => import("./components/form_inputs/CalendarPermissionsTable.vue"),
DateListInput: () => import('./components/form_inputs/DateListInput.vue'),
Datepicker: () => import('./components/Datepicker.vue'),
diff --git a/resources/vue/components/form_inputs/CaptchaInput.vue b/resources/vue/components/form_inputs/CaptchaInput.vue
new file mode 100644
index 0000000..1409aa8
--- /dev/null
+++ b/resources/vue/components/form_inputs/CaptchaInput.vue
@@ -0,0 +1,70 @@
+<template>
+ <div class="formpart">
+ <altcha-widget :challengeurl="challengeUrl" ref="widget"></altcha-widget>
+ </div>
+</template>
+<script>
+import 'altcha';
+import { $gettext } from '../../../assets/javascripts/lib/gettext';
+
+export default {
+ name: 'CaptchaInput',
+ props: {
+ name: {
+ type: String,
+ default: 'altcha'
+ },
+ challengeUrl: {
+ type: String,
+ requird: true,
+ },
+ auto: {
+ type: String,
+ default: null,
+ validator: (value) => ['onfocus', 'onload', 'onsubmit'].includes(value),
+ }
+ },
+ data() {
+ return {};
+ },
+ methods: {
+ },
+ mounted() {
+ this.$nextTick(() => {
+ this.$refs.widget.configure({
+ auto: this.auto,
+ name: this.name,
+ hidefooter: false,
+ hidelogo: false,
+ strings: {
+ error: $gettext('Überprüfung fehlgeschlagen. Versuchen Sie es später erneut.'),
+ footer: $gettext('Geschützt von <a href="https://altcha.org/" target="_blank">ALTCHA</a>'),
+ label: $gettext('Ich bin kein Bot'),
+ verified: $gettext('Überprüft'),
+ verifying: $gettext('Überprüfung...'),
+ waitAlert: $gettext('Überprüfung... Bitte warten.'),
+ },
+ });
+
+ this.$refs.widget.addEventListener('statechange', (ev) => {
+ if (ev.detail.state === 'verified') {
+ this.$emit('input', ev.detail.payload);
+ }
+ })
+ });
+ }
+}
+</script>
+<style>
+:root {
+ --altcha-border-width: 0;
+ --altcha-border-radius: 0;
+ --altcha-color-base: transparent;
+ --altcha-color-border: #a0a0a0;
+ --altcha-color-text: currentColor;
+ --altcha-color-border-focus: currentColor;
+ --altcha-color-error-text: var(--red);
+ --altcha-color-footer-bg: none;
+ --altcha-max-width: auto;
+}
+</style>
diff --git a/templates/forms/form.php b/templates/forms/form.php
index 4745225..fe19404 100644
--- a/templates/forms/form.php
+++ b/templates/forms/form.php
@@ -1,4 +1,8 @@
-<?
+<?php
+/**
+ * @var \Studip\Forms\Form $form
+ */
+
$inputs = [];
$allinputs = $form->getAllInputs();
$required_inputs = [];