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 return auth_browseruid() . 249 auth_cookiesalt() . 250 $ID . $lm . $td; 251 } 252 253 /** 254 * Adds random space characters within the given text 255 * 256 * Keeps subsequent numbers without spaces (for math problem) 257 * 258 * @param $text 259 * @return string 260 */ 261 protected function _obfuscateText($text) 262 { 263 $new = ''; 264 265 $spaces = array( 266 "\r", 267 "\n", 268 "\r\n", 269 ' ', 270 "\xC2\xA0", // \u00A0 NO-BREAK SPACE 271 "\xE2\x80\x80", // \u2000 EN QUAD 272 "\xE2\x80\x81", // \u2001 EM QUAD 273 "\xE2\x80\x82", // \u2002 EN SPACE 274 // "\xE2\x80\x83", // \u2003 EM SPACE 275 "\xE2\x80\x84", // \u2004 THREE-PER-EM SPACE 276 "\xE2\x80\x85", // \u2005 FOUR-PER-EM SPACE 277 "\xE2\x80\x86", // \u2006 SIX-PER-EM SPACE 278 "\xE2\x80\x87", // \u2007 FIGURE SPACE 279 "\xE2\x80\x88", // \u2008 PUNCTUATION SPACE 280 "\xE2\x80\x89", // \u2009 THIN SPACE 281 "\xE2\x80\x8A", // \u200A HAIR SPACE 282 "\xE2\x80\xAF", // \u202F NARROW NO-BREAK SPACE 283 "\xE2\x81\x9F", // \u205F MEDIUM MATHEMATICAL SPACE 284 285 "\xE1\xA0\x8E\r\n", // \u180E MONGOLIAN VOWEL SEPARATOR 286 "\xE2\x80\x8B\r\n", // \u200B ZERO WIDTH SPACE 287 "\xEF\xBB\xBF\r\n", // \uFEFF ZERO WIDTH NO-BREAK SPACE 288 ); 289 290 $len = strlen($text); 291 for ($i = 0; $i < $len - 1; $i++) { 292 $new .= $text[$i]; 293 294 if (!is_numeric($text[$i + 1])) { 295 $new .= $spaces[array_rand($spaces)]; 296 } 297 } 298 $new .= $text[$len - 1]; 299 return $new; 300 } 301 302 /** 303 * Generate some numbers from a known string and random number 304 * 305 * @param $fixed string the fixed part, any string 306 * @param $rand float some random number between 0 and 1 307 * @return string 308 */ 309 protected function _generateNumbers($fixed, $rand) 310 { 311 $fixed = hexdec(substr(md5($fixed), 5, 5)); // use part of the md5 to generate an int 312 $rand = $rand * 0xFFFFF; // bitmask from the random number 313 return md5($rand ^ $fixed); // combine both values 314 } 315 316 /** 317 * Generates a random char string 318 * 319 * @param $fixed string the fixed part, any string 320 * @param $rand float some random number between 0 and 1 321 * @return string 322 */ 323 public function _generateCAPTCHA($fixed, $rand) 324 { 325 $numbers = $this->_generateNumbers($fixed, $rand); 326 327 // now create the letters 328 $code = ''; 329 $lettercount = $this->getConf('lettercount') * 2; 330 if ($lettercount > strlen($numbers)) $lettercount = strlen($numbers); 331 for ($i = 0; $i < $lettercount; $i += 2) { 332 $code .= chr(floor(hexdec($numbers[$i] . $numbers[$i + 1]) / 10) + 65); 333 } 334 335 return $code; 336 } 337 338 /** 339 * Create a mathematical task and its result 340 * 341 * @param $fixed string the fixed part, any string 342 * @param $rand float some random number between 0 and 1 343 * @return array taks, result 344 */ 345 protected function _generateMATH($fixed, $rand) 346 { 347 $numbers = $this->_generateNumbers($fixed, $rand); 348 349 // first letter is the operator (+/-) 350 $op = (hexdec($numbers[0]) > 8) ? -1 : 1; 351 $num = array(hexdec($numbers[1] . $numbers[2]), hexdec($numbers[3])); 352 353 // we only want positive results 354 if (($op < 0) && ($num[0] < $num[1])) rsort($num); 355 356 // prepare result and task text 357 $res = $num[0] + ($num[1] * $op); 358 $task = $num[0] . (($op < 0) ? '-' : '+') . $num[1] . '= '; 359 360 return array($task, $res); 361 } 362 363 /** 364 * Create a CAPTCHA image 365 * 366 * @param string $text the letters to display 367 */ 368 public function _imageCAPTCHA($text) 369 { 370 $w = $this->getConf('width'); 371 $h = $this->getConf('height'); 372 373 $fonts = glob(dirname(__FILE__) . '/fonts/*.ttf'); 374 375 // create a white image 376 $img = imagecreatetruecolor($w, $h); 377 $white = imagecolorallocate($img, 255, 255, 255); 378 imagefill($img, 0, 0, $white); 379 380 // add some lines as background noise 381 for ($i = 0; $i < 30; $i++) { 382 $color = imagecolorallocate($img, rand(100, 250), rand(100, 250), rand(100, 250)); 383 imageline($img, rand(0, $w), rand(0, $h), rand(0, $w), rand(0, $h), $color); 384 } 385 386 // draw the letters 387 $txtlen = strlen($text); 388 for ($i = 0; $i < $txtlen; $i++) { 389 $font = $fonts[array_rand($fonts)]; 390 $color = imagecolorallocate($img, rand(0, 100), rand(0, 100), rand(0, 100)); 391 $size = rand(floor($h / 1.8), floor($h * 0.7)); 392 $angle = rand(-35, 35); 393 394 $x = ($w * 0.05) + $i * floor($w * 0.9 / $txtlen); 395 $cheight = $size + ($size * 0.5); 396 $y = floor($h / 2 + $cheight / 3.8); 397 398 imagettftext($img, $size, $angle, $x, $y, $color, $font, $text[$i]); 399 } 400 401 header("Content-type: image/png"); 402 imagepng($img); 403 imagedestroy($img); 404 } 405 406 /** 407 * Create an SVG of the given text 408 * 409 * @param string $text 410 * @return string 411 */ 412 public function _svgCAPTCHA($text) 413 { 414 require_once(__DIR__ . '/EasySVG.php'); 415 416 $fonts = glob(__DIR__ . '/fonts/*.svg'); 417 418 $x = 0; // where we start to draw 419 $y = 100; // our max height 420 421 $svg = new EasySVG(); 422 423 // draw the letters 424 $txtlen = strlen($text); 425 for ($i = 0; $i < $txtlen; $i++) { 426 $char = $text[$i]; 427 $size = rand($y / 2, $y - $y * 0.1); // 50-90% 428 $svg->setFontSVG($fonts[array_rand($fonts)]); 429 430 $svg->setFontSize($size); 431 $svg->setLetterSpacing(round(rand(1, 4) / 10, 2)); // 0.1 - 0.4 432 $svg->addText($char, $x, rand(0, round($y - $size))); // random up and down 433 434 list($w) = $svg->textDimensions($char); 435 $x += $w; 436 } 437 438 $svg->addAttribute('width', $x . 'px'); 439 $svg->addAttribute('height', $y . 'px'); 440 $svg->addAttribute('viewbox', "0 0 $x $y"); 441 return $svg->asXML(); 442 } 443 444 /** 445 * Encrypt the given string with the cookie salt 446 * 447 * @param string $data 448 * @return string 449 */ 450 public function encrypt($data) 451 { 452 if (function_exists('auth_encrypt')) { 453 $data = auth_encrypt($data, auth_cookiesalt()); // since binky 454 } else { 455 $data = PMA_blowfish_encrypt($data, auth_cookiesalt()); // deprecated 456 } 457 458 return base64_encode($data); 459 } 460 461 /** 462 * Decrypt the given string with the cookie salt 463 * 464 * @param string $data 465 * @return string 466 */ 467 public function decrypt($data) 468 { 469 $data = base64_decode($data); 470 if ($data === false || $data === '') return false; 471 472 if (function_exists('auth_decrypt')) { 473 return auth_decrypt($data, auth_cookiesalt()); // since binky 474 } else { 475 return PMA_blowfish_decrypt($data, auth_cookiesalt()); // deprecated 476 } 477 } 478 479} 480