1<?php 2 3use dokuwiki\JWT; 4use dokuwiki\plugin\twofactor\Manager; 5use dokuwiki\plugin\twofactor\Provider; 6 7/** 8 * DokuWiki Plugin twofactor (Action Component) 9 * 10 * @license GPL 2 http://www.gnu.org/licenses/gpl-2.0.html 11 */ 12class action_plugin_twofactor_login extends DokuWiki_Action_Plugin 13{ 14 const TWOFACTOR_COOKIE = '2FA' . DOKU_COOKIE; 15 16 /** 17 * Registers the event handlers. 18 */ 19 public function register(Doku_Event_Handler $controller) 20 { 21 // check 2fa requirements and either move to profile or login handling 22 $controller->register_hook( 23 'ACTION_ACT_PREPROCESS', 24 'BEFORE', 25 $this, 26 'handleActionPreProcess', 27 null, 28 Manager::EVENT_PRIORITY 29 ); 30 31 // display login form 32 $controller->register_hook( 33 'TPL_ACT_UNKNOWN', 34 'BEFORE', 35 $this, 36 'handleLoginDisplay' 37 ); 38 39 // disable user in all non-main screens (media, detail, ajax, ...) 40 $controller->register_hook( 41 'DOKUWIKI_INIT_DONE', 42 'BEFORE', 43 $this, 44 'handleInitDone' 45 ); 46 } 47 48 /** 49 * Decide if any 2fa handling needs to be done for the current user 50 * 51 * @param Doku_Event $event 52 */ 53 public function handleActionPreProcess(Doku_Event $event) 54 { 55 if ($event->data === 'resendpwd') { 56 // this is completely handled in resendpwd.php 57 return; 58 } 59 60 $manager = Manager::getInstance(); 61 if (!$manager->isReady()) return; 62 63 global $INPUT; 64 65 // already in a 2fa login? 66 if ($event->data === 'twofactor_login') { 67 if ($this->verify( 68 $INPUT->str('2fa_code'), 69 $INPUT->str('2fa_provider'), 70 $this->isSticky() 71 )) { 72 $event->data = 'show'; 73 return; 74 } else { 75 // show form 76 $event->preventDefault(); 77 return; 78 } 79 } 80 81 // clear cookie on logout 82 if ($event->data === 'logout') { 83 $this->deAuth(); 84 return; 85 } 86 87 // authed already, continue 88 if ($this->isAuthed()) { 89 return; 90 } 91 92 if (count($manager->getUserProviders())) { 93 // user has already 2fa set up - they need to authenticate before anything else 94 $event->data = 'twofactor_login'; 95 $event->preventDefault(); 96 $event->stopPropagation(); 97 return; 98 } 99 100 if ($manager->isRequired()) { 101 // 2fa is required - they need to set it up now 102 // this will be handled by action/profile.php 103 $event->data = 'twofactor_profile'; 104 } 105 106 // all good. proceed 107 } 108 109 /** 110 * Show a 2fa login screen 111 * 112 * @param Doku_Event $event 113 */ 114 public function handleLoginDisplay(Doku_Event $event) 115 { 116 if ($event->data !== 'twofactor_login') return; 117 $manager = Manager::getInstance(); 118 if (!$manager->isReady()) return; 119 120 $event->preventDefault(); 121 $event->stopPropagation(); 122 123 global $INPUT; 124 $providerID = $INPUT->str('2fa_provider'); 125 126 echo '<div class="plugin_twofactor_login">'; 127 echo inlineSVG(__DIR__ . '/../admin.svg'); 128 echo $this->locale_xhtml('login'); 129 echo $manager->getCodeForm($providerID)->toHTML(); 130 echo '</div>'; 131 } 132 133 /** 134 * Remove user info from non-main entry points while we wait for 2fa 135 * 136 * @param Doku_Event $event 137 */ 138 public function handleInitDone(Doku_Event $event) 139 { 140 global $INPUT; 141 $script = basename($INPUT->server->str('SCRIPT_NAME')); 142 143 if (!(Manager::getInstance())->isReady()) return; 144 if ($script == DOKU_SCRIPT) return; 145 if ($this->isAuthed()) return; 146 147 if ($this->getConf('optinout') !== 'mandatory' && empty(Manager::getInstance()->getUserProviders())) return; 148 149 // allow API access without 2fa when using token auth 150 if(in_array($script, ['xmlrpc.php', 'jsonrpc.php']) || $this->getConf('allowTokenAuth')) { 151 if ($this->hasValidTokenAuth()) return; 152 } 153 154 // temporarily remove user info from environment 155 $INPUT->server->remove('REMOTE_USER'); 156 unset($_SESSION[DOKU_COOKIE]['auth']); 157 unset($GLOBALS['USERINFO']); 158 } 159 160 /** 161 * Has the user already authenticated with the second factor? 162 * @return bool 163 */ 164 protected function isAuthed() 165 { 166 // if we trust the IP, we don't need 2fa and consider the user authed automatically 167 if ( 168 $this->getConf('trustedIPs') && 169 preg_match('/' . $this->getConf('trustedIPs') . '/', clientIP(true)) 170 ) { 171 return true; 172 } 173 174 if (!isset($_COOKIE[self::TWOFACTOR_COOKIE])) return false; 175 $data = unserialize(base64_decode($_COOKIE[self::TWOFACTOR_COOKIE])); 176 if (!is_array($data)) return false; 177 list($providerID, $hash,) = $data; 178 179 try { 180 $provider = (Manager::getInstance())->getUserProvider($providerID); 181 if ($this->cookieHash($provider) !== $hash) return false; 182 return true; 183 } catch (Exception $ignored) { 184 return false; 185 } 186 } 187 188 /** 189 * Get sticky value from standard cookie 190 * 191 * @return bool 192 */ 193 protected function isSticky() 194 { 195 if (!isset($_COOKIE[DOKU_COOKIE])) { 196 return false; 197 } 198 list(, $sticky,) = explode('|', $_COOKIE[DOKU_COOKIE], 3); 199 return (bool)$sticky; 200 } 201 202 /** 203 * Deletes the cookie 204 * 205 * @return void 206 */ 207 protected function deAuth() 208 { 209 global $conf; 210 211 $cookieDir = empty($conf['cookiedir']) ? DOKU_REL : $conf['cookiedir']; 212 $time = time() - 60 * 60 * 24 * 365; // one year in the past 213 setcookie(self::TWOFACTOR_COOKIE, null, $time, $cookieDir, '', ($conf['securecookie'] && is_ssl()), true); 214 } 215 216 /** 217 * Verify a given code 218 * 219 * @return bool 220 * @throws Exception 221 */ 222 protected function verify($code, $providerID, $sticky) 223 { 224 global $conf; 225 226 $manager = Manager::getInstance(); 227 if (!$manager->verifyCode($code, $providerID)) return false; 228 229 $provider = (Manager::getInstance())->getUserProvider($providerID); 230 231 // store cookie 232 $hash = $this->cookieHash($provider); 233 $data = base64_encode(serialize([$providerID, $hash, time()])); 234 $cookieDir = empty($conf['cookiedir']) ? DOKU_REL : $conf['cookiedir']; 235 $time = $sticky ? (time() + 60 * 60 * 24 * 30 * 3) : 0; //three months on sticky login 236 setcookie(self::TWOFACTOR_COOKIE, $data, $time, $cookieDir, '', ($conf['securecookie'] && is_ssl()), true); 237 238 return true; 239 } 240 241 /** 242 * Create a hash that validates the cookie 243 * 244 * @param Provider $provider 245 * @return string 246 */ 247 protected function cookieHash($provider) 248 { 249 return sha1(join("\n", [ 250 $provider->getProviderID(), 251 (Manager::getInstance())->getUser(), 252 $provider->getSecret(), 253 $this->getConf("useinternaluid") ? auth_browseruid() : $_SERVER['HTTP_USER_AGENT'], 254 auth_cookiesalt(false, true), 255 ])); 256 } 257 258 /** 259 * Check if the user has a valid auth token. We might skip 2fa for them. 260 * 261 * This duplicates code from auth_tokenlogin() until DokuWiki has a proper mechanism to access the token 262 * 263 * @return bool 264 */ 265 protected function hasValidTokenAuth() 266 { 267 $headers = []; 268 269 // try to get the headers from Apache 270 if (function_exists('getallheaders')) { 271 $headers = getallheaders(); 272 if (is_array($headers)) { 273 $headers = array_change_key_case($headers); 274 } 275 } 276 277 // get the headers from $_SERVER 278 if (!$headers) { 279 foreach ($_SERVER as $key => $value) { 280 if (substr($key, 0, 5) === 'HTTP_') { 281 $headers[strtolower(substr($key, 5))] = $value; 282 } 283 } 284 } 285 286 // check authorization header 287 if (isset($headers['authorization'])) { 288 [$type, $token] = sexplode(' ', $headers['authorization'], 2); 289 if ($type !== 'Bearer') $token = ''; // not the token we want 290 } 291 292 // check x-dokuwiki-token header 293 if (isset($headers['x-dokuwiki-token'])) { 294 $token = $headers['x-dokuwiki-token']; 295 } 296 297 if (empty($token)) return false; 298 299 // check token 300 try { 301 JWT::validate($token); 302 } catch (Exception $e) { 303 return false; 304 } 305 return true; 306 } 307} 308