xref: /plugin/captcha/helper.php (revision 3ee3748190499bf4e0f6396feb45c85486e47a75)
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
12class helper_plugin_captcha extends DokuWiki_Plugin {
13
14    protected $field_in = 'plugin__captcha';
15    protected $field_sec = 'plugin__captcha_secret';
16    protected $field_hp = 'plugin__captcha_honeypot';
17
18    /**
19     * Constructor. Initializes field names
20     */
21    public function __construct() {
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        if(!$this->getConf('forusers') && $_SERVER['REMOTE_USER']) return false;
34        return true;
35    }
36
37    /**
38     * Returns the HTML to display the CAPTCHA with the chosen method
39     */
40    public function getHTML() {
41        global $ID;
42
43        $rand = (float) (rand(0, 10000)) / 10000;
44        if($this->getConf('mode') == 'math') {
45            $code = $this->_generateMATH($this->_fixedIdent(), $rand);
46            $code = $code[0];
47            $text = $this->getLang('fillmath');
48        } elseif($this->getConf('mode') == 'question') {
49            $text = $this->getConf('question');
50        } else {
51            $code = $this->_generateCAPTCHA($this->_fixedIdent(), $rand);
52            $text = $this->getLang('fillcaptcha');
53        }
54        $secret = $this->encrypt($rand);
55
56        $txtlen = $this->getConf('lettercount');
57
58        $out = '';
59        $out .= '<div id="plugin__captcha_wrapper">';
60        $out .= '<input type="hidden" name="'.$this->field_sec.'" value="'.hsc($secret).'" />';
61        $out .= '<label for="plugin__captcha">'.$text.'</label> ';
62
63        switch($this->getConf('mode')) {
64            case 'math':
65            case 'text':
66                $out .= $this->_obfuscateText($code);
67                break;
68            case 'js':
69                $out .= '<span id="plugin__captcha_code">'.$this->_obfuscateText($code).'</span>';
70                break;
71            case 'image':
72                $out .= '<img src="'.DOKU_BASE.'lib/plugins/captcha/img.php?secret='.rawurlencode($secret).'&amp;id='.$ID.'" '.
73                    ' width="'.$this->getConf('width').'" height="'.$this->getConf('height').'" alt="" /> ';
74                break;
75            case 'audio':
76                $out .= '<img src="'.DOKU_BASE.'lib/plugins/captcha/img.php?secret='.rawurlencode($secret).'&amp;id='.$ID.'" '.
77                    ' width="'.$this->getConf('width').'" height="'.$this->getConf('height').'" alt="" /> ';
78                $out .= '<a href="'.DOKU_BASE.'lib/plugins/captcha/wav.php?secret='.rawurlencode($secret).'&amp;id='.$ID.'"'.
79                    ' class="JSnocheck" title="'.$this->getLang('soundlink').'">';
80                $out .= '<img src="'.DOKU_BASE.'lib/plugins/captcha/sound.png" width="16" height="16"'.
81                    ' alt="'.$this->getLang('soundlink').'" /></a>';
82                break;
83            case 'figlet':
84                require_once(dirname(__FILE__).'/figlet.php');
85                $figlet = new phpFiglet();
86                if($figlet->loadfont(dirname(__FILE__).'/figlet.flf')) {
87                    $out .= '<pre>';
88                    $out .= rtrim($figlet->fetch($code));
89                    $out .= '</pre>';
90                } else {
91                    msg('Failed to load figlet.flf font file. CAPTCHA broken', -1);
92                }
93                break;
94        }
95        $out .= ' <input type="text" size="'.$txtlen.'" name="'.$this->field_in.'" class="edit" /> ';
96
97        // add honeypot field
98        $out .= '<label class="no">'.$this->getLang('honeypot').'<input type="text" name="'.$this->field_hp.'" /></label>';
99        $out .= '</div>';
100        return $out;
101    }
102
103    /**
104     * Checks if the the CAPTCHA was solved correctly
105     *
106     * @param  bool $msg when true, an error will be signalled through the msg() method
107     * @return bool true when the answer was correct, otherwise false
108     */
109    public function check($msg = true) {
110        global $INPUT;
111
112        $code = '';
113        $field_sec = $INPUT->str($this->field_sec);
114        $field_in  = $INPUT->str($this->field_in);
115        $field_hp  = $INPUT->str($this->field_hp);
116
117        // reconstruct captcha from provided $field_sec
118        $rand = $this->decrypt($field_sec);
119
120        if($this->getConf('mode') == 'math') {
121            $code = $this->_generateMATH($this->_fixedIdent(), $rand);
122            $code = $code[1];
123        } elseif($this->getConf('mode') == 'question') {
124            $code = $this->getConf('answer');
125        } else {
126            $code = $this->_generateCAPTCHA($this->_fixedIdent(), $rand);
127        }
128
129        // compare values
130        if(!$field_sec ||
131            !$field_in ||
132            $rand === false ||
133            utf8_strtolower($field_in) != utf8_strtolower($code) ||
134            trim($field_hp) !== ''
135        ) {
136            if($msg) msg($this->getLang('testfailed'), -1);
137            return false;
138        }
139        return true;
140    }
141
142    /**
143     * Build a semi-secret fixed string identifying the current page and user
144     *
145     * This string is always the same for the current user when editing the same
146     * page revision, but only for one day. Editing a page before midnight and saving
147     * after midnight will result in a failed CAPTCHA once, but makes sure it can
148     * not be reused which is especially important for the registration form where the
149     * $ID usually won't change.
150     *
151     * @return string
152     */
153    public function _fixedIdent() {
154        global $ID;
155        $lm = @filemtime(wikiFN($ID));
156        $td = date('Y-m-d');
157        return auth_browseruid().
158        auth_cookiesalt().
159        $ID.$lm.$td;
160    }
161
162    /**
163     * Adds random space characters within the given text
164     *
165     * Keeps subsequent numbers without spaces (for math problem)
166     *
167     * @param $text
168     * @return string
169     */
170    protected function _obfuscateText($text) {
171        $new = '';
172
173        $spaces = array(
174            "\r",
175            "\n",
176            "\r\n",
177            ' ',
178            "\xC2\xA0", // \u00A0    NO-BREAK SPACE
179            "\xE2\x80\x80", // \u2000    EN QUAD
180            "\xE2\x80\x81", // \u2001    EM QUAD
181            "\xE2\x80\x82", // \u2002    EN SPACE
182            //         "\xE2\x80\x83", // \u2003    EM SPACE
183            "\xE2\x80\x84", // \u2004    THREE-PER-EM SPACE
184            "\xE2\x80\x85", // \u2005    FOUR-PER-EM SPACE
185            "\xE2\x80\x86", // \u2006    SIX-PER-EM SPACE
186            "\xE2\x80\x87", // \u2007    FIGURE SPACE
187            "\xE2\x80\x88", // \u2008    PUNCTUATION SPACE
188            "\xE2\x80\x89", // \u2009    THIN SPACE
189            "\xE2\x80\x8A", // \u200A    HAIR SPACE
190            "\xE2\x80\xAF", // \u202F    NARROW NO-BREAK SPACE
191            "\xE2\x81\x9F", // \u205F    MEDIUM MATHEMATICAL SPACE
192
193            "\xE1\xA0\x8E\r\n", // \u180E    MONGOLIAN VOWEL SEPARATOR
194            "\xE2\x80\x8B\r\n", // \u200B    ZERO WIDTH SPACE
195            "\xEF\xBB\xBF\r\n", // \uFEFF    ZERO WIDTH NO-BREAK SPACE
196        );
197
198        $len = strlen($text);
199        for($i = 0; $i < $len - 1; $i++) {
200            $new .= $text{$i};
201
202            if(!is_numeric($text{$i + 1})) {
203                $new .= $spaces[array_rand($spaces)];
204            }
205        }
206        $new .= $text{$len - 1};
207        return $new;
208    }
209
210    /**
211     * Generate some numbers from a known string and random number
212     *
213     * @param $fixed string the fixed part, any string
214     * @param $rand  float  some random number between 0 and 1
215     * @return string
216     */
217    protected function _generateNumbers($fixed, $rand) {
218        $fixed   = hexdec(substr(md5($fixed), 5, 5)); // use part of the md5 to generate an int
219        $rand = $rand * 0xFFFFF; // bitmask from the random number
220        return md5($rand ^ $fixed); // combine both values
221    }
222
223    /**
224     * Generates a random char string
225     *
226     * @param $fixed string the fixed part, any string
227     * @param $rand  float  some random number between 0 and 1
228     * @return string
229     */
230    public function _generateCAPTCHA($fixed, $rand) {
231        $numbers = $this->_generateNumbers($fixed, $rand);
232
233        // now create the letters
234        $code = '';
235        $lettercount = $this->getConf('lettercount') * 2;
236        if($lettercount > strlen($numbers)) $lettercount = strlen($numbers);
237        for($i = 0; $i < $lettercount; $i += 2) {
238            $code .= chr(floor(hexdec($numbers[$i].$numbers[$i + 1]) / 10) + 65);
239        }
240
241        return $code;
242    }
243
244    /**
245     * Create a mathematical task and its result
246     *
247     * @param $fixed string the fixed part, any string
248     * @param $rand  float  some random number between 0 and 1
249     * @return array taks, result
250     */
251    protected function _generateMATH($fixed, $rand) {
252        $numbers = $this->_generateNumbers($fixed, $rand);
253
254        // first letter is the operator (+/-)
255        $op  = (hexdec($numbers[0]) > 8) ? -1 : 1;
256        $num = array(hexdec($numbers[1].$numbers[2]), hexdec($numbers[3]));
257
258        // we only want positive results
259        if(($op < 0) && ($num[0] < $num[1])) rsort($num);
260
261        // prepare result and task text
262        $res  = $num[0] + ($num[1] * $op);
263        $task = $num[0].(($op < 0) ? '-' : '+').$num[1].'=?';
264
265        return array($task, $res);
266    }
267
268    /**
269     * Create a CAPTCHA image
270     *
271     * @param string $text the letters to display
272     */
273    public function _imageCAPTCHA($text) {
274        $w = $this->getConf('width');
275        $h = $this->getConf('height');
276
277        $fonts = glob(dirname(__FILE__).'/fonts/*.ttf');
278
279        // create a white image
280        $img   = imagecreatetruecolor($w, $h);
281        $white = imagecolorallocate($img, 255, 255, 255);
282        imagefill($img, 0, 0, $white);
283
284        // add some lines as background noise
285        for($i = 0; $i < 30; $i++) {
286            $color = imagecolorallocate($img, rand(100, 250), rand(100, 250), rand(100, 250));
287            imageline($img, rand(0, $w), rand(0, $h), rand(0, $w), rand(0, $h), $color);
288        }
289
290        // draw the letters
291        $txtlen = strlen($text);
292        for($i = 0; $i < $txtlen; $i++) {
293            $font  = $fonts[array_rand($fonts)];
294            $color = imagecolorallocate($img, rand(0, 100), rand(0, 100), rand(0, 100));
295            $size  = rand(floor($h / 1.8), floor($h * 0.7));
296            $angle = rand(-35, 35);
297
298            $x       = ($w * 0.05) + $i * floor($w * 0.9 / $txtlen);
299            $cheight = $size + ($size * 0.5);
300            $y       = floor($h / 2 + $cheight / 3.8);
301
302            imagettftext($img, $size, $angle, $x, $y, $color, $font, $text[$i]);
303        }
304
305        header("Content-type: image/png");
306        imagepng($img);
307        imagedestroy($img);
308    }
309
310    /**
311     * Encrypt the given string with the cookie salt
312     *
313     * @param string $data
314     * @return string
315     */
316    public function encrypt($data) {
317        if(function_exists('auth_encrypt')) {
318            $data = auth_encrypt($data, auth_cookiesalt()); // since binky
319        } else {
320            $data = PMA_blowfish_encrypt($data, auth_cookiesalt()); // deprecated
321        }
322
323        return base64_encode($data);
324    }
325
326    /**
327     * Decrypt the given string with the cookie salt
328     *
329     * @param string $data
330     * @return string
331     */
332    public function decrypt($data) {
333        $data = base64_decode($data);
334        if($data === false || $data === '') return false;
335
336        if(function_exists('auth_decrypt')) {
337            return auth_decrypt($data, auth_cookiesalt()); // since binky
338        } else {
339            return PMA_blowfish_decrypt($data, auth_cookiesalt()); // deprecated
340        }
341    }
342}
343