142a27035SAndreas Gohr<?php 218622736SAndreas Gohr 309b1e97eSAndreas Gohruse dokuwiki\Extension\ActionPlugin; 409b1e97eSAndreas Gohruse dokuwiki\Extension\Event; 55e79b2eeSAndreas Gohruse dokuwiki\Extension\EventHandler; 609b1e97eSAndreas Gohruse dokuwiki\Form\Form; 7c6d794b3SAndreas 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 245e79b2eeSAndreas Gohr $controller->register_hook('ACTION_ACT_PREPROCESS', 'BEFORE', $this, 'handleCaptchaInput', []); 2542a27035SAndreas Gohr 267218f96cSAndreas Gohr // inject in edit form 275e79b2eeSAndreas Gohr $controller->register_hook('FORM_EDIT_OUTPUT', 'BEFORE', $this, 'handleFormOutput', []); 2842a27035SAndreas Gohr 297218f96cSAndreas Gohr // inject in user registration 305e79b2eeSAndreas Gohr $controller->register_hook('FORM_REGISTER_OUTPUT', 'BEFORE', $this, 'handleFormOutput', []); 31f74276b8SAndreas Gohr 32f74276b8SAndreas Gohr // inject in password reset 335e79b2eeSAndreas Gohr $controller->register_hook('FORM_RESENDPWD_OUTPUT', 'BEFORE', $this, 'handleFormOutput', []); 34643f15bdSAndreas Gohr 35643f15bdSAndreas Gohr // inject in login form 365e79b2eeSAndreas Gohr $controller->register_hook('FORM_LOGIN_OUTPUT', 'BEFORE', $this, 'handleFormOutput', []); 371c08a51cSAndreas Gohr 38643f15bdSAndreas Gohr // check on login 395e79b2eeSAndreas Gohr $controller->register_hook('AUTH_LOGIN_CHECK', 'BEFORE', $this, 'handleLogin', []); 40cde3ece1SAndreas Gohr 41cde3ece1SAndreas Gohr // clean up captcha cookies 425e79b2eeSAndreas Gohr $controller->register_hook('INDEXER_TASKS_RUN', 'AFTER', $this, 'handleIndexer', []); 43969b14c4SAndreas Gohr 44969b14c4SAndreas Gohr // log authentication failures 45563fb566SAndreas Gohr if ((int)$this->getConf('loginprotect') > 1 || (int)$this->getConf('logindenial') > 0) { 465e79b2eeSAndreas Gohr $controller->register_hook('ACTION_ACT_PREPROCESS', 'BEFORE', $this, 'handleAuth', []); 47969b14c4SAndreas Gohr } 4842a27035SAndreas Gohr } 4942a27035SAndreas Gohr 5042a27035SAndreas Gohr /** 51bd26d35bSAndreas Gohr * Check if the current mode should be handled by CAPTCHA 52bd26d35bSAndreas Gohr * 53f74276b8SAndreas Gohr * Note: checking needs to be done when a form has been submitted, not when the form 54f74276b8SAndreas Gohr * is shown for the first time. Except for the editing process this is not determined 55f74276b8SAndreas Gohr * by $act alone but needs to inspect other input variables. 56f74276b8SAndreas Gohr * 57bd26d35bSAndreas Gohr * @param string $act cleaned action mode 58bd26d35bSAndreas Gohr * @return bool 59bd26d35bSAndreas Gohr */ 605e79b2eeSAndreas Gohr protected function needsChecking($act) 611c08a51cSAndreas Gohr { 62bd26d35bSAndreas Gohr global $INPUT; 63bd26d35bSAndreas Gohr 64bd26d35bSAndreas Gohr switch ($act) { 65bd26d35bSAndreas Gohr case 'save': 66bd26d35bSAndreas Gohr return true; 67bd26d35bSAndreas Gohr case 'register': 68f74276b8SAndreas Gohr case 'resendpwd': 69bd26d35bSAndreas Gohr return $INPUT->bool('save'); 70643f15bdSAndreas Gohr case 'login': 71643f15bdSAndreas Gohr // we do not handle this here, but in handle_login() 72bd26d35bSAndreas Gohr default: 73bd26d35bSAndreas Gohr return false; 74bd26d35bSAndreas Gohr } 75bd26d35bSAndreas Gohr } 76bd26d35bSAndreas Gohr 77bd26d35bSAndreas Gohr /** 78bd26d35bSAndreas Gohr * Aborts the given mode 79bd26d35bSAndreas Gohr * 80bd26d35bSAndreas Gohr * Aborting depends on the mode. It might unset certain input parameters or simply switch 81bd26d35bSAndreas Gohr * the mode to something else (giving as return which needs to be passed back to the 82bd26d35bSAndreas Gohr * ACTION_ACT_PREPROCESS event) 83bd26d35bSAndreas Gohr * 84bd26d35bSAndreas Gohr * @param string $act cleaned action mode 85bd26d35bSAndreas Gohr * @return string the new mode to use 86bd26d35bSAndreas Gohr */ 875e79b2eeSAndreas Gohr protected function abortAction($act) 881c08a51cSAndreas Gohr { 89bd26d35bSAndreas Gohr global $INPUT; 90bd26d35bSAndreas Gohr 91bd26d35bSAndreas Gohr switch ($act) { 92bd26d35bSAndreas Gohr case 'save': 93bd26d35bSAndreas Gohr return 'preview'; 94bd26d35bSAndreas Gohr case 'register': 95f74276b8SAndreas Gohr case 'resendpwd': 96bd26d35bSAndreas Gohr $INPUT->post->set('save', false); 97f74276b8SAndreas Gohr return $act; 98643f15bdSAndreas Gohr case 'login': 99643f15bdSAndreas Gohr // we do not handle this here, but in handle_login() 100bd26d35bSAndreas Gohr default: 101bd26d35bSAndreas Gohr return $act; 102bd26d35bSAndreas Gohr } 103bd26d35bSAndreas Gohr } 104bd26d35bSAndreas Gohr 105bd26d35bSAndreas Gohr /** 106969b14c4SAndreas Gohr * Should a login CAPTCHA be used? 107969b14c4SAndreas Gohr * 108969b14c4SAndreas Gohr * @return bool 109969b14c4SAndreas Gohr */ 110969b14c4SAndreas Gohr protected function protectLogin() 111969b14c4SAndreas Gohr { 112969b14c4SAndreas Gohr $config = (int)$this->getConf('loginprotect'); 113969b14c4SAndreas Gohr if ($config < 1) return false; // not wanted 114969b14c4SAndreas Gohr if ($config === 1) return true; // always wanted 115969b14c4SAndreas Gohr $count = (new IpCounter())->get(); 116969b14c4SAndreas Gohr return $count > 2; // only after 3 failed attempts 117969b14c4SAndreas Gohr } 118969b14c4SAndreas Gohr 119969b14c4SAndreas Gohr /** 120563fb566SAndreas Gohr * Check if login is blocked due to timeout 121563fb566SAndreas Gohr * 122563fb566SAndreas Gohr * @return bool True if blocked 123563fb566SAndreas Gohr */ 124563fb566SAndreas Gohr protected function isLoginBlocked() 125563fb566SAndreas Gohr { 126563fb566SAndreas Gohr $counter = new IpCounter(); 127*194d3386SAndreas Gohr return $counter->getRemainingTime() > 0; 128563fb566SAndreas Gohr } 129563fb566SAndreas Gohr 130563fb566SAndreas Gohr /** 131563fb566SAndreas Gohr * Get human-readable remaining time string 132563fb566SAndreas Gohr * 133563fb566SAndreas Gohr * @return string 134563fb566SAndreas Gohr */ 135563fb566SAndreas Gohr protected function getRemainingTimeString() 136563fb566SAndreas Gohr { 137563fb566SAndreas Gohr $counter = new IpCounter(); 138*194d3386SAndreas Gohr $remaining = $counter->getRemainingTime(); 139563fb566SAndreas Gohr 140563fb566SAndreas Gohr if ($remaining >= 3600) { 141563fb566SAndreas Gohr return sprintf($this->getLang('timeout_hours'), ceil($remaining / 3600)); 142563fb566SAndreas Gohr } elseif ($remaining >= 60) { 143563fb566SAndreas Gohr return sprintf($this->getLang('timeout_minutes'), ceil($remaining / 60)); 144563fb566SAndreas Gohr } else { 145563fb566SAndreas Gohr return sprintf($this->getLang('timeout_seconds'), $remaining); 146563fb566SAndreas Gohr } 147563fb566SAndreas Gohr } 148563fb566SAndreas Gohr 149563fb566SAndreas Gohr /** 150643f15bdSAndreas Gohr * Handles CAPTCHA check in login 151643f15bdSAndreas Gohr * 152643f15bdSAndreas Gohr * Logins happen very early in the DokuWiki lifecycle, so we have to intercept them 153643f15bdSAndreas Gohr * in their own event. 154643f15bdSAndreas Gohr * 15509b1e97eSAndreas Gohr * @param Event $event 156643f15bdSAndreas Gohr * @param $param 157643f15bdSAndreas Gohr */ 1585e79b2eeSAndreas Gohr public function handleLogin(Event $event, $param) 1591c08a51cSAndreas Gohr { 160643f15bdSAndreas Gohr global $INPUT; 161643f15bdSAndreas Gohr if (!$INPUT->bool('u')) return; // this login was not triggered by a form 162643f15bdSAndreas Gohr 163563fb566SAndreas Gohr // Check timeout first - if blocked, reject immediately 164563fb566SAndreas Gohr if ($this->isLoginBlocked()) { 165563fb566SAndreas Gohr $timeString = $this->getRemainingTimeString(); 166563fb566SAndreas Gohr msg(sprintf($this->getLang('logindenial'), $timeString), -1); 167563fb566SAndreas Gohr $event->data['silent'] = true; 168563fb566SAndreas Gohr $event->result = false; 169563fb566SAndreas Gohr $event->preventDefault(); 170563fb566SAndreas Gohr $event->stopPropagation(); 171563fb566SAndreas Gohr return; 172563fb566SAndreas Gohr } 173563fb566SAndreas Gohr 174563fb566SAndreas Gohr if (!$this->protectLogin()) return; // no CAPTCHA protection wanted 175563fb566SAndreas Gohr 176643f15bdSAndreas Gohr // we need to have $ID set for the captcha check 177643f15bdSAndreas Gohr global $ID; 178643f15bdSAndreas Gohr $ID = getID(); 179643f15bdSAndreas Gohr 180643f15bdSAndreas Gohr /** @var helper_plugin_captcha $helper */ 181643f15bdSAndreas Gohr $helper = plugin_load('helper', 'captcha'); 182643f15bdSAndreas Gohr if (!$helper->check()) { 183643f15bdSAndreas Gohr $event->data['silent'] = true; // we have our own message 184643f15bdSAndreas Gohr $event->result = false; // login fail 185643f15bdSAndreas Gohr $event->preventDefault(); 186643f15bdSAndreas Gohr $event->stopPropagation(); 187643f15bdSAndreas Gohr } 188643f15bdSAndreas Gohr } 189643f15bdSAndreas Gohr 190643f15bdSAndreas Gohr /** 191643f15bdSAndreas Gohr * Intercept all actions and check for CAPTCHA first. 19242a27035SAndreas Gohr */ 1935e79b2eeSAndreas Gohr public function handleCaptchaInput(Event $event, $param) 1941c08a51cSAndreas Gohr { 19564382f29SAndreas Gohr global $INPUT; 19664382f29SAndreas Gohr 1977218f96cSAndreas Gohr $act = act_clean($event->data); 1985e79b2eeSAndreas Gohr if (!$this->needsChecking($act)) return; 19993f66506SAndreas Gohr 20042a27035SAndreas Gohr // do nothing if logged in user and no CAPTCHA required 20164382f29SAndreas Gohr if (!$this->getConf('forusers') && $INPUT->server->str('REMOTE_USER')) { 20242a27035SAndreas Gohr return; 20342a27035SAndreas Gohr } 20442a27035SAndreas Gohr 20577e00bf9SAndreas Gohr // check captcha 2067218f96cSAndreas Gohr /** @var helper_plugin_captcha $helper */ 20777e00bf9SAndreas Gohr $helper = plugin_load('helper', 'captcha'); 20877e00bf9SAndreas Gohr if (!$helper->check()) { 2095e79b2eeSAndreas Gohr $event->data = $this->abortAction($act); 21042a27035SAndreas Gohr } 21142a27035SAndreas Gohr } 21242a27035SAndreas Gohr 21342a27035SAndreas Gohr /** 2145e79b2eeSAndreas Gohr * Inject the CAPTCHA in a \dokuwiki\Form\Form 21542a27035SAndreas Gohr */ 2165e79b2eeSAndreas Gohr public function handleFormOutput(Event $event, $param) 2171c08a51cSAndreas Gohr { 21864382f29SAndreas Gohr global $INPUT; 21964382f29SAndreas Gohr 2205e79b2eeSAndreas Gohr if ($event->name === 'FORM_LOGIN_OUTPUT' && !$this->protectLogin()) { 221c0439b03SAndreas Gohr // no login protection wanted 222c0439b03SAndreas Gohr return; 223c0439b03SAndreas Gohr } 22431c8e2bdSAndreas Gohr 22509b1e97eSAndreas Gohr /** @var Form|\Doku_Form $form */ 2261c08a51cSAndreas Gohr $form = $event->data; 2271c08a51cSAndreas Gohr 22847afabe6SAndreas Gohr // get position of submit button 2291c08a51cSAndreas Gohr $pos = $form->findPositionByAttribute('type', 'submit'); 23047afabe6SAndreas Gohr if (!$pos) return; // no button -> source view mode 23147afabe6SAndreas Gohr 23242a27035SAndreas Gohr // do nothing if logged in user and no CAPTCHA required 23364382f29SAndreas Gohr if (!$this->getConf('forusers') && $INPUT->server->str('REMOTE_USER')) { 23442a27035SAndreas Gohr return; 23542a27035SAndreas Gohr } 23642a27035SAndreas Gohr 23777e00bf9SAndreas Gohr // get the CAPTCHA 2387218f96cSAndreas Gohr /** @var helper_plugin_captcha $helper */ 23977e00bf9SAndreas Gohr $helper = plugin_load('helper', 'captcha'); 24077e00bf9SAndreas Gohr $out = $helper->getHTML(); 24147afabe6SAndreas Gohr 2421c08a51cSAndreas Gohr // insert before the submit button 2431c08a51cSAndreas Gohr $form->addHTML($out, $pos); 24442a27035SAndreas Gohr } 24542a27035SAndreas Gohr 246cde3ece1SAndreas Gohr /** 247*194d3386SAndreas Gohr * Clean up old captcha files once per day 248cde3ece1SAndreas Gohr */ 2495e79b2eeSAndreas Gohr public function handleIndexer(Event $event, $param) 2501c08a51cSAndreas Gohr { 251cde3ece1SAndreas Gohr $lastrun = getCacheName('captcha', '.captcha'); 252cde3ece1SAndreas Gohr $last = @filemtime($lastrun); 253cde3ece1SAndreas Gohr if (time() - $last < 24 * 60 * 60) return; 254cde3ece1SAndreas Gohr 255c6d794b3SAndreas Gohr FileCookie::clean(); 256*194d3386SAndreas Gohr IpCounter::clean(); 2575d59bd09SAndreas Gohr touch($lastrun); 258cde3ece1SAndreas Gohr 259cde3ece1SAndreas Gohr $event->preventDefault(); 260cde3ece1SAndreas Gohr $event->stopPropagation(); 261cde3ece1SAndreas Gohr } 262969b14c4SAndreas Gohr 263969b14c4SAndreas Gohr /** 264969b14c4SAndreas Gohr * Count failed login attempts 265969b14c4SAndreas Gohr */ 2665e79b2eeSAndreas Gohr public function handleAuth(Event $event, $param) 267969b14c4SAndreas Gohr { 268969b14c4SAndreas Gohr global $INPUT; 269969b14c4SAndreas Gohr $act = act_clean($event->data); 270969b14c4SAndreas Gohr if ( 271969b14c4SAndreas Gohr $act != 'logout' && 27231c8e2bdSAndreas Gohr $INPUT->str('u') !== '' && 273969b14c4SAndreas Gohr empty($INPUT->server->str('http_credentials')) && 274969b14c4SAndreas Gohr empty($INPUT->server->str('REMOTE_USER')) 275969b14c4SAndreas Gohr ) { 276969b14c4SAndreas Gohr // This is a failed authentication attempt, count it 277969b14c4SAndreas Gohr (new IpCounter())->increment(); 278969b14c4SAndreas Gohr } 279969b14c4SAndreas Gohr 280969b14c4SAndreas Gohr if ( 281969b14c4SAndreas Gohr $act == 'login' && 282969b14c4SAndreas Gohr !empty($INPUT->server->str('REMOTE_USER')) 283969b14c4SAndreas Gohr ) { 284969b14c4SAndreas Gohr // This is a successful login, reset the counter 285969b14c4SAndreas Gohr (new IpCounter())->reset(); 286969b14c4SAndreas Gohr } 287969b14c4SAndreas Gohr } 28842a27035SAndreas Gohr} 289