1<?php 2 3namespace dokuwiki\plugin\twofactor; 4 5use dokuwiki\Extension\Event; 6use dokuwiki\Extension\Plugin; 7use dokuwiki\Form\Form; 8 9/** 10 * Manages the child plugins etc. 11 */ 12class Manager extends Plugin 13{ 14 /** 15 * Generally all our actions should run before all other plugins 16 */ 17 const EVENT_PRIORITY = -5000; 18 19 /** @var Manager */ 20 protected static $instance; 21 22 /** @var bool */ 23 protected $ready = false; 24 25 /** @var Provider[] */ 26 protected $providers; 27 28 /** @var bool */ 29 protected $providersInitialized; 30 31 /** @var string */ 32 protected $user; 33 34 /** 35 * Constructor 36 */ 37 protected function __construct() 38 { 39 $attribute = plugin_load('helper', 'attribute'); 40 if ($attribute === null) { 41 msg('The attribute plugin is not available, 2fa disabled', -1); 42 return; 43 } 44 45 $this->loadProviders(); 46 if (!count($this->providers)) { 47 msg('No suitable 2fa providers found, 2fa disabled', -1); 48 return; 49 } 50 51 $this->ready = true; 52 } 53 54 /** 55 * This is not a conventional class, plugin name can't be determined automatically 56 * @inheritdoc 57 */ 58 public function getPluginName() 59 { 60 return 'twofactor'; 61 } 62 63 /** 64 * Get the instance of this singleton 65 * 66 * @return Manager 67 */ 68 public static function getInstance() 69 { 70 if (self::$instance === null) { 71 self::$instance = new Manager(); 72 } 73 return self::$instance; 74 } 75 76 /** 77 * Destroy the singleton instance 78 */ 79 public static function destroyInstance() 80 { 81 self::$instance = null; 82 } 83 84 /** 85 * Is the plugin ready to be used? 86 * 87 * @return bool 88 */ 89 public function isReady() 90 { 91 if (!$this->ready) return false; 92 try { 93 $this->getUser(); 94 } catch (\Exception $ignored) { 95 return false; 96 } 97 98 return true; 99 } 100 101 /** 102 * Is a 2fa login required? 103 * 104 * @return bool 105 */ 106 public function isRequired() 107 { 108 $set = $this->getConf('optinout'); 109 if ($set === 'mandatory') { 110 return true; 111 } 112 if ($set === 'optout') { 113 $setting = new Settings('twofactor', $this->getUser()); 114 if ($setting->get('state') !== 'optout') { 115 return true; 116 } 117 } 118 119 return false; 120 } 121 122 /** 123 * Convenience method to get current user 124 * 125 * @return string 126 */ 127 public function getUser() 128 { 129 if ($this->user === null) { 130 global $INPUT; 131 $this->user = $INPUT->server->str('REMOTE_USER'); 132 } 133 134 if (!$this->user) { 135 throw new \RuntimeException('2fa user specifics used before user available'); 136 } 137 return $this->user; 138 } 139 140 /** 141 * Set the current user 142 * 143 * This is only needed when running 2fa actions for a non-logged-in user (e.g. during password reset) 144 */ 145 public function setUser($user) 146 { 147 if ($this->user) { 148 throw new \RuntimeException('2fa user already set, cannot be changed'); 149 } 150 $this->user = $user; 151 } 152 153 /** 154 * Get or set the user opt-out state 155 * 156 * true: user opted out 157 * false: user did not opt out 158 * 159 * @param bool|null $set 160 * @return bool 161 */ 162 public function userOptOutState($set = null) 163 { 164 // is optout allowed? 165 if ($this->getConf('optinout') !== 'optout') return false; 166 167 $settings = new Settings('twofactor', $this->getUser()); 168 169 if ($set === null) { 170 $current = $settings->get('state'); 171 return $current === 'optout'; 172 } 173 174 if ($set) { 175 $settings->set('state', 'optout'); 176 } else { 177 $settings->delete('state'); 178 } 179 return $set; 180 } 181 182 /** 183 * Get all available providers 184 * 185 * @return Provider[] 186 */ 187 public function getAllProviders() 188 { 189 $user = $this->getUser(); 190 191 if (!$this->providersInitialized) { 192 // initialize providers with user and ensure the ID is correct 193 foreach ($this->providers as $providerID => $provider) { 194 if ($providerID !== $provider->getProviderID()) { 195 $this->providers[$provider->getProviderID()] = $provider; 196 unset($this->providers[$providerID]); 197 } 198 $provider->init($user); 199 } 200 $this->providersInitialized = true; 201 } 202 203 return $this->providers; 204 } 205 206 /** 207 * Get all providers that have been already set up by the user 208 * 209 * @param bool $configured when set to false, all providers NOT configured are returned 210 * @return Provider[] 211 */ 212 public function getUserProviders($configured = true) 213 { 214 $list = $this->getAllProviders(); 215 $list = array_filter($list, function ($provider) use ($configured) { 216 return $configured ? $provider->isConfigured() : !$provider->isConfigured(); 217 }); 218 219 return $list; 220 } 221 222 /** 223 * Get the instance of the given provider 224 * 225 * @param string $providerID 226 * @return Provider 227 * @throws \Exception 228 */ 229 public function getUserProvider($providerID) 230 { 231 $providers = $this->getUserProviders(); 232 if (isset($providers[$providerID])) return $providers[$providerID]; 233 throw new \Exception('Uncofigured provider requested'); 234 } 235 236 /** 237 * Get the user's default provider if any 238 * 239 * Autoupdates the apropriate setting 240 * 241 * @return Provider|null 242 */ 243 public function getUserDefaultProvider() 244 { 245 $setting = new Settings('twofactor', $this->getUser()); 246 $default = $setting->get('defaultmod'); 247 $providers = $this->getUserProviders(); 248 249 if (isset($providers[$default])) return $providers[$default]; 250 // still here? no valid setting. Use first available one 251 $first = array_shift($providers); 252 if ($first !== null) { 253 $this->setUserDefaultProvider($first); 254 } 255 return $first; 256 } 257 258 /** 259 * Set the default provider for the user 260 * 261 * @param Provider $provider 262 * @return void 263 */ 264 public function setUserDefaultProvider($provider) 265 { 266 $setting = new Settings('twofactor', $this->getUser()); 267 $setting->set('defaultmod', $provider->getProviderID()); 268 } 269 270 /** 271 * Load all available provider classes 272 * 273 * @return Provider[]; 274 */ 275 protected function loadProviders() 276 { 277 /** @var Provider[] providers */ 278 $this->providers = []; 279 $event = new Event('PLUGIN_TWOFACTOR_PROVIDER_REGISTER', $this->providers); 280 $event->advise_before(false); 281 $event->advise_after(); 282 return $this->providers; 283 } 284 285 286 /** 287 * Verify a given code 288 * 289 * @return bool 290 * @throws \Exception 291 */ 292 public function verifyCode($code, $providerID) 293 { 294 if (!$code) return false; 295 if (!$providerID) return false; 296 $provider = $this->getUserProvider($providerID); 297 $ok = $provider->checkCode($code); 298 if (!$ok) return false; 299 300 return true; 301 } 302 303 /** 304 * Get the form to enter a code for a given provider 305 * 306 * Calling this will generate a new code and transmit it. 307 * 308 * @param string $providerID 309 * @return Form 310 */ 311 public function getCodeForm($providerID) 312 { 313 global $INPUT; 314 315 $providers = $this->getUserProviders(); 316 $provider = $providers[$providerID] ?? $this->getUserDefaultProvider(); 317 // remove current provider from list 318 unset($providers[$provider->getProviderID()]); 319 320 $form = new Form(['method' => 'POST']); 321 322 // avoid triggering 2fa for non-document requests (like missing images that get rewritten as page) 323 if($INPUT->server->has('HTTP_SEC_FETCH_DEST') && $INPUT->server->str('HTTP_SEC_FETCH_DEST') !== 'document'){ 324 $form->addHTML('<p>Not a document request. Not initiating two factor auth</p>'); 325 return $form; 326 } 327 328 $form->setHiddenField('do', 'twofactor_login'); 329 $form->setHiddenField('2fa_provider', $provider->getProviderID()); 330 331 $form->addFieldsetOpen($provider->getLabel()); 332 try { 333 $code = $provider->generateCode(); 334 $info = $provider->transmitMessage($code); 335 $form->addHTML('<p>' . hsc($info) . '</p>'); 336 $form->addElement(new OtpField('2fa_code')); 337 $form->addTagOpen('div')->addClass('buttons'); 338 $form->addButton('2fa', $this->getLang('btn_confirm'))->attr('type', 'submit'); 339 $form->addTagClose('div'); 340 } catch (\Exception $e) { 341 msg(hsc($e->getMessage()), -1); // FIXME better handling 342 } 343 $form->addFieldsetClose(); 344 345 if (count($providers)) { 346 $form->addFieldsetOpen('Alternative methods')->addClass('list'); 347 foreach ($providers as $prov) { 348 $form->addButton('2fa_provider', $prov->getLabel()) 349 ->attr('type', 'submit') 350 ->attr('value', $prov->getProviderID()); 351 } 352 $form->addFieldsetClose(); 353 } 354 355 return $form; 356 } 357} 358