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