xref: /plugin/captcha/helper.php (revision 1cd9cde7fa3af3699251c409d44d1efc6f383e17)
1<?php
2
3use dokuwiki\Extension\Plugin;
4use dokuwiki\plugin\captcha\FileCookie;
5use dokuwiki\Utf8\PhpString;
6
7/**
8 * @license    GPL 2 (http://www.gnu.org/licenses/gpl.html)
9 * @author     Andreas Gohr <andi@splitbrain.org>
10 */
11
12/**
13 * Class helper_plugin_captcha
14 */
15class helper_plugin_captcha extends Plugin
16{
17    protected $field_in = 'plugin__captcha';
18    protected $field_sec = 'plugin__captcha_secret';
19    protected $field_hp = 'plugin__captcha_honeypot';
20
21    // region Public API
22
23    /**
24     * Constructor. Initializes field names
25     */
26    public function __construct()
27    {
28        $this->field_in = md5($this->fixedIdent() . $this->field_in);
29        $this->field_sec = md5($this->fixedIdent() . $this->field_sec);
30        $this->field_hp = md5($this->fixedIdent() . $this->field_hp);
31    }
32
33    /**
34     * Check if the CAPTCHA should be used. Always check this before using the methods below.
35     *
36     * @return bool true when the CAPTCHA should be used
37     */
38    public function isEnabled()
39    {
40        global $INPUT;
41        if (!$this->getConf('forusers') && $INPUT->server->str('REMOTE_USER')) return false;
42        return true;
43    }
44
45    /**
46     * Returns the HTML to display the CAPTCHA with the chosen method
47     *
48     * @return string The HTML to display the CAPTCHA
49     */
50    public function getHTML()
51    {
52        global $ID;
53
54        $rand = (float)(random_int(0, 10000)) / 10000;
55        $cookie = new FileCookie($this->fixedIdent(), $rand);
56        $cookie->set();
57
58        if ($this->getConf('mode') == 'math') {
59            $code = $this->generateMath($this->fixedIdent(), $rand);
60            $code = $code[0];
61            $text = $this->getLang('fillmath');
62        } elseif ($this->getConf('mode') == 'question') {
63            $code = ''; // not used
64            $text = $this->getConf('question');
65        } else {
66            $code = $this->generateCaptchaCode($this->fixedIdent(), $rand);
67            $text = $this->getLang('fillcaptcha');
68        }
69        $secret = $this->encrypt($rand);
70
71        $txtlen = $this->getConf('lettercount');
72
73        $out = '';
74        $out .= '<div id="plugin__captcha_wrapper">';
75        $out .= '<input type="hidden" name="' . $this->field_sec . '" value="' . hsc($secret) . '" />';
76        $out .= '<label for="plugin__captcha">' . $text . '</label> ';
77
78        switch ($this->getConf('mode')) {
79            case 'math':
80            case 'text':
81                $out .= $this->obfuscateText($code);
82                break;
83            case 'js':
84                $out .= sprintf('<span id="plugin__captcha_code">%s</span>', $this->obfuscateText($code));
85                break;
86            case 'svg':
87                $out .= $this->htmlSvg($code);
88                break;
89            case 'svgaudio':
90                $out .= $this->htmlSvg($code);
91                $out .= $this->htmlAudioLink($secret, $ID);
92                break;
93            case 'image':
94                $out .= $this->htmlImage($ID, $secret);
95                break;
96            case 'audio':
97                $out .= $this->htmlImage($ID, $secret);
98                $out .= $this->htmlAudioLink($secret, $ID);
99                break;
100            case 'figlet':
101                $out .= $this->htmlFiglet($code);
102                break;
103        }
104        $out .= ' <input type="text" size="' . $txtlen . '" name="' . $this->field_in . '" class="edit" /> ';
105
106        // add honeypot field
107        $out .= sprintf(
108            '<label class="no">%s<input type="text" name="%s" /></label>',
109            $this->getLang('honeypot'),
110            $this->field_hp
111        );
112        $out .= '</div>';
113        return $out;
114    }
115
116    /**
117     * Checks if the CAPTCHA was solved correctly
118     *
119     * @param bool $msg when true, an error will be signalled through the msg() method
120     * @return bool true when the answer was correct, otherwise false
121     */
122    public function check($msg = true)
123    {
124        global $INPUT;
125
126        $field_sec = $INPUT->str($this->field_sec);
127        $field_in = $INPUT->str($this->field_in);
128        $field_hp = $INPUT->str($this->field_hp);
129
130        // reconstruct captcha from provided $field_sec
131        $rand = $this->decrypt($field_sec);
132
133        if ($this->getConf('mode') == 'math') {
134            $code = $this->generateMath($this->fixedIdent(), $rand);
135            $code = $code[1];
136        } elseif ($this->getConf('mode') == 'question') {
137            $code = $this->getConf('answer');
138        } else {
139            $code = $this->generateCaptchaCode($this->fixedIdent(), $rand);
140        }
141
142        // compare values
143        if (
144            !$field_sec ||
145            !$field_in ||
146            $rand === false ||
147            PhpString::strtolower($field_in) != PhpString::strtolower($code) ||
148            trim($field_hp) !== '' ||
149            !(new FileCookie($this->fixedIdent(), $rand))->check()
150        ) {
151            if ($msg) msg($this->getLang('testfailed'), -1);
152            return false;
153        }
154        return true;
155    }
156
157    // endregion
158
159    // region Captcha Generation methods
160
161    /**
162     * Build a semi-secret fixed string identifying the current page and user
163     *
164     * This string is always the same for the current user when editing the same
165     * page revision, but only for one day. Editing a page before midnight and saving
166     * after midnight will result in a failed CAPTCHA once, but makes sure it can
167     * not be reused which is especially important for the registration form where the
168     * $ID usually won't change.
169     *
170     * @return string
171     */
172    public function fixedIdent()
173    {
174        global $ID;
175        $lm = @filemtime(wikiFN($ID));
176        $td = date('Y-m-d');
177        $ip = clientIP();
178        $salt = auth_cookiesalt();
179
180        return sha1(implode("\n", [$ID, $lm, $td, $ip, $salt]));
181    }
182
183    /**
184     * Generate a magic code based on the given data
185     *
186     * This "magic" code represents the given fixed identifier (see fixedIdent()) and the given
187     * random number. It is used to generate the actual CAPTCHA code.
188     *
189     * @param $ident string the fixed part, any string
190     * @param $rand  float  some random number between 0 and 1
191     * @return string
192     */
193    protected function generateMagicCode($ident, $rand)
194    {
195        $ident = hexdec(substr(md5($ident), 5, 5)); // use part of the md5 to generate an int
196        $rand *= 0xFFFFF; // bitmask from the random number
197        return md5($rand ^ $ident); // combine both values
198    }
199
200    /**
201     * Generates a char string based on the given data
202     *
203     * The string is pseudo random based on a fixed identifier (see fixedIdent()) and a random number.
204     *
205     * @param $ident string the fixed part, any string
206     * @param $rand  float  some random number between 0 and 1
207     * @return string
208     */
209    public function generateCaptchaCode($ident, $rand)
210    {
211        $numbers = $this->generateMagicCode($ident, $rand);
212
213        // now create the letters
214        $code = '';
215        $lettercount = $this->getConf('lettercount') * 2;
216        if ($lettercount > strlen($numbers)) $lettercount = strlen($numbers);
217        for ($i = 0; $i < $lettercount; $i += 2) {
218            $code .= chr(floor(hexdec($numbers[$i] . $numbers[$i + 1]) / 10) + 65);
219        }
220
221        return $code;
222    }
223
224    /**
225     * Create a mathematical task and its result
226     *
227     * @param $ident string the fixed part, any string
228     * @param $rand  float  some random number between 0 and 1
229     * @return array [task, result]
230     */
231    protected function generateMath($ident, $rand)
232    {
233        $numbers = $this->generateMagicCode($ident, $rand);
234
235        // first letter is the operator (+/-)
236        $op = (hexdec($numbers[0]) > 8) ? -1 : 1;
237        $num = [hexdec($numbers[1] . $numbers[2]), hexdec($numbers[3])];
238
239        // we only want positive results
240        if (($op < 0) && ($num[0] < $num[1])) rsort($num);
241
242        // prepare result and task text
243        $res = $num[0] + ($num[1] * $op);
244        $task = $num[0] . (($op < 0) ? '-' : '+') . $num[1] . '= ';
245
246        return [$task, $res];
247    }
248
249    // endregion
250
251    // region Output Builders
252
253    /**
254     * Create a CAPTCHA image
255     *
256     * @param string $text the letters to display
257     * @return string The image data
258     */
259    public function imageCaptcha($text)
260    {
261        $w = $this->getConf('width');
262        $h = $this->getConf('height');
263
264        $fonts = glob(__DIR__ . '/fonts/*.ttf');
265
266        // create a white image
267        $img = imagecreatetruecolor($w, $h);
268        $white = imagecolorallocate($img, 255, 255, 255);
269        imagefill($img, 0, 0, $white);
270
271        // add some lines as background noise
272        for ($i = 0; $i < 30; $i++) {
273            $color = imagecolorallocate($img, random_int(100, 250), random_int(100, 250), random_int(100, 250));
274            imageline($img, random_int(0, $w), random_int(0, $h), random_int(0, $w), random_int(0, $h), $color);
275        }
276
277        // draw the letters
278        $txtlen = strlen($text);
279        for ($i = 0; $i < $txtlen; $i++) {
280            $font = $fonts[array_rand($fonts)];
281            $color = imagecolorallocate($img, random_int(0, 100), random_int(0, 100), random_int(0, 100));
282            $size = random_int(floor($h / 1.8), floor($h * 0.7));
283            $angle = random_int(-35, 35);
284
285            $x = ($w * 0.05) + $i * floor($w * 0.9 / $txtlen);
286            $cheight = $size + ($size * 0.5);
287            $y = floor($h / 2 + $cheight / 3.8);
288
289            imagettftext($img, $size, $angle, $x, $y, $color, $font, $text[$i]);
290        }
291
292        ob_start();
293        imagepng($img);
294        $image = ob_get_clean();
295        imagedestroy($img);
296        return $image;
297    }
298
299    /**
300     * Generate an audio captcha
301     *
302     * @param string $text
303     * @return string The joined wav files
304     */
305    public function audioCaptcha($text)
306    {
307        global $conf;
308
309        $lc = __DIR__ . '/lang/' . $conf['lang'] . '/audio/';
310        $en = __DIR__ . '/lang/en/audio/';
311
312        $wavs = [];
313
314        $text = strtolower($text);
315        $txtlen = strlen($text);
316        for ($i = 0; $i < $txtlen; $i++) {
317            $char = $text[$i];
318            $file = $lc . $char . '.wav';
319            if (!@file_exists($file)) $file = $en . $char . '.wav';
320            $wavs[] = $file;
321        }
322
323        return $this->joinwavs($wavs);
324    }
325
326    /**
327     * Create an SVG of the given text
328     *
329     * @param string $text
330     * @return string
331     */
332    public function svgCaptcha($text)
333    {
334        require_once(__DIR__ . '/EasySVG.php');
335
336        $fonts = glob(__DIR__ . '/fonts/*.svg');
337
338        $x = 0; // where we start to draw
339        $y = 100; // our max height
340
341        $svg = new EasySVG();
342
343        // draw the letters
344        $txtlen = strlen($text);
345        for ($i = 0; $i < $txtlen; $i++) {
346            $char = $text[$i];
347            $size = random_int($y / 2, $y - $y * 0.1); // 50-90%
348            $svg->setFontSVG($fonts[array_rand($fonts)]);
349
350            $svg->setFontSize($size);
351            $svg->setLetterSpacing(round(random_int(1, 4) / 10, 2)); // 0.1 - 0.4
352            $svg->addText($char, $x, random_int(0, round($y - $size))); // random up and down
353
354            [$w] = $svg->textDimensions($char);
355            $x += $w;
356        }
357
358        $svg->addAttribute('width', $x . 'px');
359        $svg->addAttribute('height', $y . 'px');
360        $svg->addAttribute('viewbox', "0 0 $x $y");
361        return $svg->asXML();
362    }
363
364    /**
365     * Inline SVG showing the given code
366     *
367     * @param string $code
368     * @return string
369     */
370    protected function htmlSvg($code)
371    {
372        return sprintf(
373            '<span class="svg" style="width:%spx; height:%spx">%s</span>',
374            $this->getConf('width'),
375            $this->getConf('height'),
376            $this->svgCaptcha($code)
377        );
378    }
379
380    /**
381     * HTML for an img tag for the image captcha
382     *
383     * @param string $ID the page ID this is displayed on
384     * @param string $secret the encrypted random number
385     * @return string
386     */
387    protected function htmlImage($ID, $secret)
388    {
389        $img = DOKU_BASE . 'lib/plugins/captcha/img.php';
390        $param = buildURLparams([
391            'secret' => $secret,
392            'id' => $ID,
393        ]);
394
395        return sprintf(
396            '<img src="%s?%s" width="%d" height="%d" alt="" />',
397            $img,
398            $param,
399            $this->getConf('width'),
400            $this->getConf('height')
401        );
402    }
403
404    /**
405     * HTML for a link to the audio captcha
406     *
407     * @param string $secret the encrypted random number
408     * @param string $ID the page ID this is displayed on
409     * @return string
410     */
411    protected function htmlAudioLink($secret, $ID)
412    {
413        $img = DOKU_BASE . 'lib/plugins/captcha/sound.png'; // FIXME use svg icon
414        $url = DOKU_BASE . 'lib/plugins/captcha/wav.php';
415        $param = buildURLparams([
416            'secret' => $secret,
417            'id' => $ID,
418        ]);
419
420        $icon = sprintf(
421            '<img src="%s" width="16" height="16" alt="%s" /></a>',
422            $img,
423            $this->getLang('soundlink')
424        );
425
426        return sprintf(
427            '<a href="%s?%s" class="JSnocheck audiolink" title="%s">%s</a>',
428            $url,
429            $param,
430            $this->getLang('soundlink'),
431            $icon
432        );
433    }
434
435    /**
436     * The HTML to show a figlet captcha
437     *
438     * @param string $code the code to display
439     * @return string
440     */
441    protected function htmlFiglet($code)
442    {
443        require_once(__DIR__ . '/figlet.php');
444        $figlet = new phpFiglet();
445        if ($figlet->loadfont(__DIR__ . '/figlet.flf')) {
446            return '<pre>' . rtrim($figlet->fetch($code)) . '</pre>';
447        } else {
448            msg('Failed to load figlet.flf font file. CAPTCHA broken', -1);
449        }
450        return 'FAIL';
451    }
452
453    // endregion
454
455    // region Utilities
456
457    /**
458     * Encrypt the given string with the cookie salt
459     *
460     * @param string $data
461     * @return string
462     */
463    public function encrypt($data)
464    {
465        $data = auth_encrypt($data, auth_cookiesalt());
466        return base64_encode($data);
467    }
468
469    /**
470     * Decrypt the given string with the cookie salt
471     *
472     * @param string $data
473     * @return string
474     */
475    public function decrypt($data)
476    {
477        $data = base64_decode($data);
478        if ($data === false || $data === '') return false;
479
480        return auth_decrypt($data, auth_cookiesalt());
481    }
482
483    /**
484     * Adds random space characters within the given text
485     *
486     * Keeps subsequent numbers without spaces (for math problem)
487     *
488     * @param $text
489     * @return string
490     */
491    protected function obfuscateText($text)
492    {
493        $new = '';
494
495        $spaces = [
496            "\r",
497            "\n",
498            "\r\n",
499            ' ',
500            "\xC2\xA0",
501            // \u00A0    NO-BREAK SPACE
502            "\xE2\x80\x80",
503            // \u2000    EN QUAD
504            "\xE2\x80\x81",
505            // \u2001    EM QUAD
506            "\xE2\x80\x82",
507            // \u2002    EN SPACE
508            //         "\xE2\x80\x83", // \u2003    EM SPACE
509            "\xE2\x80\x84",
510            // \u2004    THREE-PER-EM SPACE
511            "\xE2\x80\x85",
512            // \u2005    FOUR-PER-EM SPACE
513            "\xE2\x80\x86",
514            // \u2006    SIX-PER-EM SPACE
515            "\xE2\x80\x87",
516            // \u2007    FIGURE SPACE
517            "\xE2\x80\x88",
518            // \u2008    PUNCTUATION SPACE
519            "\xE2\x80\x89",
520            // \u2009    THIN SPACE
521            "\xE2\x80\x8A",
522            // \u200A    HAIR SPACE
523            "\xE2\x80\xAF",
524            // \u202F    NARROW NO-BREAK SPACE
525            "\xE2\x81\x9F",
526            // \u205F    MEDIUM MATHEMATICAL SPACE
527            "\xE1\xA0\x8E\r\n",
528            // \u180E    MONGOLIAN VOWEL SEPARATOR
529            "\xE2\x80\x8B\r\n",
530            // \u200B    ZERO WIDTH SPACE
531            "\xEF\xBB\xBF\r\n",
532        ];
533
534        $len = strlen($text);
535        for ($i = 0; $i < $len - 1; $i++) {
536            $new .= $text[$i];
537
538            if (!is_numeric($text[$i + 1])) {
539                $new .= $spaces[array_rand($spaces)];
540            }
541        }
542        $new .= $text[$len - 1];
543        return $new;
544    }
545
546
547    /**
548     * Join multiple wav files
549     *
550     * All wave files need to have the same format and need to be uncompressed.
551     * The headers of the last file will be used (with recalculated datasize
552     * of course)
553     *
554     * @link http://ccrma.stanford.edu/CCRMA/Courses/422/projects/WaveFormat/
555     * @link http://www.thescripts.com/forum/thread3770.html
556     */
557    protected function joinwavs($wavs)
558    {
559        $fields = implode(
560            '/',
561            [
562                'H8ChunkID',
563                'VChunkSize',
564                'H8Format',
565                'H8Subchunk1ID',
566                'VSubchunk1Size',
567                'vAudioFormat',
568                'vNumChannels',
569                'VSampleRate',
570                'VByteRate',
571                'vBlockAlign',
572                'vBitsPerSample'
573            ]
574        );
575
576        $data = '';
577        foreach ($wavs as $wav) {
578            $fp = fopen($wav, 'rb');
579            $header = fread($fp, 36);
580            $info = unpack($fields, $header);
581
582            // read optional extra stuff
583            if ($info['Subchunk1Size'] > 16) {
584                $header .= fread($fp, ($info['Subchunk1Size'] - 16));
585            }
586
587            // read SubChunk2ID
588            $header .= fread($fp, 4);
589
590            // read Subchunk2Size
591            $size = unpack('vsize', fread($fp, 4));
592            $size = $size['size'];
593
594            // read data
595            $data .= fread($fp, $size);
596        }
597
598        return $header . pack('V', strlen($data)) . $data;
599    }
600
601    // endregion
602}
603