1fca58076SAndreas Gohr<?php 2fca58076SAndreas Gohr 3fca58076SAndreas Gohrnamespace dokuwiki\plugin\twofactor; 4fca58076SAndreas Gohr 55f8f561aSAndreas Gohruse dokuwiki\Extension\Event; 6a386a536SAndreas Gohruse dokuwiki\Extension\Plugin; 7c8525a21SAndreas Gohruse dokuwiki\Form\Form; 8a386a536SAndreas Gohr 9fca58076SAndreas Gohr/** 10fca58076SAndreas Gohr * Manages the child plugins etc. 11fca58076SAndreas Gohr */ 12a386a536SAndreas Gohrclass Manager extends Plugin 138b7620a8SAndreas Gohr{ 145f8f561aSAndreas Gohr /** 155f8f561aSAndreas Gohr * Generally all our actions should run before all other plugins 165f8f561aSAndreas Gohr */ 175f8f561aSAndreas Gohr const EVENT_PRIORITY = -5000; 185f8f561aSAndreas Gohr 198b7620a8SAndreas Gohr /** @var Manager */ 208b7620a8SAndreas Gohr protected static $instance; 218b7620a8SAndreas Gohr 228b7620a8SAndreas Gohr /** @var bool */ 238b7620a8SAndreas Gohr protected $ready = false; 248b7620a8SAndreas Gohr 258b7620a8SAndreas Gohr /** @var Provider[] */ 268b7620a8SAndreas Gohr protected $providers; 27fca58076SAndreas Gohr 285f8f561aSAndreas Gohr /** @var bool */ 295f8f561aSAndreas Gohr protected $providersInitialized; 305f8f561aSAndreas Gohr 31c8525a21SAndreas Gohr /** @var string */ 32c8525a21SAndreas Gohr protected $user; 33c8525a21SAndreas Gohr 34fca58076SAndreas Gohr /** 358b7620a8SAndreas Gohr * Constructor 36fca58076SAndreas Gohr */ 378b7620a8SAndreas Gohr protected function __construct() 388b7620a8SAndreas Gohr { 398b7620a8SAndreas Gohr $attribute = plugin_load('helper', 'attribute'); 408b7620a8SAndreas Gohr if ($attribute === null) { 418b7620a8SAndreas Gohr msg('The attribute plugin is not available, 2fa disabled', -1); 425f8f561aSAndreas Gohr return; 438b7620a8SAndreas Gohr } 448b7620a8SAndreas Gohr 455f8f561aSAndreas Gohr $this->loadProviders(); 465f8f561aSAndreas Gohr if (!count($this->providers)) { 478b7620a8SAndreas Gohr msg('No suitable 2fa providers found, 2fa disabled', -1); 488b7620a8SAndreas Gohr return; 498b7620a8SAndreas Gohr } 508b7620a8SAndreas Gohr 518b7620a8SAndreas Gohr $this->ready = true; 528b7620a8SAndreas Gohr } 538b7620a8SAndreas Gohr 548b7620a8SAndreas Gohr /** 554b9cff8aSAndreas Gohr * This is not a conventional class, plugin name can't be determined automatically 564b9cff8aSAndreas Gohr * @inheritdoc 574b9cff8aSAndreas Gohr */ 585f8f561aSAndreas Gohr public function getPluginName() 595f8f561aSAndreas Gohr { 604b9cff8aSAndreas Gohr return 'twofactor'; 614b9cff8aSAndreas Gohr } 624b9cff8aSAndreas Gohr 634b9cff8aSAndreas Gohr /** 648b7620a8SAndreas Gohr * Get the instance of this singleton 658b7620a8SAndreas Gohr * 668b7620a8SAndreas Gohr * @return Manager 678b7620a8SAndreas Gohr */ 688b7620a8SAndreas Gohr public static function getInstance() 698b7620a8SAndreas Gohr { 708b7620a8SAndreas Gohr if (self::$instance === null) { 718b7620a8SAndreas Gohr self::$instance = new Manager(); 728b7620a8SAndreas Gohr } 738b7620a8SAndreas Gohr return self::$instance; 748b7620a8SAndreas Gohr } 758b7620a8SAndreas Gohr 768b7620a8SAndreas Gohr /** 77c8525a21SAndreas Gohr * Destroy the singleton instance 78c8525a21SAndreas Gohr */ 79c8525a21SAndreas Gohr public static function destroyInstance() 80c8525a21SAndreas Gohr { 81c8525a21SAndreas Gohr self::$instance = null; 82c8525a21SAndreas Gohr } 83c8525a21SAndreas Gohr 84c8525a21SAndreas Gohr /** 858b7620a8SAndreas Gohr * Is the plugin ready to be used? 868b7620a8SAndreas Gohr * 878b7620a8SAndreas Gohr * @return bool 888b7620a8SAndreas Gohr */ 898b7620a8SAndreas Gohr public function isReady() 908b7620a8SAndreas Gohr { 915f8f561aSAndreas Gohr if (!$this->ready) return false; 925f8f561aSAndreas Gohr try { 935f8f561aSAndreas Gohr $this->getUser(); 945f8f561aSAndreas Gohr } catch (\Exception $ignored) { 955f8f561aSAndreas Gohr return false; 965f8f561aSAndreas Gohr } 975f8f561aSAndreas Gohr 985f8f561aSAndreas Gohr return true; 998b7620a8SAndreas Gohr } 1008b7620a8SAndreas Gohr 1018b7620a8SAndreas Gohr /** 102b6119621SAndreas Gohr * Is a 2fa login required? 103b6119621SAndreas Gohr * 104b6119621SAndreas Gohr * @return bool 105a386a536SAndreas Gohr */ 106a386a536SAndreas Gohr public function isRequired() 107a386a536SAndreas Gohr { 108a386a536SAndreas Gohr $set = $this->getConf('optinout'); 109a386a536SAndreas Gohr if ($set === 'mandatory') { 110a386a536SAndreas Gohr return true; 111a386a536SAndreas Gohr } 1124b9cff8aSAndreas Gohr if ($set === 'optout') { 1134b9cff8aSAndreas Gohr $setting = new Settings('twofactor', $this->getUser()); 1144b9cff8aSAndreas Gohr if ($setting->get('state') !== 'optout') { 1154b9cff8aSAndreas Gohr return true; 1164b9cff8aSAndreas Gohr } 1174b9cff8aSAndreas Gohr } 118a386a536SAndreas Gohr 119a386a536SAndreas Gohr return false; 120a386a536SAndreas Gohr } 121a386a536SAndreas Gohr 122a386a536SAndreas Gohr /** 123a386a536SAndreas Gohr * Convenience method to get current user 124a386a536SAndreas Gohr * 125a386a536SAndreas Gohr * @return string 126a386a536SAndreas Gohr */ 127a386a536SAndreas Gohr public function getUser() 128a386a536SAndreas Gohr { 129c8525a21SAndreas Gohr if ($this->user === null) { 130a386a536SAndreas Gohr global $INPUT; 131c8525a21SAndreas Gohr $this->user = $INPUT->server->str('REMOTE_USER'); 132c8525a21SAndreas Gohr } 133c8525a21SAndreas Gohr 134c8525a21SAndreas Gohr if (!$this->user) { 135b6119621SAndreas Gohr throw new \RuntimeException('2fa user specifics used before user available'); 136b6119621SAndreas Gohr } 137c8525a21SAndreas Gohr return $this->user; 138c8525a21SAndreas Gohr } 139c8525a21SAndreas Gohr 140c8525a21SAndreas Gohr /** 141c8525a21SAndreas Gohr * Set the current user 142c8525a21SAndreas Gohr * 143c8525a21SAndreas Gohr * This is only needed when running 2fa actions for a non-logged-in user (e.g. during password reset) 144c8525a21SAndreas Gohr */ 145c8525a21SAndreas Gohr public function setUser($user) 146c8525a21SAndreas Gohr { 147c8525a21SAndreas Gohr if ($this->user) { 148c8525a21SAndreas Gohr throw new \RuntimeException('2fa user already set, cannot be changed'); 149c8525a21SAndreas Gohr } 150c8525a21SAndreas Gohr $this->user = $user; 151a386a536SAndreas Gohr } 152a386a536SAndreas Gohr 153a386a536SAndreas Gohr /** 1544b9cff8aSAndreas Gohr * Get or set the user opt-out state 1554b9cff8aSAndreas Gohr * 1564b9cff8aSAndreas Gohr * true: user opted out 1574b9cff8aSAndreas Gohr * false: user did not opt out 1584b9cff8aSAndreas Gohr * 1594b9cff8aSAndreas Gohr * @param bool|null $set 1604b9cff8aSAndreas Gohr * @return bool 1614b9cff8aSAndreas Gohr */ 1624b9cff8aSAndreas Gohr public function userOptOutState($set = null) 1634b9cff8aSAndreas Gohr { 1644b9cff8aSAndreas Gohr // is optout allowed? 1654b9cff8aSAndreas Gohr if ($this->getConf('optinout') !== 'optout') return false; 1664b9cff8aSAndreas Gohr 1674b9cff8aSAndreas Gohr $settings = new Settings('twofactor', $this->getUser()); 1684b9cff8aSAndreas Gohr 1694b9cff8aSAndreas Gohr if ($set === null) { 1704b9cff8aSAndreas Gohr $current = $settings->get('state'); 1714b9cff8aSAndreas Gohr return $current === 'optout'; 1724b9cff8aSAndreas Gohr } 1734b9cff8aSAndreas Gohr 1744b9cff8aSAndreas Gohr if ($set) { 1754b9cff8aSAndreas Gohr $settings->set('state', 'optout'); 1764b9cff8aSAndreas Gohr } else { 1774b9cff8aSAndreas Gohr $settings->delete('state'); 1784b9cff8aSAndreas Gohr } 1794b9cff8aSAndreas Gohr return $set; 1804b9cff8aSAndreas Gohr } 1814b9cff8aSAndreas Gohr 1824b9cff8aSAndreas Gohr /** 1838b7620a8SAndreas Gohr * Get all available providers 1848b7620a8SAndreas Gohr * 1858b7620a8SAndreas Gohr * @return Provider[] 1868b7620a8SAndreas Gohr */ 1878b7620a8SAndreas Gohr public function getAllProviders() 1888b7620a8SAndreas Gohr { 189a386a536SAndreas Gohr $user = $this->getUser(); 1908b7620a8SAndreas Gohr 1915f8f561aSAndreas Gohr if (!$this->providersInitialized) { 1925f8f561aSAndreas Gohr // initialize providers with user and ensure the ID is correct 1935f8f561aSAndreas Gohr foreach ($this->providers as $providerID => $provider) { 1945f8f561aSAndreas Gohr if ($providerID !== $provider->getProviderID()) { 1955f8f561aSAndreas Gohr $this->providers[$provider->getProviderID()] = $provider; 1965f8f561aSAndreas Gohr unset($this->providers[$providerID]); 1978b7620a8SAndreas Gohr } 1985f8f561aSAndreas Gohr $provider->init($user); 1995f8f561aSAndreas Gohr } 2005f8f561aSAndreas Gohr $this->providersInitialized = true; 2018b7620a8SAndreas Gohr } 2028b7620a8SAndreas Gohr 2038b7620a8SAndreas Gohr return $this->providers; 2048b7620a8SAndreas Gohr } 2058b7620a8SAndreas Gohr 2068b7620a8SAndreas Gohr /** 207a386a536SAndreas Gohr * Get all providers that have been already set up by the user 208a386a536SAndreas Gohr * 2091c8522cbSAndreas Gohr * @param bool $configured when set to false, all providers NOT configured are returned 210a386a536SAndreas Gohr * @return Provider[] 211a386a536SAndreas Gohr */ 2121c8522cbSAndreas Gohr public function getUserProviders($configured = true) 213a386a536SAndreas Gohr { 214a386a536SAndreas Gohr $list = $this->getAllProviders(); 2151c8522cbSAndreas Gohr $list = array_filter($list, function ($provider) use ($configured) { 2161c8522cbSAndreas Gohr return $configured ? $provider->isConfigured() : !$provider->isConfigured(); 217a386a536SAndreas Gohr }); 218a386a536SAndreas Gohr 219a386a536SAndreas Gohr return $list; 220a386a536SAndreas Gohr } 221a386a536SAndreas Gohr 222a386a536SAndreas Gohr /** 223a386a536SAndreas Gohr * Get the instance of the given provider 224a386a536SAndreas Gohr * 225a386a536SAndreas Gohr * @param string $providerID 226a386a536SAndreas Gohr * @return Provider 2276c996db8SAndreas Gohr * @throws \Exception 228a386a536SAndreas Gohr */ 229a386a536SAndreas Gohr public function getUserProvider($providerID) 230a386a536SAndreas Gohr { 231a386a536SAndreas Gohr $providers = $this->getUserProviders(); 232a386a536SAndreas Gohr if (isset($providers[$providerID])) return $providers[$providerID]; 2336c996db8SAndreas Gohr throw new \Exception('Uncofigured provider requested'); 234a386a536SAndreas Gohr } 235a386a536SAndreas Gohr 236a386a536SAndreas Gohr /** 237b6119621SAndreas Gohr * Get the user's default provider if any 238b6119621SAndreas Gohr * 239b6119621SAndreas Gohr * Autoupdates the apropriate setting 240b6119621SAndreas Gohr * 241b6119621SAndreas Gohr * @return Provider|null 242b6119621SAndreas Gohr */ 243b6119621SAndreas Gohr public function getUserDefaultProvider() 244b6119621SAndreas Gohr { 245b6119621SAndreas Gohr $setting = new Settings('twofactor', $this->getUser()); 246b6119621SAndreas Gohr $default = $setting->get('defaultmod'); 247b6119621SAndreas Gohr $providers = $this->getUserProviders(); 248b6119621SAndreas Gohr 249b6119621SAndreas Gohr if (isset($providers[$default])) return $providers[$default]; 250b6119621SAndreas Gohr // still here? no valid setting. Use first available one 251b6119621SAndreas Gohr $first = array_shift($providers); 252b6119621SAndreas Gohr if ($first !== null) { 253b6119621SAndreas Gohr $this->setUserDefaultProvider($first); 254b6119621SAndreas Gohr } 255b6119621SAndreas Gohr return $first; 256b6119621SAndreas Gohr } 257b6119621SAndreas Gohr 258b6119621SAndreas Gohr /** 259b6119621SAndreas Gohr * Set the default provider for the user 260b6119621SAndreas Gohr * 261b6119621SAndreas Gohr * @param Provider $provider 262b6119621SAndreas Gohr * @return void 263b6119621SAndreas Gohr */ 264b6119621SAndreas Gohr public function setUserDefaultProvider($provider) 265b6119621SAndreas Gohr { 266b6119621SAndreas Gohr $setting = new Settings('twofactor', $this->getUser()); 267b6119621SAndreas Gohr $setting->set('defaultmod', $provider->getProviderID()); 268b6119621SAndreas Gohr } 269b6119621SAndreas Gohr 270b6119621SAndreas Gohr /** 2715f8f561aSAndreas Gohr * Load all available provider classes 2728b7620a8SAndreas Gohr * 2735f8f561aSAndreas Gohr * @return Provider[]; 2748b7620a8SAndreas Gohr */ 2755f8f561aSAndreas Gohr protected function loadProviders() 2768b7620a8SAndreas Gohr { 2775f8f561aSAndreas Gohr /** @var Provider[] providers */ 2785f8f561aSAndreas Gohr $this->providers = []; 2795f8f561aSAndreas Gohr $event = new Event('PLUGIN_TWOFACTOR_PROVIDER_REGISTER', $this->providers); 2805f8f561aSAndreas Gohr $event->advise_before(false); 2815f8f561aSAndreas Gohr $event->advise_after(); 2825f8f561aSAndreas Gohr return $this->providers; 283fca58076SAndreas Gohr } 284fca58076SAndreas Gohr 285c8525a21SAndreas Gohr 286c8525a21SAndreas Gohr /** 287c8525a21SAndreas Gohr * Verify a given code 288c8525a21SAndreas Gohr * 289c8525a21SAndreas Gohr * @return bool 290c8525a21SAndreas Gohr * @throws \Exception 291c8525a21SAndreas Gohr */ 292c8525a21SAndreas Gohr public function verifyCode($code, $providerID) 293c8525a21SAndreas Gohr { 294c8525a21SAndreas Gohr if (!$code) return false; 295c8525a21SAndreas Gohr if (!$providerID) return false; 296c8525a21SAndreas Gohr $provider = $this->getUserProvider($providerID); 297c8525a21SAndreas Gohr $ok = $provider->checkCode($code); 298c8525a21SAndreas Gohr if (!$ok) return false; 299c8525a21SAndreas Gohr 300c8525a21SAndreas Gohr return true; 301c8525a21SAndreas Gohr } 302c8525a21SAndreas Gohr 303c8525a21SAndreas Gohr /** 304c8525a21SAndreas Gohr * Get the form to enter a code for a given provider 305c8525a21SAndreas Gohr * 306c8525a21SAndreas Gohr * Calling this will generate a new code and transmit it. 307c8525a21SAndreas Gohr * 308c8525a21SAndreas Gohr * @param string $providerID 309c8525a21SAndreas Gohr * @return Form 310c8525a21SAndreas Gohr */ 311c8525a21SAndreas Gohr public function getCodeForm($providerID) 312c8525a21SAndreas Gohr { 313*f04d92b8SAndreas Gohr global $INPUT; 314*f04d92b8SAndreas Gohr 315c8525a21SAndreas Gohr $providers = $this->getUserProviders(); 316c8525a21SAndreas Gohr $provider = $providers[$providerID] ?? $this->getUserDefaultProvider(); 317c8525a21SAndreas Gohr // remove current provider from list 318c8525a21SAndreas Gohr unset($providers[$provider->getProviderID()]); 319c8525a21SAndreas Gohr 320c8525a21SAndreas Gohr $form = new Form(['method' => 'POST']); 321*f04d92b8SAndreas Gohr 322*f04d92b8SAndreas Gohr // avoid triggering 2fa for non-document requests (like missing images that get rewritten as page) 323*f04d92b8SAndreas Gohr if($INPUT->server->has('HTTP_SEC_FETCH_DEST') && $INPUT->server->str('HTTP_SEC_FETCH_DEST') !== 'document'){ 324*f04d92b8SAndreas Gohr $form->addHTML('<p>Not a document request. Not initiating two factor auth</p>'); 325*f04d92b8SAndreas Gohr return $form; 326*f04d92b8SAndreas Gohr } 327*f04d92b8SAndreas Gohr 328c8525a21SAndreas Gohr $form->setHiddenField('do', 'twofactor_login'); 329c8525a21SAndreas Gohr $form->setHiddenField('2fa_provider', $provider->getProviderID()); 330c8525a21SAndreas Gohr 331c8525a21SAndreas Gohr $form->addFieldsetOpen($provider->getLabel()); 332c8525a21SAndreas Gohr try { 333c8525a21SAndreas Gohr $code = $provider->generateCode(); 334c8525a21SAndreas Gohr $info = $provider->transmitMessage($code); 335c8525a21SAndreas Gohr $form->addHTML('<p>' . hsc($info) . '</p>'); 336c8525a21SAndreas Gohr $form->addElement(new OtpField('2fa_code')); 337c8525a21SAndreas Gohr $form->addTagOpen('div')->addClass('buttons'); 338c8525a21SAndreas Gohr $form->addButton('2fa', $this->getLang('btn_confirm'))->attr('type', 'submit'); 339c8525a21SAndreas Gohr $form->addTagClose('div'); 340c8525a21SAndreas Gohr } catch (\Exception $e) { 341c8525a21SAndreas Gohr msg(hsc($e->getMessage()), -1); // FIXME better handling 342c8525a21SAndreas Gohr } 343c8525a21SAndreas Gohr $form->addFieldsetClose(); 344c8525a21SAndreas Gohr 345c8525a21SAndreas Gohr if (count($providers)) { 346c8525a21SAndreas Gohr $form->addFieldsetOpen('Alternative methods')->addClass('list'); 347c8525a21SAndreas Gohr foreach ($providers as $prov) { 348c8525a21SAndreas Gohr $form->addButton('2fa_provider', $prov->getLabel()) 349c8525a21SAndreas Gohr ->attr('type', 'submit') 350c8525a21SAndreas Gohr ->attr('value', $prov->getProviderID()); 351c8525a21SAndreas Gohr } 352c8525a21SAndreas Gohr $form->addFieldsetClose(); 353c8525a21SAndreas Gohr } 354c8525a21SAndreas Gohr 355c8525a21SAndreas Gohr return $form; 356c8525a21SAndreas Gohr } 357fca58076SAndreas Gohr} 358