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