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