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