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