Passed
Push — master ( 25a926...39145a )
by
unknown
20:30
created

RecoveryCodesProvider::activate()   A

Complexity

Conditions 6
Paths 6

Size

Total Lines 28
Code Lines 14

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
eloc 14
c 1
b 0
f 0
dl 0
loc 28
rs 9.2222
cc 6
nc 6
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
        $recoveryCode = $this->getRecoveryCode($request);
119
        $codes = $propertyManager->getProperty('codes', []);
120
        $recoveryCodes = GeneralUtility::makeInstance(RecoveryCodes::class, $this->getMode($propertyManager));
121
        if (!$recoveryCodes->verifyRecoveryCode($recoveryCode, $codes)) {
122
            $attempts = $propertyManager->getProperty('attempts', 0);
123
            $propertyManager->updateProperties(['attempts' => ++$attempts]);
124
            return false;
125
        }
126
127
        // Since the codes were passed by reference to the verify method, the matching code was
128
        // unset so we simply need to write the array back. However, if the update fails, we must
129
        // return FALSE even if the authentication was successful to prevent data inconsistency.
130
        return $propertyManager->updateProperties([
131
            'codes' => $codes,
132
            'attempts' => 0,
133
            'lastUsed' => $this->context->getPropertyFromAspect('date', 'timestamp')
134
        ]);
135
    }
136
137
    /**
138
     * Render the provider specific response for the given content type
139
     *
140
     * @param ServerRequestInterface $request
141
     * @param MfaProviderPropertyManager $propertyManager
142
     * @param string $type
143
     * @return ResponseInterface
144
     * @throws PropagateResponseException
145
     */
146
    public function handleRequest(
147
        ServerRequestInterface $request,
148
        MfaProviderPropertyManager $propertyManager,
149
        string $type
150
    ): ResponseInterface {
151
        $view = GeneralUtility::makeInstance(StandaloneView::class);
152
        $view->setTemplateRootPaths(['EXT:core/Resources/Private/Templates/Authentication/MfaProvider/RecoveryCodes']);
153
        switch ($type) {
154
            case MfaViewType::SETUP:
155
                if (!$this->activeProvidersExist($propertyManager)) {
156
                    // If no active providers are present for the current user, add a flash message and redirect
157
                    $lang = $this->getLanguageService();
158
                    $this->addFlashMessage(
159
                        $lang->sL('LLL:EXT:core/Resources/Private/Language/locallang_mfa_provider.xlf:setup.recoveryCodes.noActiveProviders.message'),
160
                        $lang->sL('LLL:EXT:core/Resources/Private/Language/locallang_mfa_provider.xlf:setup.recoveryCodes.noActiveProviders.title'),
161
                        FlashMessage::WARNING
162
                    );
163
                    if (($normalizedParams = $request->getAttribute('normalizedParams'))) {
164
                        $returnUrl = $normalizedParams->getHttpReferer();
165
                    } else {
166
                        // @todo this will not work for FE - make this more generic!
167
                        $returnUrl = $this->uriBuilder->buildUriFromRoute('mfa');
168
                    }
169
                    throw new PropagateResponseException(new RedirectResponse($returnUrl, 303), 1612883326);
170
                }
171
                $codes = GeneralUtility::makeInstance(RecoveryCodes::class, $this->getMode($propertyManager))->generatePlainRecoveryCodes();
172
                $view->setTemplate('Setup');
173
                $view->assignMultiple([
174
                    'recoveryCodes' => implode(PHP_EOL, $codes),
175
                    // Generate hmac of the recovery codes to prevent them from being changed in the setup from
176
                    'checksum' => GeneralUtility::hmac(json_encode($codes), 'recovery-codes-setup')
177
                ]);
178
                break;
179
            case MfaViewType::EDIT:
180
                $view->setTemplate('Edit');
181
                $view->assignMultiple([
182
                    'name' => $propertyManager->getProperty('name'),
183
                    '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

183
                    'amountOfCodesLeft' => count(/** @scrutinizer ignore-type */ $propertyManager->getProperty('codes', [])),
Loading history...
184
                    '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

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