1<?php 2/** 3 * @license GPL 2 (http://www.gnu.org/licenses/gpl.html) 4 * @author Andreas Gohr <andi@splitbrain.org> 5 */ 6 7// must be run within Dokuwiki 8if(!defined('DOKU_INC')) die(); 9if(!defined('DOKU_PLUGIN')) define('DOKU_PLUGIN', DOKU_INC . 'lib/plugins/'); 10 11/** 12 * Class helper_plugin_captcha 13 */ 14class helper_plugin_captcha extends DokuWiki_Plugin { 15 16 protected $field_in = 'plugin__captcha'; 17 protected $field_sec = 'plugin__captcha_secret'; 18 protected $field_hp = 'plugin__captcha_honeypot'; 19 20 /** 21 * Constructor. Initializes field names 22 */ 23 public function __construct() { 24 $this->field_in = md5($this->_fixedIdent().$this->field_in); 25 $this->field_sec = md5($this->_fixedIdent().$this->field_sec); 26 $this->field_hp = md5($this->_fixedIdent().$this->field_hp); 27 } 28 29 /** 30 * Check if the CAPTCHA should be used. Always check this before using the methods below. 31 * 32 * @return bool true when the CAPTCHA should be used 33 */ 34 public function isEnabled() { 35 if(!$this->getConf('forusers') && $_SERVER['REMOTE_USER']) return false; 36 return true; 37 } 38 39 /** 40 * Returns the HTML to display the CAPTCHA with the chosen method 41 */ 42 public function getHTML() { 43 global $ID; 44 45 $rand = (float) (rand(0, 10000)) / 10000; 46 $this->storeCaptchaCookie($this->_fixedIdent(), $rand); 47 48 if($this->getConf('mode') == 'math') { 49 $code = $this->_generateMATH($this->_fixedIdent(), $rand); 50 $code = $code[0]; 51 $text = $this->getLang('fillmath'); 52 } elseif($this->getConf('mode') == 'question') { 53 $code = ''; // not used 54 $text = $this->getConf('question'); 55 } else { 56 $code = $this->_generateCAPTCHA($this->_fixedIdent(), $rand); 57 $text = $this->getLang('fillcaptcha'); 58 } 59 $secret = $this->encrypt($rand); 60 61 $txtlen = $this->getConf('lettercount'); 62 63 $out = ''; 64 $out .= '<div id="plugin__captcha_wrapper">'; 65 $out .= '<input type="hidden" name="'.$this->field_sec.'" value="'.hsc($secret).'" />'; 66 $out .= '<label for="plugin__captcha">'.$text.'</label> '; 67 68 switch($this->getConf('mode')) { 69 case 'math': 70 case 'text': 71 $out .= $this->_obfuscateText($code); 72 break; 73 case 'js': 74 $out .= '<span id="plugin__captcha_code">'.$this->_obfuscateText($code).'</span>'; 75 break; 76 case 'svg': 77 $out .= '<span class="svg" style="width:'.$this->getConf('width').'px; height:'.$this->getConf('height').'px">'; 78 $out .= $this->_svgCAPTCHA($code); 79 $out .= '</span>'; 80 break; 81 case 'svgaudio': 82 $out .= '<span class="svg" style="width:'.$this->getConf('width').'px; height:'.$this->getConf('height').'px">'; 83 $out .= $this->_svgCAPTCHA($code); 84 $out .= '</span>'; 85 $out .= '<a href="'.DOKU_BASE.'lib/plugins/captcha/wav.php?secret='.rawurlencode($secret).'&id='.$ID.'"'. 86 ' class="JSnocheck" title="'.$this->getLang('soundlink').'">'; 87 $out .= '<img src="'.DOKU_BASE.'lib/plugins/captcha/sound.png" width="16" height="16"'. 88 ' alt="'.$this->getLang('soundlink').'" /></a>'; 89 break; 90 case 'image': 91 $out .= '<img src="'.DOKU_BASE.'lib/plugins/captcha/img.php?secret='.rawurlencode($secret).'&id='.$ID.'" '. 92 ' width="'.$this->getConf('width').'" height="'.$this->getConf('height').'" alt="" /> '; 93 break; 94 case 'audio': 95 $out .= '<img src="'.DOKU_BASE.'lib/plugins/captcha/img.php?secret='.rawurlencode($secret).'&id='.$ID.'" '. 96 ' width="'.$this->getConf('width').'" height="'.$this->getConf('height').'" alt="" /> '; 97 $out .= '<a href="'.DOKU_BASE.'lib/plugins/captcha/wav.php?secret='.rawurlencode($secret).'&id='.$ID.'"'. 98 ' class="JSnocheck" title="'.$this->getLang('soundlink').'">'; 99 $out .= '<img src="'.DOKU_BASE.'lib/plugins/captcha/sound.png" width="16" height="16"'. 100 ' alt="'.$this->getLang('soundlink').'" /></a>'; 101 break; 102 case 'figlet': 103 require_once(dirname(__FILE__).'/figlet.php'); 104 $figlet = new phpFiglet(); 105 if($figlet->loadfont(dirname(__FILE__).'/figlet.flf')) { 106 $out .= '<pre>'; 107 $out .= rtrim($figlet->fetch($code)); 108 $out .= '</pre>'; 109 } else { 110 msg('Failed to load figlet.flf font file. CAPTCHA broken', -1); 111 } 112 break; 113 } 114 $out .= ' <input type="text" size="'.$txtlen.'" name="'.$this->field_in.'" class="edit" /> '; 115 116 // add honeypot field 117 $out .= '<label class="no">'.$this->getLang('honeypot').'<input type="text" name="'.$this->field_hp.'" /></label>'; 118 $out .= '</div>'; 119 return $out; 120 } 121 122 /** 123 * Checks if the the CAPTCHA was solved correctly 124 * 125 * @param bool $msg when true, an error will be signalled through the msg() method 126 * @return bool true when the answer was correct, otherwise false 127 */ 128 public function check($msg = true) { 129 global $INPUT; 130 131 $field_sec = $INPUT->str($this->field_sec); 132 $field_in = $INPUT->str($this->field_in); 133 $field_hp = $INPUT->str($this->field_hp); 134 135 // reconstruct captcha from provided $field_sec 136 $rand = $this->decrypt($field_sec); 137 138 if($this->getConf('mode') == 'math') { 139 $code = $this->_generateMATH($this->_fixedIdent(), $rand); 140 $code = $code[1]; 141 } elseif($this->getConf('mode') == 'question') { 142 $code = $this->getConf('answer'); 143 } else { 144 $code = $this->_generateCAPTCHA($this->_fixedIdent(), $rand); 145 } 146 147 // compare values 148 if(!$field_sec || 149 !$field_in || 150 $rand === false || 151 utf8_strtolower($field_in) != utf8_strtolower($code) || 152 trim($field_hp) !== '' || 153 !$this->retrieveCaptchaCookie($this->_fixedIdent(), $rand) 154 ) { 155 if($msg) msg($this->getLang('testfailed'), -1); 156 return false; 157 } 158 return true; 159 } 160 161 /** 162 * Get the path where a captcha cookie would be stored 163 * 164 * We use a daily temp directory which is easy to clean up 165 * 166 * @param $fixed string the fixed part, any string 167 * @param $rand float some random number between 0 and 1 168 * @return string the path to the cookie file 169 */ 170 protected function getCaptchaCookiePath($fixed, $rand) { 171 global $conf; 172 $path = $conf['tmpdir'] . '/captcha/' . date('Y-m-d') . '/' . md5($fixed . $rand) . '.cookie'; 173 io_makeFileDir($path); 174 return $path; 175 } 176 177 /** 178 * remove all outdated captcha cookies 179 */ 180 public function _cleanCaptchaCookies() { 181 global $conf; 182 $path = $conf['tmpdir'] . '/captcha/'; 183 $dirs = glob("$path/*", GLOB_ONLYDIR); 184 $today = date('Y-m-d'); 185 foreach($dirs as $dir) { 186 if(basename($dir) === $today) continue; 187 if(!preg_match('/\/captcha\//', $dir)) continue; // safety net 188 io_rmdir($dir, true); 189 } 190 } 191 192 /** 193 * Creates a one time captcha cookie 194 * 195 * This is used to prevent replay attacks. It is generated when the captcha form 196 * is shown and checked with the captcha check. Since we can not be sure about the 197 * session state (might be closed or open) we're not using it. 198 * 199 * We're not using the stored values for displaying the captcha image (or audio) 200 * but continue to use our encryption scheme. This way it's still possible to have 201 * multiple captcha checks going on in parallel (eg. with multiple browser tabs) 202 * 203 * @param $fixed string the fixed part, any string 204 * @param $rand float some random number between 0 and 1 205 */ 206 protected function storeCaptchaCookie($fixed, $rand) { 207 $cache = $this->getCaptchaCookiePath($fixed, $rand); 208 touch($cache); 209 } 210 211 /** 212 * Checks if the captcha cookie exists and deletes it 213 * 214 * @param $fixed string the fixed part, any string 215 * @param $rand float some random number between 0 and 1 216 * @return bool true if the cookie existed 217 */ 218 protected function retrieveCaptchaCookie($fixed, $rand) { 219 $cache = $this->getCaptchaCookiePath($fixed, $rand); 220 if(file_exists($cache)) { 221 unlink($cache); 222 return true; 223 } 224 return false; 225 } 226 227 /** 228 * Build a semi-secret fixed string identifying the current page and user 229 * 230 * This string is always the same for the current user when editing the same 231 * page revision, but only for one day. Editing a page before midnight and saving 232 * after midnight will result in a failed CAPTCHA once, but makes sure it can 233 * not be reused which is especially important for the registration form where the 234 * $ID usually won't change. 235 * 236 * @return string 237 */ 238 public function _fixedIdent() { 239 global $ID; 240 $lm = @filemtime(wikiFN($ID)); 241 $td = date('Y-m-d'); 242 return auth_browseruid() . 243 auth_cookiesalt() . 244 $ID . $lm . $td; 245 } 246 247 /** 248 * Adds random space characters within the given text 249 * 250 * Keeps subsequent numbers without spaces (for math problem) 251 * 252 * @param $text 253 * @return string 254 */ 255 protected function _obfuscateText($text) { 256 $new = ''; 257 258 $spaces = array( 259 "\r", 260 "\n", 261 "\r\n", 262 ' ', 263 "\xC2\xA0", // \u00A0 NO-BREAK SPACE 264 "\xE2\x80\x80", // \u2000 EN QUAD 265 "\xE2\x80\x81", // \u2001 EM QUAD 266 "\xE2\x80\x82", // \u2002 EN SPACE 267 // "\xE2\x80\x83", // \u2003 EM SPACE 268 "\xE2\x80\x84", // \u2004 THREE-PER-EM SPACE 269 "\xE2\x80\x85", // \u2005 FOUR-PER-EM SPACE 270 "\xE2\x80\x86", // \u2006 SIX-PER-EM SPACE 271 "\xE2\x80\x87", // \u2007 FIGURE SPACE 272 "\xE2\x80\x88", // \u2008 PUNCTUATION SPACE 273 "\xE2\x80\x89", // \u2009 THIN SPACE 274 "\xE2\x80\x8A", // \u200A HAIR SPACE 275 "\xE2\x80\xAF", // \u202F NARROW NO-BREAK SPACE 276 "\xE2\x81\x9F", // \u205F MEDIUM MATHEMATICAL SPACE 277 278 "\xE1\xA0\x8E\r\n", // \u180E MONGOLIAN VOWEL SEPARATOR 279 "\xE2\x80\x8B\r\n", // \u200B ZERO WIDTH SPACE 280 "\xEF\xBB\xBF\r\n", // \uFEFF ZERO WIDTH NO-BREAK SPACE 281 ); 282 283 $len = strlen($text); 284 for($i = 0; $i < $len - 1; $i++) { 285 $new .= $text{$i}; 286 287 if(!is_numeric($text{$i + 1})) { 288 $new .= $spaces[array_rand($spaces)]; 289 } 290 } 291 $new .= $text{$len - 1}; 292 return $new; 293 } 294 295 /** 296 * Generate some numbers from a known string and random number 297 * 298 * @param $fixed string the fixed part, any string 299 * @param $rand float some random number between 0 and 1 300 * @return string 301 */ 302 protected function _generateNumbers($fixed, $rand) { 303 $fixed = hexdec(substr(md5($fixed), 5, 5)); // use part of the md5 to generate an int 304 $rand = $rand * 0xFFFFF; // bitmask from the random number 305 return md5($rand ^ $fixed); // combine both values 306 } 307 308 /** 309 * Generates a random char string 310 * 311 * @param $fixed string the fixed part, any string 312 * @param $rand float some random number between 0 and 1 313 * @return string 314 */ 315 public function _generateCAPTCHA($fixed, $rand) { 316 $numbers = $this->_generateNumbers($fixed, $rand); 317 318 // now create the letters 319 $code = ''; 320 $lettercount = $this->getConf('lettercount') * 2; 321 if($lettercount > strlen($numbers)) $lettercount = strlen($numbers); 322 for($i = 0; $i < $lettercount; $i += 2) { 323 $code .= chr(floor(hexdec($numbers[$i].$numbers[$i + 1]) / 10) + 65); 324 } 325 326 return $code; 327 } 328 329 /** 330 * Create a mathematical task and its result 331 * 332 * @param $fixed string the fixed part, any string 333 * @param $rand float some random number between 0 and 1 334 * @return array taks, result 335 */ 336 protected function _generateMATH($fixed, $rand) { 337 $numbers = $this->_generateNumbers($fixed, $rand); 338 339 // first letter is the operator (+/-) 340 $op = (hexdec($numbers[0]) > 8) ? -1 : 1; 341 $num = array(hexdec($numbers[1].$numbers[2]), hexdec($numbers[3])); 342 343 // we only want positive results 344 if(($op < 0) && ($num[0] < $num[1])) rsort($num); 345 346 // prepare result and task text 347 $res = $num[0] + ($num[1] * $op); 348 $task = $num[0].(($op < 0) ? '-' : '+').$num[1].'=?'; 349 350 return array($task, $res); 351 } 352 353 /** 354 * Create a CAPTCHA image 355 * 356 * @param string $text the letters to display 357 */ 358 public function _imageCAPTCHA($text) { 359 $w = $this->getConf('width'); 360 $h = $this->getConf('height'); 361 362 $fonts = glob(dirname(__FILE__).'/fonts/*.ttf'); 363 364 // create a white image 365 $img = imagecreatetruecolor($w, $h); 366 $white = imagecolorallocate($img, 255, 255, 255); 367 imagefill($img, 0, 0, $white); 368 369 // add some lines as background noise 370 for($i = 0; $i < 30; $i++) { 371 $color = imagecolorallocate($img, rand(100, 250), rand(100, 250), rand(100, 250)); 372 imageline($img, rand(0, $w), rand(0, $h), rand(0, $w), rand(0, $h), $color); 373 } 374 375 // draw the letters 376 $txtlen = strlen($text); 377 for($i = 0; $i < $txtlen; $i++) { 378 $font = $fonts[array_rand($fonts)]; 379 $color = imagecolorallocate($img, rand(0, 100), rand(0, 100), rand(0, 100)); 380 $size = rand(floor($h / 1.8), floor($h * 0.7)); 381 $angle = rand(-35, 35); 382 383 $x = ($w * 0.05) + $i * floor($w * 0.9 / $txtlen); 384 $cheight = $size + ($size * 0.5); 385 $y = floor($h / 2 + $cheight / 3.8); 386 387 imagettftext($img, $size, $angle, $x, $y, $color, $font, $text[$i]); 388 } 389 390 header("Content-type: image/png"); 391 imagepng($img); 392 imagedestroy($img); 393 } 394 395 /** 396 * Create an SVG of the given text 397 * 398 * @param string $text 399 * @return string 400 */ 401 public function _svgCAPTCHA($text) { 402 require_once(__DIR__ . '/EasySVG.php'); 403 404 $fonts = glob(__DIR__ . '/fonts/*.svg'); 405 406 $x = 0; // where we start to draw 407 $y = 100; // our max height 408 409 $svg = new EasySVG(); 410 411 // draw the letters 412 $txtlen = strlen($text); 413 for($i = 0; $i < $txtlen; $i++) { 414 $char = $text[$i]; 415 $size = rand($y / 2, $y - $y * 0.1); // 50-90% 416 $svg->setFontSVG($fonts[array_rand($fonts)]); 417 418 $svg->setFontSize($size); 419 $svg->setLetterSpacing(round(rand(1, 4) / 10, 2)); // 0.1 - 0.4 420 $svg->addText($char, $x, rand(0, round($y - $size))); // random up and down 421 422 list($w) = $svg->textDimensions($char); 423 $x += $w; 424 } 425 426 $svg->addAttribute('width', $x . 'px'); 427 $svg->addAttribute('height', $y . 'px'); 428 $svg->addAttribute('viewbox', "0 0 $x $y"); 429 return $svg->asXML(); 430 } 431 432 /** 433 * Encrypt the given string with the cookie salt 434 * 435 * @param string $data 436 * @return string 437 */ 438 public function encrypt($data) { 439 if(function_exists('auth_encrypt')) { 440 $data = auth_encrypt($data, auth_cookiesalt()); // since binky 441 } else { 442 $data = PMA_blowfish_encrypt($data, auth_cookiesalt()); // deprecated 443 } 444 445 return base64_encode($data); 446 } 447 448 /** 449 * Decrypt the given string with the cookie salt 450 * 451 * @param string $data 452 * @return string 453 */ 454 public function decrypt($data) { 455 $data = base64_decode($data); 456 if($data === false || $data === '') return false; 457 458 if(function_exists('auth_decrypt')) { 459 return auth_decrypt($data, auth_cookiesalt()); // since binky 460 } else { 461 return PMA_blowfish_decrypt($data, auth_cookiesalt()); // deprecated 462 } 463 } 464} 465