diff --git a/app/controllers/accessibility/forms.php b/app/controllers/accessibility/forms.php index 476f2fecc890be763a39d8c53bdc893498a822ed..e240a6cecb125565e52485cc72ad24b264dbf198 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 0000000000000000000000000000000000000000..37bac4745ff91d7267c8246322dbf0271f2c9dc9 --- /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 0000000000000000000000000000000000000000..3749c7140dde074b858f8490795a5df53a917baa --- /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 0000000000000000000000000000000000000000..c01b702815e4ce46b12b566ecaa4377c92a29ba7 --- /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 0000000000000000000000000000000000000000..6476f87b7fe0b3b0f3a2710756eeec42d6ded0a5 --- /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 fa0422e1a92070e3e7ed68fccfa69db2ebb94304..8977a3732a09db9fd54906c6954617acafc06541 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 fdca8f5395af8e88dd99b8b5f05bba4735709360..779cab76bbbbfb318b523f17886b9e06b64c91c9 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 13d3029a0cadc4d7d40ed19ae2f4a2f911371f91..e426cf33b9b6cde129519d390add4f4593cf3e14 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 0000000000000000000000000000000000000000..446985d9abeafdd0a7984fec6c8d465971bf9081 --- /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 f80853092e84325238ad4cd956c64e6d654535ae..cef443dade3e259e5dc2a5101f8bbaa770e2ceec 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 fef26f2cd91df069502e21bf9e1f93ee40ff9672..3d382e5aa1a4b0e77e04f4cf8d6406f2bc5d9f99 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 65b467bde7cbd8a3747999dec6adfe1abc5c4d35..5d11daadc55c6e0e8a7d67f4f73034f044c762ac 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 0000000000000000000000000000000000000000..1409aa88794ab66e6614ade3ae4c961c4d01db4d --- /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 4745225414db9e1858bb65d4cdb8a543d2b4543d..fe19404e67dea6eea527350e565f1b49a2fb3af9 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 = [];