xref: /plugin/captcha/action.php (revision 563fb5668f85b2cd8c67a6d8af2d4597cbcfdaa7)
1<?php
2
3use dokuwiki\Extension\ActionPlugin;
4use dokuwiki\Extension\Event;
5use dokuwiki\Extension\EventHandler;
6use dokuwiki\Form\Form;
7use dokuwiki\plugin\captcha\FileCookie;
8use dokuwiki\plugin\captcha\IpCounter;
9
10/**
11 * CAPTCHA antispam plugin
12 *
13 * @license    GPL 2 (http://www.gnu.org/licenses/gpl.html)
14 * @author     Andreas Gohr <gohr@cosmocode.de>
15 */
16class action_plugin_captcha extends ActionPlugin
17{
18    /**
19     * register the eventhandlers
20     */
21    public function register(EventHandler $controller)
22    {
23        // check CAPTCHA success
24        $controller->register_hook('ACTION_ACT_PREPROCESS', 'BEFORE', $this, 'handleCaptchaInput', []);
25
26        // inject in edit form
27        $controller->register_hook('FORM_EDIT_OUTPUT', 'BEFORE', $this, 'handleFormOutput', []);
28
29        // inject in user registration
30        $controller->register_hook('FORM_REGISTER_OUTPUT', 'BEFORE', $this, 'handleFormOutput', []);
31
32        // inject in password reset
33        $controller->register_hook('FORM_RESENDPWD_OUTPUT', 'BEFORE', $this, 'handleFormOutput', []);
34
35        // inject in login form
36        $controller->register_hook('FORM_LOGIN_OUTPUT', 'BEFORE', $this, 'handleFormOutput', []);
37
38        // check on login
39        $controller->register_hook('AUTH_LOGIN_CHECK', 'BEFORE', $this, 'handleLogin', []);
40
41        // clean up captcha cookies
42        $controller->register_hook('INDEXER_TASKS_RUN', 'AFTER', $this, 'handleIndexer', []);
43
44        // log authentication failures
45        if ((int)$this->getConf('loginprotect') > 1 || (int)$this->getConf('logindenial') > 0) {
46            $controller->register_hook('ACTION_ACT_PREPROCESS', 'BEFORE', $this, 'handleAuth', []);
47        }
48    }
49
50    /**
51     * Check if the current mode should be handled by CAPTCHA
52     *
53     * Note: checking needs to be done when a form has been submitted, not when the form
54     * is shown for the first time. Except for the editing process this is not determined
55     * by $act alone but needs to inspect other input variables.
56     *
57     * @param string $act cleaned action mode
58     * @return bool
59     */
60    protected function needsChecking($act)
61    {
62        global $INPUT;
63
64        switch ($act) {
65            case 'save':
66                return true;
67            case 'register':
68            case 'resendpwd':
69                return $INPUT->bool('save');
70            case 'login':
71                // we do not handle this here, but in handle_login()
72            default:
73                return false;
74        }
75    }
76
77    /**
78     * Aborts the given mode
79     *
80     * Aborting depends on the mode. It might unset certain input parameters or simply switch
81     * the mode to something else (giving as return which needs to be passed back to the
82     * ACTION_ACT_PREPROCESS event)
83     *
84     * @param string $act cleaned action mode
85     * @return string the new mode to use
86     */
87    protected function abortAction($act)
88    {
89        global $INPUT;
90
91        switch ($act) {
92            case 'save':
93                return 'preview';
94            case 'register':
95            case 'resendpwd':
96                $INPUT->post->set('save', false);
97                return $act;
98            case 'login':
99                // we do not handle this here, but in handle_login()
100            default:
101                return $act;
102        }
103    }
104
105    /**
106     * Should a login CAPTCHA be used?
107     *
108     * @return bool
109     */
110    protected function protectLogin()
111    {
112        $config = (int)$this->getConf('loginprotect');
113        if ($config < 1) return false; // not wanted
114        if ($config === 1) return true; // always wanted
115        $count = (new IpCounter())->get();
116        return $count > 2; // only after 3 failed attempts
117    }
118
119    /**
120     * Check if login is blocked due to timeout
121     *
122     * @return bool True if blocked
123     */
124    protected function isLoginBlocked()
125    {
126        $base = (int)$this->getConf('logindenial');
127        if ($base < 1) return false;
128        $counter = new IpCounter();
129        return $counter->getRemainingTime($base, $this->getConf('logindenial_max')) > 0;
130    }
131
132    /**
133     * Get human-readable remaining time string
134     *
135     * @return string
136     */
137    protected function getRemainingTimeString()
138    {
139        $counter = new IpCounter();
140        $remaining = $counter->getRemainingTime(
141            $this->getConf('logindenial'),
142            $this->getConf('logindenial_max')
143        );
144
145        if ($remaining >= 3600) {
146            return sprintf($this->getLang('timeout_hours'), ceil($remaining / 3600));
147        } elseif ($remaining >= 60) {
148            return sprintf($this->getLang('timeout_minutes'), ceil($remaining / 60));
149        } else {
150            return sprintf($this->getLang('timeout_seconds'), $remaining);
151        }
152    }
153
154    /**
155     * Handles CAPTCHA check in login
156     *
157     * Logins happen very early in the DokuWiki lifecycle, so we have to intercept them
158     * in their own event.
159     *
160     * @param Event $event
161     * @param $param
162     */
163    public function handleLogin(Event $event, $param)
164    {
165        global $INPUT;
166        if (!$INPUT->bool('u')) return; // this login was not triggered by a form
167
168        // Check timeout first - if blocked, reject immediately
169        if ($this->isLoginBlocked()) {
170            $timeString = $this->getRemainingTimeString();
171            msg(sprintf($this->getLang('logindenial'), $timeString), -1);
172            $event->data['silent'] = true;
173            $event->result = false;
174            $event->preventDefault();
175            $event->stopPropagation();
176            return;
177        }
178
179        if (!$this->protectLogin()) return; // no CAPTCHA protection wanted
180
181        // we need to have $ID set for the captcha check
182        global $ID;
183        $ID = getID();
184
185        /** @var helper_plugin_captcha $helper */
186        $helper = plugin_load('helper', 'captcha');
187        if (!$helper->check()) {
188            $event->data['silent'] = true; // we have our own message
189            $event->result = false; // login fail
190            $event->preventDefault();
191            $event->stopPropagation();
192        }
193    }
194
195    /**
196     * Intercept all actions and check for CAPTCHA first.
197     */
198    public function handleCaptchaInput(Event $event, $param)
199    {
200        global $INPUT;
201
202        $act = act_clean($event->data);
203        if (!$this->needsChecking($act)) return;
204
205        // do nothing if logged in user and no CAPTCHA required
206        if (!$this->getConf('forusers') && $INPUT->server->str('REMOTE_USER')) {
207            return;
208        }
209
210        // check captcha
211        /** @var helper_plugin_captcha $helper */
212        $helper = plugin_load('helper', 'captcha');
213        if (!$helper->check()) {
214            $event->data = $this->abortAction($act);
215        }
216    }
217
218    /**
219     * Inject the CAPTCHA in a \dokuwiki\Form\Form
220     */
221    public function handleFormOutput(Event $event, $param)
222    {
223        global $INPUT;
224
225        if ($event->name === 'FORM_LOGIN_OUTPUT' && !$this->protectLogin()) {
226            // no login protection wanted
227            return;
228        }
229
230        /** @var Form|\Doku_Form $form */
231        $form = $event->data;
232
233        // get position of submit button
234        $pos = $form->findPositionByAttribute('type', 'submit');
235        if (!$pos) return; // no button -> source view mode
236
237        // do nothing if logged in user and no CAPTCHA required
238        if (!$this->getConf('forusers') && $INPUT->server->str('REMOTE_USER')) {
239            return;
240        }
241
242        // get the CAPTCHA
243        /** @var helper_plugin_captcha $helper */
244        $helper = plugin_load('helper', 'captcha');
245        $out = $helper->getHTML();
246
247        // insert before the submit button
248        $form->addHTML($out, $pos);
249    }
250
251    /**
252     * Clean cookies once per day
253     */
254    public function handleIndexer(Event $event, $param)
255    {
256        $lastrun = getCacheName('captcha', '.captcha');
257        $last = @filemtime($lastrun);
258        if (time() - $last < 24 * 60 * 60) return;
259
260        FileCookie::clean();
261        touch($lastrun);
262
263        $event->preventDefault();
264        $event->stopPropagation();
265    }
266
267    /**
268     * Count failed login attempts
269     */
270    public function handleAuth(Event $event, $param)
271    {
272        global $INPUT;
273        $act = act_clean($event->data);
274        if (
275            $act != 'logout' &&
276            $INPUT->str('u') !== '' &&
277            empty($INPUT->server->str('http_credentials')) &&
278            empty($INPUT->server->str('REMOTE_USER'))
279        ) {
280            // This is a failed authentication attempt, count it
281            (new IpCounter())->increment();
282        }
283
284        if (
285            $act == 'login' &&
286            !empty($INPUT->server->str('REMOTE_USER'))
287        ) {
288            // This is a successful login, reset the counter
289            (new IpCounter())->reset();
290        }
291    }
292}
293