Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
80.19% covered (warning)
80.19%
85 / 106
66.67% covered (warning)
66.67%
8 / 12
CRAP
0.00% covered (danger)
0.00%
0 / 1
SecurityController
80.19% covered (warning)
80.19%
85 / 106
66.67% covered (warning)
66.67%
8 / 12
31.26
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
1
 loginAction
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
1
 oauthConnectAction
75.00% covered (warning)
75.00%
6 / 8
0.00% covered (danger)
0.00%
0 / 1
4.25
 oauthCheckAction
54.84% covered (warning)
54.84%
17 / 31
0.00% covered (danger)
0.00%
0 / 1
9.32
 forgetPasswordAction
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 forgetPasswordConfirmAction
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
2
 resetPasswordAction
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
4
 _handleUserLoggedAlreadyAssociated
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 _handleSsoClientError
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
2
 _handleUserNotFound
85.71% covered (warning)
85.71%
6 / 7
0.00% covered (danger)
0.00%
0 / 1
2.01
 _handleDuplicateUserWithSsoKey
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 _associateUserWithSsoKey
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
2
1<?php
2
3/**
4 * This file is part of the MADIS - RGPD Management application.
5 *
6 * @copyright Copyright (c) 2018-2019 Soluris - Solutions Numériques Territoriales Innovantes
7 *
8 * This program is free software: you can redistribute it and/or modify
9 * it under the terms of the GNU Affero General Public License as published by
10 * the Free Software Foundation, either version 3 of the License, or
11 * (at your option) any later version.
12 *
13 * This program is distributed in the hope that it will be useful,
14 * but WITHOUT ANY WARRANTY; without even the implied warranty of
15 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
16 * GNU Affero General Public License for more details.
17 *
18 * You should have received a copy of the GNU Affero General Public License
19 * along with this program. If not, see <https://www.gnu.org/licenses/>.
20 */
21
22declare(strict_types=1);
23
24namespace App\Domain\User\Controller;
25
26use App\Application\Controller\ControllerHelper;
27use App\Application\Symfony\Security\UserProvider;
28use App\Domain\User\Component\Mailer;
29use App\Domain\User\Component\TokenGenerator;
30use App\Domain\User\Form\Type\ResetPasswordType;
31use App\Domain\User\Model\User;
32use App\Domain\User\Repository;
33use Doctrine\ORM\EntityManagerInterface;
34use KnpU\OAuth2ClientBundle\Client\ClientRegistry;
35use KnpU\OAuth2ClientBundle\Client\OAuth2Client;
36use League\OAuth2\Client\Provider\Exception\IdentityProviderException;
37use Psr\Log\LoggerInterface;
38use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
39use Symfony\Component\HttpFoundation\RedirectResponse;
40use Symfony\Component\HttpFoundation\Request;
41use Symfony\Component\HttpFoundation\Response;
42use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
43use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface;
44use Symfony\Component\Security\Core\Authentication\Token\UsernamePasswordToken;
45use Symfony\Component\Security\Http\Authentication\AuthenticationUtils;
46use Twig\Error\LoaderError;
47use Twig\Error\RuntimeError;
48use Twig\Error\SyntaxError;
49
50class SecurityController extends AbstractController
51{
52    private ControllerHelper $helper;
53    private AuthenticationUtils $authenticationUtils;
54    private TokenGenerator $tokenGenerator;
55    private Repository\User $userRepository;
56    private Mailer $mailer;
57    private UserProvider $userProvider;
58    private EntityManagerInterface $entityManager;
59
60    private ?string $sso_type;
61    private ?string $sso_key_field;
62
63    public function __construct(
64        ControllerHelper $helper,
65        AuthenticationUtils $authenticationUtils,
66        TokenGenerator $tokenGenerator,
67        Repository\User $userRepository,
68        Mailer $mailer,
69        UserProvider $userProvider,
70        EntityManagerInterface $entityManager,
71        ?string $sso_type,
72        ?string $sso_key_field,
73    ) {
74        $this->helper              = $helper;
75        $this->authenticationUtils = $authenticationUtils;
76        $this->tokenGenerator      = $tokenGenerator;
77        $this->userRepository      = $userRepository;
78        $this->mailer              = $mailer;
79        $this->userProvider        = $userProvider;
80        $this->entityManager       = $entityManager;
81        $this->sso_type            = $sso_type;
82        $this->sso_key_field       = $sso_key_field;
83    }
84
85    /**
86     * Display login page.
87     *
88     * @throws LoaderError
89     * @throws RuntimeError
90     * @throws SyntaxError
91     */
92    public function loginAction(): Response
93    {
94        $error        = $this->authenticationUtils->getLastAuthenticationError();
95        $lastUsername = $this->authenticationUtils->getLastUsername();
96
97        return $this->helper->render('User/Security/login.html.twig', [
98            'last_username' => $lastUsername,
99            'error'         => $error,
100            'sso_type'      => $this->sso_type,
101        ]);
102    }
103
104    public function oauthConnectAction(Request $request, ClientRegistry $clientRegistry): RedirectResponse
105    {
106        $currentUser = $this->userProvider->getAuthenticatedUser();
107        if ($currentUser && null !== $currentUser->getSsoKey()) {
108            return $this->_handleUserLoggedAlreadyAssociated();
109        }
110
111        $oauthServiceName = $request->get('service');
112        try {
113            $client = $clientRegistry->getClient($oauthServiceName);
114        } catch (\Exception) {
115            return $this->_handleSsoClientError();
116        }
117
118        // scope openid required to get id_token needed for logout
119        return $client->redirect(['openid'], []);
120    }
121
122    public function oauthCheckAction(Request $request, ClientRegistry $clientRegistry, TokenStorageInterface $tokenStorage, LoggerInterface $logger): RedirectResponse
123    {
124        $oauthServiceName = $request->get('service');
125
126        /** @var OAuth2Client $client */
127        $client = $clientRegistry->getClient($oauthServiceName);
128        try {
129            // scope openid required to get id_token needed for logout
130            $accessToken   = $client->getAccessToken(['scope' => 'openid']);
131            $userOAuthData = $client->fetchUserFromToken($accessToken)->toArray();
132        } catch (IdentityProviderException) {
133            return $this->_handleSsoClientError();
134        }
135
136        $sso_key_field = $this->sso_key_field;
137        try {
138            $sso_value = $userOAuthData[$sso_key_field];
139        } catch (\Exception) {
140            $logger->error('SSO field "' . $sso_key_field . '" not found.');
141            $logger->info('Data returned by SSO: ' . json_encode($userOAuthData));
142
143            return $this->_handleSsoClientError();
144        }
145
146        $ssoLogoutUrl = null;
147        $provider     = $client->getOAuth2Provider();
148        if (method_exists($provider, 'getLogoutUrl')) {
149            $tokenValues  = $accessToken->getValues();
150            $ssoLogoutUrl = $provider->getLogoutUrl([
151                'id_token_hint'            => $tokenValues['id_token'],
152                'post_logout_redirect_uri' => $this->generateUrl('login', [], UrlGeneratorInterface::ABSOLUTE_URL),
153            ]);
154
155            $session = $request->getSession();
156            $session->set('ssoLogoutUrl', $ssoLogoutUrl);
157        }
158
159        $currentUser = $this->userProvider->getAuthenticatedUser();
160        if ($currentUser) {
161            return $this->_associateUserWithSsoKey($sso_value, $currentUser);
162        }
163
164        $user = $this->userRepository->findOneOrNullBySsoKey($sso_value);
165        if (!$user) {
166            return $this->_handleUserNotFound($ssoLogoutUrl);
167        }
168
169        // Login Programmatically
170        $token = new UsernamePasswordToken($user, $user->getPassword(), 'public', $user->getRoles());
171        $tokenStorage->setToken($token);
172
173        return $this->helper->redirectToRoute('reporting_dashboard_index');
174    }
175
176    /**
177     * Display Forget password page.
178     *
179     * @throws LoaderError
180     * @throws RuntimeError
181     * @throws SyntaxError
182     */
183    public function forgetPasswordAction(): Response
184    {
185        return $this->helper->render('User/Security/forget_password.html.twig');
186    }
187
188    /**
189     * Forget password confirmation
190     * - Check if email exists on DB (redirect to forgetPasswordAction is not exists)
191     * - Generate user token
192     * - Send forget password email
193     * - Display forget password confirmation page.
194     *
195     * @throws RuntimeError
196     * @throws SyntaxError
197     * @throws LoaderError
198     */
199    public function forgetPasswordConfirmAction(Request $request): Response
200    {
201        $email = $request->request->get('email');
202        $user  = $this->userRepository->findOneOrNullByEmail($email);
203
204        if (!$user) {
205            return $this->helper->render('User/Security/forget_password_confirm.html.twig');
206        }
207
208        $user->setForgetPasswordToken($this->tokenGenerator->generateToken());
209        $this->userRepository->update($user);
210
211        $this->mailer->sendForgetPassword($user);
212
213        return $this->helper->render('User/Security/forget_password_confirm.html.twig');
214    }
215
216    /**
217     * Reset user password
218     * - Token does not exists in DB: redirect to login page with flashbag error
219     * - Token exists in DB: show reset password form for related user.
220     *
221     * @param Request $request The Request
222     * @param string  $token   The forgetPasswordToken to search the user with
223     *
224     * @throws LoaderError
225     * @throws RuntimeError
226     * @throws SyntaxError
227     */
228    public function resetPasswordAction(Request $request, string $token): Response
229    {
230        $user = $this->userRepository->findOneOrNullByForgetPasswordToken($token);
231
232        // If user doesn't exists, add flashbag error & return to login page
233        if (!$user) {
234            return $this->_handleUserNotFound();
235        }
236
237        // User exist, display reset password form
238        $form = $this->helper->createForm(ResetPasswordType::class, $user);
239
240        $form->handleRequest($request);
241        if ($form->isSubmitted() && $form->isValid()) {
242            // Remove forgetPasswordToken on user since password is not reset
243            $user->setForgetPasswordToken(null);
244            $this->userRepository->update($user);
245
246            $this->helper->addFlash('success', $this->helper->trans('user.security.flashbag.success.reset_password'));
247
248            return $this->helper->redirectToRoute('login');
249        }
250
251        return $this->helper->render('User/Security/reset_password.html.twig', [
252            'form' => $form->createView(),
253        ]);
254    }
255
256    private function _handleUserLoggedAlreadyAssociated(): RedirectResponse
257    {
258        $this->helper->addFlash('warning',
259            $this->helper->trans('user.user.flashbag.error.sso_already_associated')
260        );
261
262        return $this->helper->redirectToRoute('user_profile_user_edit');
263    }
264
265    private function _handleSsoClientError(): RedirectResponse
266    {
267        $this->helper->addFlash('danger',
268            $this->helper->trans('user.security.flashbag.error.sso_client_error')
269        );
270
271        return $this->helper->redirectToRoute('login');
272    }
273
274    private function _handleUserNotFound(?string $logoutUrl = null): RedirectResponse
275    {
276        $this->helper->addFlash(
277            'danger',
278            $this->helper->trans('user.security.flashbag.error.sso_linked_auth')
279        );
280
281        if ($logoutUrl) {
282            return $this->redirect($logoutUrl);
283        }
284
285        return $this->helper->redirectToRoute('login');
286    }
287
288    private function _handleDuplicateUserWithSsoKey(User $alreadyExists): RedirectResponse
289    {
290        $this->helper->addFlash('danger',
291            $this->helper->trans('user.user.flashbag.error.sso_key_duplicate', ['email' => $alreadyExists->getEmail()])
292        );
293
294        return $this->helper->redirectToRoute('user_profile_user_edit');
295    }
296
297    private function _associateUserWithSsoKey(mixed $sso_value, User $currentUser): RedirectResponse
298    {
299        $alreadyExists = $this->userRepository->findOneOrNullBySsoKey($sso_value);
300        if ($alreadyExists) {
301            return $this->_handleDuplicateUserWithSsoKey($alreadyExists);
302        }
303
304        // associate user with sso key
305        $currentUser->setSsoKey($sso_value);
306        $this->entityManager->persist($currentUser);
307        $this->entityManager->flush();
308        $this->helper->addFlash('success',
309            $this->helper->trans('user.user.flashbag.success.sso_associated')
310        );
311
312        return $this->helper->redirectToRoute('user_profile_user_edit');
313    }
314}