xref: /plugin/captcha/helper.php (revision 63609b6ea4366bd9c8e1bff48a61b5e5cd514a21)
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) . '&amp;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) . '&amp;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) . '&amp;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) . '&amp;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