xref: /plugin/twofactor/action/login.php (revision afea3dda94c0b8f4ef81f2e0c2e20990da9f65d4)
1fca58076SAndreas Gohr<?php
28b7620a8SAndreas Gohr
38bfbdf72SAndreas Gohruse dokuwiki\JWT;
48b7620a8SAndreas Gohruse dokuwiki\plugin\twofactor\Manager;
56c996db8SAndreas Gohruse dokuwiki\plugin\twofactor\Provider;
68b7620a8SAndreas Gohr
730625b49SAndreas Gohr/**
830625b49SAndreas Gohr * DokuWiki Plugin twofactor (Action Component)
930625b49SAndreas Gohr *
1030625b49SAndreas Gohr * @license GPL 2 http://www.gnu.org/licenses/gpl-2.0.html
1130625b49SAndreas Gohr */
12fca58076SAndreas Gohrclass action_plugin_twofactor_login extends DokuWiki_Action_Plugin
13fca58076SAndreas Gohr{
14848a9be0SAndreas Gohr    const TWOFACTOR_COOKIE = '2FA' . DOKU_COOKIE;
15848a9be0SAndreas Gohr
16fca58076SAndreas Gohr    /**
17fca58076SAndreas Gohr     * Registers the event handlers.
18fca58076SAndreas Gohr     */
19fca58076SAndreas Gohr    public function register(Doku_Event_Handler $controller)
20fca58076SAndreas Gohr    {
21a386a536SAndreas Gohr        // check 2fa requirements and either move to profile or login handling
22a386a536SAndreas Gohr        $controller->register_hook(
23a386a536SAndreas Gohr            'ACTION_ACT_PREPROCESS',
24a386a536SAndreas Gohr            'BEFORE',
25a386a536SAndreas Gohr            $this,
26a386a536SAndreas Gohr            'handleActionPreProcess',
27a386a536SAndreas Gohr            null,
285f8f561aSAndreas Gohr            Manager::EVENT_PRIORITY
29a386a536SAndreas Gohr        );
30fca58076SAndreas Gohr
31a386a536SAndreas Gohr        // display login form
32a386a536SAndreas Gohr        $controller->register_hook(
33a386a536SAndreas Gohr            'TPL_ACT_UNKNOWN',
34a386a536SAndreas Gohr            'BEFORE',
35a386a536SAndreas Gohr            $this,
36a386a536SAndreas Gohr            'handleLoginDisplay'
37a386a536SAndreas Gohr        );
38a386a536SAndreas Gohr
39210d81e3SAndreas Gohr        // disable user in all non-main screens (media, detail, ajax, ...)
40210d81e3SAndreas Gohr        $controller->register_hook(
41210d81e3SAndreas Gohr            'DOKUWIKI_INIT_DONE',
42210d81e3SAndreas Gohr            'BEFORE',
43210d81e3SAndreas Gohr            $this,
44210d81e3SAndreas Gohr            'handleInitDone'
45210d81e3SAndreas Gohr        );
46fca58076SAndreas Gohr    }
47fca58076SAndreas Gohr
48fca58076SAndreas Gohr    /**
49a386a536SAndreas Gohr     * Decide if any 2fa handling needs to be done for the current user
50a386a536SAndreas Gohr     *
51a386a536SAndreas Gohr     * @param Doku_Event $event
52a386a536SAndreas Gohr     */
53a386a536SAndreas Gohr    public function handleActionPreProcess(Doku_Event $event)
54a386a536SAndreas Gohr    {
55c8525a21SAndreas Gohr        if ($event->data === 'resendpwd') {
56c8525a21SAndreas Gohr            // this is completely handled in resendpwd.php
57c8525a21SAndreas Gohr            return;
58c8525a21SAndreas Gohr        }
59c8525a21SAndreas Gohr
605f8f561aSAndreas Gohr        $manager = Manager::getInstance();
615f8f561aSAndreas Gohr        if (!$manager->isReady()) return;
62a386a536SAndreas Gohr
63a386a536SAndreas Gohr        global $INPUT;
64a386a536SAndreas Gohr
65a386a536SAndreas Gohr        // already in a 2fa login?
66a386a536SAndreas Gohr        if ($event->data === 'twofactor_login') {
67848a9be0SAndreas Gohr            if ($this->verify(
68848a9be0SAndreas Gohr                $INPUT->str('2fa_code'),
69848a9be0SAndreas Gohr                $INPUT->str('2fa_provider'),
7003bae0e0SAndreas Gohr                $this->isSticky()
71848a9be0SAndreas Gohr            )) {
72a386a536SAndreas Gohr                $event->data = 'show';
73848a9be0SAndreas Gohr                return;
74a386a536SAndreas Gohr            } else {
75a386a536SAndreas Gohr                // show form
76a386a536SAndreas Gohr                $event->preventDefault();
77a386a536SAndreas Gohr                return;
78a386a536SAndreas Gohr            }
79a386a536SAndreas Gohr        }
80a386a536SAndreas Gohr
81857c5abcSAndreas Gohr        // clear cookie on logout
82857c5abcSAndreas Gohr        if ($event->data === 'logout') {
83857c5abcSAndreas Gohr            $this->deAuth();
84857c5abcSAndreas Gohr            return;
85857c5abcSAndreas Gohr        }
86857c5abcSAndreas Gohr
87a386a536SAndreas Gohr        // authed already, continue
88a386a536SAndreas Gohr        if ($this->isAuthed()) {
89a386a536SAndreas Gohr            return;
90a386a536SAndreas Gohr        }
91a386a536SAndreas Gohr
925f8f561aSAndreas Gohr        if (count($manager->getUserProviders())) {
93a386a536SAndreas Gohr            // user has already 2fa set up - they need to authenticate before anything else
94a386a536SAndreas Gohr            $event->data = 'twofactor_login';
95a386a536SAndreas Gohr            $event->preventDefault();
96a386a536SAndreas Gohr            $event->stopPropagation();
97a386a536SAndreas Gohr            return;
98a386a536SAndreas Gohr        }
99a386a536SAndreas Gohr
1005f8f561aSAndreas Gohr        if ($manager->isRequired()) {
101a386a536SAndreas Gohr            // 2fa is required - they need to set it up now
102a386a536SAndreas Gohr            // this will be handled by action/profile.php
103a386a536SAndreas Gohr            $event->data = 'twofactor_profile';
104a386a536SAndreas Gohr        }
105a386a536SAndreas Gohr
106a386a536SAndreas Gohr        // all good. proceed
107a386a536SAndreas Gohr    }
108a386a536SAndreas Gohr
109a386a536SAndreas Gohr    /**
110a386a536SAndreas Gohr     * Show a 2fa login screen
111a386a536SAndreas Gohr     *
112a386a536SAndreas Gohr     * @param Doku_Event $event
113a386a536SAndreas Gohr     */
114a386a536SAndreas Gohr    public function handleLoginDisplay(Doku_Event $event)
115a386a536SAndreas Gohr    {
116a386a536SAndreas Gohr        if ($event->data !== 'twofactor_login') return;
1175f8f561aSAndreas Gohr        $manager = Manager::getInstance();
1185f8f561aSAndreas Gohr        if (!$manager->isReady()) return;
1195f8f561aSAndreas Gohr
120a386a536SAndreas Gohr        $event->preventDefault();
121a386a536SAndreas Gohr        $event->stopPropagation();
122a386a536SAndreas Gohr
123a386a536SAndreas Gohr        global $INPUT;
124a386a536SAndreas Gohr        $providerID = $INPUT->str('2fa_provider');
125a386a536SAndreas Gohr
1260407d282SAndreas Gohr        echo '<div class="plugin_twofactor_login">';
1270407d282SAndreas Gohr        echo inlineSVG(__DIR__ . '/../admin.svg');
128857c5abcSAndreas Gohr        echo $this->locale_xhtml('login');
129c8525a21SAndreas Gohr        echo $manager->getCodeForm($providerID)->toHTML();
1300407d282SAndreas Gohr        echo '</div>';
131a386a536SAndreas Gohr    }
132a386a536SAndreas Gohr
133a386a536SAndreas Gohr    /**
134210d81e3SAndreas Gohr     * Remove user info from non-main entry points while we wait for 2fa
135210d81e3SAndreas Gohr     *
136210d81e3SAndreas Gohr     * @param Doku_Event $event
137210d81e3SAndreas Gohr     */
138210d81e3SAndreas Gohr    public function handleInitDone(Doku_Event $event)
139210d81e3SAndreas Gohr    {
140210d81e3SAndreas Gohr        global $INPUT;
1418bfbdf72SAndreas Gohr        $script = basename($INPUT->server->str('SCRIPT_NAME'));
142210d81e3SAndreas Gohr
143210d81e3SAndreas Gohr        if (!(Manager::getInstance())->isReady()) return;
1448bfbdf72SAndreas Gohr        if ($script == DOKU_SCRIPT) return;
145210d81e3SAndreas Gohr        if ($this->isAuthed()) return;
146210d81e3SAndreas Gohr
1470d5f8055SAnna Dabrowska        if ($this->getConf('optinout') !== 'mandatory' && empty(Manager::getInstance()->getUserProviders())) return;
1480d5f8055SAnna Dabrowska
1498bfbdf72SAndreas Gohr        // allow API access without 2fa when using token auth
150*afea3ddaSAndreas Gohr        if(in_array($script, ['xmlrpc.php', 'jsonrpc.php']) || $this->getConf('allowTokenAuth')) {
1518bfbdf72SAndreas Gohr            if ($this->hasValidTokenAuth()) return;
1528bfbdf72SAndreas Gohr        }
1538bfbdf72SAndreas Gohr
154210d81e3SAndreas Gohr        // temporarily remove user info from environment
155210d81e3SAndreas Gohr        $INPUT->server->remove('REMOTE_USER');
156210d81e3SAndreas Gohr        unset($_SESSION[DOKU_COOKIE]['auth']);
157210d81e3SAndreas Gohr        unset($GLOBALS['USERINFO']);
158210d81e3SAndreas Gohr    }
159210d81e3SAndreas Gohr
160210d81e3SAndreas Gohr    /**
161a386a536SAndreas Gohr     * Has the user already authenticated with the second factor?
162a386a536SAndreas Gohr     * @return bool
163a386a536SAndreas Gohr     */
164a386a536SAndreas Gohr    protected function isAuthed()
165a386a536SAndreas Gohr    {
16695ed3afaSAndreas Gohr        // if we trust the IP, we don't need 2fa and consider the user authed automatically
16795ed3afaSAndreas Gohr        if (
16895ed3afaSAndreas Gohr            $this->getConf('trustedIPs') &&
16995ed3afaSAndreas Gohr            preg_match('/' . $this->getConf('trustedIPs') . '/', clientIP(true))
17095ed3afaSAndreas Gohr        ) {
17195ed3afaSAndreas Gohr            return true;
17295ed3afaSAndreas Gohr        }
17395ed3afaSAndreas Gohr
174848a9be0SAndreas Gohr        if (!isset($_COOKIE[self::TWOFACTOR_COOKIE])) return false;
175848a9be0SAndreas Gohr        $data = unserialize(base64_decode($_COOKIE[self::TWOFACTOR_COOKIE]));
176848a9be0SAndreas Gohr        if (!is_array($data)) return false;
1776c996db8SAndreas Gohr        list($providerID, $hash,) = $data;
178848a9be0SAndreas Gohr
179848a9be0SAndreas Gohr        try {
1805f8f561aSAndreas Gohr            $provider = (Manager::getInstance())->getUserProvider($providerID);
1816c996db8SAndreas Gohr            if ($this->cookieHash($provider) !== $hash) return false;
182848a9be0SAndreas Gohr            return true;
1835f8f561aSAndreas Gohr        } catch (Exception $ignored) {
184a386a536SAndreas Gohr            return false;
185a386a536SAndreas Gohr        }
186848a9be0SAndreas Gohr    }
187a386a536SAndreas Gohr
188a386a536SAndreas Gohr    /**
18903bae0e0SAndreas Gohr     * Get sticky value from standard cookie
19003bae0e0SAndreas Gohr     *
19103bae0e0SAndreas Gohr     * @return bool
19203bae0e0SAndreas Gohr     */
19303bae0e0SAndreas Gohr    protected function isSticky()
19403bae0e0SAndreas Gohr    {
19503bae0e0SAndreas Gohr        if (!isset($_COOKIE[DOKU_COOKIE])) {
19603bae0e0SAndreas Gohr            return false;
19703bae0e0SAndreas Gohr        }
19803bae0e0SAndreas Gohr        list(, $sticky,) = explode('|', $_COOKIE[DOKU_COOKIE], 3);
19903bae0e0SAndreas Gohr        return (bool)$sticky;
20003bae0e0SAndreas Gohr    }
20103bae0e0SAndreas Gohr
20203bae0e0SAndreas Gohr    /**
203857c5abcSAndreas Gohr     * Deletes the cookie
204857c5abcSAndreas Gohr     *
205857c5abcSAndreas Gohr     * @return void
206857c5abcSAndreas Gohr     */
207857c5abcSAndreas Gohr    protected function deAuth()
208857c5abcSAndreas Gohr    {
209857c5abcSAndreas Gohr        global $conf;
210857c5abcSAndreas Gohr
211857c5abcSAndreas Gohr        $cookieDir = empty($conf['cookiedir']) ? DOKU_REL : $conf['cookiedir'];
212857c5abcSAndreas Gohr        $time = time() - 60 * 60 * 24 * 365; // one year in the past
213857c5abcSAndreas Gohr        setcookie(self::TWOFACTOR_COOKIE, null, $time, $cookieDir, '', ($conf['securecookie'] && is_ssl()), true);
214857c5abcSAndreas Gohr    }
215857c5abcSAndreas Gohr
216857c5abcSAndreas Gohr    /**
217a386a536SAndreas Gohr     * Verify a given code
218a386a536SAndreas Gohr     *
219a386a536SAndreas Gohr     * @return bool
220a386a536SAndreas Gohr     * @throws Exception
221a386a536SAndreas Gohr     */
222848a9be0SAndreas Gohr    protected function verify($code, $providerID, $sticky)
223a386a536SAndreas Gohr    {
224848a9be0SAndreas Gohr        global $conf;
225848a9be0SAndreas Gohr
226c8525a21SAndreas Gohr        $manager = Manager::getInstance();
227c8525a21SAndreas Gohr        if (!$manager->verifyCode($code, $providerID)) return false;
228c8525a21SAndreas Gohr
2295f8f561aSAndreas Gohr        $provider = (Manager::getInstance())->getUserProvider($providerID);
230a386a536SAndreas Gohr
231848a9be0SAndreas Gohr        // store cookie
2326c996db8SAndreas Gohr        $hash = $this->cookieHash($provider);
2336c996db8SAndreas Gohr        $data = base64_encode(serialize([$providerID, $hash, time()]));
234848a9be0SAndreas Gohr        $cookieDir = empty($conf['cookiedir']) ? DOKU_REL : $conf['cookiedir'];
23503bae0e0SAndreas Gohr        $time = $sticky ? (time() + 60 * 60 * 24 * 30 * 3) : 0; //three months on sticky login
236848a9be0SAndreas Gohr        setcookie(self::TWOFACTOR_COOKIE, $data, $time, $cookieDir, '', ($conf['securecookie'] && is_ssl()), true);
237a386a536SAndreas Gohr
238a386a536SAndreas Gohr        return true;
239a386a536SAndreas Gohr    }
2406c996db8SAndreas Gohr
2416c996db8SAndreas Gohr    /**
2426c996db8SAndreas Gohr     * Create a hash that validates the cookie
2436c996db8SAndreas Gohr     *
2446c996db8SAndreas Gohr     * @param Provider $provider
2456c996db8SAndreas Gohr     * @return string
2466c996db8SAndreas Gohr     */
2476c996db8SAndreas Gohr    protected function cookieHash($provider)
2486c996db8SAndreas Gohr    {
2496c996db8SAndreas Gohr        return sha1(join("\n", [
2506c996db8SAndreas Gohr            $provider->getProviderID(),
2515f8f561aSAndreas Gohr            (Manager::getInstance())->getUser(),
2526c996db8SAndreas Gohr            $provider->getSecret(),
25309c2ba1aSalexdraconian            $this->getConf("useinternaluid") ? auth_browseruid() : $_SERVER['HTTP_USER_AGENT'],
2546c996db8SAndreas Gohr            auth_cookiesalt(false, true),
2556c996db8SAndreas Gohr        ]));
2566c996db8SAndreas Gohr    }
2578bfbdf72SAndreas Gohr
2588bfbdf72SAndreas Gohr    /**
2598bfbdf72SAndreas Gohr     * Check if the user has a valid auth token. We might skip 2fa for them.
2608bfbdf72SAndreas Gohr     *
2618bfbdf72SAndreas Gohr     * This duplicates code from auth_tokenlogin() until DokuWiki has a proper mechanism to access the token
2628bfbdf72SAndreas Gohr     *
2638bfbdf72SAndreas Gohr     * @return bool
2648bfbdf72SAndreas Gohr     */
2658bfbdf72SAndreas Gohr    protected function hasValidTokenAuth()
2668bfbdf72SAndreas Gohr    {
2678bfbdf72SAndreas Gohr        $headers = [];
2688bfbdf72SAndreas Gohr
2698bfbdf72SAndreas Gohr        // try to get the headers from Apache
2708bfbdf72SAndreas Gohr        if (function_exists('getallheaders')) {
2718bfbdf72SAndreas Gohr            $headers = getallheaders();
2728bfbdf72SAndreas Gohr            if (is_array($headers)) {
2738bfbdf72SAndreas Gohr                $headers = array_change_key_case($headers);
2748bfbdf72SAndreas Gohr            }
2758bfbdf72SAndreas Gohr        }
2768bfbdf72SAndreas Gohr
2778bfbdf72SAndreas Gohr        // get the headers from $_SERVER
2788bfbdf72SAndreas Gohr        if (!$headers) {
2798bfbdf72SAndreas Gohr            foreach ($_SERVER as $key => $value) {
2808bfbdf72SAndreas Gohr                if (substr($key, 0, 5) === 'HTTP_') {
2818bfbdf72SAndreas Gohr                    $headers[strtolower(substr($key, 5))] = $value;
2828bfbdf72SAndreas Gohr                }
2838bfbdf72SAndreas Gohr            }
2848bfbdf72SAndreas Gohr        }
2858bfbdf72SAndreas Gohr
2868bfbdf72SAndreas Gohr        // check authorization header
2878bfbdf72SAndreas Gohr        if (isset($headers['authorization'])) {
2888bfbdf72SAndreas Gohr            [$type, $token] = sexplode(' ', $headers['authorization'], 2);
2898bfbdf72SAndreas Gohr            if ($type !== 'Bearer') $token = ''; // not the token we want
2908bfbdf72SAndreas Gohr        }
2918bfbdf72SAndreas Gohr
2928bfbdf72SAndreas Gohr        // check x-dokuwiki-token header
2938bfbdf72SAndreas Gohr        if (isset($headers['x-dokuwiki-token'])) {
2948bfbdf72SAndreas Gohr            $token = $headers['x-dokuwiki-token'];
2958bfbdf72SAndreas Gohr        }
2968bfbdf72SAndreas Gohr
2978bfbdf72SAndreas Gohr        if (empty($token)) return false;
2988bfbdf72SAndreas Gohr
2998bfbdf72SAndreas Gohr        // check token
3008bfbdf72SAndreas Gohr        try {
3018bfbdf72SAndreas Gohr            JWT::validate($token);
3028bfbdf72SAndreas Gohr        } catch (Exception $e) {
3038bfbdf72SAndreas Gohr            return false;
3048bfbdf72SAndreas Gohr        }
3058bfbdf72SAndreas Gohr        return true;
3068bfbdf72SAndreas Gohr    }
307fca58076SAndreas Gohr}
308