Passed
Push — master ( 738d52...2688e0 )
by
unknown
12:36
created

RecoveryCodesProvider::deactivate()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 12
Code Lines 3

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
eloc 3
c 1
b 0
f 0
dl 0
loc 12
rs 10
cc 2
nc 2
nop 2
1
<?php
2
3
/*
4
 * This file is part of the TYPO3 CMS project.
5
 *
6
 * It is free software; you can redistribute it and/or modify it under
7
 * the terms of the GNU General Public License, either version 2
8
 * of the License, or any later version.
9
 *
10
 * For the full copyright and license information, please read the
11
 * LICENSE.txt file that was distributed with this source code.
12
 *
13
 * The TYPO3 project - inspiring people to share!
14
 */
15
16
declare(strict_types=1);
17
18
namespace TYPO3\CMS\Core\Authentication\Mfa\Provider;
19
20
use Psr\Http\Message\ResponseInterface;
21
use Psr\Http\Message\ServerRequestInterface;
22
use TYPO3\CMS\Backend\Routing\UriBuilder;
23
use TYPO3\CMS\Core\Authentication\Mfa\MfaProviderInterface;
24
use TYPO3\CMS\Core\Authentication\Mfa\MfaProviderPropertyManager;
25
use TYPO3\CMS\Core\Authentication\Mfa\MfaProviderRegistry;
26
use TYPO3\CMS\Core\Authentication\Mfa\MfaViewType;
27
use TYPO3\CMS\Core\Context\Context;
28
use TYPO3\CMS\Core\Http\HtmlResponse;
29
use TYPO3\CMS\Core\Http\PropagateResponseException;
30
use TYPO3\CMS\Core\Http\RedirectResponse;
31
use TYPO3\CMS\Core\Localization\LanguageService;
32
use TYPO3\CMS\Core\Messaging\FlashMessage;
33
use TYPO3\CMS\Core\Messaging\FlashMessageService;
34
use TYPO3\CMS\Core\Utility\GeneralUtility;
35
use TYPO3\CMS\Fluid\View\StandaloneView;
36
37
/**
38
 * MFA provider for authentication with recovery codes
39
 *
40
 * @internal should only be used by the TYPO3 Core
41
 */
42
class RecoveryCodesProvider implements MfaProviderInterface
43
{
44
    protected MfaProviderRegistry $mfaProviderRegistry;
45
    protected Context $context;
46
    protected UriBuilder $uriBuilder;
47
    protected FlashMessageService $flashMessageService;
48
49
    public function __construct(
50
        MfaProviderRegistry $mfaProviderRegistry,
51
        Context $context,
52
        UriBuilder $uriBuilder,
53
        FlashMessageService $flashMessageService
54
    ) {
55
        $this->mfaProviderRegistry = $mfaProviderRegistry;
56
        $this->context = $context;
57
        $this->uriBuilder = $uriBuilder;
58
        $this->flashMessageService = $flashMessageService;
59
    }
60
61
    private const MAX_ATTEMPTS = 3;
62
63
    /**
64
     * Check if a recovery code is given in the current request
65
     *
66
     * @param ServerRequestInterface $request
67
     * @return bool
68
     */
69
    public function canProcess(ServerRequestInterface $request): bool
70
    {
71
        return $this->getRecoveryCode($request) !== '';
72
    }
73
74
    /**
75
     * Evaluate if the provider is activated by checking the
76
     * active state from the provider properties. This provider
77
     * furthermore has a mannerism that it only works if at least
78
     * one other MFA provider is activated for the user.
79
     *
80
     * @param MfaProviderPropertyManager $propertyManager
81
     * @return bool
82
     */
83
    public function isActive(MfaProviderPropertyManager $propertyManager): bool
84
    {
85
        return (bool)$propertyManager->getProperty('active')
86
            && $this->activeProvidersExist($propertyManager);
87
    }
88
89
    /**
90
     * Evaluate if the provider is temporarily locked by checking
91
     * the current attempts state from the provider properties and
92
     * if there are still recovery codes left.
93
     *
94
     * @param MfaProviderPropertyManager $propertyManager
95
     * @return bool
96
     */
97
    public function isLocked(MfaProviderPropertyManager $propertyManager): bool
98
    {
99
        $attempts = (int)$propertyManager->getProperty('attempts', 0);
100
        $codes = (array)$propertyManager->getProperty('codes', []);
101
102
        // Assume the provider is locked in case either the maximum attempts are exceeded or no codes
103
        // are available. A provider however can only be locked if set up - an entry exists in database.
104
        return $propertyManager->hasProviderEntry() && ($attempts >= self::MAX_ATTEMPTS || $codes === []);
105
    }
106
107
    /**
108
     * Verify the given recovery code and remove it from the
109
     * provider properties if valid.
110
     *
111
     * @param ServerRequestInterface $request
112
     * @param MfaProviderPropertyManager $propertyManager
113
     *
114
     * @return bool
115
     */
116
    public function verify(ServerRequestInterface $request, MfaProviderPropertyManager $propertyManager): bool
117
    {
118
        if (!$this->isActive($propertyManager) || $this->isLocked($propertyManager)) {
119
            // Can not verify an inactive or locked provider
120
            return false;
121
        }
122
123
        $recoveryCode = $this->getRecoveryCode($request);
124
        $codes = $propertyManager->getProperty('codes', []);
125
        $recoveryCodes = GeneralUtility::makeInstance(RecoveryCodes::class, $this->getMode($propertyManager));
126
        if (!$recoveryCodes->verifyRecoveryCode($recoveryCode, $codes)) {
127
            $attempts = $propertyManager->getProperty('attempts', 0);
128
            $propertyManager->updateProperties(['attempts' => ++$attempts]);
129
            return false;
130
        }
131
132
        // Since the codes were passed by reference to the verify method, the matching code was
133
        // unset so we simply need to write the array back. However, if the update fails, we must
134
        // return FALSE even if the authentication was successful to prevent data inconsistency.
135
        return $propertyManager->updateProperties([
136
            'codes' => $codes,
137
            'attempts' => 0,
138
            'lastUsed' => $this->context->getPropertyFromAspect('date', 'timestamp')
139
        ]);
140
    }
141
142
    /**
143
     * Render the provider specific response for the given content type
144
     *
145
     * @param ServerRequestInterface $request
146
     * @param MfaProviderPropertyManager $propertyManager
147
     * @param string $type
148
     * @return ResponseInterface
149
     * @throws PropagateResponseException
150
     */
151
    public function handleRequest(
152
        ServerRequestInterface $request,
153
        MfaProviderPropertyManager $propertyManager,
154
        string $type
155
    ): ResponseInterface {
156
        $view = GeneralUtility::makeInstance(StandaloneView::class);
157
        $view->setTemplateRootPaths(['EXT:core/Resources/Private/Templates/Authentication/MfaProvider/RecoveryCodes']);
158
        switch ($type) {
159
            case MfaViewType::SETUP:
160
                if (!$this->activeProvidersExist($propertyManager)) {
161
                    // If no active providers are present for the current user, add a flash message and redirect
162
                    $lang = $this->getLanguageService();
163
                    $this->addFlashMessage(
164
                        $lang->sL('LLL:EXT:core/Resources/Private/Language/locallang_mfa_provider.xlf:setup.recoveryCodes.noActiveProviders.message'),
165
                        $lang->sL('LLL:EXT:core/Resources/Private/Language/locallang_mfa_provider.xlf:setup.recoveryCodes.noActiveProviders.title'),
166
                        FlashMessage::WARNING
167
                    );
168
                    if (($normalizedParams = $request->getAttribute('normalizedParams'))) {
169
                        $returnUrl = $normalizedParams->getHttpReferer();
170
                    } else {
171
                        // @todo this will not work for FE - make this more generic!
172
                        $returnUrl = $this->uriBuilder->buildUriFromRoute('mfa');
173
                    }
174
                    throw new PropagateResponseException(new RedirectResponse($returnUrl, 303), 1612883326);
175
                }
176
                $codes = GeneralUtility::makeInstance(RecoveryCodes::class, $this->getMode($propertyManager))->generatePlainRecoveryCodes();
177
                $view->setTemplate('Setup');
178
                $view->assignMultiple([
179
                    'recoveryCodes' => implode(PHP_EOL, $codes),
180
                    // Generate hmac of the recovery codes to prevent them from being changed in the setup from
181
                    'checksum' => GeneralUtility::hmac(json_encode($codes), 'recovery-codes-setup')
182
                ]);
183
                break;
184
            case MfaViewType::EDIT:
185
                $view->setTemplate('Edit');
186
                $view->assignMultiple([
187
                    'name' => $propertyManager->getProperty('name'),
188
                    'amountOfCodesLeft' => count($propertyManager->getProperty('codes', [])),
0 ignored issues
show
Bug introduced by
It seems like $propertyManager->getProperty('codes', array()) can also be of type null; however, parameter $value of count() does only seem to accept Countable|array, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

188
                    'amountOfCodesLeft' => count(/** @scrutinizer ignore-type */ $propertyManager->getProperty('codes', [])),
Loading history...
189
                    'lastUsed' => $this->getDateTime($propertyManager->getProperty('lastUsed', 0)),
0 ignored issues
show
Bug introduced by
It seems like $propertyManager->getProperty('lastUsed', 0) can also be of type null; however, parameter $timestamp of TYPO3\CMS\Core\Authentic...Provider::getDateTime() does only seem to accept integer, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

189
                    'lastUsed' => $this->getDateTime(/** @scrutinizer ignore-type */ $propertyManager->getProperty('lastUsed', 0)),
Loading history...
190
                    'updated' => $this->getDateTime($propertyManager->getProperty('updated', 0))
191
                ]);
192
                break;
193
            case MfaViewType::AUTH:
194
                $view->setTemplate('Auth');
195
                $view->assign('isLocked', $this->isLocked($propertyManager));
196
                break;
197
        }
198
        return new HtmlResponse($view->assign('providerIdentifier', $propertyManager->getIdentifier())->render());
199
    }
200
201
    /**
202
     * Activate the provider by hashing and storing the given recovery codes
203
     *
204
     * @param ServerRequestInterface $request
205
     * @param MfaProviderPropertyManager $propertyManager
206
     * @return bool
207
     */
208
    public function activate(ServerRequestInterface $request, MfaProviderPropertyManager $propertyManager): bool
209
    {
210
        if ($this->isActive($propertyManager)) {
211
            // Can not activate an active provider
212
            return false;
213
        }
214
215
        if (!$this->activeProvidersExist($propertyManager)) {
216
            // Can not activate since no other provider is activated yet
217
            return false;
218
        }
219
220
        $recoveryCodes = GeneralUtility::trimExplode(PHP_EOL, (string)($request->getParsedBody()['recoveryCodes'] ?? ''));
221
        $checksum = (string)($request->getParsedBody()['checksum'] ?? '');
222
        if ($recoveryCodes === []
223
            || !hash_equals(GeneralUtility::hmac(json_encode($recoveryCodes), 'recovery-codes-setup'), $checksum)
224
        ) {
225
            // Return since the request does not contain the initially created recovery codes
226
            return false;
227
        }
228
229
        // Hash given plain recovery codes and prepare the properties array with active state and custom name
230
        $hashedCodes = GeneralUtility::makeInstance(RecoveryCodes::class, $this->getMode($propertyManager))->generatedHashedRecoveryCodes($recoveryCodes);
231
        $properties = ['codes' => $hashedCodes, 'active' => true];
232
        if (($name = (string)($request->getParsedBody()['name'] ?? '')) !== '') {
233
            $properties['name'] = $name;
234
        }
235
236
        // Usually there should be no entry if the provider is not activated, but to prevent the
237
        // provider from being unable to activate again, we update the existing entry in such case.
238
        return $propertyManager->hasProviderEntry()
239
            ? $propertyManager->updateProperties($properties)
240
            : $propertyManager->createProviderEntry($properties);
241
    }
242
243
    /**
244
     * Handle the deactivate action by removing the provider entry
245
     *
246
     * @param ServerRequestInterface $request
247
     * @param MfaProviderPropertyManager $propertyManager
248
     * @return bool
249
     */
250
    public function deactivate(ServerRequestInterface $request, MfaProviderPropertyManager $propertyManager): bool
251
    {
252
        // Only check for the active property here to enable bulk deactivation,
253
        // e.g. in FormEngine. Otherwise it would not be possible to deactivate
254
        // this provider if the last "fully" provider was deactivated before.
255
        if (!(bool)$propertyManager->getProperty('active')) {
256
            // Can not deactivate an inactive provider
257
            return false;
258
        }
259
260
        // Delete the provider entry
261
        return $propertyManager->deleteProviderEntry();
262
    }
263
264
    /**
265
     * Handle the unlock action by resetting the attempts
266
     * provider property and issuing new codes.
267
     *
268
     * @param ServerRequestInterface $request
269
     * @param MfaProviderPropertyManager $propertyManager
270
     * @return bool
271
     */
272
    public function unlock(ServerRequestInterface $request, MfaProviderPropertyManager $propertyManager): bool
273
    {
274
        if (!$this->isActive($propertyManager) || !$this->isLocked($propertyManager)) {
275
            // Can not unlock an inactive or not locked provider
276
            return false;
277
        }
278
279
        // Reset attempts
280
        if ((int)$propertyManager->getProperty('attempts', 0) !== 0
281
            && !$propertyManager->updateProperties(['attempts' => 0])
282
        ) {
283
            // Could not reset the attempts, so we can not unlock the provider
284
            return false;
285
        }
286
287
        // Regenerate codes
288
        if ($propertyManager->getProperty('codes', []) === []) {
289
            // Generate new codes and store the hashed ones
290
            $recoveryCodes = GeneralUtility::makeInstance(RecoveryCodes::class, $this->getMode($propertyManager))->generateRecoveryCodes();
291
            if (!$propertyManager->updateProperties(['codes' => array_values($recoveryCodes)])) {
292
                // Codes could not be stored, so we can not unlock the provider
293
                return false;
294
            }
295
            // Add the newly generated codes to a flash message so the user can copy them
296
            $lang = $this->getLanguageService();
297
            $this->addFlashMessage(
298
                sprintf(
299
                    $lang->sL('LLL:EXT:core/Resources/Private/Language/locallang_mfa_provider.xlf:unlock.recoveryCodes.message'),
300
                    implode(' ', array_keys($recoveryCodes))
301
                ),
302
                $lang->sL('LLL:EXT:core/Resources/Private/Language/locallang_mfa_provider.xlf:unlock.recoveryCodes.title'),
303
                FlashMessage::WARNING
304
            );
305
        }
306
307
        return true;
308
    }
309
310
    public function update(ServerRequestInterface $request, MfaProviderPropertyManager $propertyManager): bool
311
    {
312
        if (!$this->isActive($propertyManager) || $this->isLocked($propertyManager)) {
313
            // Can not update an inactive or locked provider
314
            return false;
315
        }
316
317
        $name = (string)($request->getParsedBody()['name'] ?? '');
318
        if ($name !== '' && !$propertyManager->updateProperties(['name' => $name])) {
319
            return false;
320
        }
321
322
        if ((bool)($request->getParsedBody()['regenerateCodes'] ?? false)) {
323
            // Generate new codes and store the hashed ones
324
            $recoveryCodes = GeneralUtility::makeInstance(RecoveryCodes::class, $this->getMode($propertyManager))->generateRecoveryCodes();
325
            if (!$propertyManager->updateProperties(['codes' => array_values($recoveryCodes)])) {
326
                // Codes could not be stored, so we can not update the provider
327
                return false;
328
            }
329
            // Add the newly generated codes to a flash message so the user can copy them
330
            $lang = $this->getLanguageService();
331
            $this->addFlashMessage(
332
                sprintf(
333
                    $lang->sL('LLL:EXT:core/Resources/Private/Language/locallang_mfa_provider.xlf:update.recoveryCodes.message'),
334
                    implode(' ', array_keys($recoveryCodes))
335
                ),
336
                $lang->sL('LLL:EXT:core/Resources/Private/Language/locallang_mfa_provider.xlf:update.recoveryCodes.title'),
337
                FlashMessage::OK
338
            );
339
        }
340
341
        // Provider properties successfully updated
342
        return true;
343
    }
344
345
    /**
346
     * Check if the current user has other active providers
347
     *
348
     * @param MfaProviderPropertyManager $currentPropertyManager
349
     * @return bool
350
     */
351
    protected function activeProvidersExist(MfaProviderPropertyManager $currentPropertyManager): bool
352
    {
353
        $user = $currentPropertyManager->getUser();
354
        foreach ($this->mfaProviderRegistry->getProviders() as $identifier => $provider) {
355
            $propertyManager = MfaProviderPropertyManager::create($provider, $user);
356
            if ($identifier !== $currentPropertyManager->getIdentifier() && $provider->isActive($propertyManager)) {
357
                return true;
358
            }
359
        }
360
        return false;
361
    }
362
363
    /**
364
     * Internal helper method for fetching the recovery code from the request
365
     *
366
     * @param ServerRequestInterface $request
367
     * @return string
368
     */
369
    protected function getRecoveryCode(ServerRequestInterface $request): string
370
    {
371
        return trim((string)($request->getQueryParams()['rc'] ?? $request->getParsedBody()['rc'] ?? ''));
372
    }
373
374
    /**
375
     * Determine the mode (used for the hash instance) based on the current users table
376
     *
377
     * @param MfaProviderPropertyManager $propertyManager
378
     * @return string
379
     */
380
    protected function getMode(MfaProviderPropertyManager $propertyManager): string
381
    {
382
        return $propertyManager->getUser()->loginType;
383
    }
384
385
    /**
386
     * Add a custom flash message for this provider
387
     * Note: The flash messages added by the main controller are still shown to the user.
388
     *
389
     * @param string $message
390
     * @param string $title
391
     * @param int $severity
392
     */
393
    protected function addFlashMessage(string $message, string $title = '', int $severity = FlashMessage::INFO): void
394
    {
395
        $this->flashMessageService->getMessageQueueByIdentifier()->enqueue(
396
            GeneralUtility::makeInstance(FlashMessage::class, $message, $title, $severity, true)
397
        );
398
    }
399
400
    /**
401
     * Return the timestamp as local time (date string) by applying the globally configured format
402
     *
403
     * @param int $timestamp
404
     * @return string
405
     */
406
    protected function getDateTime(int $timestamp): string
407
    {
408
        if ($timestamp === 0) {
409
            return '';
410
        }
411
412
        return date(
413
            $GLOBALS['TYPO3_CONF_VARS']['SYS']['ddmmyy'] . ' ' . $GLOBALS['TYPO3_CONF_VARS']['SYS']['hhmm'],
414
            $timestamp
415
        ) ?: '';
416
    }
417
418
    protected function getLanguageService(): LanguageService
419
    {
420
        return $GLOBALS['LANG'];
421
    }
422
}
423