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