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