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