1<?php 2/** 3 * @license GPL 2 (http://www.gnu.org/licenses/gpl.html) 4 * @author Andreas Gohr <andi@splitbrain.org> 5 */ 6 7/** 8 * Class helper_plugin_captcha 9 */ 10class helper_plugin_captcha extends DokuWiki_Plugin 11{ 12 13 protected $field_in = 'plugin__captcha'; 14 protected $field_sec = 'plugin__captcha_secret'; 15 protected $field_hp = 'plugin__captcha_honeypot'; 16 17 /** 18 * Constructor. Initializes field names 19 */ 20 public function __construct() 21 { 22 $this->field_in = md5($this->_fixedIdent() . $this->field_in); 23 $this->field_sec = md5($this->_fixedIdent() . $this->field_sec); 24 $this->field_hp = md5($this->_fixedIdent() . $this->field_hp); 25 } 26 27 /** 28 * Check if the CAPTCHA should be used. Always check this before using the methods below. 29 * 30 * @return bool true when the CAPTCHA should be used 31 */ 32 public function isEnabled() 33 { 34 if (!$this->getConf('forusers') && $_SERVER['REMOTE_USER']) return false; 35 return true; 36 } 37 38 /** 39 * Returns the HTML to display the CAPTCHA with the chosen method 40 */ 41 public function getHTML() 42 { 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 audiolink" 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 audiolink" 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 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 { 130 global $INPUT; 131 132 $field_sec = $INPUT->str($this->field_sec); 133 $field_in = $INPUT->str($this->field_in); 134 $field_hp = $INPUT->str($this->field_hp); 135 136 // reconstruct captcha from provided $field_sec 137 $rand = $this->decrypt($field_sec); 138 139 if ($this->getConf('mode') == 'math') { 140 $code = $this->_generateMATH($this->_fixedIdent(), $rand); 141 $code = $code[1]; 142 } elseif ($this->getConf('mode') == 'question') { 143 $code = $this->getConf('answer'); 144 } else { 145 $code = $this->_generateCAPTCHA($this->_fixedIdent(), $rand); 146 } 147 148 // compare values 149 if (!$field_sec || 150 !$field_in || 151 $rand === false || 152 utf8_strtolower($field_in) != utf8_strtolower($code) || 153 trim($field_hp) !== '' || 154 !$this->retrieveCaptchaCookie($this->_fixedIdent(), $rand) 155 ) { 156 if ($msg) msg($this->getLang('testfailed'), -1); 157 return false; 158 } 159 return true; 160 } 161 162 /** 163 * Get the path where a captcha cookie would be stored 164 * 165 * We use a daily temp directory which is easy to clean up 166 * 167 * @param $fixed string the fixed part, any string 168 * @param $rand float some random number between 0 and 1 169 * @return string the path to the cookie file 170 */ 171 protected function getCaptchaCookiePath($fixed, $rand) 172 { 173 global $conf; 174 $path = $conf['tmpdir'] . '/captcha/' . date('Y-m-d') . '/' . md5($fixed . $rand) . '.cookie'; 175 io_makeFileDir($path); 176 return $path; 177 } 178 179 /** 180 * remove all outdated captcha cookies 181 */ 182 public function _cleanCaptchaCookies() 183 { 184 global $conf; 185 $path = $conf['tmpdir'] . '/captcha/'; 186 $dirs = glob("$path/*", GLOB_ONLYDIR); 187 $today = date('Y-m-d'); 188 foreach ($dirs as $dir) { 189 if (basename($dir) === $today) continue; 190 if (!preg_match('/\/captcha\//', $dir)) continue; // safety net 191 io_rmdir($dir, true); 192 } 193 } 194 195 /** 196 * Creates a one time captcha cookie 197 * 198 * This is used to prevent replay attacks. It is generated when the captcha form 199 * is shown and checked with the captcha check. Since we can not be sure about the 200 * session state (might be closed or open) we're not using it. 201 * 202 * We're not using the stored values for displaying the captcha image (or audio) 203 * but continue to use our encryption scheme. This way it's still possible to have 204 * multiple captcha checks going on in parallel (eg. with multiple browser tabs) 205 * 206 * @param $fixed string the fixed part, any string 207 * @param $rand float some random number between 0 and 1 208 */ 209 protected function storeCaptchaCookie($fixed, $rand) 210 { 211 $cache = $this->getCaptchaCookiePath($fixed, $rand); 212 touch($cache); 213 } 214 215 /** 216 * Checks if the captcha cookie exists and deletes it 217 * 218 * @param $fixed string the fixed part, any string 219 * @param $rand float some random number between 0 and 1 220 * @return bool true if the cookie existed 221 */ 222 protected function retrieveCaptchaCookie($fixed, $rand) 223 { 224 $cache = $this->getCaptchaCookiePath($fixed, $rand); 225 if (file_exists($cache)) { 226 unlink($cache); 227 return true; 228 } 229 return false; 230 } 231 232 /** 233 * Build a semi-secret fixed string identifying the current page and user 234 * 235 * This string is always the same for the current user when editing the same 236 * page revision, but only for one day. Editing a page before midnight and saving 237 * after midnight will result in a failed CAPTCHA once, but makes sure it can 238 * not be reused which is especially important for the registration form where the 239 * $ID usually won't change. 240 * 241 * @return string 242 */ 243 public function _fixedIdent() 244 { 245 global $ID; 246 $lm = @filemtime(wikiFN($ID)); 247 $td = date('Y-m-d'); 248 $ip = clientIP(); 249 $salt = auth_cookiesalt(); 250 251 return sha1(join("\n", [$ID, $lm, $td, $ip, $salt])); 252 } 253 254 /** 255 * Adds random space characters within the given text 256 * 257 * Keeps subsequent numbers without spaces (for math problem) 258 * 259 * @param $text 260 * @return string 261 */ 262 protected function _obfuscateText($text) 263 { 264 $new = ''; 265 266 $spaces = array( 267 "\r", 268 "\n", 269 "\r\n", 270 ' ', 271 "\xC2\xA0", // \u00A0 NO-BREAK SPACE 272 "\xE2\x80\x80", // \u2000 EN QUAD 273 "\xE2\x80\x81", // \u2001 EM QUAD 274 "\xE2\x80\x82", // \u2002 EN SPACE 275 // "\xE2\x80\x83", // \u2003 EM SPACE 276 "\xE2\x80\x84", // \u2004 THREE-PER-EM SPACE 277 "\xE2\x80\x85", // \u2005 FOUR-PER-EM SPACE 278 "\xE2\x80\x86", // \u2006 SIX-PER-EM SPACE 279 "\xE2\x80\x87", // \u2007 FIGURE SPACE 280 "\xE2\x80\x88", // \u2008 PUNCTUATION SPACE 281 "\xE2\x80\x89", // \u2009 THIN SPACE 282 "\xE2\x80\x8A", // \u200A HAIR SPACE 283 "\xE2\x80\xAF", // \u202F NARROW NO-BREAK SPACE 284 "\xE2\x81\x9F", // \u205F MEDIUM MATHEMATICAL SPACE 285 286 "\xE1\xA0\x8E\r\n", // \u180E MONGOLIAN VOWEL SEPARATOR 287 "\xE2\x80\x8B\r\n", // \u200B ZERO WIDTH SPACE 288 "\xEF\xBB\xBF\r\n", // \uFEFF ZERO WIDTH NO-BREAK SPACE 289 ); 290 291 $len = strlen($text); 292 for ($i = 0; $i < $len - 1; $i++) { 293 $new .= $text[$i]; 294 295 if (!is_numeric($text[$i + 1])) { 296 $new .= $spaces[array_rand($spaces)]; 297 } 298 } 299 $new .= $text[$len - 1]; 300 return $new; 301 } 302 303 /** 304 * Generate some numbers from a known string and random number 305 * 306 * @param $fixed string the fixed part, any string 307 * @param $rand float some random number between 0 and 1 308 * @return string 309 */ 310 protected function _generateNumbers($fixed, $rand) 311 { 312 $fixed = hexdec(substr(md5($fixed), 5, 5)); // use part of the md5 to generate an int 313 $rand = $rand * 0xFFFFF; // bitmask from the random number 314 return md5($rand ^ $fixed); // combine both values 315 } 316 317 /** 318 * Generates a random char string 319 * 320 * @param $fixed string the fixed part, any string 321 * @param $rand float some random number between 0 and 1 322 * @return string 323 */ 324 public function _generateCAPTCHA($fixed, $rand) 325 { 326 $numbers = $this->_generateNumbers($fixed, $rand); 327 328 // now create the letters 329 $code = ''; 330 $lettercount = $this->getConf('lettercount') * 2; 331 if ($lettercount > strlen($numbers)) $lettercount = strlen($numbers); 332 for ($i = 0; $i < $lettercount; $i += 2) { 333 $code .= chr(floor(hexdec($numbers[$i] . $numbers[$i + 1]) / 10) + 65); 334 } 335 336 return $code; 337 } 338 339 /** 340 * Create a mathematical task and its result 341 * 342 * @param $fixed string the fixed part, any string 343 * @param $rand float some random number between 0 and 1 344 * @return array taks, result 345 */ 346 protected function _generateMATH($fixed, $rand) 347 { 348 $numbers = $this->_generateNumbers($fixed, $rand); 349 350 // first letter is the operator (+/-) 351 $op = (hexdec($numbers[0]) > 8) ? -1 : 1; 352 $num = array(hexdec($numbers[1] . $numbers[2]), hexdec($numbers[3])); 353 354 // we only want positive results 355 if (($op < 0) && ($num[0] < $num[1])) rsort($num); 356 357 // prepare result and task text 358 $res = $num[0] + ($num[1] * $op); 359 $task = $num[0] . (($op < 0) ? '-' : '+') . $num[1] . '= '; 360 361 return array($task, $res); 362 } 363 364 /** 365 * Create a CAPTCHA image 366 * 367 * @param string $text the letters to display 368 */ 369 public function _imageCAPTCHA($text) 370 { 371 $w = $this->getConf('width'); 372 $h = $this->getConf('height'); 373 374 $fonts = glob(dirname(__FILE__) . '/fonts/*.ttf'); 375 376 // create a white image 377 $img = imagecreatetruecolor($w, $h); 378 $white = imagecolorallocate($img, 255, 255, 255); 379 imagefill($img, 0, 0, $white); 380 381 // add some lines as background noise 382 for ($i = 0; $i < 30; $i++) { 383 $color = imagecolorallocate($img, rand(100, 250), rand(100, 250), rand(100, 250)); 384 imageline($img, rand(0, $w), rand(0, $h), rand(0, $w), rand(0, $h), $color); 385 } 386 387 // draw the letters 388 $txtlen = strlen($text); 389 for ($i = 0; $i < $txtlen; $i++) { 390 $font = $fonts[array_rand($fonts)]; 391 $color = imagecolorallocate($img, rand(0, 100), rand(0, 100), rand(0, 100)); 392 $size = rand(floor($h / 1.8), floor($h * 0.7)); 393 $angle = rand(-35, 35); 394 395 $x = ($w * 0.05) + $i * floor($w * 0.9 / $txtlen); 396 $cheight = $size + ($size * 0.5); 397 $y = floor($h / 2 + $cheight / 3.8); 398 399 imagettftext($img, $size, $angle, $x, $y, $color, $font, $text[$i]); 400 } 401 402 header("Content-type: image/png"); 403 imagepng($img); 404 imagedestroy($img); 405 } 406 407 /** 408 * Create an SVG of the given text 409 * 410 * @param string $text 411 * @return string 412 */ 413 public function _svgCAPTCHA($text) 414 { 415 require_once(__DIR__ . '/EasySVG.php'); 416 417 $fonts = glob(__DIR__ . '/fonts/*.svg'); 418 419 $x = 0; // where we start to draw 420 $y = 100; // our max height 421 422 $svg = new EasySVG(); 423 424 // draw the letters 425 $txtlen = strlen($text); 426 for ($i = 0; $i < $txtlen; $i++) { 427 $char = $text[$i]; 428 $size = rand($y / 2, $y - $y * 0.1); // 50-90% 429 $svg->setFontSVG($fonts[array_rand($fonts)]); 430 431 $svg->setFontSize($size); 432 $svg->setLetterSpacing(round(rand(1, 4) / 10, 2)); // 0.1 - 0.4 433 $svg->addText($char, $x, rand(0, round($y - $size))); // random up and down 434 435 list($w) = $svg->textDimensions($char); 436 $x += $w; 437 } 438 439 $svg->addAttribute('width', $x . 'px'); 440 $svg->addAttribute('height', $y . 'px'); 441 $svg->addAttribute('viewbox', "0 0 $x $y"); 442 return $svg->asXML(); 443 } 444 445 /** 446 * Generate an audio captcha 447 * 448 * @param string $text 449 */ 450 public function _audioCAPTCHA($text){ 451 global $conf; 452 453 $lc = __DIR__ . '/lang/' . $conf['lang'] . '/audio/'; 454 $en = __DIR__ . '/lang/en/audio/'; 455 456 $wavs = []; 457 458 $text = strtolower($text); 459 $txtlen = strlen($text); 460 for ($i = 0; $i < $txtlen; $i++) { 461 $char = $text[$i]; 462 $file = $lc . $char . '.wav'; 463 if (!@file_exists($file)) $file = $en . $char . '.wav'; 464 $wavs[] = $file; 465 } 466 467 header('Content-type: audio/x-wav'); 468 header('Content-Disposition: attachment;filename=captcha.wav'); 469 470 echo $this->joinwavs($wavs); 471 } 472 473 /** 474 * Encrypt the given string with the cookie salt 475 * 476 * @param string $data 477 * @return string 478 */ 479 public function encrypt($data) 480 { 481 if (function_exists('auth_encrypt')) { 482 $data = auth_encrypt($data, auth_cookiesalt()); // since binky 483 } else { 484 $data = PMA_blowfish_encrypt($data, auth_cookiesalt()); // deprecated 485 } 486 487 return base64_encode($data); 488 } 489 490 /** 491 * Decrypt the given string with the cookie salt 492 * 493 * @param string $data 494 * @return string 495 */ 496 public function decrypt($data) 497 { 498 $data = base64_decode($data); 499 if ($data === false || $data === '') return false; 500 501 if (function_exists('auth_decrypt')) { 502 return auth_decrypt($data, auth_cookiesalt()); // since binky 503 } else { 504 return PMA_blowfish_decrypt($data, auth_cookiesalt()); // deprecated 505 } 506 } 507 508 509 /** 510 * Join multiple wav files 511 * 512 * All wave files need to have the same format and need to be uncompressed. 513 * The headers of the last file will be used (with recalculated datasize 514 * of course) 515 * 516 * @link http://ccrma.stanford.edu/CCRMA/Courses/422/projects/WaveFormat/ 517 * @link http://www.thescripts.com/forum/thread3770.html 518 */ 519 protected function joinwavs($wavs) 520 { 521 $fields = join( 522 '/', array( 523 'H8ChunkID', 524 'VChunkSize', 525 'H8Format', 526 'H8Subchunk1ID', 527 'VSubchunk1Size', 528 'vAudioFormat', 529 'vNumChannels', 530 'VSampleRate', 531 'VByteRate', 532 'vBlockAlign', 533 'vBitsPerSample', 534 ) 535 ); 536 537 $data = ''; 538 foreach ($wavs as $wav) { 539 $fp = fopen($wav, 'rb'); 540 $header = fread($fp, 36); 541 $info = unpack($fields, $header); 542 543 // read optional extra stuff 544 if ($info['Subchunk1Size'] > 16) { 545 $header .= fread($fp, ($info['Subchunk1Size'] - 16)); 546 } 547 548 // read SubChunk2ID 549 $header .= fread($fp, 4); 550 551 // read Subchunk2Size 552 $size = unpack('vsize', fread($fp, 4)); 553 $size = $size['size']; 554 555 // read data 556 $data .= fread($fp, $size); 557 } 558 559 return $header . pack('V', strlen($data)) . $data; 560 } 561 562} 563