xref: /plugin/twofactor/action/login.php (revision a386a536946c52f862186062d92ef1b00c31406b)
1<?php
2
3/**
4 * Two Factor Action Plugin
5 *
6 * @author Mike Wilmes mwilmes@avc.edu
7 * Big thanks to Daniel Popp and his Google 2FA code (authgoogle2fa) as a
8 * starting reference.
9 *
10 * Overview:
11 * The plugin provides for two opportunities to perform two factor
12 * authentication. The first is on the main login page, via a code provided by
13 * an external authenticator. The second is at a separate prompt after the
14 * initial login. By default, all modules will process from the second login,
15 * but a module can subscribe to accepting a password from the main login when
16 * it makes sense, because the user has access to the code in advance.
17 *
18 * If a user only has configured modules that provide for login at the main
19 * screen, the code will only be accepted at the main login screen for
20 * security purposes.
21 *
22 * Modules will be called to render their configuration forms on the profile
23 * page and to verify a user's submitted code. If any module accepts the
24 * submitted code, then the user is granted access.
25 *
26 * Each module may be used to transmit a message to the user that their
27 * account has been logged into. One module may be used as the default
28 * transmit option. These options are handled by the parent module.
29 */
30
31// Create a definition for a 2FA cookie.
32use dokuwiki\plugin\twofactor\Manager;
33
34define('TWOFACTOR_COOKIE', '2FA' . DOKU_COOKIE);
35
36class action_plugin_twofactor_login extends DokuWiki_Action_Plugin
37{
38    /** @var Manager */
39    protected $manager;
40
41    /**
42     * Constructor
43     */
44    public function __construct()
45    {
46        $this->manager = Manager::getInstance();
47    }
48
49    /**
50     * Registers the event handlers.
51     */
52    public function register(Doku_Event_Handler $controller)
53    {
54        if (!(Manager::getInstance())->isReady()) return;
55
56        // check 2fa requirements and either move to profile or login handling
57        $controller->register_hook(
58            'ACTION_ACT_PREPROCESS',
59            'BEFORE',
60            $this,
61            'handleActionPreProcess',
62            null,
63            -999999
64        );
65
66        // display login form
67        $controller->register_hook(
68            'TPL_ACT_UNKNOWN',
69            'BEFORE',
70            $this,
71            'handleLoginDisplay'
72        );
73
74        // FIXME disable user in all non-main screens (media, detail, ajax, ...)
75
76        /*
77        $controller->register_hook('HTML_LOGINFORM_OUTPUT', 'BEFORE', $this, 'twofactor_login_form');
78
79        // Manage action flow around the twofactor authentication requirements.
80
81        // Handle the twofactor login and profile actions.
82        $controller->register_hook('TPL_ACT_UNKNOWN', 'BEFORE', $this, 'twofactor_handle_unknown_action');
83        $controller->register_hook('TPL_ACTION_GET', 'BEFORE', $this, 'twofactor_get_unknown_action');
84
85        // If the user supplies a token code at login, checks it before logging the user in.
86        $controller->register_hook('AUTH_LOGIN_CHECK', 'BEFORE', $this, 'twofactor_before_auth_check', null,
87            -999999);
88        // Atempts to process the second login if the user hasn't done so already.
89        $controller->register_hook('AUTH_LOGIN_CHECK', 'AFTER', $this, 'twofactor_after_auth_check');
90        $this->log('register: Session: ' . print_r($_SESSION, true), self::LOGGING_DEBUGPLUS);
91        */
92    }
93
94    /**
95     * Decide if any 2fa handling needs to be done for the current user
96     *
97     * @param Doku_Event $event
98     */
99    public function handleActionPreProcess(Doku_Event $event)
100    {
101        if (!$this->manager->getUser()) return;
102
103        global $INPUT;
104
105        // already in a 2fa login?
106        if ($event->data === 'twofactor_login') {
107            if ($this->verify($INPUT->str('2fa_code'), $INPUT->str('2fa_provider'))) {
108                $event->data = 'show';
109            } else {
110                // show form
111                $event->preventDefault();
112                return;
113            }
114        }
115
116        // authed already, continue
117        if ($this->isAuthed()) {
118            return;
119        }
120
121        if (count($this->manager->getUserProviders())) {
122            // user has already 2fa set up - they need to authenticate before anything else
123            $event->data = 'twofactor_login';
124            $event->preventDefault();
125            $event->stopPropagation();
126            return;
127        }
128
129        if ($this->manager->isRequired()) {
130            // 2fa is required - they need to set it up now
131            // this will be handled by action/profile.php
132            $event->data = 'twofactor_profile';
133        }
134
135        // all good. proceed
136    }
137
138    /**
139     * Show a 2fa login screen
140     *
141     * @param Doku_Event $event
142     */
143    public function handleLoginDisplay(Doku_Event $event)
144    {
145        if ($event->data !== 'twofactor_login') return;
146        $event->preventDefault();
147        $event->stopPropagation();
148
149        global $INPUT;
150        $providerID = $INPUT->str('2fa_provider');
151        $providers = $this->manager->getUserProviders();
152        if (isset($providers[$providerID])) {
153            $provider = $providers[$providerID];
154            unset($providers[$providerID]);
155        } else {
156            $provider = array_shift($providers);
157        }
158
159        $form = new dokuwiki\Form\Form(['method' => 'code']);
160        $form->setHiddenField('2fa_provider', $provider->getProviderID());
161        $form->addFieldsetOpen($provider->getLabel());
162        try {
163            $code = $provider->generateCode();
164            $info = $provider->transmitMessage($code);
165            $form->addHTML('<p>' . hsc($info) . '</p>');
166            $form->addTextInput('2fa_code', 'Your Code');
167            $form->addButton('2fa', 'Submit')->attr('type', 'submit');
168        } catch (\Exception $e) {
169            msg(hsc($e->getMessage()), -1); // FIXME better handling
170        }
171        $form->addFieldsetClose();
172
173        if (count($providers)) {
174            $form->addFieldsetOpen('Alternative methods');
175            foreach ($providers as $prov) {
176                $link = $prov->getProviderID(); // FIXME build correct links
177
178                $form->addHTML($link);
179            }
180            $form->addFieldsetClose();
181        }
182
183        echo $form->toHTML();
184    }
185
186    /**
187     * Has the user already authenticated with the second factor?
188     * @return bool
189     */
190    protected function isAuthed()
191    {
192        // FIXME implement by checking cookie
193        // cookie should have
194        // provider
195        // some kind of secret?
196        // expire at the end of a session? What about remember me?
197        return false;
198    }
199
200    /**
201     * Verify a given code
202     *
203     * @return bool
204     * @throws Exception
205     */
206    protected function verify($code, $providerID)
207    {
208        if (!$code) return false;
209        if (!$providerID) return false;
210        $provider = $this->manager->getUserProvider($providerID);
211        $ok = $provider->checkCode($code);
212        if (!$ok) return false;
213
214        // FIXME store cookie
215
216        return true;
217    }
218
219
220    // region old shit
221
222    /**
223     * Handles the login form rendering.
224     */
225    public function twofactor_login_form(&$event, $param)
226    {
227        $this->log('twofactor_login_form: start', self::LOGGING_DEBUG);
228        $twofa_form = form_makeTextField('otp', '', $this->getLang('twofactor_login'), '', 'block',
229            array('size' => '50', 'autocomplete' => 'off'));
230        $pos = $event->data->findElementByAttribute('name', 'p');
231        $event->data->insertElement($pos + 1, $twofa_form);
232    }
233
234    /**
235     * Action process redirector.  If logging out, processes the logout
236     * function.  If visiting the profile, sets a flag to confirm that the
237     * profile is being viewed in order to enable OTP attribute updates.
238     */
239    public function twofactor_action_process_handler(&$event, $param)
240    {
241        global $USERINFO, $ID, $INFO, $INPUT;
242        $this->log('twofactor_action_process_handler: start ' . $event->data, self::LOGGING_DEBUG);
243        // Handle logout.
244        if ($event->data == 'logout') {
245            $this->_logout();
246            return;
247        }
248        // Handle main login.
249        if ($event->data == 'login') {
250            // To support loglog or any other module that hooks login checking for success,
251            // Confirm that the user is logged in.  If not, then redirect to twofactor_login
252            // and fail the login.
253            if ($USERINFO && !$this->get_clearance()) {
254                // Hijack this event.  We need to resend it after 2FA is done.
255                $event->stopPropagation();
256                // Send loglog an event to show the user logged in but needs OTP code.
257                $log = array('message' => 'logged in, ' . $this->getLang('requires_otp'), 'user' => $user);
258                trigger_event('PLUGIN_LOGLOG_LOG', $log);
259            }
260            return;
261        }
262
263        // Check to see if we are heading to the twofactor login.
264        if ($event->data == 'twofactor_login') {
265            // Check if we already have clearance- just in case.
266            if ($this->get_clearance()) {
267                // Okay, this continues on with normal processing.
268                return;
269            }
270            // We will be handling this action's permissions here.
271            $event->preventDefault();
272            $event->stopPropagation();
273            // If not logged into the main auth plugin then send there.
274            if (!$USERINFO) {
275                $event->result = false;
276                send_redirect(wl($ID, array('do' => 'login'), true, '&'));
277                return;
278            }
279            if (count($this->otpMods) == 0) {
280                $this->log('No available otp modules.', self::LOGGING_DEBUG);
281                // There is no way to handle this login.
282                msg($this->getLang('mustusetoken'), -1);
283                $event->result = false;
284                send_redirect(wl($ID, array('do' => 'logout'), true, '&'));
285                return;
286            }
287            // Otherwise handle the action.
288            $act = $this->_process_otp($event, $param);
289            $event->result = true;
290            if ($act) {
291                send_redirect(wl($ID, array('do' => $act), true, '&'));
292            }
293            return;
294        }
295
296        // Is the user logged into the wiki?
297        if (!$USERINFO) {
298            // If not logged in, then do nothing.
299            return;
300        }
301
302        // See if this user has any OTP methods configured.
303        $available = count($this->tokenMods) + count($this->otpMods) > 0;
304        // Check if this user needs to login with 2FA.
305        // Wiki mandatory is on if user is logged in and config is mandatory
306        $mandatory = $this->getConf("optinout") == 'mandatory' && $INPUT->server->str('REMOTE_USER', '');
307        // User is NOT OPTED OUT if the optin setting is undefined and the wiki config is optout.
308        $not_opted_out = $this->attribute->get("twofactor", "state") == '' && $this->getConf("optinout") == 'optout';
309        // The user must login if wiki mandatory is on or if the user is logged in and user is opt in.
310        $must_login = $mandatory || ($this->attribute->get("twofactor",
311                    "state") == 'in' && $INPUT->server->str('REMOTE_USER', ''));
312        $has_clearance = $this->get_clearance() === true;
313        $this->log('twofactor_action_process_handler: USERINFO: ' . print_r($USERINFO, true), self::LOGGING_DEBUGPLUS);
314
315        // Possible combination skipped- not logged in and 2FA is not requred for user {optout conf or (no selection and optin conf)}.
316
317        // Check to see if updating twofactor is required.
318        // This happens if the wiki is mandatory, the user has not opted out of an opt-out wiki, or if the user has opted in, and if there are no available mods for use.
319        // The user cannot have available mods without setting them up, and cannot unless the wiki is mandatory or the user has opted in.
320        if (($must_login || $not_opted_out) && !$available) {
321            // If the user has not been granted access at this point, do so or they will get booted after setting up 2FA.
322            if (!$has_clearance) {
323                $this->_grant_clearance();
324            }
325            // We need to go to the twofactor profile.
326            // If we were setup properly, we would not be here in the code.
327            $event->preventDefault();
328            $event->stopPropagation();
329            $event->result = false;
330            // Send loglog an event to show the user aborted 2FA.
331            $log = array('message' => 'logged in, ' . $this->getLang('2fa_mandatory'), 'user' => $user);
332            trigger_event('PLUGIN_LOGLOG_LOG', $log);
333            send_redirect(wl($ID, array('do' => 'twofactor_profile'), true, '&'));
334            return;
335        }
336
337        // Now validate login before proceeding.
338        if (!$has_clearance) {
339            if ($must_login) {
340                if (!in_array($event->data, array('login', 'twofactor_login'))) {
341                    // If not logged in then force to the login page.
342                    $event->preventDefault();
343                    $event->stopPropagation();
344                    $event->result = false;
345                    // If there are OTP generators, then use them.
346                    send_redirect(wl($ID, array('do' => 'twofactor_login'), true, '&'));
347                    return;
348                }
349                // Otherwise go to where we are told.
350                return;
351            }
352            // The user is not set with 2FA and is not required to.
353            // Grant clearance and continue.
354            $this->_grant_clearance();
355        }
356        // Otherwise everything is good!
357        return;
358    }
359
360    public function twofactor_handle_unknown_action(Doku_Event $event, $param)
361    {
362        if ($event->data == 'twofactor_login') {
363            $event->preventDefault();
364            $event->stopPropagation();
365            $event->result = $this->twofactor_otp_login($event, $param);
366            return;
367        }
368    }
369
370    public function twofactor_get_unknown_action(&$event, $param)
371    {
372        $this->log('start: twofactor_before_auth_check', self::LOGGING_DEBUG);
373        switch ($event->data['type']) {
374            case 'twofactor_profile':
375                $event->data['params'] = array('do' => 'twofactor_profile');
376                // Inject text into $lang.
377                global $lang;
378                $lang['btn_twofactor_profile'] = $this->getLang('btn_twofactor_profile');
379                $event->preventDefault();
380                $event->stopPropagation();
381                $event->result = false;
382                break;
383        }
384    }
385
386    /**
387     * Logout this session from two factor authentication.  Purge any existing
388     * OTP from the user's attributes.
389     */
390    private function _logout()
391    {
392        global $conf, $INPUT;
393        $this->log('_logout: start', self::LOGGING_DEBUG);
394        $this->log(print_r(array($_SESSION, $_COOKIE), true), self::LOGGING_DEBUGPLUS);
395        // No need to do this as long as no Cookie or session for login is present!
396        if (empty($_SESSION[DOKU_COOKIE]['twofactor_clearance']) && empty($_COOKIE[TWOFACTOR_COOKIE])) {
397            $this->log('_logout: quitting, no cookies', self::LOGGING_DEBUG);
398            return;
399        }
400        // Audit log.
401        $this->log("2FA Logout: " . $INPUT->server->str('REMOTE_USER', $_REQUEST['r']), self::LOGGING_AUDIT);
402        if ($this->attribute) {
403            // Purge outstanding OTPs.
404            $this->attribute->del("twofactor", "otp");
405            // Purge cookie and session ID relation.
406            $key = $_COOKIE[TWOFACTOR_COOKIE];
407            if (!empty($key) && substr($key, 0, 3) != 'id.') {
408                $id = $this->attribute->del("twofactor", $key);
409            }
410            // Wipe out 2FA cookie.
411            $this->log('del cookies: ' . TWOFACTOR_COOKIE . ' ' . print_r(headers_sent(), true),
412                self::LOGGING_DEBUGPLUS);
413            $cookie = '';
414            $cookieDir = empty($conf['cookiedir']) ? DOKU_REL : $conf['cookiedir'];
415            $time = time() - 600000; //many seconds ago
416            setcookie(TWOFACTOR_COOKIE, $cookie, $time, $cookieDir, '', ($conf['securecookie'] && is_ssl()), true);
417            unset($_COOKIE[TWOFACTOR_COOKIE]);
418            // Just in case, unset the setTime flag so attributes will be saved again.
419            $this->setTime = false;
420        }
421        // Before we get here, the session is closed. Reopen it to logout the user.
422        if (!headers_sent()) {
423            $session = session_status() != PHP_SESSION_NONE;
424            if (!$session) {
425                session_start();
426            }
427            $_SESSION[DOKU_COOKIE]['twofactor_clearance'] = false;
428            unset($_SESSION[DOKU_COOKIE]['twofactor_clearance']);
429            if (!$session) {
430                session_write_close();
431            }
432        } else {
433            msg("Error! You have not been logged off!!!", -1);
434        }
435    }
436
437    /**
438     * See if the current session has passed two factor authentication.
439     * @return bool - true if the session as successfully passed two factor
440     *      authentication.
441     */
442    public function get_clearance($user = null)
443    {
444        global $INPUT;
445        $this->log("get_clearance: start", self::LOGGING_DEBUG);
446        $this->log("User:" . $INPUT->server->str('REMOTE_USER', null), self::LOGGING_DEBUGPLUS);
447        # Get and correct the refresh expiry.
448        # At least 5 min, at most 1440 min (1 day).
449        $refreshexpiry = min(max($this->getConf('refreshexpiry'), 5), 1400) * 60;
450        # First check if we have a key.  No key === no login.
451        $key = $_COOKIE[TWOFACTOR_COOKIE];
452        if (empty($key)) {
453            $this->log("get_clearance: No cookie.", self::LOGGING_DEBUGPLUS);
454            return false;
455        }
456        # If the key is not valid, logout.
457        if (substr($key, 0, 3) != 'id.') {
458            $this->log("get_clearance: BAD cookie.", self::LOGGING_DEBUGPLUS);
459            // Purge the login data just in case.
460            $this->_logout();
461            return false;
462        }
463        # Load the expiry value from session.
464        $expiry = $_SESSION[DOKU_COOKIE]['twofactor_clearance'];
465        # Check if this time is valid.
466        $clearance = (!empty($expiry) && $expiry + $refreshexpiry > time());
467        if (!$clearance) {
468            # First use this time to purge the old IDs from attribute.
469            foreach (array_filter($this->attribute->enumerateAttributes("twofactor", $user), function ($key) {
470                substr($key, 0, 3) == 'id.';
471            }) as $attr) {
472                if ($this->attribute->get("twofactor", $attr, $user) + $refreshexpiry < time()) {
473                    $this->attribute->del("twofactor", $attr, $user);
474                }
475            }
476            # Check if this key still exists.
477            $clearance = $this->attribute->exists("twofactor", $key, $user);
478            if ($clearance) {
479                $this->log("get_clearance: 2FA revived by cookie. Expiry: " . print_r($expiry,
480                        true) . " Session: " . print_r($_SESSION, true), self::LOGGING_DEBUGPLUS);
481            }
482        }
483        if ($clearance && !$this->setTime) {
484            $session = session_status() != PHP_SESSION_NONE;
485            if (!$session) {
486                session_start();
487            }
488            $_SESSION[DOKU_COOKIE]['twofactor_clearance'] = time();
489            if (!$session) {
490                session_write_close();
491            }
492            $this->attribute->set("twofactor", $key, $_SESSION[DOKU_COOKIE]['twofactor_clearance'], $user);
493            // Set this flag to stop future updates.
494            $this->setTime = true;
495            $this->log("get_clearance: Session reset. Session: " . print_r($_SESSION, true), self::LOGGING_DEBUGPLUS);
496        } elseif (!$clearance) {
497            // Otherwise logout.
498            $this->_logout();
499        }
500        return $clearance;
501    }
502
503    /**
504     * Flags this session as having passed two factor authentication.
505     * @return bool - returns true on successfully granting two factor clearance.
506     */
507    private function _grant_clearance($user = null)
508    {
509        global $conf, $INPUT;
510        $this->log("_grant_clearance: start", self::LOGGING_DEBUG);
511        $this->log('2FA Login: ' . $INPUT->server->str("REMOTE_USER", $user), self::LOGGING_AUDIT);
512        if ($INPUT->server->str("REMOTE_USER", $user) == 1) {
513            $this->log("_grant_clearance: start", self::LOGGING_DEBUGPLUS);
514        }
515        // Purge the otp code as a security measure.
516        $this->attribute->del("twofactor", "otp", $user);
517        if (!headers_sent()) {
518            $session = session_status() != PHP_SESSION_NONE;
519            if (!$session) {
520                session_start();
521            }
522            $_SESSION[DOKU_COOKIE]['twofactor_clearance'] = time();
523            // Set the notify flag if set or required by wiki.
524            $this->log('_grant_clearance: conf:' . $this->getConf('loginnotice') . ' user:' . ($this->attribute->get("twofactor",
525                    "loginnotice", $user) === true ? 'true' : 'false'), self::LOGGING_DEBUG);
526            $send_wanted = $this->getConf('loginnotice') == 'always' || ($this->getConf('loginnotice') == 'user' && $this->attribute->get("twofactor",
527                        "loginnotice", $user) == true);
528            if ($send_wanted) {
529                $_SESSION[DOKU_COOKIE]['twofactor_notify'] = true;
530            }
531            if (!$session) {
532                session_write_close();
533            }
534        } else {
535            msg("Error! You have not been logged in!!!", -1);
536        }
537        // Creating a cookie in case the session purges.
538        $key = 'id.' . session_id();
539        // Storing a timeout value.
540        $this->attribute->set("twofactor", $key, $_SESSION[DOKU_COOKIE]['twofactor_clearance'], $user);
541        // Set the 2FA cookie.
542        $this->log('_grant_clearance: new cookies: ' . TWOFACTOR_COOKIE . ' ' . print_r(headers_sent(), true),
543            self::LOGGING_DEBUGPLUS);
544        $cookieDir = empty($conf['cookiedir']) ? DOKU_REL : $conf['cookiedir'];
545        $time = time() + 60 * 60 * 24 * 365; //one year
546        setcookie(TWOFACTOR_COOKIE, $key, $time, $cookieDir, '', ($conf['securecookie'] && is_ssl()), true);
547        $_COOKIE[TWOFACTOR_COOKIE] = $key;
548        return !empty($_SESSION[DOKU_COOKIE]['twofactor_clearance']);
549    }
550
551    /**
552     * Sends emails notifying user of successfult 2FA login.
553     * @return mixed - returns true on successfully sending notification to all
554     *     modules, false if no notifications were sent, or a number indicating
555     *     the number of modules that suceeded.
556     */
557    private function _send_login_notification()
558    {
559        $this->log("_send_login_notification: start", self::LOGGING_DEBUG);
560        // Send login notification.
561        $module = $this->attribute->exists("twofactor", "defaultmod") ? $this->attribute->get("twofactor",
562            "defaultmod") : null;
563        $subject = $this->getConf('loginsubject');
564        $time = date(DATE_RFC2822);
565        $message = str_replace('$time', $time, $this->getConf('logincontent'));
566        $result = $this->_send_message($subject, $message, $module);
567        return $result;
568    }
569
570    /**
571     * Handles the authentication check. Screens Google Authenticator OTP, if available.
572     * NOTE: NOT LOGGED IN YET. Attribute requires user name.
573     */
574    function twofactor_before_auth_check(&$event, $param)
575    {
576        global $ACT, $INPUT;
577        $this->log("twofactor_before_auth_check: start $ACT", self::LOGGING_DEBUG);
578        $this->log("twofactor_before_auth_check: Cookie: " . print_r($_COOKIE, true), self::LOGGING_DEBUGPLUS);
579        // Only operate if this is a login.
580        if ($ACT !== 'login') {
581            return;
582        }
583        // If there is no supplied username, then there is nothing to check at this time.
584        if (!$event->data['user']) {
585            return;
586        }
587        $user = $INPUT->server->str('REMOTE_USER', $event->data['user']);
588        // Set helper variables here.
589        $this->_setHelperVariables($user);
590        // If the user still has clearance, then we can skip this.
591        if ($this->get_clearance($user)) {
592            return;
593        }
594        // Allow the user to try to use login tokens, even if the account cannot use them.
595        $otp = $INPUT->str('otp', '');
596        if ($otp !== '') {
597            // Check for any modules that support OTP at login and are ready for use.
598            foreach ($this->tokenMods as $mod) {
599                $result = $mod->processLogin($otp, $user);
600                if ($result) {
601                    // The OTP code was valid.
602                    $this->_grant_clearance($user);
603                    // Send loglog an event to show the user logged in using a token.
604                    $log = array('message' => 'logged in ' . $this->getLang('token_ok'), 'user' => $user);
605                    trigger_event('PLUGIN_LOGLOG_LOG', $log);
606                    return;
607                }
608            }
609            global $lang;
610            msg($lang['badlogin'], -1);
611            $event->preventDefault();
612            $event->result = false;
613            // Send loglog an event to show the failure
614            if (count($this->tokenMods) == 0) {
615                $log = array('message' => 'failed ' . $this->getLang('no_tokens'), 'user' => $user);
616            } else {
617                $log = array('message' => 'failed ' . $this->getLang('token_mismatch'), 'user' => $user);
618            }
619            trigger_event('PLUGIN_LOGLOG_LOG', $log);
620            return;
621        }
622        // No GA OTP was supplied.
623        // If the user has no modules available, then grant access.
624        // The action preprocessing will send the user to the profile if needed.
625        $available = count($this->tokenMods) + count($this->otpMods) > 0;
626        $this->log('twofactor_before_auth_check: Tokens:' . count($this->tokenMods) . ' Codes:' . count($this->otpMods) . " Available:" . (int)$available,
627            self::LOGGING_DEBUGPLUS);
628        if (!$available) {
629            // The user could not authenticate if they wanted to.
630            // Set this so they don't get auth prompted while setting up 2FA.
631            $this->_grant_clearance($user);
632            return;
633        }
634        // At this point, the user has a working module.
635        // If the only working module is for a token, then fail.
636        if (count($this->otpMods) == 0) {
637            msg($this->getLang('mustusetoken'), -1);
638            $event->preventDefault();
639            return;
640        }
641        // The user is logged in to auth, but not into twofactor.
642        // The redirection handler will send the user to the twofactor login.
643        return;
644    }
645
646    /**
647     * @param $event
648     * @param $param
649     */
650    function twofactor_after_auth_check(&$event, $param)
651    {
652        global $ACT;
653        global $INPUT;
654        $this->log("twofactor_after_auth_check: start", self::LOGGING_DEBUG);
655        // Check if the action was login.
656        if ($ACT == 'login') {
657            // If there *was* no one logged in, then purge 2FA tokens.
658            if ($INPUT->server->str('REMOTE_USER', '') == '') {
659                $this->_logout();
660                // If someone *just* logged in, then fire off a log.
661                if ($event->data['user']) {
662                    // Send loglog an event to show the user logged in but needs OTP code.
663                    $log = array(
664                        'message' => 'logged in, ' . $this->getLang('requires_otp'),
665                        'user' => $event->data['user'],
666                    );
667                    trigger_event('PLUGIN_LOGLOG_LOG', $log);
668                }
669                return;
670            }
671        }
672        // Update helper variables here since we are logged in.
673        $this->_setHelperVariables();
674        // If set, then send login notification and clear flag.
675        if ($_SESSION[DOKU_COOKIE]['twofactor_notify'] == true) {
676            // Set the clear flag if no messages can be sent or if the result is not false.
677            $clear = count($this_ > otpMods) > 0 || $this->_send_login_notification() !== false;
678            if ($clear) {
679                unset($_SESSION[DOKU_COOKIE]['twofactor_notify']);
680            }
681        }
682        return;
683    }
684
685    /* Returns action to take. */
686    private function _process_otp(&$event, $param)
687    {
688        global $ACT, $ID, $INPUT;
689        $this->log("_process_otp: start", self::LOGGING_DEBUG);
690        // Get the logged in user.
691        $user = $INPUT->server->str('REMOTE_USER');
692        // See if the user is quitting OTP.  We don't call it logoff because we don't want the user to think they are logged in!
693        // This has to be checked before the template is started.
694        if ($INPUT->has('otpquit')) {
695            // Send loglog an event to show the user aborted 2FA.
696            $log = array('message' => 'logged off, ' . $this->getLang('quit_otp'), 'user' => $user);
697            trigger_event('PLUGIN_LOGLOG_LOG', $log);
698            // Redirect to logout.
699            return 'logout';
700        }
701        // Check if the user asked to generate and resend the OTP.
702        if ($INPUT->has('resend')) {
703            if ($INPUT->has('useall')) {
704                $defaultMod = null;
705            } else {
706                $defaultMod = $this->attribute->exists("twofactor", "defaultmod") ? $this->attribute->get("twofactor",
707                    "defaultmod") : null;
708            }
709            // At this point, try to send the OTP.
710            $mod = array_key_exists($defaultMod, $this->otpMods) ? $this->otpMods[$defaultMod] : null;
711            $this->_send_otp($mod);
712            return;
713        }
714        // If a OTP has been submitted by the user, then verify the OTP.
715        // If verified, then grant clearance and continue normally.
716        $otp = $INPUT->str('otpcode');
717        if ($otp) {
718            foreach ($this->otpMods as $mod) {
719                $result = $mod->processLogin($otp);
720                if ($result) {
721                    // The OTP code was valid.
722                    $this->_grant_clearance();
723                    // Send loglog an event to show the user passed 2FA.
724                    $log = array('message' => 'logged in ' . $this->getLang('otp_ok'), 'user' => $user);
725                    trigger_event('PLUGIN_LOGLOG_LOG', $log);
726                    /*
727                    // This bypasses sending any further events to other modules for the login we stole earlier.
728                    return 'show';
729                    */
730                    // This will trigger the login events again.  However, this is to ensure
731                    // that other modules work correctly because we hijacked this event earlier.
732                    return 'login';
733                }
734            }
735            // Send loglog an event to show the user entered the wrong OTP code.
736            $log = array('message' => 'failed OTP login, ' . $this->getLang('otp_mismatch'), 'user' => $user);
737            trigger_event('PLUGIN_LOGLOG_LOG', $log);
738            msg($this->getLang('twofactor_invalidotp'), -1);
739        }
740        return;
741    }
742
743    /**
744     * Process any updates to two factor settings.
745     */
746    private function _process_changes(&$event, $param)
747    {
748        // If the plugin is disabled, then exit.
749        $this->log("_process_changes: start", self::LOGGING_DEBUG);
750        $changed = false;
751        global $INPUT, $USERINFO, $conf, $auth, $lang, $ACT;
752        if (!$INPUT->has('save')) {
753            return;
754        }
755        // In needed, verify password.
756        if ($conf['profileconfirm']) {
757            if (!$auth->checkPass($INPUT->server->str('REMOTE_USER'), $INPUT->post->str('oldpass'))) {
758                msg($lang['badpassconfirm'], -1);
759                return;
760            }
761        }
762        // Process opt in/out.
763        if ($this->getConf("optinout") != 'mandatory') {
764            $oldoptinout = $this->attribute->get("twofactor", "state");
765            $optinout = $INPUT->bool('optinout', false) ? 'in' : 'out';
766            if ($oldoptinout != $optinout) {
767                $this->attribute->set("twofactor", "state", $optinout);
768                $changed = true;
769            }
770        }
771        // Process notifications.
772        if ($this->getConf("loginnotice") == 'user') {
773            $oldloginnotice = $this->attribute->get("twofactor", "loginnotice");
774            $loginnotice = $INPUT->bool('loginnotice', false);
775            if ($oldloginnotice != $loginnotice) {
776                $this->attribute->set("twofactor", "loginnotice", $loginnotice);
777                $changed = true;
778            }
779        }
780        // Process default module.
781        $defaultmodule = $INPUT->str('default_module', '');
782        if ($defaultmodule) {
783            if ($defaultmodule === $this->getLang('useallotp')) {
784                // Set to use ALL OTP channels.
785                $this->attribute->set("twofactor", "defaultmod", null);
786                $changed = true;
787            } else {
788                $useableMods = array();
789                foreach ($this->modules as $name => $mod) {
790                    if (!$mod->canAuthLogin() && $mod->canUse()) {
791                        $useableMods[$mod->getLang("name")] = $mod;
792                    }
793                }
794                if (array_key_exists($defaultmodule, $useableMods)) {
795                    $this->attribute->set("twofactor", "defaultmod", $defaultmodule);
796                    $changed = true;
797                }
798            }
799        }
800        // Update module settings.
801        $sendotp = null;
802        foreach ($this->modules as $name => $mod) {
803            $this->log('_process_changes: processing ' . get_class($mod) . '::processProfileForm()',
804                self::LOGGING_DEBUG);
805            $result = $mod->processProfileForm();
806            $this->log('_process_changes: processing ' . get_class($mod) . '::processProfileForm() == ' . $result,
807                self::LOGGING_DEBUGPLUS);
808            // false:change failed  'failed':OTP failed  null: no change made
809            $changed |= $result !== false && $result !== 'failed' && $result !== null;
810            switch ((string)$result) {
811                case 'verified':
812                    // Remove used OTP.
813                    $this->attribute->del("twofactor", "otp");
814                    msg($mod->getLang('passedsetup'), 1);
815                    // Reset helper variables.
816                    $this->_setHelperVariables();
817                    $this->log("2FA Added: " . $INPUT->server->str('REMOTE_USER', '') . ' ' . get_class($mod),
818                        self::LOGGING_AUDIT);
819                    break;
820                case 'failed':
821                    msg($mod->getLang('failedsetup'), -1);
822                    break;
823                case 'otp':
824                    if (!$sendotp) {
825                        $sendotp = $mod;
826                    }
827                    break;
828                case 'deleted':
829                    $this->log("2FA Removed: " . $INPUT->server->str('REMOTE_USER', '') . ' ' . get_class($mod),
830                        self::LOGGING_AUDIT);
831                    // Reset helper variables.
832                    $this->_setHelperVariables();
833                    break;
834            }
835        }
836        // Send OTP if requested.
837        if (is_object($sendotp)) {
838            // Force the message since it will fail the canUse function.
839            if ($this->_send_otp($sendotp, true)) {
840                msg($sendotp->getLang('needsetup'), 1);
841            } else {
842                msg("Could not send message using " . get_class($sendotp), -1);
843            }
844        }
845        // Update change status if changed.
846        if ($changed) {
847            // If there were any changes, update the available tokens accordingly.
848            $this->_setHelperVariables();
849            msg($this->getLang('updated'), 1);
850        }
851        return true;
852    }
853
854    /**
855     * Handles the email and text OTP options.
856     * NOTE: The user will be technically logged in at this point.  This module will rewrite the
857     * page with the prompt for the OTP until validated or the user logs out.
858     */
859    function twofactor_otp_login(&$event, $param)
860    {
861        $this->log("twofactor_otp_login: start", self::LOGGING_DEBUG);
862        // Skip this if not logged in or already two factor authenticated.
863        // Ensure the OTP exists and is still valid. If we need to, send a OTP.
864        $otpQuery = $this->get_otp_code();
865        if ($otpQuery == false) {
866            $useableMods = array();
867            foreach ($this->modules as $name => $mod) {
868                if (!$mod->canAuthLogin() && $mod->canUse()) {
869                    $useableMods[$mod->getLang("name")] = $mod;
870                }
871            }
872            $defaultMod = $this->attribute->exists("twofactor", "defaultmod") ? $this->attribute->get("twofactor",
873                "defaultmod") : null;
874            $mod = array_key_exists($defaultMod, $useableMods) ? $useableMods[$defaultMod] : null;
875            $this->_send_otp($mod);
876        }
877        // Generate the form to login.
878        // If we are here, then only provide options to accept the OTP or to logout.
879        global $lang;
880        $form = new Doku_Form(array('id' => 'otp_setup'));
881        $form->startFieldset($this->getLang('twofactor_otplogin'));
882        $form->addElement(form_makeTextField('otpcode', '', $this->getLang('twofactor_otplogin'), '', 'block',
883            array('size' => '50', 'autocomplete' => 'off')));
884        $form->addElement(form_makeButton('submit', '', $this->getLang('btn_login')));
885        $form->addElement(form_makeTag('br'));
886        $form->addElement(form_makeCheckboxField('useall', '1', $this->getLang('twofactor_useallmods'), '', 'block'));
887        $form->addElement(form_makeTag('br'));
888        $form->addElement(form_makeButton('submit', '', $this->getLang('btn_resend'), array('name' => 'resend')));
889        $form->addElement(form_makeButton('submit', '', $this->getLang('btn_quit'), array('name' => 'otpquit')));
890        $form->endFieldset();
891        echo '<div class="centeralign">' . NL . $form->getForm() . '</div>' . NL;
892    }
893
894    /**
895     * Sends a message using configured modules.
896     * If $module is set to a specific instance, that instance will be used to
897     * send the message. If not supplied or null, then all configured modules
898     * will be used to send the message. $module can also be an array of
899     * selected modules.
900     * If $force is true, then will try to send the message even if the module
901     * has not been validated.
902     * @return array(array, mixed) - The first item in the array is an array
903     *     of all modules that the message was successfully sent by.  The
904     *     second item is true if successfull to all attempted tramsmission
905     *     modules, false if all failed, and a number of how many successes
906     *     if only some modules failed.
907     */
908    private function _send_message($subject, $message, $module = null, $force = false)
909    {
910        global $INPUT;
911        $this->log("_send_message: start", self::LOGGING_DEBUG);
912        if ($module === null) {
913            $module = $this->otpMods;
914        }
915        if (!is_array($module)) {
916            $module = array($module);
917        }
918        if (count($module) >= 1) {
919            $modulekeys = array_keys($module);
920            $modulekey = $modulekeys[0];
921            $modname = get_class($module[$modulekey]);
922        } else {
923            $modname = null;
924        }
925        // Attempt to deliver messages.
926        $user = $INPUT->server->str('REMOTE_USER', '*unknown*');
927        $success = 0;
928        $modname = array();
929        foreach ($module as $mod) {
930            if ($mod->canTransmitMessage()) {
931                $worked = $mod->transmitMessage($subject, $message, $force);
932                if ($worked) {
933                    $success += 1;
934                    $modname[] = get_class($mod);
935                }
936                $this->log("Message " . ($worked ? '' : 'not ') . "sent to $user via " . get_class($mod),
937                    self::LOGGING_AUDITPLUS);
938            }
939        }
940        return array($modname, $success == 0 ? false : ($success == count($module) ? true : $success));
941    }
942
943    /**
944     * Transmits a One-Time Password (OTP) using configured modules.
945     * If $module is set to a specific instance, that instance will be used to
946     * send the OTP. If not supplied or null, then all configured modules will
947     * be used to send the OTP. $module can also be an array of selected
948     * modules.
949     * If $force is true, then will try to send the message even if the module
950     * has not been validated.
951     * @return mixed - true if successfull to all attempted tramsmission
952     *     modules, false if all failed, and a number of how many successes
953     *     if only some modules failed.
954     */
955    private function _send_otp($module = null, $force = false)
956    {
957        $this->log("_send_otp: start", self::LOGGING_DEBUG);
958        // Generate the OTP code.
959        $characters = '0123456789';
960        $otp = '';
961        for ($index = 0; $index < $this->getConf('otplength'); ++$index) {
962            $otp .= $characters[rand(0, strlen($characters) - 1)];
963        }
964        // Create the subject.
965        $subject = $this->getConf('otpsubject');
966        // Create the message.
967        $message = str_replace('$otp', $otp, $this->getConf('otpcontent'));
968        // Attempt to deliver the message.
969        list($modname, $result) = $this->_send_message($subject, $message, $module, $force);
970        // If partially successful, store the OTP code and the timestamp the OTP expires at.
971        if ($result) {
972            $otpData = array($otp, time() + $this->getConf('sentexpiry') * 60, $modname);
973            if (!$this->attribute->set("twofactor", "otp", $otpData)) {
974                msg("Unable to record OTP for later use.", -1);
975            }
976        }
977        return $result;
978    }
979
980    /**
981     * Returns the OTP code sent to the user, if it has not expired.
982     * @return mixed - false if there is no unexpired OTP, otherwise
983     *     array of the OTP and the modules that successfully sent it.
984     */
985    public function get_otp_code()
986    {
987        $this->log("get_otp_code: start", self::LOGGING_DEBUG);
988        $otpQuery = $this->attribute->get("twofactor", "otp", $success);
989        if (!$success) {
990            return false;
991        }
992        list($otp, $expiry, $modname) = $otpQuery;
993        if (time() > $expiry) {
994            $this->attribute->del("twofactor", "otp");
995            return false;
996        }
997        return array($otp, $modname);
998    }
999
1000    private function _setHelperVariables($user = null)
1001    {
1002        $this->log("_setHelperVariables: start", self::LOGGING_DEBUGPLUS);
1003        $tokenMods = array();
1004        $otpMods = array();
1005        $state = $this->attribute->get("twofactor", "state");
1006        $optinout = $this->getConf("optinout");
1007        $enabled = $optinout == 'mandatory' || ($state == '' ? $optinout == 'optin' : $state == 'in');
1008        $this->log("_setHelperVariables: " . print_r(array($optinout, $state, $enabled), true), self::LOGGING_DEBUG);
1009        // Skip if not enabled for user
1010        if ($enabled) {
1011            // List all working token modules (GA, RSA, etc.).
1012            foreach ($this->modules as $name => $mod) {
1013                if ($mod->canAuthLogin() && $mod->canUse($user)) {
1014                    $this->log('Can use ' . get_class($mod) . ' for tokens', self::LOGGING_DEBUG);
1015                    $tokenMods[$mod->getLang("name")] = $mod;
1016                } else {
1017                    $this->log('Can NOT use ' . get_class($mod) . ' for tokens', self::LOGGING_DEBUG);
1018                }
1019            }
1020            // List all working OTP modules (SMS, Twilio, etc.).
1021            foreach ($this->modules as $name => $mod) {
1022                if (!$mod->canAuthLogin() && $mod->canUse($user)) {
1023                    $this->log('Can use ' . get_class($mod) . ' for otp', self::LOGGING_DEBUG);
1024                    $otpMods[$mod->getLang("name")] = $mod;
1025                } else {
1026                    $this->log('Can NOT use ' . get_class($mod) . ' for otp', self::LOGGING_DEBUG);
1027                }
1028            }
1029        }
1030        $this->tokenMods = $tokenMods;
1031        $this->otpMods = $otpMods;
1032    }
1033
1034    const LOGGING_AUDIT = 1;     // Audit records 2FA login and logout activity.
1035    const LOGGING_AUDITPLUS = 2; // Audit+ also records sending of notifications.
1036    const LOGGING_DEBUG = 3;     // Debug provides detailed workflow data.
1037    const LOGGING_DEBUGPLUS = 4; // Debug+ also includes variables passed to and from functions.
1038
1039    public function log($message, $level = 1)
1040    {
1041        // If the log level requested is below audit or greater than what is permitted in the configuration, then exit.
1042        if ($level < self::LOGGING_AUDIT || $level > $this->getConf('logging_level')) {
1043            return;
1044        }
1045        global $conf;
1046        // Always purge line containing "[pass]".
1047        $message = implode("\n", array_filter(explode("\n", $message), function ($x) {
1048            return !strstr($x, '[pass]');
1049        }));
1050        // If DEBUGPLUS, then append the trace log.
1051        if ($level == self::LOGGING_DEBUGPLUS) {
1052            $e = new Exception();
1053            $message .= "\n" . print_r(str_replace(DOKU_REL, '', $e->getTraceAsString()), true);
1054        }
1055        $logfile = $this->getConf('logging_path');
1056        $logfile = substr($logfile, 0, 1) == '/' ? $logfile : DOKU_INC . $conf['savedir'] . '/' . $logfile;
1057        io_lock($logfile);
1058        #open for append logfile
1059        $handle = @fopen($logfile, 'at');
1060        if ($handle) {
1061            $date = date(DATE_RFC2822);
1062            $IP = $_SERVER["REMOTE_ADDR"];
1063            $id = session_id();
1064            fwrite($handle, "$date,$id,$IP,$level,\"$message\"\n");
1065            fclose($handle);
1066        }
1067        #write "date level message"
1068        io_unlock($logfile);
1069    }
1070
1071    // endregion
1072}
1073