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