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 $counter = new IpCounter(); 127 return $counter->getRemainingTime() > 0; 128 } 129 130 /** 131 * Get human-readable remaining time string 132 * 133 * @return string 134 */ 135 protected function getRemainingTimeString() 136 { 137 $counter = new IpCounter(); 138 $remaining = $counter->getRemainingTime(); 139 140 if ($remaining >= 3600) { 141 return sprintf($this->getLang('timeout_hours'), ceil($remaining / 3600)); 142 } elseif ($remaining >= 60) { 143 return sprintf($this->getLang('timeout_minutes'), ceil($remaining / 60)); 144 } else { 145 return sprintf($this->getLang('timeout_seconds'), $remaining); 146 } 147 } 148 149 /** 150 * Handles CAPTCHA check in login 151 * 152 * Logins happen very early in the DokuWiki lifecycle, so we have to intercept them 153 * in their own event. 154 * 155 * @param Event $event 156 * @param $param 157 */ 158 public function handleLogin(Event $event, $param) 159 { 160 global $INPUT; 161 if (!$INPUT->bool('u')) return; // this login was not triggered by a form 162 163 // Check timeout first - if blocked, reject immediately 164 if ($this->isLoginBlocked()) { 165 $timeString = $this->getRemainingTimeString(); 166 msg(sprintf($this->getLang('logindenial'), $timeString), -1); 167 $event->data['silent'] = true; 168 $event->result = false; 169 $event->preventDefault(); 170 $event->stopPropagation(); 171 return; 172 } 173 174 if (!$this->protectLogin()) return; // no CAPTCHA protection wanted 175 176 // we need to have $ID set for the captcha check 177 global $ID; 178 $ID = getID(); 179 180 /** @var helper_plugin_captcha $helper */ 181 $helper = plugin_load('helper', 'captcha'); 182 if (!$helper->check()) { 183 $event->data['silent'] = true; // we have our own message 184 $event->result = false; // login fail 185 $event->preventDefault(); 186 $event->stopPropagation(); 187 } 188 } 189 190 /** 191 * Intercept all actions and check for CAPTCHA first. 192 */ 193 public function handleCaptchaInput(Event $event, $param) 194 { 195 global $INPUT; 196 197 $act = act_clean($event->data); 198 if (!$this->needsChecking($act)) return; 199 200 // do nothing if logged in user and no CAPTCHA required 201 if (!$this->getConf('forusers') && $INPUT->server->str('REMOTE_USER')) { 202 return; 203 } 204 205 // check captcha 206 /** @var helper_plugin_captcha $helper */ 207 $helper = plugin_load('helper', 'captcha'); 208 if (!$helper->check()) { 209 $event->data = $this->abortAction($act); 210 } 211 } 212 213 /** 214 * Inject the CAPTCHA in a \dokuwiki\Form\Form 215 */ 216 public function handleFormOutput(Event $event, $param) 217 { 218 global $INPUT; 219 220 if ($event->name === 'FORM_LOGIN_OUTPUT' && !$this->protectLogin()) { 221 // no login protection wanted 222 return; 223 } 224 225 /** @var Form|\Doku_Form $form */ 226 $form = $event->data; 227 228 // get position of submit button 229 $pos = $form->findPositionByAttribute('type', 'submit'); 230 if (!$pos) return; // no button -> source view mode 231 232 // do nothing if logged in user and no CAPTCHA required 233 if (!$this->getConf('forusers') && $INPUT->server->str('REMOTE_USER')) { 234 return; 235 } 236 237 // get the CAPTCHA 238 /** @var helper_plugin_captcha $helper */ 239 $helper = plugin_load('helper', 'captcha'); 240 $out = $helper->getHTML(); 241 242 // insert before the submit button 243 $form->addHTML($out, $pos); 244 } 245 246 /** 247 * Clean up old captcha files once per day 248 */ 249 public function handleIndexer(Event $event, $param) 250 { 251 $lastrun = getCacheName('captcha', '.captcha'); 252 $last = @filemtime($lastrun); 253 if (time() - $last < 24 * 60 * 60) return; 254 255 FileCookie::clean(); 256 IpCounter::clean(); 257 touch($lastrun); 258 259 $event->preventDefault(); 260 $event->stopPropagation(); 261 } 262 263 /** 264 * Count failed login attempts 265 */ 266 public function handleAuth(Event $event, $param) 267 { 268 global $INPUT; 269 $act = act_clean($event->data); 270 if ( 271 $act != 'logout' && 272 $INPUT->str('u') !== '' && 273 empty($INPUT->server->str('http_credentials')) && 274 empty($INPUT->server->str('REMOTE_USER')) 275 ) { 276 // This is a failed authentication attempt, count it 277 (new IpCounter())->increment(); 278 } 279 280 if ( 281 $act == 'login' && 282 !empty($INPUT->server->str('REMOTE_USER')) 283 ) { 284 // This is a successful login, reset the counter 285 (new IpCounter())->reset(); 286 } 287 } 288} 289