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