xref: /plugin/twofactor/Manager.php (revision f04d92b8f19e94b063c4246f59212be0c36ca05c)
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