1<?php 2 3use dokuwiki\Extension\ActionPlugin; 4use dokuwiki\Extension\Event; 5use dokuwiki\Extension\EventHandler; 6use dokuwiki\Form\Form; 7use dokuwiki\plugin\captcha\FileCookie; 8use dokuwiki\plugin\captcha\IpCounter; 9 10/** 11 * CAPTCHA antispam plugin 12 * 13 * @license GPL 2 (http://www.gnu.org/licenses/gpl.html) 14 * @author Andreas Gohr <gohr@cosmocode.de> 15 */ 16class action_plugin_captcha extends ActionPlugin 17{ 18 /** 19 * register the eventhandlers 20 */ 21 public function register(EventHandler $controller) 22 { 23 // check CAPTCHA success 24 $controller->register_hook('ACTION_ACT_PREPROCESS', 'BEFORE', $this, 'handleCaptchaInput', []); 25 26 // inject in edit form 27 $controller->register_hook('FORM_EDIT_OUTPUT', 'BEFORE', $this, 'handleFormOutput', []); 28 29 // inject in user registration 30 $controller->register_hook('FORM_REGISTER_OUTPUT', 'BEFORE', $this, 'handleFormOutput', []); 31 32 // inject in password reset 33 $controller->register_hook('FORM_RESENDPWD_OUTPUT', 'BEFORE', $this, 'handleFormOutput', []); 34 35 // inject in login form 36 $controller->register_hook('FORM_LOGIN_OUTPUT', 'BEFORE', $this, 'handleFormOutput', []); 37 38 // check on login 39 $controller->register_hook('AUTH_LOGIN_CHECK', 'BEFORE', $this, 'handleLogin', []); 40 41 // clean up captcha cookies 42 $controller->register_hook('INDEXER_TASKS_RUN', 'AFTER', $this, 'handleIndexer', []); 43 44 // log authentication failures 45 if ((int)$this->getConf('loginprotect') > 1 || (int)$this->getConf('logindenial') > 0) { 46 $controller->register_hook('ACTION_ACT_PREPROCESS', 'BEFORE', $this, 'handleAuth', []); 47 } 48 } 49 50 /** 51 * Check if the current mode should be handled by CAPTCHA 52 * 53 * Note: checking needs to be done when a form has been submitted, not when the form 54 * is shown for the first time. Except for the editing process this is not determined 55 * by $act alone but needs to inspect other input variables. 56 * 57 * @param string $act cleaned action mode 58 * @return bool 59 */ 60 protected function needsChecking($act) 61 { 62 global $INPUT; 63 64 switch ($act) { 65 case 'save': 66 return true; 67 case 'register': 68 case 'resendpwd': 69 return $INPUT->bool('save'); 70 case 'login': 71 // we do not handle this here, but in handle_login() 72 default: 73 return false; 74 } 75 } 76 77 /** 78 * Aborts the given mode 79 * 80 * Aborting depends on the mode. It might unset certain input parameters or simply switch 81 * the mode to something else (giving as return which needs to be passed back to the 82 * ACTION_ACT_PREPROCESS event) 83 * 84 * @param string $act cleaned action mode 85 * @return string the new mode to use 86 */ 87 protected function abortAction($act) 88 { 89 global $INPUT; 90 91 switch ($act) { 92 case 'save': 93 return 'preview'; 94 case 'register': 95 case 'resendpwd': 96 $INPUT->post->set('save', false); 97 return $act; 98 case 'login': 99 // we do not handle this here, but in handle_login() 100 default: 101 return $act; 102 } 103 } 104 105 /** 106 * Should a login CAPTCHA be used? 107 * 108 * @return bool 109 */ 110 protected function protectLogin() 111 { 112 $config = (int)$this->getConf('loginprotect'); 113 if ($config < 1) return false; // not wanted 114 if ($config === 1) return true; // always wanted 115 $count = (new IpCounter())->get(); 116 return $count > 2; // only after 3 failed attempts 117 } 118 119 /** 120 * Check if login is blocked due to timeout 121 * 122 * @return bool True if blocked 123 */ 124 protected function isLoginBlocked() 125 { 126 $base = (int)$this->getConf('logindenial'); 127 if ($base < 1) return false; 128 $counter = new IpCounter(); 129 return $counter->getRemainingTime($base, $this->getConf('logindenial_max')) > 0; 130 } 131 132 /** 133 * Get human-readable remaining time string 134 * 135 * @return string 136 */ 137 protected function getRemainingTimeString() 138 { 139 $counter = new IpCounter(); 140 $remaining = $counter->getRemainingTime( 141 $this->getConf('logindenial'), 142 $this->getConf('logindenial_max') 143 ); 144 145 if ($remaining >= 3600) { 146 return sprintf($this->getLang('timeout_hours'), ceil($remaining / 3600)); 147 } elseif ($remaining >= 60) { 148 return sprintf($this->getLang('timeout_minutes'), ceil($remaining / 60)); 149 } else { 150 return sprintf($this->getLang('timeout_seconds'), $remaining); 151 } 152 } 153 154 /** 155 * Handles CAPTCHA check in login 156 * 157 * Logins happen very early in the DokuWiki lifecycle, so we have to intercept them 158 * in their own event. 159 * 160 * @param Event $event 161 * @param $param 162 */ 163 public function handleLogin(Event $event, $param) 164 { 165 global $INPUT; 166 if (!$INPUT->bool('u')) return; // this login was not triggered by a form 167 168 // Check timeout first - if blocked, reject immediately 169 if ($this->isLoginBlocked()) { 170 $timeString = $this->getRemainingTimeString(); 171 msg(sprintf($this->getLang('logindenial'), $timeString), -1); 172 $event->data['silent'] = true; 173 $event->result = false; 174 $event->preventDefault(); 175 $event->stopPropagation(); 176 return; 177 } 178 179 if (!$this->protectLogin()) return; // no CAPTCHA protection wanted 180 181 // we need to have $ID set for the captcha check 182 global $ID; 183 $ID = getID(); 184 185 /** @var helper_plugin_captcha $helper */ 186 $helper = plugin_load('helper', 'captcha'); 187 if (!$helper->check()) { 188 $event->data['silent'] = true; // we have our own message 189 $event->result = false; // login fail 190 $event->preventDefault(); 191 $event->stopPropagation(); 192 } 193 } 194 195 /** 196 * Intercept all actions and check for CAPTCHA first. 197 */ 198 public function handleCaptchaInput(Event $event, $param) 199 { 200 global $INPUT; 201 202 $act = act_clean($event->data); 203 if (!$this->needsChecking($act)) return; 204 205 // do nothing if logged in user and no CAPTCHA required 206 if (!$this->getConf('forusers') && $INPUT->server->str('REMOTE_USER')) { 207 return; 208 } 209 210 // check captcha 211 /** @var helper_plugin_captcha $helper */ 212 $helper = plugin_load('helper', 'captcha'); 213 if (!$helper->check()) { 214 $event->data = $this->abortAction($act); 215 } 216 } 217 218 /** 219 * Inject the CAPTCHA in a \dokuwiki\Form\Form 220 */ 221 public function handleFormOutput(Event $event, $param) 222 { 223 global $INPUT; 224 225 if ($event->name === 'FORM_LOGIN_OUTPUT' && !$this->protectLogin()) { 226 // no login protection wanted 227 return; 228 } 229 230 /** @var Form|\Doku_Form $form */ 231 $form = $event->data; 232 233 // get position of submit button 234 $pos = $form->findPositionByAttribute('type', 'submit'); 235 if (!$pos) return; // no button -> source view mode 236 237 // do nothing if logged in user and no CAPTCHA required 238 if (!$this->getConf('forusers') && $INPUT->server->str('REMOTE_USER')) { 239 return; 240 } 241 242 // get the CAPTCHA 243 /** @var helper_plugin_captcha $helper */ 244 $helper = plugin_load('helper', 'captcha'); 245 $out = $helper->getHTML(); 246 247 // insert before the submit button 248 $form->addHTML($out, $pos); 249 } 250 251 /** 252 * Clean cookies once per day 253 */ 254 public function handleIndexer(Event $event, $param) 255 { 256 $lastrun = getCacheName('captcha', '.captcha'); 257 $last = @filemtime($lastrun); 258 if (time() - $last < 24 * 60 * 60) return; 259 260 FileCookie::clean(); 261 touch($lastrun); 262 263 $event->preventDefault(); 264 $event->stopPropagation(); 265 } 266 267 /** 268 * Count failed login attempts 269 */ 270 public function handleAuth(Event $event, $param) 271 { 272 global $INPUT; 273 $act = act_clean($event->data); 274 if ( 275 $act != 'logout' && 276 $INPUT->str('u') !== '' && 277 empty($INPUT->server->str('http_credentials')) && 278 empty($INPUT->server->str('REMOTE_USER')) 279 ) { 280 // This is a failed authentication attempt, count it 281 (new IpCounter())->increment(); 282 } 283 284 if ( 285 $act == 'login' && 286 !empty($INPUT->server->str('REMOTE_USER')) 287 ) { 288 // This is a successful login, reset the counter 289 (new IpCounter())->reset(); 290 } 291 } 292} 293