xref: /plugin/captcha/helper.php (revision 5697ecf8bf298450d6722c7b7298c8ff44eda91e)
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        $comb = (int) $rand ^ $ident; // combine both values
198        return md5($comb);
199    }
200
201    /**
202     * Generates a char string based on the given data
203     *
204     * The string is pseudo random based on a fixed identifier (see fixedIdent()) and a random number.
205     *
206     * @param $ident string the fixed part, any string
207     * @param $rand  float  some random number between 0 and 1
208     * @return string
209     */
210    public function generateCaptchaCode($ident, $rand)
211    {
212        $numbers = $this->generateMagicCode($ident, $rand);
213
214        // now create the letters
215        $code = '';
216        $lettercount = $this->getConf('lettercount') * 2;
217        if ($lettercount > strlen($numbers)) $lettercount = strlen($numbers);
218        for ($i = 0; $i < $lettercount; $i += 2) {
219            $code .= chr(floor(hexdec($numbers[$i] . $numbers[$i + 1]) / 10) + 65);
220        }
221
222        return $code;
223    }
224
225    /**
226     * Create a mathematical task and its result
227     *
228     * @param $ident string the fixed part, any string
229     * @param $rand  float  some random number between 0 and 1
230     * @return array [task, result]
231     */
232    protected function generateMath($ident, $rand)
233    {
234        $numbers = $this->generateMagicCode($ident, $rand);
235
236        // first letter is the operator (+/-)
237        $op = (hexdec($numbers[0]) > 8) ? -1 : 1;
238        $num = [hexdec($numbers[1] . $numbers[2]), hexdec($numbers[3])];
239
240        // we only want positive results
241        if (($op < 0) && ($num[0] < $num[1])) rsort($num);
242
243        // prepare result and task text
244        $res = $num[0] + ($num[1] * $op);
245        $task = $num[0] . (($op < 0) ? '-' : '+') . $num[1] . '= ';
246
247        return [$task, $res];
248    }
249
250    // endregion
251
252    // region Output Builders
253
254    /**
255     * Create a CAPTCHA image
256     *
257     * @param string $text the letters to display
258     * @return string The image data
259     */
260    public function imageCaptcha($text)
261    {
262        $w = $this->getConf('width');
263        $h = $this->getConf('height');
264
265        $fonts = glob(__DIR__ . '/fonts/*.ttf');
266
267        // create a white image
268        $img = imagecreatetruecolor($w, $h);
269        $white = imagecolorallocate($img, 255, 255, 255);
270        imagefill($img, 0, 0, $white);
271
272        // add some lines as background noise
273        for ($i = 0; $i < 30; $i++) {
274            $color = imagecolorallocate($img, random_int(100, 250), random_int(100, 250), random_int(100, 250));
275            imageline($img, random_int(0, $w), random_int(0, $h), random_int(0, $w), random_int(0, $h), $color);
276        }
277
278        // draw the letters
279        $txtlen = strlen($text);
280        for ($i = 0; $i < $txtlen; $i++) {
281            $font = $fonts[array_rand($fonts)];
282            $color = imagecolorallocate($img, random_int(0, 100), random_int(0, 100), random_int(0, 100));
283            $size = random_int(floor($h / 1.8), floor($h * 0.7));
284            $angle = random_int(-35, 35);
285
286            $x = ($w * 0.05) + $i * floor($w * 0.9 / $txtlen);
287            $cheight = $size + ($size * 0.5);
288            $y = floor($h / 2 + $cheight / 3.8);
289
290            imagettftext($img, $size, $angle, $x, $y, $color, $font, $text[$i]);
291        }
292
293        ob_start();
294        imagepng($img);
295        $image = ob_get_clean();
296        imagedestroy($img);
297        return $image;
298    }
299
300    /**
301     * Generate an audio captcha
302     *
303     * @param string $text
304     * @return string The joined wav files
305     */
306    public function audioCaptcha($text)
307    {
308        global $conf;
309
310        $lc = __DIR__ . '/lang/' . $conf['lang'] . '/audio/';
311        $en = __DIR__ . '/lang/en/audio/';
312
313        $wavs = [];
314
315        $text = strtolower($text);
316        $txtlen = strlen($text);
317        for ($i = 0; $i < $txtlen; $i++) {
318            $char = $text[$i];
319            $file = $lc . $char . '.wav';
320            if (!@file_exists($file)) $file = $en . $char . '.wav';
321            $wavs[] = $file;
322        }
323
324        return $this->joinwavs($wavs);
325    }
326
327    /**
328     * Create an SVG of the given text
329     *
330     * @param string $text
331     * @return string
332     */
333    public function svgCaptcha($text)
334    {
335        require_once(__DIR__ . '/EasySVG.php');
336
337        $fonts = glob(__DIR__ . '/fonts/*.svg');
338
339        $x = 0; // where we start to draw
340        $y = 100; // our max height
341
342        $svg = new EasySVG();
343
344        // draw the letters
345        $txtlen = strlen($text);
346        for ($i = 0; $i < $txtlen; $i++) {
347            $char = $text[$i];
348            $size = random_int($y / 2, $y - $y * 0.1); // 50-90%
349            $svg->setFontSVG($fonts[array_rand($fonts)]);
350
351            $svg->setFontSize($size);
352            $svg->setLetterSpacing(round(random_int(1, 4) / 10, 2)); // 0.1 - 0.4
353            $svg->addText($char, $x, random_int(0, round($y - $size))); // random up and down
354
355            [$w] = $svg->textDimensions($char);
356            $x += $w;
357        }
358
359        $svg->addAttribute('width', $x . 'px');
360        $svg->addAttribute('height', $y . 'px');
361        $svg->addAttribute('viewbox', "0 0 $x $y");
362        return $svg->asXML();
363    }
364
365    /**
366     * Inline SVG showing the given code
367     *
368     * @param string $code
369     * @return string
370     */
371    protected function htmlSvg($code)
372    {
373        return sprintf(
374            '<span class="svg" style="width:%spx; height:%spx">%s</span>',
375            $this->getConf('width'),
376            $this->getConf('height'),
377            $this->svgCaptcha($code)
378        );
379    }
380
381    /**
382     * HTML for an img tag for the image captcha
383     *
384     * @param string $ID the page ID this is displayed on
385     * @param string $secret the encrypted random number
386     * @return string
387     */
388    protected function htmlImage($ID, $secret)
389    {
390        $img = DOKU_BASE . 'lib/plugins/captcha/img.php';
391        $param = buildURLparams([
392            'secret' => $secret,
393            'id' => $ID,
394        ]);
395
396        return sprintf(
397            '<img src="%s?%s" width="%d" height="%d" alt="" />',
398            $img,
399            $param,
400            $this->getConf('width'),
401            $this->getConf('height')
402        );
403    }
404
405    /**
406     * HTML for a link to the audio captcha
407     *
408     * @param string $secret the encrypted random number
409     * @param string $ID the page ID this is displayed on
410     * @return string
411     */
412    protected function htmlAudioLink($secret, $ID)
413    {
414        $img = DOKU_BASE . 'lib/plugins/captcha/sound.png'; // FIXME use svg icon
415        $url = DOKU_BASE . 'lib/plugins/captcha/wav.php';
416        $param = buildURLparams([
417            'secret' => $secret,
418            'id' => $ID,
419        ]);
420
421        $icon = sprintf(
422            '<img src="%s" width="16" height="16" alt="%s" /></a>',
423            $img,
424            $this->getLang('soundlink')
425        );
426
427        return sprintf(
428            '<a href="%s?%s" class="JSnocheck audiolink" title="%s">%s</a>',
429            $url,
430            $param,
431            $this->getLang('soundlink'),
432            $icon
433        );
434    }
435
436    /**
437     * The HTML to show a figlet captcha
438     *
439     * @param string $code the code to display
440     * @return string
441     */
442    protected function htmlFiglet($code)
443    {
444        require_once(__DIR__ . '/figlet.php');
445        $figlet = new phpFiglet();
446        if ($figlet->loadfont(__DIR__ . '/figlet.flf')) {
447            return '<pre>' . rtrim($figlet->fetch($code)) . '</pre>';
448        } else {
449            msg('Failed to load figlet.flf font file. CAPTCHA broken', -1);
450        }
451        return 'FAIL';
452    }
453
454    // endregion
455
456    // region Utilities
457
458    /**
459     * Encrypt the given string with the cookie salt
460     *
461     * @param string $data
462     * @return string
463     */
464    public function encrypt($data)
465    {
466        $data = auth_encrypt($data, auth_cookiesalt());
467        return base64_encode($data);
468    }
469
470    /**
471     * Decrypt the given string with the cookie salt
472     *
473     * @param string $data
474     * @return string
475     */
476    public function decrypt($data)
477    {
478        $data = base64_decode($data);
479        if ($data === false || $data === '') return false;
480
481        return auth_decrypt($data, auth_cookiesalt());
482    }
483
484    /**
485     * Adds random space characters within the given text
486     *
487     * Keeps subsequent numbers without spaces (for math problem)
488     *
489     * @param $text
490     * @return string
491     */
492    protected function obfuscateText($text)
493    {
494        $new = '';
495
496        $spaces = [
497            "\r",
498            "\n",
499            "\r\n",
500            ' ',
501            "\xC2\xA0",
502            // \u00A0    NO-BREAK SPACE
503            "\xE2\x80\x80",
504            // \u2000    EN QUAD
505            "\xE2\x80\x81",
506            // \u2001    EM QUAD
507            "\xE2\x80\x82",
508            // \u2002    EN SPACE
509            //         "\xE2\x80\x83", // \u2003    EM SPACE
510            "\xE2\x80\x84",
511            // \u2004    THREE-PER-EM SPACE
512            "\xE2\x80\x85",
513            // \u2005    FOUR-PER-EM SPACE
514            "\xE2\x80\x86",
515            // \u2006    SIX-PER-EM SPACE
516            "\xE2\x80\x87",
517            // \u2007    FIGURE SPACE
518            "\xE2\x80\x88",
519            // \u2008    PUNCTUATION SPACE
520            "\xE2\x80\x89",
521            // \u2009    THIN SPACE
522            "\xE2\x80\x8A",
523            // \u200A    HAIR SPACE
524            "\xE2\x80\xAF",
525            // \u202F    NARROW NO-BREAK SPACE
526            "\xE2\x81\x9F",
527            // \u205F    MEDIUM MATHEMATICAL SPACE
528            "\xE1\xA0\x8E\r\n",
529            // \u180E    MONGOLIAN VOWEL SEPARATOR
530            "\xE2\x80\x8B\r\n",
531            // \u200B    ZERO WIDTH SPACE
532            "\xEF\xBB\xBF\r\n",
533        ];
534
535        $len = strlen($text);
536        for ($i = 0; $i < $len - 1; $i++) {
537            $new .= $text[$i];
538
539            if (!is_numeric($text[$i + 1])) {
540                $new .= $spaces[array_rand($spaces)];
541            }
542        }
543        $new .= $text[$len - 1];
544        return $new;
545    }
546
547
548    /**
549     * Join multiple wav files
550     *
551     * All wave files need to have the same format and need to be uncompressed.
552     * The headers of the last file will be used (with recalculated datasize
553     * of course)
554     *
555     * @link http://ccrma.stanford.edu/CCRMA/Courses/422/projects/WaveFormat/
556     * @link http://www.thescripts.com/forum/thread3770.html
557     */
558    protected function joinwavs($wavs)
559    {
560        $fields = implode(
561            '/',
562            [
563                'H8ChunkID',
564                'VChunkSize',
565                'H8Format',
566                'H8Subchunk1ID',
567                'VSubchunk1Size',
568                'vAudioFormat',
569                'vNumChannels',
570                'VSampleRate',
571                'VByteRate',
572                'vBlockAlign',
573                'vBitsPerSample'
574            ]
575        );
576
577        $data = '';
578        foreach ($wavs as $wav) {
579            $fp = fopen($wav, 'rb');
580            $header = fread($fp, 36);
581            $info = unpack($fields, $header);
582
583            // read optional extra stuff
584            if ($info['Subchunk1Size'] > 16) {
585                $header .= fread($fp, ($info['Subchunk1Size'] - 16));
586            }
587
588            // read SubChunk2ID
589            $header .= fread($fp, 4);
590
591            // read Subchunk2Size
592            $size = unpack('vsize', fread($fp, 4));
593            $size = $size['size'];
594
595            // read data
596            $data .= fread($fp, $size);
597        }
598
599        return $header . pack('V', strlen($data)) . $data;
600    }
601
602    // endregion
603}
604