Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
80.19% |
85 / 106 |
|
66.67% |
8 / 12 |
CRAP | |
0.00% |
0 / 1 |
SecurityController | |
80.19% |
85 / 106 |
|
66.67% |
8 / 12 |
31.26 | |
0.00% |
0 / 1 |
__construct | |
100.00% |
9 / 9 |
|
100.00% |
1 / 1 |
1 | |||
loginAction | |
100.00% |
7 / 7 |
|
100.00% |
1 / 1 |
1 | |||
oauthConnectAction | |
75.00% |
6 / 8 |
|
0.00% |
0 / 1 |
4.25 | |||
oauthCheckAction | |
54.84% |
17 / 31 |
|
0.00% |
0 / 1 |
9.32 | |||
forgetPasswordAction | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
forgetPasswordConfirmAction | |
100.00% |
8 / 8 |
|
100.00% |
1 / 1 |
2 | |||
resetPasswordAction | |
100.00% |
13 / 13 |
|
100.00% |
1 / 1 |
4 | |||
_handleUserLoggedAlreadyAssociated | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
1 | |||
_handleSsoClientError | |
0.00% |
0 / 4 |
|
0.00% |
0 / 1 |
2 | |||
_handleUserNotFound | |
85.71% |
6 / 7 |
|
0.00% |
0 / 1 |
2.01 | |||
_handleDuplicateUserWithSsoKey | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
1 | |||
_associateUserWithSsoKey | |
100.00% |
10 / 10 |
|
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 | |
22 | declare(strict_types=1); |
23 | |
24 | namespace App\Domain\User\Controller; |
25 | |
26 | use App\Application\Controller\ControllerHelper; |
27 | use App\Application\Symfony\Security\UserProvider; |
28 | use App\Domain\User\Component\Mailer; |
29 | use App\Domain\User\Component\TokenGenerator; |
30 | use App\Domain\User\Form\Type\ResetPasswordType; |
31 | use App\Domain\User\Model\User; |
32 | use App\Domain\User\Repository; |
33 | use Doctrine\ORM\EntityManagerInterface; |
34 | use KnpU\OAuth2ClientBundle\Client\ClientRegistry; |
35 | use KnpU\OAuth2ClientBundle\Client\OAuth2Client; |
36 | use League\OAuth2\Client\Provider\Exception\IdentityProviderException; |
37 | use Psr\Log\LoggerInterface; |
38 | use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; |
39 | use Symfony\Component\HttpFoundation\RedirectResponse; |
40 | use Symfony\Component\HttpFoundation\Request; |
41 | use Symfony\Component\HttpFoundation\Response; |
42 | use Symfony\Component\Routing\Generator\UrlGeneratorInterface; |
43 | use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface; |
44 | use Symfony\Component\Security\Core\Authentication\Token\UsernamePasswordToken; |
45 | use Symfony\Component\Security\Http\Authentication\AuthenticationUtils; |
46 | use Twig\Error\LoaderError; |
47 | use Twig\Error\RuntimeError; |
48 | use Twig\Error\SyntaxError; |
49 | |
50 | class 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 | } |