1<?php 2 3use dokuwiki\plugin\twofactor\Manager; 4use dokuwiki\plugin\twofactor\OtpField; 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 $manager = Manager::getInstance(); 56 if (!$manager->isReady()) return; 57 58 global $INPUT; 59 60 // already in a 2fa login? 61 if ($event->data === 'twofactor_login') { 62 if ($this->verify( 63 $INPUT->str('2fa_code'), 64 $INPUT->str('2fa_provider'), 65 $this->isSticky() 66 )) { 67 $event->data = 'show'; 68 return; 69 } else { 70 // show form 71 $event->preventDefault(); 72 return; 73 } 74 } 75 76 // clear cookie on logout 77 if ($event->data === 'logout') { 78 $this->deAuth(); 79 return; 80 } 81 82 // authed already, continue 83 if ($this->isAuthed()) { 84 return; 85 } 86 87 if (count($manager->getUserProviders())) { 88 // user has already 2fa set up - they need to authenticate before anything else 89 $event->data = 'twofactor_login'; 90 $event->preventDefault(); 91 $event->stopPropagation(); 92 return; 93 } 94 95 if ($manager->isRequired()) { 96 // 2fa is required - they need to set it up now 97 // this will be handled by action/profile.php 98 $event->data = 'twofactor_profile'; 99 } 100 101 // all good. proceed 102 } 103 104 /** 105 * Show a 2fa login screen 106 * 107 * @param Doku_Event $event 108 */ 109 public function handleLoginDisplay(Doku_Event $event) 110 { 111 if ($event->data !== 'twofactor_login') return; 112 $manager = Manager::getInstance(); 113 if (!$manager->isReady()) return; 114 115 $event->preventDefault(); 116 $event->stopPropagation(); 117 118 global $INPUT; 119 global $ID; 120 121 $providerID = $INPUT->str('2fa_provider'); 122 $providers = $manager->getUserProviders(); 123 $provider = $providers[$providerID] ?? $manager->getUserDefaultProvider(); 124 // remove current provider from list 125 unset($providers[$provider->getProviderID()]); 126 127 echo '<div class="plugin_twofactor_login">'; 128 echo inlineSVG(__DIR__ . '/../admin.svg'); 129 echo $this->locale_xhtml('login'); 130 $form = new dokuwiki\Form\Form(['method' => 'POST']); 131 $form->setHiddenField('do', 'twofactor_login'); 132 $form->setHiddenField('2fa_provider', $provider->getProviderID()); 133 $form->addFieldsetOpen($provider->getLabel()); 134 try { 135 $code = $provider->generateCode(); 136 $info = $provider->transmitMessage($code); 137 $form->addHTML('<p>' . hsc($info) . '</p>'); 138 $form->addElement(new OtpField('2fa_code')); 139 $form->addTagOpen('div')->addClass('buttons'); 140 $form->addButton('2fa', $this->getLang('btn_confirm'))->attr('type', 'submit'); 141 $form->addTagClose('div'); 142 } catch (Exception $e) { 143 msg(hsc($e->getMessage()), -1); // FIXME better handling 144 } 145 $form->addFieldsetClose(); 146 147 if (count($providers)) { 148 $form->addFieldsetOpen('Alternative methods')->addClass('list'); 149 $form->addTagOpen('ul'); 150 foreach ($providers as $prov) { 151 $url = wl($ID, [ 152 'do' => 'twofactor_login', 153 '2fa_provider' => $prov->getProviderID(), 154 ]); 155 $form->addHTML( 156 '<li><div class="li"><a href="' . $url . '">' . hsc($prov->getLabel()) . '</a></div></li>' 157 ); 158 } 159 160 $form->addTagClose('ul'); 161 $form->addFieldsetClose(); 162 } 163 164 echo $form->toHTML(); 165 echo '</div>'; 166 } 167 168 /** 169 * Remove user info from non-main entry points while we wait for 2fa 170 * 171 * @param Doku_Event $event 172 */ 173 public function handleInitDone(Doku_Event $event) 174 { 175 global $INPUT; 176 177 if (!(Manager::getInstance())->isReady()) return; 178 if (basename($INPUT->server->str('SCRIPT_NAME')) == DOKU_SCRIPT) return; 179 if ($this->isAuthed()) return; 180 181 if ($this->getConf('optinout') !== 'mandatory' && empty(Manager::getInstance()->getUserProviders())) return; 182 183 // temporarily remove user info from environment 184 $INPUT->server->remove('REMOTE_USER'); 185 unset($_SESSION[DOKU_COOKIE]['auth']); 186 unset($GLOBALS['USERINFO']); 187 } 188 189 /** 190 * Has the user already authenticated with the second factor? 191 * @return bool 192 */ 193 protected function isAuthed() 194 { 195 if (!isset($_COOKIE[self::TWOFACTOR_COOKIE])) return false; 196 $data = unserialize(base64_decode($_COOKIE[self::TWOFACTOR_COOKIE])); 197 if (!is_array($data)) return false; 198 list($providerID, $hash,) = $data; 199 200 try { 201 $provider = (Manager::getInstance())->getUserProvider($providerID); 202 if ($this->cookieHash($provider) !== $hash) return false; 203 return true; 204 } catch (Exception $ignored) { 205 return false; 206 } 207 } 208 209 /** 210 * Get sticky value from standard cookie 211 * 212 * @return bool 213 */ 214 protected function isSticky() 215 { 216 if (!isset($_COOKIE[DOKU_COOKIE])) { 217 return false; 218 } 219 list(, $sticky,) = explode('|', $_COOKIE[DOKU_COOKIE], 3); 220 return (bool)$sticky; 221 } 222 223 /** 224 * Deletes the cookie 225 * 226 * @return void 227 */ 228 protected function deAuth() 229 { 230 global $conf; 231 232 $cookieDir = empty($conf['cookiedir']) ? DOKU_REL : $conf['cookiedir']; 233 $time = time() - 60 * 60 * 24 * 365; // one year in the past 234 setcookie(self::TWOFACTOR_COOKIE, null, $time, $cookieDir, '', ($conf['securecookie'] && is_ssl()), true); 235 } 236 237 /** 238 * Verify a given code 239 * 240 * @return bool 241 * @throws Exception 242 */ 243 protected function verify($code, $providerID, $sticky) 244 { 245 global $conf; 246 247 if (!$code) return false; 248 if (!$providerID) return false; 249 $provider = (Manager::getInstance())->getUserProvider($providerID); 250 $ok = $provider->checkCode($code); 251 if (!$ok) return false; 252 253 // store cookie 254 $hash = $this->cookieHash($provider); 255 $data = base64_encode(serialize([$providerID, $hash, time()])); 256 $cookieDir = empty($conf['cookiedir']) ? DOKU_REL : $conf['cookiedir']; 257 $time = $sticky ? (time() + 60 * 60 * 24 * 30 * 3) : 0; //three months on sticky login 258 setcookie(self::TWOFACTOR_COOKIE, $data, $time, $cookieDir, '', ($conf['securecookie'] && is_ssl()), true); 259 260 return true; 261 } 262 263 /** 264 * Create a hash that validates the cookie 265 * 266 * @param Provider $provider 267 * @return string 268 */ 269 protected function cookieHash($provider) 270 { 271 return sha1(join("\n", [ 272 $provider->getProviderID(), 273 (Manager::getInstance())->getUser(), 274 $provider->getSecret(), 275 auth_browseruid(), 276 auth_cookiesalt(false, true), 277 ])); 278 } 279} 280