Files
simple-math-captcha/simple-math-captcha.php
T

330 lines
13 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
/**
* Plugin Name: Simple Math CAPTCHA
* Description: Minimalistische Mathe-CAPTCHA fuer Login, Registrierung und Passwort-Reset.
* Keine externen Requests, kein JavaScript, kein Tracking, keine Tabellen.
* Loesung wird serverseitig als einmal-verwendbarer Transient gespeichert (10 min TTL).
* Einstellungen unter "Einstellungen > Math CAPTCHA".
* Version: 1.3.4
* Author: Ingo Höttges
* License: GPL-2.0-or-later
*/
if (!defined('ABSPATH')) { exit; }
class Simple_Math_Captcha {
const PREFIX = 'smc_';
const TTL = 600; // 10 Minuten
const OPT_MAX = 'smc_max';
const OPT_MAX_SUM = 'smc_max_sum';
const OPT_POSITION = 'smc_position';
const POSITIONS = ['first', 'second', 'sum', 'random'];
const DEFAULT_MAX = 10;
const DEFAULT_MAX_SUM = 100;
const DEFAULT_POSITION = 'random';
public function __construct() {
// Frontend-Hooks
add_action('login_form', [$this, 'render']);
add_action('lostpassword_form', [$this, 'render']);
if ((int) get_option('users_can_register', 0) === 1) {
add_action('register_form', [$this, 'render']);
add_filter('registration_errors', [$this, 'verify_register'], 10, 3);
}
add_filter('authenticate', [$this, 'verify_login'], 30, 3);
add_action('lostpassword_post', [$this, 'verify_lostpassword']);
// Admin-Einstellungen
add_action('admin_init', [$this, 'register_settings']);
add_action('admin_menu', [$this, 'register_menu']);
}
// ---- Rendering ----------------------------------------------------------
public function render(): void {
$max_gap = $this->get_max(); // max an der Luecke
$max_sum = $this->get_max_sum(); // max fuer die Gesamtsumme
$pos = $this->resolve_position();
switch ($pos) {
case 'first':
$a_cap = max(1, min($max_gap, $max_sum - 1));
$a = random_int(1, $a_cap);
$b = random_int(1, max(1, $max_sum - $a));
$sum = $a + $b;
$expected = $a;
break;
case 'second':
$b_cap = max(1, min($max_gap, $max_sum - 1));
$b = random_int(1, $b_cap);
$a = random_int(1, max(1, $max_sum - $b));
$sum = $a + $b;
$expected = $b;
break;
case 'sum':
default:
$s_cap = max(2, min($max_gap, $max_sum));
$sum = random_int(2, $s_cap);
$a = random_int(1, $sum - 1);
$b = $sum - $a;
$expected = $sum;
break;
}
$id = wp_generate_uuid4();
set_transient(self::PREFIX . $id, (int) $expected, self::TTL);
// Inline-Eingabefeld an der Luecke: max 3 Ziffern, schmal, zentriert.
// Theme/WP-Login erzwingen sonst 100% Breite via eigenem CSS -> hier per !important ueberschreiben.
$input_html = '<input type="text" name="smc_answer" id="smc_answer" '
. 'required autocomplete="off" inputmode="numeric" '
. 'pattern="[0-9]{1,3}" maxlength="3">';
switch ($pos) {
case 'first':
$equation = $input_html . ' + <strong>' . $b . '</strong> = <strong>' . $sum . '</strong>';
break;
case 'second':
$equation = '<strong>' . $a . '</strong> + ' . $input_html . ' = <strong>' . $sum . '</strong>';
break;
case 'sum':
default:
$equation = '<strong>' . $a . '</strong> + <strong>' . $b . '</strong> = ' . $input_html;
break;
}
// CSS-Override mit hoher Spezifitaet: Theme-Styles schlagen sonst die inline-Breite.
?>
<style>
input#smc_answer[type=text] {
width: 2.8em !important;
max-width: 2.8em !important;
min-width: 0 !important;
height: 1.5em !important;
display: inline-block !important;
padding: 0 4px !important;
text-align: center !important;
font-weight: bold !important;
font-size: 1em !important;
line-height: 1 !important;
margin: 0 4px !important;
box-sizing: content-box !important;
vertical-align: middle !important;
}
</style>
<?php
echo '<p>';
printf(
'<label>%s: %s</label>',
esc_html__('Sicherheitsfrage', 'simple-math-captcha'),
$equation
);
echo '<input type="hidden" name="smc_id" value="' . esc_attr($id) . '">';
echo '</p>';
}
// ---- Validation ---------------------------------------------------------
private function check(): bool {
$id = isset($_POST['smc_id']) ? sanitize_text_field(wp_unslash($_POST['smc_id'])) : '';
$ans = isset($_POST['smc_answer']) ? (int) $_POST['smc_answer'] : null;
if ($id === '' || !preg_match('/^[0-9a-f-]{36}$/', $id)) {
return false;
}
$expected = get_transient(self::PREFIX . $id);
delete_transient(self::PREFIX . $id);
if ($expected === false || $ans === null) {
return false;
}
return (int) $ans === (int) $expected;
}
public function verify_login($user, $username, $password) {
if (empty($_POST) || empty($_POST['log']) || empty($_POST['pwd'])) {
return $user;
}
if (!$this->check()) {
return new WP_Error(
'captcha_failed',
'<strong>' . esc_html__('Fehler', 'simple-math-captcha') . ':</strong> '
. esc_html__('Sicherheitsfrage falsch beantwortet.', 'simple-math-captcha')
);
}
return $user;
}
public function verify_register($errors, $username = '', $email = '') {
if (!$this->check()) {
$errors->add(
'captcha_failed',
'<strong>' . esc_html__('Fehler', 'simple-math-captcha') . ':</strong> '
. esc_html__('Sicherheitsfrage falsch beantwortet.', 'simple-math-captcha')
);
}
return $errors;
}
public function verify_lostpassword(): void {
if (!$this->check()) {
wp_die(
esc_html__('Sicherheitsfrage falsch beantwortet.', 'simple-math-captcha'),
esc_html__('Fehler', 'simple-math-captcha'),
['back_link' => true, 'response' => 403]
);
}
}
// ---- Settings-Helpers ---------------------------------------------------
private function get_max(): int {
$v = (int) get_option(self::OPT_MAX, self::DEFAULT_MAX);
return max(2, min(99, $v));
}
private function get_max_sum(): int {
$v = (int) get_option(self::OPT_MAX_SUM, self::DEFAULT_MAX_SUM);
return max(2, min(999, $v));
}
private function resolve_position(): string {
$p = (string) get_option(self::OPT_POSITION, self::DEFAULT_POSITION);
if ($p === 'random') {
$pool = ['first', 'second', 'sum'];
return $pool[random_int(0, count($pool) - 1)];
}
return in_array($p, self::POSITIONS, true) ? $p : self::DEFAULT_POSITION;
}
// ---- Admin: Settings API ------------------------------------------------
public function register_settings(): void {
register_setting('smc_settings', self::OPT_MAX, [
'type' => 'integer',
'default' => self::DEFAULT_MAX,
'sanitize_callback' => function ($v) { return max(2, min(99, (int) $v)); },
]);
register_setting('smc_settings', self::OPT_MAX_SUM, [
'type' => 'integer',
'default' => self::DEFAULT_MAX_SUM,
'sanitize_callback' => function ($v) { return max(2, min(999, (int) $v)); },
]);
register_setting('smc_settings', self::OPT_POSITION, [
'type' => 'string',
'default' => self::DEFAULT_POSITION,
'sanitize_callback' => function ($v) {
return in_array((string) $v, self::POSITIONS, true) ? (string) $v : self::DEFAULT_POSITION;
},
]);
add_settings_section(
'smc_main',
__('Konfiguration', 'simple-math-captcha'),
function () {
echo '<p>' . esc_html__(
'Einstellungen der Mathe-Sicherheitsfrage auf Login- und Passwort-Reset-Formularen.',
'simple-math-captcha'
) . '</p>';
},
'smc_settings'
);
add_settings_field(
self::OPT_MAX,
__('Maximaler Wert an der Lücke', 'simple-math-captcha'),
function () {
$v = $this->get_max();
echo '<input type="number" name="' . esc_attr(self::OPT_MAX) . '" '
. 'value="' . esc_attr((string) $v) . '" min="2" max="99" class="small-text"> ';
echo '<p class="description">'
. esc_html__('Der gesuchte Wert an der mit ? markierten Stelle wird maximal so groß. Erlaubt: 299.', 'simple-math-captcha')
. '</p>';
},
'smc_settings',
'smc_main'
);
add_settings_field(
self::OPT_MAX_SUM,
__('Maximale Summe', 'simple-math-captcha'),
function () {
$v = $this->get_max_sum();
echo '<input type="number" name="' . esc_attr(self::OPT_MAX_SUM) . '" '
. 'value="' . esc_attr((string) $v) . '" min="2" max="999" class="small-text"> ';
echo '<p class="description">'
. esc_html__('Obergrenze für die Gesamtsumme der Aufgabe (A + B). Begrenzt die sichtbaren Zahlen bei Lücke am Summanden. Erlaubt: 2999.', 'simple-math-captcha')
. '</p>';
},
'smc_settings',
'smc_main'
);
add_settings_field(
self::OPT_POSITION,
__('Position der Lücke', 'simple-math-captcha'),
function () {
$v = (string) get_option(self::OPT_POSITION, self::DEFAULT_POSITION);
$opts = [
'first' => __('Erster Summand (? + B = S)', 'simple-math-captcha'),
'second' => __('Zweiter Summand (A + ? = S)', 'simple-math-captcha'),
'sum' => __('Summe (A + B = ?)', 'simple-math-captcha'),
'random' => __('Zufall (eine der drei Positionen)', 'simple-math-captcha'),
];
echo '<select name="' . esc_attr(self::OPT_POSITION) . '">';
foreach ($opts as $key => $label) {
echo '<option value="' . esc_attr($key) . '"' . selected($v, $key, false) . '>'
. esc_html($label) . '</option>';
}
echo '</select>';
},
'smc_settings',
'smc_main'
);
}
public function register_menu(): void {
add_options_page(
__('Simple Math CAPTCHA', 'simple-math-captcha'),
__('Math CAPTCHA', 'simple-math-captcha'),
'manage_options',
'smc_settings',
[$this, 'render_settings_page']
);
}
public function render_settings_page(): void {
if (!current_user_can('manage_options')) { return; }
echo '<div class="wrap"><h1>' . esc_html__('Simple Math CAPTCHA', 'simple-math-captcha') . '</h1>';
echo '<form method="post" action="options.php">';
settings_fields('smc_settings');
do_settings_sections('smc_settings');
submit_button();
echo '</form></div>';
}
}
new Simple_Math_Captcha();
// ----------------------------------------------------------------------------
// Auto-Updates von eigener Gitea-Instanz (plugin-update-checker, MIT).
// Pruef-URL: info.json auf main-Branch. Bei neuer Version laedt WP das ZIP
// aus der download_url im info.json (Gitea-Archive eines Tags).
// ----------------------------------------------------------------------------
$smc_puc = __DIR__ . '/plugin-update-checker/plugin-update-checker.php';
if (is_readable($smc_puc)) {
require_once $smc_puc;
if (class_exists('\\YahnisElsts\\PluginUpdateChecker\\v5\\PucFactory')) {
\YahnisElsts\PluginUpdateChecker\v5\PucFactory::buildUpdateChecker(
'https://git.rosinenkot.de/ingo/simple-math-captcha/raw/branch/main/info.json',
__FILE__,
'simple-math-captcha'
);
}
}
unset($smc_puc);