1<?php
2
3use dokuwiki\JWT;
4use dokuwiki\plugin\twofactor\Manager;
5use dokuwiki\plugin\twofactor\Provider;
6
7/**
8 * DokuWiki Plugin twofactor (Action Component)
9 *
10 * @license GPL 2 http://www.gnu.org/licenses/gpl-2.0.html
11 */
12class action_plugin_twofactor_login extends DokuWiki_Action_Plugin
13{
14    const TWOFACTOR_COOKIE = '2FA' . DOKU_COOKIE;
15
16    /**
17     * Registers the event handlers.
18     */
19    public function register(Doku_Event_Handler $controller)
20    {
21        // check 2fa requirements and either move to profile or login handling
22        $controller->register_hook(
23            'ACTION_ACT_PREPROCESS',
24            'BEFORE',
25            $this,
26            'handleActionPreProcess',
27            null,
28            Manager::EVENT_PRIORITY
29        );
30
31        // display login form
32        $controller->register_hook(
33            'TPL_ACT_UNKNOWN',
34            'BEFORE',
35            $this,
36            'handleLoginDisplay'
37        );
38
39        // disable user in all non-main screens (media, detail, ajax, ...)
40        $controller->register_hook(
41            'DOKUWIKI_INIT_DONE',
42            'BEFORE',
43            $this,
44            'handleInitDone'
45        );
46    }
47
48    /**
49     * Decide if any 2fa handling needs to be done for the current user
50     *
51     * @param Doku_Event $event
52     */
53    public function handleActionPreProcess(Doku_Event $event)
54    {
55        if ($event->data === 'resendpwd') {
56            // this is completely handled in resendpwd.php
57            return;
58        }
59
60        $manager = Manager::getInstance();
61        if (!$manager->isReady()) return;
62
63        global $INPUT;
64
65        // already in a 2fa login?
66        if ($event->data === 'twofactor_login') {
67            if ($this->verify(
68                $INPUT->str('2fa_code'),
69                $INPUT->str('2fa_provider'),
70                $this->isSticky()
71            )) {
72                $event->data = 'show';
73                return;
74            } else {
75                // show form
76                $event->preventDefault();
77                return;
78            }
79        }
80
81        // clear cookie on logout
82        if ($event->data === 'logout') {
83            $this->deAuth();
84            return;
85        }
86
87        // authed already, continue
88        if ($this->isAuthed()) {
89            return;
90        }
91
92        if (count($manager->getUserProviders())) {
93            // user has already 2fa set up - they need to authenticate before anything else
94            $event->data = 'twofactor_login';
95            $event->preventDefault();
96            $event->stopPropagation();
97            return;
98        }
99
100        if ($manager->isRequired()) {
101            // 2fa is required - they need to set it up now
102            // this will be handled by action/profile.php
103            $event->data = 'twofactor_profile';
104        }
105
106        // all good. proceed
107    }
108
109    /**
110     * Show a 2fa login screen
111     *
112     * @param Doku_Event $event
113     */
114    public function handleLoginDisplay(Doku_Event $event)
115    {
116        if ($event->data !== 'twofactor_login') return;
117        $manager = Manager::getInstance();
118        if (!$manager->isReady()) return;
119
120        $event->preventDefault();
121        $event->stopPropagation();
122
123        global $INPUT;
124        $providerID = $INPUT->str('2fa_provider');
125
126        echo '<div class="plugin_twofactor_login">';
127        echo inlineSVG(__DIR__ . '/../admin.svg');
128        echo $this->locale_xhtml('login');
129        echo $manager->getCodeForm($providerID)->toHTML();
130        echo '</div>';
131    }
132
133    /**
134     * Remove user info from non-main entry points while we wait for 2fa
135     *
136     * @param Doku_Event $event
137     */
138    public function handleInitDone(Doku_Event $event)
139    {
140        global $INPUT;
141        $script = basename($INPUT->server->str('SCRIPT_NAME'));
142
143        if (!(Manager::getInstance())->isReady()) return;
144        if ($script == DOKU_SCRIPT) return;
145        if ($this->isAuthed()) return;
146
147        if ($this->getConf('optinout') !== 'mandatory' && empty(Manager::getInstance()->getUserProviders())) return;
148
149        // allow API access without 2fa when using token auth
150        if(in_array($script, ['xmlrpc.php', 'jsonrpc.php']) || $this->getConf('allowTokenAuth')) {
151            if ($this->hasValidTokenAuth()) return;
152        }
153
154        // temporarily remove user info from environment
155        $INPUT->server->remove('REMOTE_USER');
156        unset($_SESSION[DOKU_COOKIE]['auth']);
157        unset($GLOBALS['USERINFO']);
158    }
159
160    /**
161     * Has the user already authenticated with the second factor?
162     * @return bool
163     */
164    protected function isAuthed()
165    {
166        // if we trust the IP, we don't need 2fa and consider the user authed automatically
167        if (
168            $this->getConf('trustedIPs') &&
169            preg_match('/' . $this->getConf('trustedIPs') . '/', clientIP(true))
170        ) {
171            return true;
172        }
173
174        if (!isset($_COOKIE[self::TWOFACTOR_COOKIE])) return false;
175        $data = unserialize(base64_decode($_COOKIE[self::TWOFACTOR_COOKIE]));
176        if (!is_array($data)) return false;
177        list($providerID, $hash,) = $data;
178
179        try {
180            $provider = (Manager::getInstance())->getUserProvider($providerID);
181            if ($this->cookieHash($provider) !== $hash) return false;
182            return true;
183        } catch (Exception $ignored) {
184            return false;
185        }
186    }
187
188    /**
189     * Get sticky value from standard cookie
190     *
191     * @return bool
192     */
193    protected function isSticky()
194    {
195        if (!isset($_COOKIE[DOKU_COOKIE])) {
196            return false;
197        }
198        list(, $sticky,) = explode('|', $_COOKIE[DOKU_COOKIE], 3);
199        return (bool)$sticky;
200    }
201
202    /**
203     * Deletes the cookie
204     *
205     * @return void
206     */
207    protected function deAuth()
208    {
209        global $conf;
210
211        $cookieDir = empty($conf['cookiedir']) ? DOKU_REL : $conf['cookiedir'];
212        $time = time() - 60 * 60 * 24 * 365; // one year in the past
213        setcookie(self::TWOFACTOR_COOKIE, null, $time, $cookieDir, '', ($conf['securecookie'] && is_ssl()), true);
214    }
215
216    /**
217     * Verify a given code
218     *
219     * @return bool
220     * @throws Exception
221     */
222    protected function verify($code, $providerID, $sticky)
223    {
224        global $conf;
225
226        $manager = Manager::getInstance();
227        if (!$manager->verifyCode($code, $providerID)) return false;
228
229        $provider = (Manager::getInstance())->getUserProvider($providerID);
230
231        // store cookie
232        $hash = $this->cookieHash($provider);
233        $data = base64_encode(serialize([$providerID, $hash, time()]));
234        $cookieDir = empty($conf['cookiedir']) ? DOKU_REL : $conf['cookiedir'];
235        $time = $sticky ? (time() + 60 * 60 * 24 * 30 * 3) : 0; //three months on sticky login
236        setcookie(self::TWOFACTOR_COOKIE, $data, $time, $cookieDir, '', ($conf['securecookie'] && is_ssl()), true);
237
238        return true;
239    }
240
241    /**
242     * Create a hash that validates the cookie
243     *
244     * @param Provider $provider
245     * @return string
246     */
247    protected function cookieHash($provider)
248    {
249        return sha1(join("\n", [
250            $provider->getProviderID(),
251            (Manager::getInstance())->getUser(),
252            $provider->getSecret(),
253            $this->getConf("useinternaluid") ? auth_browseruid() : $_SERVER['HTTP_USER_AGENT'],
254            auth_cookiesalt(false, true),
255        ]));
256    }
257
258    /**
259     * Check if the user has a valid auth token. We might skip 2fa for them.
260     *
261     * This duplicates code from auth_tokenlogin() until DokuWiki has a proper mechanism to access the token
262     *
263     * @return bool
264     */
265    protected function hasValidTokenAuth()
266    {
267        $headers = [];
268
269        // try to get the headers from Apache
270        if (function_exists('getallheaders')) {
271            $headers = getallheaders();
272            if (is_array($headers)) {
273                $headers = array_change_key_case($headers);
274            }
275        }
276
277        // get the headers from $_SERVER
278        if (!$headers) {
279            foreach ($_SERVER as $key => $value) {
280                if (substr($key, 0, 5) === 'HTTP_') {
281                    $headers[strtolower(substr($key, 5))] = $value;
282                }
283            }
284        }
285
286        // check authorization header
287        if (isset($headers['authorization'])) {
288            [$type, $token] = sexplode(' ', $headers['authorization'], 2);
289            if ($type !== 'Bearer') $token = ''; // not the token we want
290        }
291
292        // check x-dokuwiki-token header
293        if (isset($headers['x-dokuwiki-token'])) {
294            $token = $headers['x-dokuwiki-token'];
295        }
296
297        if (empty($token)) return false;
298
299        // check token
300        try {
301            JWT::validate($token);
302        } catch (Exception $e) {
303            return false;
304        }
305        return true;
306    }
307}
308