xref: /plugin/captcha/action.php (revision 194d338681b559bf46a61e6fd49d98dea7193d22)
142a27035SAndreas Gohr<?php
218622736SAndreas Gohr
309b1e97eSAndreas Gohruse dokuwiki\Extension\ActionPlugin;
409b1e97eSAndreas Gohruse dokuwiki\Extension\Event;
55e79b2eeSAndreas Gohruse dokuwiki\Extension\EventHandler;
609b1e97eSAndreas Gohruse dokuwiki\Form\Form;
7c6d794b3SAndreas Gohruse dokuwiki\plugin\captcha\FileCookie;
8969b14c4SAndreas Gohruse dokuwiki\plugin\captcha\IpCounter;
9969b14c4SAndreas Gohr
1042a27035SAndreas Gohr/**
1142a27035SAndreas Gohr * CAPTCHA antispam plugin
1242a27035SAndreas Gohr *
1342a27035SAndreas Gohr * @license    GPL 2 (http://www.gnu.org/licenses/gpl.html)
1442a27035SAndreas Gohr * @author     Andreas Gohr <gohr@cosmocode.de>
1542a27035SAndreas Gohr */
1609b1e97eSAndreas Gohrclass action_plugin_captcha extends ActionPlugin
171c08a51cSAndreas Gohr{
1842a27035SAndreas Gohr    /**
1942a27035SAndreas Gohr     * register the eventhandlers
2042a27035SAndreas Gohr     */
2109b1e97eSAndreas Gohr    public function register(EventHandler $controller)
221c08a51cSAndreas Gohr    {
237218f96cSAndreas Gohr        // check CAPTCHA success
245e79b2eeSAndreas Gohr        $controller->register_hook('ACTION_ACT_PREPROCESS', 'BEFORE', $this, 'handleCaptchaInput', []);
2542a27035SAndreas Gohr
267218f96cSAndreas Gohr        // inject in edit form
275e79b2eeSAndreas Gohr        $controller->register_hook('FORM_EDIT_OUTPUT', 'BEFORE', $this, 'handleFormOutput', []);
2842a27035SAndreas Gohr
297218f96cSAndreas Gohr        // inject in user registration
305e79b2eeSAndreas Gohr        $controller->register_hook('FORM_REGISTER_OUTPUT', 'BEFORE', $this, 'handleFormOutput', []);
31f74276b8SAndreas Gohr
32f74276b8SAndreas Gohr        // inject in password reset
335e79b2eeSAndreas Gohr        $controller->register_hook('FORM_RESENDPWD_OUTPUT', 'BEFORE', $this, 'handleFormOutput', []);
34643f15bdSAndreas Gohr
35643f15bdSAndreas Gohr        // inject in login form
365e79b2eeSAndreas Gohr        $controller->register_hook('FORM_LOGIN_OUTPUT', 'BEFORE', $this, 'handleFormOutput', []);
371c08a51cSAndreas Gohr
38643f15bdSAndreas Gohr        // check on login
395e79b2eeSAndreas Gohr        $controller->register_hook('AUTH_LOGIN_CHECK', 'BEFORE', $this, 'handleLogin', []);
40cde3ece1SAndreas Gohr
41cde3ece1SAndreas Gohr        // clean up captcha cookies
425e79b2eeSAndreas Gohr        $controller->register_hook('INDEXER_TASKS_RUN', 'AFTER', $this, 'handleIndexer', []);
43969b14c4SAndreas Gohr
44969b14c4SAndreas Gohr        // log authentication failures
45563fb566SAndreas Gohr        if ((int)$this->getConf('loginprotect') > 1 || (int)$this->getConf('logindenial') > 0) {
465e79b2eeSAndreas Gohr            $controller->register_hook('ACTION_ACT_PREPROCESS', 'BEFORE', $this, 'handleAuth', []);
47969b14c4SAndreas Gohr        }
4842a27035SAndreas Gohr    }
4942a27035SAndreas Gohr
5042a27035SAndreas Gohr    /**
51bd26d35bSAndreas Gohr     * Check if the current mode should be handled by CAPTCHA
52bd26d35bSAndreas Gohr     *
53f74276b8SAndreas Gohr     * Note: checking needs to be done when a form has been submitted, not when the form
54f74276b8SAndreas Gohr     * is shown for the first time. Except for the editing process this is not determined
55f74276b8SAndreas Gohr     * by $act alone but needs to inspect other input variables.
56f74276b8SAndreas Gohr     *
57bd26d35bSAndreas Gohr     * @param string $act cleaned action mode
58bd26d35bSAndreas Gohr     * @return bool
59bd26d35bSAndreas Gohr     */
605e79b2eeSAndreas Gohr    protected function needsChecking($act)
611c08a51cSAndreas Gohr    {
62bd26d35bSAndreas Gohr        global $INPUT;
63bd26d35bSAndreas Gohr
64bd26d35bSAndreas Gohr        switch ($act) {
65bd26d35bSAndreas Gohr            case 'save':
66bd26d35bSAndreas Gohr                return true;
67bd26d35bSAndreas Gohr            case 'register':
68f74276b8SAndreas Gohr            case 'resendpwd':
69bd26d35bSAndreas Gohr                return $INPUT->bool('save');
70643f15bdSAndreas Gohr            case 'login':
71643f15bdSAndreas Gohr                // we do not handle this here, but in handle_login()
72bd26d35bSAndreas Gohr            default:
73bd26d35bSAndreas Gohr                return false;
74bd26d35bSAndreas Gohr        }
75bd26d35bSAndreas Gohr    }
76bd26d35bSAndreas Gohr
77bd26d35bSAndreas Gohr    /**
78bd26d35bSAndreas Gohr     * Aborts the given mode
79bd26d35bSAndreas Gohr     *
80bd26d35bSAndreas Gohr     * Aborting depends on the mode. It might unset certain input parameters or simply switch
81bd26d35bSAndreas Gohr     * the mode to something else (giving as return which needs to be passed back to the
82bd26d35bSAndreas Gohr     * ACTION_ACT_PREPROCESS event)
83bd26d35bSAndreas Gohr     *
84bd26d35bSAndreas Gohr     * @param string $act cleaned action mode
85bd26d35bSAndreas Gohr     * @return string the new mode to use
86bd26d35bSAndreas Gohr     */
875e79b2eeSAndreas Gohr    protected function abortAction($act)
881c08a51cSAndreas Gohr    {
89bd26d35bSAndreas Gohr        global $INPUT;
90bd26d35bSAndreas Gohr
91bd26d35bSAndreas Gohr        switch ($act) {
92bd26d35bSAndreas Gohr            case 'save':
93bd26d35bSAndreas Gohr                return 'preview';
94bd26d35bSAndreas Gohr            case 'register':
95f74276b8SAndreas Gohr            case 'resendpwd':
96bd26d35bSAndreas Gohr                $INPUT->post->set('save', false);
97f74276b8SAndreas Gohr                return $act;
98643f15bdSAndreas Gohr            case 'login':
99643f15bdSAndreas Gohr                // we do not handle this here, but in handle_login()
100bd26d35bSAndreas Gohr            default:
101bd26d35bSAndreas Gohr                return $act;
102bd26d35bSAndreas Gohr        }
103bd26d35bSAndreas Gohr    }
104bd26d35bSAndreas Gohr
105bd26d35bSAndreas Gohr    /**
106969b14c4SAndreas Gohr     * Should a login CAPTCHA be used?
107969b14c4SAndreas Gohr     *
108969b14c4SAndreas Gohr     * @return bool
109969b14c4SAndreas Gohr     */
110969b14c4SAndreas Gohr    protected function protectLogin()
111969b14c4SAndreas Gohr    {
112969b14c4SAndreas Gohr        $config = (int)$this->getConf('loginprotect');
113969b14c4SAndreas Gohr        if ($config < 1) return false; // not wanted
114969b14c4SAndreas Gohr        if ($config === 1) return true; // always wanted
115969b14c4SAndreas Gohr        $count = (new IpCounter())->get();
116969b14c4SAndreas Gohr        return $count > 2; // only after 3 failed attempts
117969b14c4SAndreas Gohr    }
118969b14c4SAndreas Gohr
119969b14c4SAndreas Gohr    /**
120563fb566SAndreas Gohr     * Check if login is blocked due to timeout
121563fb566SAndreas Gohr     *
122563fb566SAndreas Gohr     * @return bool True if blocked
123563fb566SAndreas Gohr     */
124563fb566SAndreas Gohr    protected function isLoginBlocked()
125563fb566SAndreas Gohr    {
126563fb566SAndreas Gohr        $counter = new IpCounter();
127*194d3386SAndreas Gohr        return $counter->getRemainingTime() > 0;
128563fb566SAndreas Gohr    }
129563fb566SAndreas Gohr
130563fb566SAndreas Gohr    /**
131563fb566SAndreas Gohr     * Get human-readable remaining time string
132563fb566SAndreas Gohr     *
133563fb566SAndreas Gohr     * @return string
134563fb566SAndreas Gohr     */
135563fb566SAndreas Gohr    protected function getRemainingTimeString()
136563fb566SAndreas Gohr    {
137563fb566SAndreas Gohr        $counter = new IpCounter();
138*194d3386SAndreas Gohr        $remaining = $counter->getRemainingTime();
139563fb566SAndreas Gohr
140563fb566SAndreas Gohr        if ($remaining >= 3600) {
141563fb566SAndreas Gohr            return sprintf($this->getLang('timeout_hours'), ceil($remaining / 3600));
142563fb566SAndreas Gohr        } elseif ($remaining >= 60) {
143563fb566SAndreas Gohr            return sprintf($this->getLang('timeout_minutes'), ceil($remaining / 60));
144563fb566SAndreas Gohr        } else {
145563fb566SAndreas Gohr            return sprintf($this->getLang('timeout_seconds'), $remaining);
146563fb566SAndreas Gohr        }
147563fb566SAndreas Gohr    }
148563fb566SAndreas Gohr
149563fb566SAndreas Gohr    /**
150643f15bdSAndreas Gohr     * Handles CAPTCHA check in login
151643f15bdSAndreas Gohr     *
152643f15bdSAndreas Gohr     * Logins happen very early in the DokuWiki lifecycle, so we have to intercept them
153643f15bdSAndreas Gohr     * in their own event.
154643f15bdSAndreas Gohr     *
15509b1e97eSAndreas Gohr     * @param Event $event
156643f15bdSAndreas Gohr     * @param $param
157643f15bdSAndreas Gohr     */
1585e79b2eeSAndreas Gohr    public function handleLogin(Event $event, $param)
1591c08a51cSAndreas Gohr    {
160643f15bdSAndreas Gohr        global $INPUT;
161643f15bdSAndreas Gohr        if (!$INPUT->bool('u')) return; // this login was not triggered by a form
162643f15bdSAndreas Gohr
163563fb566SAndreas Gohr        // Check timeout first - if blocked, reject immediately
164563fb566SAndreas Gohr        if ($this->isLoginBlocked()) {
165563fb566SAndreas Gohr            $timeString = $this->getRemainingTimeString();
166563fb566SAndreas Gohr            msg(sprintf($this->getLang('logindenial'), $timeString), -1);
167563fb566SAndreas Gohr            $event->data['silent'] = true;
168563fb566SAndreas Gohr            $event->result = false;
169563fb566SAndreas Gohr            $event->preventDefault();
170563fb566SAndreas Gohr            $event->stopPropagation();
171563fb566SAndreas Gohr            return;
172563fb566SAndreas Gohr        }
173563fb566SAndreas Gohr
174563fb566SAndreas Gohr        if (!$this->protectLogin()) return; // no CAPTCHA protection wanted
175563fb566SAndreas Gohr
176643f15bdSAndreas Gohr        // we need to have $ID set for the captcha check
177643f15bdSAndreas Gohr        global $ID;
178643f15bdSAndreas Gohr        $ID = getID();
179643f15bdSAndreas Gohr
180643f15bdSAndreas Gohr        /** @var helper_plugin_captcha $helper */
181643f15bdSAndreas Gohr        $helper = plugin_load('helper', 'captcha');
182643f15bdSAndreas Gohr        if (!$helper->check()) {
183643f15bdSAndreas Gohr            $event->data['silent'] = true; // we have our own message
184643f15bdSAndreas Gohr            $event->result = false; // login fail
185643f15bdSAndreas Gohr            $event->preventDefault();
186643f15bdSAndreas Gohr            $event->stopPropagation();
187643f15bdSAndreas Gohr        }
188643f15bdSAndreas Gohr    }
189643f15bdSAndreas Gohr
190643f15bdSAndreas Gohr    /**
191643f15bdSAndreas Gohr     * Intercept all actions and check for CAPTCHA first.
19242a27035SAndreas Gohr     */
1935e79b2eeSAndreas Gohr    public function handleCaptchaInput(Event $event, $param)
1941c08a51cSAndreas Gohr    {
19564382f29SAndreas Gohr        global $INPUT;
19664382f29SAndreas Gohr
1977218f96cSAndreas Gohr        $act = act_clean($event->data);
1985e79b2eeSAndreas Gohr        if (!$this->needsChecking($act)) return;
19993f66506SAndreas Gohr
20042a27035SAndreas Gohr        // do nothing if logged in user and no CAPTCHA required
20164382f29SAndreas Gohr        if (!$this->getConf('forusers') && $INPUT->server->str('REMOTE_USER')) {
20242a27035SAndreas Gohr            return;
20342a27035SAndreas Gohr        }
20442a27035SAndreas Gohr
20577e00bf9SAndreas Gohr        // check captcha
2067218f96cSAndreas Gohr        /** @var helper_plugin_captcha $helper */
20777e00bf9SAndreas Gohr        $helper = plugin_load('helper', 'captcha');
20877e00bf9SAndreas Gohr        if (!$helper->check()) {
2095e79b2eeSAndreas Gohr            $event->data = $this->abortAction($act);
21042a27035SAndreas Gohr        }
21142a27035SAndreas Gohr    }
21242a27035SAndreas Gohr
21342a27035SAndreas Gohr    /**
2145e79b2eeSAndreas Gohr     * Inject the CAPTCHA in a \dokuwiki\Form\Form
21542a27035SAndreas Gohr     */
2165e79b2eeSAndreas Gohr    public function handleFormOutput(Event $event, $param)
2171c08a51cSAndreas Gohr    {
21864382f29SAndreas Gohr        global $INPUT;
21964382f29SAndreas Gohr
2205e79b2eeSAndreas Gohr        if ($event->name === 'FORM_LOGIN_OUTPUT' && !$this->protectLogin()) {
221c0439b03SAndreas Gohr            // no login protection wanted
222c0439b03SAndreas Gohr            return;
223c0439b03SAndreas Gohr        }
22431c8e2bdSAndreas Gohr
22509b1e97eSAndreas Gohr        /** @var Form|\Doku_Form $form */
2261c08a51cSAndreas Gohr        $form = $event->data;
2271c08a51cSAndreas Gohr
22847afabe6SAndreas Gohr        // get position of submit button
2291c08a51cSAndreas Gohr        $pos = $form->findPositionByAttribute('type', 'submit');
23047afabe6SAndreas Gohr        if (!$pos) return; // no button -> source view mode
23147afabe6SAndreas Gohr
23242a27035SAndreas Gohr        // do nothing if logged in user and no CAPTCHA required
23364382f29SAndreas Gohr        if (!$this->getConf('forusers') && $INPUT->server->str('REMOTE_USER')) {
23442a27035SAndreas Gohr            return;
23542a27035SAndreas Gohr        }
23642a27035SAndreas Gohr
23777e00bf9SAndreas Gohr        // get the CAPTCHA
2387218f96cSAndreas Gohr        /** @var helper_plugin_captcha $helper */
23977e00bf9SAndreas Gohr        $helper = plugin_load('helper', 'captcha');
24077e00bf9SAndreas Gohr        $out = $helper->getHTML();
24147afabe6SAndreas Gohr
2421c08a51cSAndreas Gohr        // insert before the submit button
2431c08a51cSAndreas Gohr        $form->addHTML($out, $pos);
24442a27035SAndreas Gohr    }
24542a27035SAndreas Gohr
246cde3ece1SAndreas Gohr    /**
247*194d3386SAndreas Gohr     * Clean up old captcha files once per day
248cde3ece1SAndreas Gohr     */
2495e79b2eeSAndreas Gohr    public function handleIndexer(Event $event, $param)
2501c08a51cSAndreas Gohr    {
251cde3ece1SAndreas Gohr        $lastrun = getCacheName('captcha', '.captcha');
252cde3ece1SAndreas Gohr        $last = @filemtime($lastrun);
253cde3ece1SAndreas Gohr        if (time() - $last < 24 * 60 * 60) return;
254cde3ece1SAndreas Gohr
255c6d794b3SAndreas Gohr        FileCookie::clean();
256*194d3386SAndreas Gohr        IpCounter::clean();
2575d59bd09SAndreas Gohr        touch($lastrun);
258cde3ece1SAndreas Gohr
259cde3ece1SAndreas Gohr        $event->preventDefault();
260cde3ece1SAndreas Gohr        $event->stopPropagation();
261cde3ece1SAndreas Gohr    }
262969b14c4SAndreas Gohr
263969b14c4SAndreas Gohr    /**
264969b14c4SAndreas Gohr     * Count failed login attempts
265969b14c4SAndreas Gohr     */
2665e79b2eeSAndreas Gohr    public function handleAuth(Event $event, $param)
267969b14c4SAndreas Gohr    {
268969b14c4SAndreas Gohr        global $INPUT;
269969b14c4SAndreas Gohr        $act = act_clean($event->data);
270969b14c4SAndreas Gohr        if (
271969b14c4SAndreas Gohr            $act != 'logout' &&
27231c8e2bdSAndreas Gohr            $INPUT->str('u') !== '' &&
273969b14c4SAndreas Gohr            empty($INPUT->server->str('http_credentials')) &&
274969b14c4SAndreas Gohr            empty($INPUT->server->str('REMOTE_USER'))
275969b14c4SAndreas Gohr        ) {
276969b14c4SAndreas Gohr            // This is a failed authentication attempt, count it
277969b14c4SAndreas Gohr            (new IpCounter())->increment();
278969b14c4SAndreas Gohr        }
279969b14c4SAndreas Gohr
280969b14c4SAndreas Gohr        if (
281969b14c4SAndreas Gohr            $act == 'login' &&
282969b14c4SAndreas Gohr            !empty($INPUT->server->str('REMOTE_USER'))
283969b14c4SAndreas Gohr        ) {
284969b14c4SAndreas Gohr            // This is a successful login, reset the counter
285969b14c4SAndreas Gohr            (new IpCounter())->reset();
286969b14c4SAndreas Gohr        }
287969b14c4SAndreas Gohr    }
28842a27035SAndreas Gohr}
289