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