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