142a27035SAndreas Gohr<?php 218622736SAndreas Gohr 309b1e97eSAndreas Gohruse dokuwiki\Extension\ActionPlugin; 409b1e97eSAndreas Gohruse dokuwiki\Extension\EventHandler; 509b1e97eSAndreas Gohruse dokuwiki\Extension\Event; 609b1e97eSAndreas Gohruse dokuwiki\Form\Form; 7*c6d794b3SAndreas Gohruse dokuwiki\plugin\captcha\FileCookie; 8969b14c4SAndreas Gohruse dokuwiki\plugin\captcha\IpCounter; 9969b14c4SAndreas Gohr 1042a27035SAndreas Gohr/** 1142a27035SAndreas Gohr * CAPTCHA antispam plugin 1242a27035SAndreas Gohr * 1342a27035SAndreas Gohr * @license GPL 2 (http://www.gnu.org/licenses/gpl.html) 1442a27035SAndreas Gohr * @author Andreas Gohr <gohr@cosmocode.de> 1542a27035SAndreas Gohr */ 1609b1e97eSAndreas Gohrclass action_plugin_captcha extends ActionPlugin 171c08a51cSAndreas Gohr{ 1842a27035SAndreas Gohr /** 1942a27035SAndreas Gohr * register the eventhandlers 2042a27035SAndreas Gohr */ 2109b1e97eSAndreas Gohr public function register(EventHandler $controller) 221c08a51cSAndreas Gohr { 237218f96cSAndreas Gohr // check CAPTCHA success 241c08a51cSAndreas Gohr $controller->register_hook('ACTION_ACT_PREPROCESS', 'BEFORE', $this, 'handle_captcha_input', []); 2542a27035SAndreas Gohr 267218f96cSAndreas Gohr // inject in edit form 271c08a51cSAndreas Gohr $controller->register_hook('HTML_EDITFORM_OUTPUT', 'BEFORE', $this, 'handle_form_output', []); //old 281c08a51cSAndreas Gohr $controller->register_hook('FORM_EDIT_OUTPUT', 'BEFORE', $this, 'handle_form_output', []); //new 2942a27035SAndreas Gohr 307218f96cSAndreas Gohr // inject in user registration 311c08a51cSAndreas Gohr $controller->register_hook('HTML_REGISTERFORM_OUTPUT', 'BEFORE', $this, 'handle_form_output', []); //old 321c08a51cSAndreas Gohr $controller->register_hook('FORM_REGISTER_OUTPUT', 'BEFORE', $this, 'handle_form_output', []); //new 33f74276b8SAndreas Gohr 34f74276b8SAndreas Gohr // inject in password reset 351c08a51cSAndreas Gohr $controller->register_hook('HTML_RESENDPWDFORM_OUTPUT', 'BEFORE', $this, 'handle_form_output', []); //old 361c08a51cSAndreas Gohr $controller->register_hook('FORM_RESENDPWD_OUTPUT', 'BEFORE', $this, 'handle_form_output', []); //new 37643f15bdSAndreas Gohr 38643f15bdSAndreas Gohr // inject in login form 391c08a51cSAndreas Gohr $controller->register_hook('HTML_LOGINFORM_OUTPUT', 'BEFORE', $this, 'handle_form_output', []); // old 401c08a51cSAndreas Gohr $controller->register_hook('FORM_LOGIN_OUTPUT', 'BEFORE', $this, 'handle_form_output', []); // new 411c08a51cSAndreas Gohr 42643f15bdSAndreas Gohr // check on login 431c08a51cSAndreas Gohr $controller->register_hook('AUTH_LOGIN_CHECK', 'BEFORE', $this, 'handle_login', []); 44cde3ece1SAndreas Gohr 45cde3ece1SAndreas Gohr // clean up captcha cookies 461c08a51cSAndreas Gohr $controller->register_hook('INDEXER_TASKS_RUN', 'AFTER', $this, 'handle_indexer', []); 47969b14c4SAndreas Gohr 4809b1e97eSAndreas Gohr $this->getConf('loginprotect'); 49969b14c4SAndreas Gohr 50969b14c4SAndreas Gohr // log authentication failures 51969b14c4SAndreas Gohr if ((int)$this->getConf('loginprotect') > 1) { 52969b14c4SAndreas Gohr $controller->register_hook('ACTION_ACT_PREPROCESS', 'BEFORE', $this, 'handle_auth', []); 53969b14c4SAndreas Gohr } 5442a27035SAndreas Gohr } 5542a27035SAndreas Gohr 5642a27035SAndreas Gohr /** 57bd26d35bSAndreas Gohr * Check if the current mode should be handled by CAPTCHA 58bd26d35bSAndreas Gohr * 59f74276b8SAndreas Gohr * Note: checking needs to be done when a form has been submitted, not when the form 60f74276b8SAndreas Gohr * is shown for the first time. Except for the editing process this is not determined 61f74276b8SAndreas Gohr * by $act alone but needs to inspect other input variables. 62f74276b8SAndreas Gohr * 63bd26d35bSAndreas Gohr * @param string $act cleaned action mode 64bd26d35bSAndreas Gohr * @return bool 65bd26d35bSAndreas Gohr */ 661c08a51cSAndreas Gohr protected function needs_checking($act) 671c08a51cSAndreas Gohr { 68bd26d35bSAndreas Gohr global $INPUT; 69bd26d35bSAndreas Gohr 70bd26d35bSAndreas Gohr switch ($act) { 71bd26d35bSAndreas Gohr case 'save': 72bd26d35bSAndreas Gohr return true; 73bd26d35bSAndreas Gohr case 'register': 74f74276b8SAndreas Gohr case 'resendpwd': 75bd26d35bSAndreas Gohr return $INPUT->bool('save'); 76643f15bdSAndreas Gohr case 'login': 77643f15bdSAndreas Gohr // we do not handle this here, but in handle_login() 78bd26d35bSAndreas Gohr default: 79bd26d35bSAndreas Gohr return false; 80bd26d35bSAndreas Gohr } 81bd26d35bSAndreas Gohr } 82bd26d35bSAndreas Gohr 83bd26d35bSAndreas Gohr /** 84bd26d35bSAndreas Gohr * Aborts the given mode 85bd26d35bSAndreas Gohr * 86bd26d35bSAndreas Gohr * Aborting depends on the mode. It might unset certain input parameters or simply switch 87bd26d35bSAndreas Gohr * the mode to something else (giving as return which needs to be passed back to the 88bd26d35bSAndreas Gohr * ACTION_ACT_PREPROCESS event) 89bd26d35bSAndreas Gohr * 90bd26d35bSAndreas Gohr * @param string $act cleaned action mode 91bd26d35bSAndreas Gohr * @return string the new mode to use 92bd26d35bSAndreas Gohr */ 931c08a51cSAndreas Gohr protected function abort_action($act) 941c08a51cSAndreas Gohr { 95bd26d35bSAndreas Gohr global $INPUT; 96bd26d35bSAndreas Gohr 97bd26d35bSAndreas Gohr switch ($act) { 98bd26d35bSAndreas Gohr case 'save': 99bd26d35bSAndreas Gohr return 'preview'; 100bd26d35bSAndreas Gohr case 'register': 101f74276b8SAndreas Gohr case 'resendpwd': 102bd26d35bSAndreas Gohr $INPUT->post->set('save', false); 103f74276b8SAndreas Gohr return $act; 104643f15bdSAndreas Gohr case 'login': 105643f15bdSAndreas Gohr // we do not handle this here, but in handle_login() 106bd26d35bSAndreas Gohr default: 107bd26d35bSAndreas Gohr return $act; 108bd26d35bSAndreas Gohr } 109bd26d35bSAndreas Gohr } 110bd26d35bSAndreas Gohr 111bd26d35bSAndreas Gohr /** 112969b14c4SAndreas Gohr * Should a login CAPTCHA be used? 113969b14c4SAndreas Gohr * 114969b14c4SAndreas Gohr * @return bool 115969b14c4SAndreas Gohr */ 116969b14c4SAndreas Gohr protected function protectLogin() 117969b14c4SAndreas Gohr { 118969b14c4SAndreas Gohr $config = (int)$this->getConf('loginprotect'); 119969b14c4SAndreas Gohr if ($config < 1) return false; // not wanted 120969b14c4SAndreas Gohr if ($config === 1) return true; // always wanted 121969b14c4SAndreas Gohr $count = (new IpCounter())->get(); 122969b14c4SAndreas Gohr return $count > 2; // only after 3 failed attempts 123969b14c4SAndreas Gohr } 124969b14c4SAndreas Gohr 125969b14c4SAndreas Gohr /** 126643f15bdSAndreas Gohr * Handles CAPTCHA check in login 127643f15bdSAndreas Gohr * 128643f15bdSAndreas Gohr * Logins happen very early in the DokuWiki lifecycle, so we have to intercept them 129643f15bdSAndreas Gohr * in their own event. 130643f15bdSAndreas Gohr * 13109b1e97eSAndreas Gohr * @param Event $event 132643f15bdSAndreas Gohr * @param $param 133643f15bdSAndreas Gohr */ 13409b1e97eSAndreas Gohr public function handle_login(Event $event, $param) 1351c08a51cSAndreas Gohr { 136643f15bdSAndreas Gohr global $INPUT; 137969b14c4SAndreas Gohr if (!$this->protectLogin()) return; // no protection wanted 138643f15bdSAndreas Gohr if (!$INPUT->bool('u')) return; // this login was not triggered by a form 139643f15bdSAndreas Gohr 140643f15bdSAndreas Gohr // we need to have $ID set for the captcha check 141643f15bdSAndreas Gohr global $ID; 142643f15bdSAndreas Gohr $ID = getID(); 143643f15bdSAndreas Gohr 144643f15bdSAndreas Gohr /** @var helper_plugin_captcha $helper */ 145643f15bdSAndreas Gohr $helper = plugin_load('helper', 'captcha'); 146643f15bdSAndreas Gohr if (!$helper->check()) { 147643f15bdSAndreas Gohr $event->data['silent'] = true; // we have our own message 148643f15bdSAndreas Gohr $event->result = false; // login fail 149643f15bdSAndreas Gohr $event->preventDefault(); 150643f15bdSAndreas Gohr $event->stopPropagation(); 151643f15bdSAndreas Gohr } 152643f15bdSAndreas Gohr } 153643f15bdSAndreas Gohr 154643f15bdSAndreas Gohr /** 155643f15bdSAndreas Gohr * Intercept all actions and check for CAPTCHA first. 15642a27035SAndreas Gohr */ 15709b1e97eSAndreas Gohr public function handle_captcha_input(Event $event, $param) 1581c08a51cSAndreas Gohr { 15964382f29SAndreas Gohr global $INPUT; 16064382f29SAndreas Gohr 1617218f96cSAndreas Gohr $act = act_clean($event->data); 162f74276b8SAndreas Gohr if (!$this->needs_checking($act)) return; 16393f66506SAndreas Gohr 16442a27035SAndreas Gohr // do nothing if logged in user and no CAPTCHA required 16564382f29SAndreas Gohr if (!$this->getConf('forusers') && $INPUT->server->str('REMOTE_USER')) { 16642a27035SAndreas Gohr return; 16742a27035SAndreas Gohr } 16842a27035SAndreas Gohr 16977e00bf9SAndreas Gohr // check captcha 1707218f96cSAndreas Gohr /** @var helper_plugin_captcha $helper */ 17177e00bf9SAndreas Gohr $helper = plugin_load('helper', 'captcha'); 17277e00bf9SAndreas Gohr if (!$helper->check()) { 173bd26d35bSAndreas Gohr $event->data = $this->abort_action($act); 17442a27035SAndreas Gohr } 17542a27035SAndreas Gohr } 17642a27035SAndreas Gohr 17742a27035SAndreas Gohr /** 1781c08a51cSAndreas Gohr * Inject the CAPTCHA in a DokuForm or \dokuwiki\Form\Form 17942a27035SAndreas Gohr */ 18009b1e97eSAndreas Gohr public function handle_form_output(Event $event, $param) 1811c08a51cSAndreas Gohr { 18264382f29SAndreas Gohr global $INPUT; 18364382f29SAndreas Gohr 184c0439b03SAndreas Gohr if ( 185c0439b03SAndreas Gohr ($event->name === 'FORM_LOGIN_OUTPUT' || $event->name === 'HTML_LOGINFORM_OUTPUT') 186c0439b03SAndreas Gohr && 187c0439b03SAndreas Gohr !$this->protectLogin() 188c0439b03SAndreas Gohr ) { 189c0439b03SAndreas Gohr // no login protection wanted 190c0439b03SAndreas Gohr return; 191c0439b03SAndreas Gohr } 19231c8e2bdSAndreas Gohr 19309b1e97eSAndreas Gohr /** @var Form|\Doku_Form $form */ 1941c08a51cSAndreas Gohr $form = $event->data; 1951c08a51cSAndreas Gohr 19647afabe6SAndreas Gohr // get position of submit button 19709b1e97eSAndreas Gohr if (is_a($form, Form::class)) { 1981c08a51cSAndreas Gohr $pos = $form->findPositionByAttribute('type', 'submit'); 1991c08a51cSAndreas Gohr } else { 2001c08a51cSAndreas Gohr $pos = $form->findElementByAttribute('type', 'submit'); 2011c08a51cSAndreas Gohr } 20247afabe6SAndreas Gohr if (!$pos) return; // no button -> source view mode 20347afabe6SAndreas Gohr 20442a27035SAndreas Gohr // do nothing if logged in user and no CAPTCHA required 20564382f29SAndreas Gohr if (!$this->getConf('forusers') && $INPUT->server->str('REMOTE_USER')) { 20642a27035SAndreas Gohr return; 20742a27035SAndreas Gohr } 20842a27035SAndreas Gohr 20977e00bf9SAndreas Gohr // get the CAPTCHA 2107218f96cSAndreas Gohr /** @var helper_plugin_captcha $helper */ 21177e00bf9SAndreas Gohr $helper = plugin_load('helper', 'captcha'); 21277e00bf9SAndreas Gohr $out = $helper->getHTML(); 21347afabe6SAndreas Gohr 2141c08a51cSAndreas Gohr // insert before the submit button 21509b1e97eSAndreas Gohr if (is_a($form, Form::class)) { 2161c08a51cSAndreas Gohr $form->addHTML($out, $pos); 2171c08a51cSAndreas Gohr } else { 2181c08a51cSAndreas Gohr $form->insertElement($pos, $out); 2191c08a51cSAndreas Gohr } 22042a27035SAndreas Gohr } 22142a27035SAndreas Gohr 222cde3ece1SAndreas Gohr /** 223cde3ece1SAndreas Gohr * Clean cookies once per day 224cde3ece1SAndreas Gohr */ 22509b1e97eSAndreas Gohr public function handle_indexer(Event $event, $param) 2261c08a51cSAndreas Gohr { 227cde3ece1SAndreas Gohr $lastrun = getCacheName('captcha', '.captcha'); 228cde3ece1SAndreas Gohr $last = @filemtime($lastrun); 229cde3ece1SAndreas Gohr if (time() - $last < 24 * 60 * 60) return; 230cde3ece1SAndreas Gohr 231*c6d794b3SAndreas Gohr FileCookie::clean(); 2325d59bd09SAndreas Gohr touch($lastrun); 233cde3ece1SAndreas Gohr 234cde3ece1SAndreas Gohr $event->preventDefault(); 235cde3ece1SAndreas Gohr $event->stopPropagation(); 236cde3ece1SAndreas Gohr } 237969b14c4SAndreas Gohr 238969b14c4SAndreas Gohr /** 239969b14c4SAndreas Gohr * Count failed login attempts 240969b14c4SAndreas Gohr */ 24109b1e97eSAndreas Gohr public function handle_auth(Event $event, $param) 242969b14c4SAndreas Gohr { 243969b14c4SAndreas Gohr global $INPUT; 244969b14c4SAndreas Gohr $act = act_clean($event->data); 245969b14c4SAndreas Gohr if ( 246969b14c4SAndreas Gohr $act != 'logout' && 24731c8e2bdSAndreas Gohr $INPUT->str('u') !== '' && 248969b14c4SAndreas Gohr empty($INPUT->server->str('http_credentials')) && 249969b14c4SAndreas Gohr empty($INPUT->server->str('REMOTE_USER')) 250969b14c4SAndreas Gohr ) { 251969b14c4SAndreas Gohr // This is a failed authentication attempt, count it 252969b14c4SAndreas Gohr (new IpCounter())->increment(); 253969b14c4SAndreas Gohr } 254969b14c4SAndreas Gohr 255969b14c4SAndreas Gohr if ( 256969b14c4SAndreas Gohr $act == 'login' && 257969b14c4SAndreas Gohr !empty($INPUT->server->str('REMOTE_USER')) 258969b14c4SAndreas Gohr ) { 259969b14c4SAndreas Gohr // This is a successful login, reset the counter 260969b14c4SAndreas Gohr (new IpCounter())->reset(); 261969b14c4SAndreas Gohr } 262969b14c4SAndreas Gohr } 26342a27035SAndreas Gohr} 264