xref: /plugin/captcha/helper.php (revision 186227361d0efe9f0e20bd52c87dfbf939efb53a)
1<?php
2/**
3 * @license    GPL 2 (http://www.gnu.org/licenses/gpl.html)
4 * @author     Andreas Gohr <andi@splitbrain.org>
5 */
6
7// must be run within Dokuwiki
8if (!defined('DOKU_INC')) die();
9if (!defined('DOKU_PLUGIN')) define('DOKU_PLUGIN', DOKU_INC . 'lib/plugins/');
10
11/**
12 * Class helper_plugin_captcha
13 */
14class helper_plugin_captcha extends DokuWiki_Plugin
15{
16
17    protected $field_in = 'plugin__captcha';
18    protected $field_sec = 'plugin__captcha_secret';
19    protected $field_hp = 'plugin__captcha_honeypot';
20
21    /**
22     * Constructor. Initializes field names
23     */
24    public function __construct()
25    {
26        $this->field_in = md5($this->_fixedIdent() . $this->field_in);
27        $this->field_sec = md5($this->_fixedIdent() . $this->field_sec);
28        $this->field_hp = md5($this->_fixedIdent() . $this->field_hp);
29    }
30
31    /**
32     * Check if the CAPTCHA should be used. Always check this before using the methods below.
33     *
34     * @return bool true when the CAPTCHA should be used
35     */
36    public function isEnabled()
37    {
38        if (!$this->getConf('forusers') && $_SERVER['REMOTE_USER']) return false;
39        return true;
40    }
41
42    /**
43     * Returns the HTML to display the CAPTCHA with the chosen method
44     */
45    public function getHTML()
46    {
47        global $ID;
48
49        $rand = (float)(rand(0, 10000)) / 10000;
50        $this->storeCaptchaCookie($this->_fixedIdent(), $rand);
51
52        if ($this->getConf('mode') == 'math') {
53            $code = $this->_generateMATH($this->_fixedIdent(), $rand);
54            $code = $code[0];
55            $text = $this->getLang('fillmath');
56        } elseif ($this->getConf('mode') == 'question') {
57            $code = ''; // not used
58            $text = $this->getConf('question');
59        } else {
60            $code = $this->_generateCAPTCHA($this->_fixedIdent(), $rand);
61            $text = $this->getLang('fillcaptcha');
62        }
63        $secret = $this->encrypt($rand);
64
65        $txtlen = $this->getConf('lettercount');
66
67        $out = '';
68        $out .= '<div id="plugin__captcha_wrapper">';
69        $out .= '<input type="hidden" name="' . $this->field_sec . '" value="' . hsc($secret) . '" />';
70        $out .= '<label for="plugin__captcha">' . $text . '</label> ';
71
72        switch ($this->getConf('mode')) {
73            case 'math':
74            case 'text':
75                $out .= $this->_obfuscateText($code);
76                break;
77            case 'js':
78                $out .= '<span id="plugin__captcha_code">' . $this->_obfuscateText($code) . '</span>';
79                break;
80            case 'svg':
81                $out .= '<span class="svg" style="width:' . $this->getConf('width') . 'px; height:' . $this->getConf('height') . 'px">';
82                $out .= $this->_svgCAPTCHA($code);
83                $out .= '</span>';
84                break;
85            case 'svgaudio':
86                $out .= '<span class="svg" style="width:' . $this->getConf('width') . 'px; height:' . $this->getConf('height') . 'px">';
87                $out .= $this->_svgCAPTCHA($code);
88                $out .= '</span>';
89                $out .= '<a href="' . DOKU_BASE . 'lib/plugins/captcha/wav.php?secret=' . rawurlencode($secret) . '&amp;id=' . $ID . '"' .
90                    ' class="JSnocheck audiolink" title="' . $this->getLang('soundlink') . '">';
91                $out .= '<img src="' . DOKU_BASE . 'lib/plugins/captcha/sound.png" width="16" height="16"' .
92                    ' alt="' . $this->getLang('soundlink') . '" /></a>';
93                break;
94            case 'image':
95                $out .= '<img src="' . DOKU_BASE . 'lib/plugins/captcha/img.php?secret=' . rawurlencode($secret) . '&amp;id=' . $ID . '" ' .
96                    ' width="' . $this->getConf('width') . '" height="' . $this->getConf('height') . '" alt="" /> ';
97                break;
98            case 'audio':
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                $out .= '<a href="' . DOKU_BASE . 'lib/plugins/captcha/wav.php?secret=' . rawurlencode($secret) . '&amp;id=' . $ID . '"' .
102                    ' class="JSnocheck audiolink" title="' . $this->getLang('soundlink') . '">';
103                $out .= '<img src="' . DOKU_BASE . 'lib/plugins/captcha/sound.png" width="16" height="16"' .
104                    ' alt="' . $this->getLang('soundlink') . '" /></a>';
105                break;
106            case 'figlet':
107                require_once(dirname(__FILE__) . '/figlet.php');
108                $figlet = new phpFiglet();
109                if ($figlet->loadfont(dirname(__FILE__) . '/figlet.flf')) {
110                    $out .= '<pre>';
111                    $out .= rtrim($figlet->fetch($code));
112                    $out .= '</pre>';
113                } else {
114                    msg('Failed to load figlet.flf font file. CAPTCHA broken', -1);
115                }
116                break;
117        }
118        $out .= ' <input type="text" size="' . $txtlen . '" name="' . $this->field_in . '" class="edit" /> ';
119
120        // add honeypot field
121        $out .= '<label class="no">' . $this->getLang('honeypot') . '<input type="text" name="' . $this->field_hp . '" /></label>';
122        $out .= '</div>';
123        return $out;
124    }
125
126    /**
127     * Checks if the CAPTCHA was solved correctly
128     *
129     * @param bool $msg when true, an error will be signalled through the msg() method
130     * @return bool true when the answer was correct, otherwise false
131     */
132    public function check($msg = true)
133    {
134        global $INPUT;
135
136        $field_sec = $INPUT->str($this->field_sec);
137        $field_in = $INPUT->str($this->field_in);
138        $field_hp = $INPUT->str($this->field_hp);
139
140        // reconstruct captcha from provided $field_sec
141        $rand = $this->decrypt($field_sec);
142
143        if ($this->getConf('mode') == 'math') {
144            $code = $this->_generateMATH($this->_fixedIdent(), $rand);
145            $code = $code[1];
146        } elseif ($this->getConf('mode') == 'question') {
147            $code = $this->getConf('answer');
148        } else {
149            $code = $this->_generateCAPTCHA($this->_fixedIdent(), $rand);
150        }
151
152        // compare values
153        if (!$field_sec ||
154            !$field_in ||
155            $rand === false ||
156            utf8_strtolower($field_in) != utf8_strtolower($code) ||
157            trim($field_hp) !== '' ||
158            !$this->retrieveCaptchaCookie($this->_fixedIdent(), $rand)
159        ) {
160            if ($msg) msg($this->getLang('testfailed'), -1);
161            return false;
162        }
163        return true;
164    }
165
166    /**
167     * Get the path where a captcha cookie would be stored
168     *
169     * We use a daily temp directory which is easy to clean up
170     *
171     * @param $fixed string the fixed part, any string
172     * @param $rand  float  some random number between 0 and 1
173     * @return string the path to the cookie file
174     */
175    protected function getCaptchaCookiePath($fixed, $rand)
176    {
177        global $conf;
178        $path = $conf['tmpdir'] . '/captcha/' . date('Y-m-d') . '/' . md5($fixed . $rand) . '.cookie';
179        io_makeFileDir($path);
180        return $path;
181    }
182
183    /**
184     * remove all outdated captcha cookies
185     */
186    public function _cleanCaptchaCookies()
187    {
188        global $conf;
189        $path = $conf['tmpdir'] . '/captcha/';
190        $dirs = glob("$path/*", GLOB_ONLYDIR);
191        $today = date('Y-m-d');
192        foreach ($dirs as $dir) {
193            if (basename($dir) === $today) continue;
194            if (!preg_match('/\/captcha\//', $dir)) continue; // safety net
195            io_rmdir($dir, true);
196        }
197    }
198
199    /**
200     * Creates a one time captcha cookie
201     *
202     * This is used to prevent replay attacks. It is generated when the captcha form
203     * is shown and checked with the captcha check. Since we can not be sure about the
204     * session state (might be closed or open) we're not using it.
205     *
206     * We're not using the stored values for displaying the captcha image (or audio)
207     * but continue to use our encryption scheme. This way it's still possible to have
208     * multiple captcha checks going on in parallel (eg. with multiple browser tabs)
209     *
210     * @param $fixed string the fixed part, any string
211     * @param $rand  float  some random number between 0 and 1
212     */
213    protected function storeCaptchaCookie($fixed, $rand)
214    {
215        $cache = $this->getCaptchaCookiePath($fixed, $rand);
216        touch($cache);
217    }
218
219    /**
220     * Checks if the captcha cookie exists and deletes it
221     *
222     * @param $fixed string the fixed part, any string
223     * @param $rand  float  some random number between 0 and 1
224     * @return bool true if the cookie existed
225     */
226    protected function retrieveCaptchaCookie($fixed, $rand)
227    {
228        $cache = $this->getCaptchaCookiePath($fixed, $rand);
229        if (file_exists($cache)) {
230            unlink($cache);
231            return true;
232        }
233        return false;
234    }
235
236    /**
237     * Build a semi-secret fixed string identifying the current page and user
238     *
239     * This string is always the same for the current user when editing the same
240     * page revision, but only for one day. Editing a page before midnight and saving
241     * after midnight will result in a failed CAPTCHA once, but makes sure it can
242     * not be reused which is especially important for the registration form where the
243     * $ID usually won't change.
244     *
245     * @return string
246     */
247    public function _fixedIdent()
248    {
249        global $ID;
250        $lm = @filemtime(wikiFN($ID));
251        $td = date('Y-m-d');
252        return auth_browseruid() .
253            auth_cookiesalt() .
254            $ID . $lm . $td;
255    }
256
257    /**
258     * Adds random space characters within the given text
259     *
260     * Keeps subsequent numbers without spaces (for math problem)
261     *
262     * @param $text
263     * @return string
264     */
265    protected function _obfuscateText($text)
266    {
267        $new = '';
268
269        $spaces = array(
270            "\r",
271            "\n",
272            "\r\n",
273            ' ',
274            "\xC2\xA0", // \u00A0    NO-BREAK SPACE
275            "\xE2\x80\x80", // \u2000    EN QUAD
276            "\xE2\x80\x81", // \u2001    EM QUAD
277            "\xE2\x80\x82", // \u2002    EN SPACE
278            //         "\xE2\x80\x83", // \u2003    EM SPACE
279            "\xE2\x80\x84", // \u2004    THREE-PER-EM SPACE
280            "\xE2\x80\x85", // \u2005    FOUR-PER-EM SPACE
281            "\xE2\x80\x86", // \u2006    SIX-PER-EM SPACE
282            "\xE2\x80\x87", // \u2007    FIGURE SPACE
283            "\xE2\x80\x88", // \u2008    PUNCTUATION SPACE
284            "\xE2\x80\x89", // \u2009    THIN SPACE
285            "\xE2\x80\x8A", // \u200A    HAIR SPACE
286            "\xE2\x80\xAF", // \u202F    NARROW NO-BREAK SPACE
287            "\xE2\x81\x9F", // \u205F    MEDIUM MATHEMATICAL SPACE
288
289            "\xE1\xA0\x8E\r\n", // \u180E    MONGOLIAN VOWEL SEPARATOR
290            "\xE2\x80\x8B\r\n", // \u200B    ZERO WIDTH SPACE
291            "\xEF\xBB\xBF\r\n", // \uFEFF    ZERO WIDTH NO-BREAK SPACE
292        );
293
294        $len = strlen($text);
295        for ($i = 0; $i < $len - 1; $i++) {
296            $new .= $text[$i];
297
298            if (!is_numeric($text[$i + 1])) {
299                $new .= $spaces[array_rand($spaces)];
300            }
301        }
302        $new .= $text[$len - 1];
303        return $new;
304    }
305
306    /**
307     * Generate some numbers from a known string and random number
308     *
309     * @param $fixed string the fixed part, any string
310     * @param $rand  float  some random number between 0 and 1
311     * @return string
312     */
313    protected function _generateNumbers($fixed, $rand)
314    {
315        $fixed = hexdec(substr(md5($fixed), 5, 5)); // use part of the md5 to generate an int
316        $rand = $rand * 0xFFFFF; // bitmask from the random number
317        return md5($rand ^ $fixed); // combine both values
318    }
319
320    /**
321     * Generates a random char string
322     *
323     * @param $fixed string the fixed part, any string
324     * @param $rand  float  some random number between 0 and 1
325     * @return string
326     */
327    public function _generateCAPTCHA($fixed, $rand)
328    {
329        $numbers = $this->_generateNumbers($fixed, $rand);
330
331        // now create the letters
332        $code = '';
333        $lettercount = $this->getConf('lettercount') * 2;
334        if ($lettercount > strlen($numbers)) $lettercount = strlen($numbers);
335        for ($i = 0; $i < $lettercount; $i += 2) {
336            $code .= chr(floor(hexdec($numbers[$i] . $numbers[$i + 1]) / 10) + 65);
337        }
338
339        return $code;
340    }
341
342    /**
343     * Create a mathematical task and its result
344     *
345     * @param $fixed string the fixed part, any string
346     * @param $rand  float  some random number between 0 and 1
347     * @return array taks, result
348     */
349    protected function _generateMATH($fixed, $rand)
350    {
351        $numbers = $this->_generateNumbers($fixed, $rand);
352
353        // first letter is the operator (+/-)
354        $op = (hexdec($numbers[0]) > 8) ? -1 : 1;
355        $num = array(hexdec($numbers[1] . $numbers[2]), hexdec($numbers[3]));
356
357        // we only want positive results
358        if (($op < 0) && ($num[0] < $num[1])) rsort($num);
359
360        // prepare result and task text
361        $res = $num[0] + ($num[1] * $op);
362        $task = $num[0] . (($op < 0) ? '-' : '+') . $num[1] . '= ';
363
364        return array($task, $res);
365    }
366
367    /**
368     * Create a CAPTCHA image
369     *
370     * @param string $text the letters to display
371     */
372    public function _imageCAPTCHA($text)
373    {
374        $w = $this->getConf('width');
375        $h = $this->getConf('height');
376
377        $fonts = glob(dirname(__FILE__) . '/fonts/*.ttf');
378
379        // create a white image
380        $img = imagecreatetruecolor($w, $h);
381        $white = imagecolorallocate($img, 255, 255, 255);
382        imagefill($img, 0, 0, $white);
383
384        // add some lines as background noise
385        for ($i = 0; $i < 30; $i++) {
386            $color = imagecolorallocate($img, rand(100, 250), rand(100, 250), rand(100, 250));
387            imageline($img, rand(0, $w), rand(0, $h), rand(0, $w), rand(0, $h), $color);
388        }
389
390        // draw the letters
391        $txtlen = strlen($text);
392        for ($i = 0; $i < $txtlen; $i++) {
393            $font = $fonts[array_rand($fonts)];
394            $color = imagecolorallocate($img, rand(0, 100), rand(0, 100), rand(0, 100));
395            $size = rand(floor($h / 1.8), floor($h * 0.7));
396            $angle = rand(-35, 35);
397
398            $x = ($w * 0.05) + $i * floor($w * 0.9 / $txtlen);
399            $cheight = $size + ($size * 0.5);
400            $y = floor($h / 2 + $cheight / 3.8);
401
402            imagettftext($img, $size, $angle, $x, $y, $color, $font, $text[$i]);
403        }
404
405        header("Content-type: image/png");
406        imagepng($img);
407        imagedestroy($img);
408    }
409
410    /**
411     * Create an SVG of the given text
412     *
413     * @param string $text
414     * @return string
415     */
416    public function _svgCAPTCHA($text)
417    {
418        require_once(__DIR__ . '/EasySVG.php');
419
420        $fonts = glob(__DIR__ . '/fonts/*.svg');
421
422        $x = 0; // where we start to draw
423        $y = 100; // our max height
424
425        $svg = new EasySVG();
426
427        // draw the letters
428        $txtlen = strlen($text);
429        for ($i = 0; $i < $txtlen; $i++) {
430            $char = $text[$i];
431            $size = rand($y / 2, $y - $y * 0.1); // 50-90%
432            $svg->setFontSVG($fonts[array_rand($fonts)]);
433
434            $svg->setFontSize($size);
435            $svg->setLetterSpacing(round(rand(1, 4) / 10, 2)); // 0.1 - 0.4
436            $svg->addText($char, $x, rand(0, round($y - $size))); // random up and down
437
438            list($w) = $svg->textDimensions($char);
439            $x += $w;
440        }
441
442        $svg->addAttribute('width', $x . 'px');
443        $svg->addAttribute('height', $y . 'px');
444        $svg->addAttribute('viewbox', "0 0 $x $y");
445        return $svg->asXML();
446    }
447
448    /**
449     * Encrypt the given string with the cookie salt
450     *
451     * @param string $data
452     * @return string
453     */
454    public function encrypt($data)
455    {
456        if (function_exists('auth_encrypt')) {
457            $data = auth_encrypt($data, auth_cookiesalt()); // since binky
458        } else {
459            $data = PMA_blowfish_encrypt($data, auth_cookiesalt()); // deprecated
460        }
461
462        return base64_encode($data);
463    }
464
465    /**
466     * Decrypt the given string with the cookie salt
467     *
468     * @param string $data
469     * @return string
470     */
471    public function decrypt($data)
472    {
473        $data = base64_decode($data);
474        if ($data === false || $data === '') return false;
475
476        if (function_exists('auth_decrypt')) {
477            return auth_decrypt($data, auth_cookiesalt()); // since binky
478        } else {
479            return PMA_blowfish_decrypt($data, auth_cookiesalt()); // deprecated
480        }
481    }
482}
483