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