337 lines
13 KiB
PHP
337 lines
13 KiB
PHP
<?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.5
|
||
* 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;
|
||
}
|
||
// Wenn WordPress bereits einen Fehler hat (z. B. falsches Passwort),
|
||
// durchreichen — sonst wuerde unsere Captcha-Fehlermeldung den
|
||
// originalen Fehler ueberdecken und die native rote WP-Meldung
|
||
// fuer falsche Anmeldedaten ginge verloren.
|
||
if (is_wp_error($user)) {
|
||
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: 2–99.', '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: 2–999.', '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);
|