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