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