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