UserSettingsController::resetTrustedDevices()   A
last analyzed

Complexity

Conditions 4
Paths 4

Size

Total Lines 27
Code Lines 15

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 4
eloc 15
nc 4
nop 2
dl 0
loc 27
rs 9.7666
c 0
b 0
f 0
1
<?php
2
/**
3
 * This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
4
 *
5
 * Copyright (C) 2019 - 2022 Jan Böhmer (https://github.com/jbtronics)
6
 *
7
 * This program is free software: you can redistribute it and/or modify
8
 * it under the terms of the GNU Affero General Public License as published
9
 * by the Free Software Foundation, either version 3 of the License, or
10
 * (at your option) any later version.
11
 *
12
 * This program is distributed in the hope that it will be useful,
13
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
14
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
15
 * GNU Affero General Public License for more details.
16
 *
17
 * You should have received a copy of the GNU Affero General Public License
18
 * along with this program.  If not, see <https://www.gnu.org/licenses/>.
19
 */
20
21
declare(strict_types=1);
22
23
namespace App\Controller;
24
25
use App\Entity\UserSystem\U2FKey;
26
use App\Entity\UserSystem\User;
27
use App\Entity\UserSystem\WebauthnKey;
28
use App\Events\SecurityEvent;
29
use App\Events\SecurityEvents;
30
use App\Form\TFAGoogleSettingsType;
31
use App\Form\UserSettingsType;
32
use App\Services\UserSystem\TFA\BackupCodeManager;
33
use App\Services\UserSystem\UserAvatarHelper;
34
use Doctrine\ORM\EntityManagerInterface;
35
use RuntimeException;
36
use Scheb\TwoFactorBundle\Security\TwoFactor\Provider\Google\GoogleAuthenticator;
37
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
38
use Symfony\Component\EventDispatcher\EventDispatcher;
39
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
40
use Symfony\Component\Form\Extension\Core\Type\PasswordType;
41
use Symfony\Component\Form\Extension\Core\Type\RepeatedType;
42
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
43
use Symfony\Component\Form\Extension\Core\Type\TextType;
44
use Symfony\Component\Form\Form;
45
use Symfony\Component\Form\FormFactoryInterface;
46
use Symfony\Component\HttpFoundation\RedirectResponse;
47
use Symfony\Component\HttpFoundation\Request;
48
use Symfony\Component\HttpFoundation\Response;
49
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
50
use Symfony\Component\Routing\Annotation\Route;
51
use Symfony\Component\Security\Core\Validator\Constraints\UserPassword;
52
use Symfony\Component\Validator\Constraints\Length;
53
54
/**
55
 * @Route("/user")
56
 */
57
class UserSettingsController extends AbstractController
58
{
59
    protected bool $demo_mode;
60
61
    /**
62
     * @var EventDispatcher|EventDispatcherInterface
63
     */
64
    protected $eventDispatcher;
65
66
    public function __construct(bool $demo_mode, EventDispatcherInterface $eventDispatcher)
67
    {
68
        $this->demo_mode = $demo_mode;
69
        $this->eventDispatcher = $eventDispatcher;
70
    }
71
72
    /**
73
     * @Route("/2fa_backup_codes", name="show_backup_codes")
74
     */
75
    public function showBackupCodes()
76
    {
77
        $user = $this->getUser();
78
79
        //When user change its settings, he should be logged  in fully.
80
        $this->denyAccessUnlessGranted('IS_AUTHENTICATED_FULLY');
81
82
        if (!$user instanceof User) {
83
            return new RuntimeException('This controller only works only for Part-DB User objects!');
84
        }
85
86
        if (empty($user->getBackupCodes())) {
87
            $this->addFlash('error', 'tfa_backup.no_codes_enabled');
88
89
            throw new RuntimeException('You do not have any backup codes enabled, therefore you can not view them!');
90
        }
91
92
        return $this->render('users/backup_codes.html.twig', [
93
            'user' => $user,
94
        ]);
95
    }
96
97
    /**
98
     * @Route("/u2f_delete", name="u2f_delete", methods={"DELETE"})
99
     */
100
    public function removeU2FToken(Request $request, EntityManagerInterface $entityManager, BackupCodeManager $backupCodeManager): RedirectResponse
101
    {
102
        if ($this->demo_mode) {
103
            throw new RuntimeException('You can not do 2FA things in demo mode');
104
        }
105
106
        $user = $this->getUser();
107
108
        //When user change its settings, he should be logged  in fully.
109
        $this->denyAccessUnlessGranted('IS_AUTHENTICATED_FULLY');
110
111
        if (!$user instanceof User) {
112
            throw new RuntimeException('This controller only works only for Part-DB User objects!');
113
        }
114
115
        if ($this->isCsrfTokenValid('delete'.$user->getId(), $request->request->get('_token'))) {
116
            //Handle U2F key removal
117
            if ($request->request->has('key_id')) {
118
                $key_id = $request->request->get('key_id');
119
                $key_repo = $entityManager->getRepository(U2FKey::class);
120
                /** @var U2FKey|null $u2f */
121
                $u2f = $key_repo->find($key_id);
122
                if (null === $u2f) {
123
                    $this->addFlash('danger', 'tfa_u2f.u2f_delete.not_existing');
124
125
                    return $this->redirectToRoute('user_settings');
126
                }
127
128
                //User can only delete its own U2F keys
129
                if ($u2f->getUser() !== $user) {
130
                    $this->addFlash('danger', 'tfa_u2f.u2f_delete.access_denied');
131
132
                    return $this->redirectToRoute('user_settings');
133
                }
134
135
                $backupCodeManager->disableBackupCodesIfUnused($user);
136
                $entityManager->remove($u2f);
137
                $entityManager->flush();
138
                $this->addFlash('success', 'tfa.u2f.u2f_delete.success');
139
140
                $security_event = new SecurityEvent($user);
141
                $this->eventDispatcher->dispatch($security_event, SecurityEvents::U2F_REMOVED);
142
            } else if ($request->request->has('webauthn_key_id')) {
143
                $key_id = $request->request->get('webauthn_key_id');
144
                $key_repo = $entityManager->getRepository(WebauthnKey::class);
145
                /** @var WebauthnKey|null $key */
146
                $key = $key_repo->find($key_id);
147
                if (null === $key) {
148
                    $this->addFlash('error', 'tfa_u2f.u2f_delete.not_existing');
149
150
                    return $this->redirectToRoute('user_settings');
151
                }
152
153
                //User can only delete its own U2F keys
154
                if ($key->getUser() !== $user) {
155
                    $this->addFlash('error', 'tfa_u2f.u2f_delete.access_denied');
156
157
                    return $this->redirectToRoute('user_settings');
158
                }
159
160
                $backupCodeManager->disableBackupCodesIfUnused($user);
161
                $entityManager->remove($key);
162
                $entityManager->flush();
163
                $this->addFlash('success', 'tfa.u2f.u2f_delete.success');
164
165
                $security_event = new SecurityEvent($user);
166
                $this->eventDispatcher->dispatch($security_event, SecurityEvents::U2F_REMOVED);
167
            }
168
        } else {
169
            $this->addFlash('error', 'csfr_invalid');
170
        }
171
172
        return $this->redirectToRoute('user_settings');
173
    }
174
175
    /**
176
     * @Route("/invalidate_trustedDevices", name="tfa_trustedDevices_invalidate", methods={"DELETE"})
177
     *
178
     * @return RuntimeException|RedirectResponse
179
     */
180
    public function resetTrustedDevices(Request $request, EntityManagerInterface $entityManager)
181
    {
182
        if ($this->demo_mode) {
183
            throw new RuntimeException('You can not do 2FA things in demo mode');
184
        }
185
186
        $user = $this->getUser();
187
188
        //When user change its settings, he should be logged  in fully.
189
        $this->denyAccessUnlessGranted('IS_AUTHENTICATED_FULLY');
190
191
        if (!$user instanceof User) {
192
            return new RuntimeException('This controller only works only for Part-DB User objects!');
193
        }
194
195
        if ($this->isCsrfTokenValid('devices_reset'.$user->getId(), $request->request->get('_token'))) {
196
            $user->invalidateTrustedDeviceTokens();
197
            $entityManager->flush();
198
            $this->addFlash('success', 'tfa_trustedDevice.invalidate.success');
199
200
            $security_event = new SecurityEvent($user);
201
            $this->eventDispatcher->dispatch($security_event, SecurityEvents::TRUSTED_DEVICE_RESET);
202
        } else {
203
            $this->addFlash('error', 'csfr_invalid');
204
        }
205
206
        return $this->redirectToRoute('user_settings');
207
    }
208
209
    /**
210
     * @Route("/settings", name="user_settings")
211
     *
212
     * @return RedirectResponse|Response
213
     */
214
    public function userSettings(Request $request, EntityManagerInterface $em, UserPasswordHasherInterface $passwordEncoder, GoogleAuthenticator $googleAuthenticator, BackupCodeManager $backupCodeManager, FormFactoryInterface $formFactory, UserAvatarHelper $avatarHelper)
215
    {
216
        /** @var User */
217
        $user = $this->getUser();
218
219
        $page_need_reload = false;
220
221
        //When user change its settings, he should be logged  in fully.
222
        $this->denyAccessUnlessGranted('IS_AUTHENTICATED_FULLY');
223
224
        if (!$user instanceof User) {
225
            throw new RuntimeException('This controller only works only for Part-DB User objects!');
226
        }
227
228
        $security_event = new SecurityEvent($user);
229
230
        /***************************
231
         * User settings form
232
         ***************************/
233
234
        $form = $this->createForm(UserSettingsType::class, $user);
235
236
        $form->handleRequest($request);
237
238
        if (!$this->demo_mode && $form->isSubmitted() && $form->isValid()) {
239
            //Check if user theme setting has changed
240
            if ($user->getTheme() !== $em->getUnitOfWork()->getOriginalEntityData($user)['theme']) {
241
                $page_need_reload = true;
242
            }
243
244
            if ($form['avatar_file']->getData() !== null) {
245
                $attachment = $avatarHelper->handleAvatarUpload($user, $form['avatar_file']->getData());
246
                //$em->flush();
247
                //For some reason the avatar is not set as master picture attachment, so we do it again here
248
                $user->setMasterPictureAttachment($attachment);
249
                $page_need_reload = true;
250
            }
251
252
            /** @var Form $form We need an form implementation for the next calls */
253
            if ($form->getClickedButton() && 'remove_avatar' === $form->getClickedButton()->getName()) {
254
                //Remove the avatar attachment from the user if requested
255
                if ($user->getMasterPictureAttachment() !== null) {
256
                    $em->remove($user->getMasterPictureAttachment());
257
                    $user->setMasterPictureAttachment(null);
258
                    $page_need_reload = true;
259
                }
260
            }
261
262
            $em->flush();
263
            $this->addFlash('success', 'user.settings.saved_flash');
264
        }
265
266
        /*****************************
267
         * Password change form
268
         ****************************/
269
270
        $pw_form = $this->createFormBuilder()
271
            //Username field for autocomplete
272
            ->add('username', TextType::class, [
273
                'data' => $user->getName(),
274
                'attr' => [
275
                    'autocomplete' => 'username',
276
                ],
277
                'disabled' => true,
278
                'row_attr' => [
279
                    'class' => 'd-none',
280
                ],
281
            ])
282
            ->add('old_password', PasswordType::class, [
283
                'label' => 'user.settings.pw_old.label',
284
                'disabled' => $this->demo_mode,
285
                'attr' => [
286
                    'autocomplete' => 'current-password',
287
                ],
288
                'constraints' => [new UserPassword()],
289
            ]) //This constraint checks, if the current user pw was inputted.
290
            ->add('new_password', RepeatedType::class, [
291
                'disabled' => $this->demo_mode,
292
                'type' => PasswordType::class,
293
                'first_options' => [
294
                    'label' => 'user.settings.pw_new.label',
295
                ],
296
                'second_options' => [
297
                    'label' => 'user.settings.pw_confirm.label',
298
                ],
299
                'invalid_message' => 'password_must_match',
300
                'options' => [
301
                    'attr' => [
302
                        'autocomplete' => 'new-password',
303
                    ],
304
                ],
305
                'constraints' => [new Length([
306
                    'min' => 6,
307
                    'max' => 128,
308
                ])],
309
            ])
310
            ->add('submit', SubmitType::class, ['label' => 'save'])
311
            ->getForm();
312
313
        $pw_form->handleRequest($request);
314
315
        //Check if password if everything was correct, then save it to User and DB
316
        if (!$this->demo_mode && $pw_form->isSubmitted() && $pw_form->isValid()) {
317
            $password = $passwordEncoder->hashPassword($user, $pw_form['new_password']->getData());
318
            $user->setPassword($password);
319
320
            //After the change reset the password change needed setting
321
            $user->setNeedPwChange(false);
322
323
            $em->persist($user);
324
            $em->flush();
325
            $this->addFlash('success', 'user.settings.pw_changed_flash');
326
            $this->eventDispatcher->dispatch($security_event, SecurityEvents::PASSWORD_CHANGED);
327
        }
328
329
        //Handle 2FA things
330
        $google_form = $this->createForm(TFAGoogleSettingsType::class, $user);
331
        $google_enabled = $user->isGoogleAuthenticatorEnabled();
332
        if (!$google_enabled && !$form->isSubmitted()) {
333
            $user->setGoogleAuthenticatorSecret($googleAuthenticator->generateSecret());
334
            $google_form->get('googleAuthenticatorSecret')->setData($user->getGoogleAuthenticatorSecret());
335
        }
336
        $google_form->handleRequest($request);
337
338
        if (!$this->demo_mode && $google_form->isSubmitted() && $google_form->isValid()) {
339
            if (!$google_enabled) {
340
                //Save 2FA settings (save secrets)
341
                $user->setGoogleAuthenticatorSecret($google_form->get('googleAuthenticatorSecret')->getData());
342
                $backupCodeManager->enableBackupCodes($user);
343
344
                $em->flush();
345
                $this->addFlash('success', 'user.settings.2fa.google.activated');
346
347
                $this->eventDispatcher->dispatch($security_event, SecurityEvents::GOOGLE_ENABLED);
348
349
                return $this->redirectToRoute('user_settings');
350
            }
351
352
            //Remove secret to disable google authenticator
353
            $user->setGoogleAuthenticatorSecret(null);
354
            $backupCodeManager->disableBackupCodesIfUnused($user);
355
            $em->flush();
356
            $this->addFlash('success', 'user.settings.2fa.google.disabled');
357
            $this->eventDispatcher->dispatch($security_event, SecurityEvents::GOOGLE_DISABLED);
358
359
            return $this->redirectToRoute('user_settings');
360
        }
361
362
363
        $backup_form = $formFactory->createNamedBuilder('backup_codes')->add('reset_codes', SubmitType::class, [
364
            'label' => 'tfa_backup.regenerate_codes',
365
            'attr' => [
366
                'class' => 'btn-danger',
367
            ],
368
            'disabled' => empty($user->getBackupCodes()),
369
        ])->getForm();
370
371
        $backup_form->handleRequest($request);
372
        if (!$this->demo_mode && $backup_form->isSubmitted() && $backup_form->isValid()) {
373
            $backupCodeManager->regenerateBackupCodes($user);
374
            $em->flush();
375
            $this->addFlash('success', 'user.settings.2fa.backup_codes.regenerated');
376
            $this->eventDispatcher->dispatch($security_event, SecurityEvents::BACKUP_KEYS_RESET);
377
        }
378
379
        /******************************
380
         * Output both forms
381
         *****************************/
382
383
        return $this->renderForm('users/user_settings.html.twig', [
384
            'user' => $user,
385
            'settings_form' => $form,
386
            'pw_form' => $pw_form,
387
            'global_reload_needed' => $page_need_reload,
388
389
            'google_form' => $google_form,
390
            'backup_form' => $backup_form,
391
            'tfa_google' => [
392
                'enabled' => $google_enabled,
393
                'qrContent' => $googleAuthenticator->getQRContent($user),
394
                'secret' => $user->getGoogleAuthenticatorSecret(),
395
                'username' => $user->getGoogleAuthenticatorUsername(),
396
            ],
397
        ]);
398
    }
399
}
400