]> Raphaël G. Git Repositories - userbundle/blob - Handler/AuthenticationFailureHandler.php
Cleanup
[userbundle] / Handler / AuthenticationFailureHandler.php
1 <?php declare(strict_types=1);
2
3 /*
4 * This file is part of the Rapsys UserBundle package.
5 *
6 * (c) Raphaël Gertz <symfony@rapsys.eu>
7 *
8 * For the full copyright and license information, please view the LICENSE
9 * file that was distributed with this source code.
10 */
11
12 namespace Rapsys\UserBundle\Handler;
13
14 use Doctrine\Persistence\ManagerRegistry;
15 use Psr\Log\LoggerInterface;
16 use Symfony\Bridge\Twig\Mime\TemplatedEmail;
17 use Symfony\Component\DependencyInjection\ContainerInterface;
18 use Symfony\Component\HttpFoundation\RedirectResponse;
19 use Symfony\Component\HttpFoundation\Request;
20 use Symfony\Component\HttpFoundation\RequestStack;
21 use Symfony\Component\HttpFoundation\Response;
22 use Symfony\Component\HttpFoundation\Session\FlashBagAwareSessionInterface;
23 use Symfony\Component\HttpKernel\HttpKernelInterface;
24 use Symfony\Component\Mailer\MailerInterface;
25 use Symfony\Component\Mime\Address;
26 use Symfony\Component\Routing\Exception\ResourceNotFoundException;
27 use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
28 use Symfony\Component\Routing\RequestContext;
29 use Symfony\Component\Routing\RouterInterface;
30 use Symfony\Component\Security\Core\Exception\AuthenticationException;
31 use Symfony\Component\Security\Core\Exception\BadCredentialsException;
32 use Symfony\Component\Security\Core\Exception\UserNotFoundException;
33 use Symfony\Component\Security\Http\Authentication\DefaultAuthenticationFailureHandler;
34 use Symfony\Component\Security\Http\HttpUtils;
35 use Symfony\Contracts\Translation\TranslatorInterface;
36
37 use Rapsys\PackBundle\Util\SluggerUtil;
38 use Rapsys\UserBundle\RapsysUserBundle;
39
40 /**
41 * {@inheritdoc}
42 */
43 class AuthenticationFailureHandler extends DefaultAuthenticationFailureHandler {
44 /**
45 * Config array
46 */
47 protected array $config;
48 protected array $options;
49 protected array $defaultOptions = [
50 'failure_path' => null,
51 'failure_forward' => false,
52 'login_path' => '/login',
53 'failure_path_parameter' => '_failure_path',
54 ];
55
56 /**
57 * Doctrine instance
58 */
59 protected ManagerRegistry $doctrine;
60
61 /**
62 * MailerInterface
63 */
64 protected MailerInterface $mailer;
65
66 /**
67 * Router instance
68 */
69 protected RouterInterface $router;
70
71 /**
72 * Slugger instance
73 */
74 protected SluggerUtil $slugger;
75
76 /**
77 * RequestStack instance
78 */
79 protected RequestStack $stack;
80
81 /**
82 * Translator instance
83 */
84 protected TranslatorInterface $translator;
85
86 /**
87 * @xxx Second argument will be replaced by security.firewalls.main.logout.target
88 * @see vendor/symfony/security-bundle/DependencyInjection/SecurityExtension.php +360
89 *
90 * @param HttpKernelInterface $httpKernel The http kernel
91 * @param HttpUtils $httpUtils The http utils
92 * @param array $options The options
93 * @param LoggerInterface $logger The logger instance
94 * @param ContainerInterface $container The container instance
95 * @param ManagerRegistry $doctrine The doctrine instance
96 * @param MailerInterface $mailer The mailer instance
97 * @param RouterInterface $router The router instance
98 * @param SluggerUtil $slugger The slugger instance
99 * @param RequestStack $stack The stack instance
100 * @param TranslatorInterface $translator The translator instance
101 *
102 * {@inheritdoc}
103 */
104 public function __construct(HttpKernelInterface $httpKernel, HttpUtils $httpUtils, array $options, LoggerInterface $logger, ContainerInterface $container, ManagerRegistry $doctrine, MailerInterface $mailer, RouterInterface $router, SluggerUtil $slugger, RequestStack $stack, TranslatorInterface $translator) {
105 //Set config
106 $this->config = $container->getParameter(self::getAlias());
107
108 //Set doctrine
109 $this->doctrine = $doctrine;
110
111 //Set mailer
112 $this->mailer = $mailer;
113
114 //Set router
115 $this->router = $router;
116
117 //Set slugger
118 $this->slugger = $slugger;
119
120 //Set stack
121 $this->stack = $stack;
122
123 //Set translator
124 $this->translator = $translator;
125
126 //Call parent constructor
127 parent::__construct($httpKernel, $httpUtils, $options, $logger);
128 }
129
130 /**
131 * Adds a flash message to the current session for type.
132 *
133 * @throws \LogicException
134 */
135 protected function addFlash(string $type, mixed $message): void {
136 try {
137 $session = $this->stack->getSession();
138 } catch (SessionNotFoundException $e) {
139 throw new \LogicException('You cannot use the addFlash method if sessions are disabled. Enable them in "config/packages/framework.yaml".', 0, $e);
140 }
141
142 if (!$session instanceof FlashBagAwareSessionInterface) {
143 throw new \LogicException(sprintf('You cannot use the addFlash method because class "%s" doesn\'t implement "%s".', get_debug_type($session), FlashBagAwareSessionInterface::class));
144 }
145
146 $session->getFlashBag()->add($type, $message);
147 }
148
149 /**
150 * This is called when an interactive authentication attempt fails
151 *
152 * User may retrieve mail + field + hash for each unactivated/locked accounts
153 *
154 * {@inheritdoc}
155 */
156 public function onAuthenticationFailure(Request $request, AuthenticationException $exception): Response {
157 //With bad credential exception
158 if ($exception instanceof BadCredentialsException) {
159 //With parent exception
160 if (($parent = $exception->getPrevious()) instanceof UserNotFoundException) {
161 //Retrieve login
162 //TODO: check form _token validity ???
163 if (
164 !empty($login = $request->get('login')) &&
165 !empty($mail = $login['mail'])
166 ) {
167 //Set extra parameters
168 $extra = ['mail' => $smail = $this->slugger->short($mail), 'field' => $sfield = $this->slugger->serialize([]), 'hash' => $this->slugger->hash($smail.$sfield)];
169
170 //With failure target path option
171 if (!empty($failurePath = $this->options['failure_path'])) {
172 //With path
173 if ($failurePath[0] == '/') {
174 //Create login path request instance
175 $req = Request::create($failurePath);
176
177 //Get login path pathinfo
178 $path = $req->getPathInfo();
179
180 //Remove script name
181 $path = str_replace($request->getScriptName(), '', $path);
182
183 //Try with login path path
184 try {
185 //Save old context
186 $oldContext = $this->router->getContext();
187
188 //Force clean context
189 //XXX: prevent MethodNotAllowedException on GET only routes because our context method is POST
190 //XXX: see vendor/symfony/routing/Matcher/Dumper/CompiledUrlMatcherTrait.php +42
191 $this->router->setContext(new RequestContext());
192
193 //Retrieve route matching path
194 $route = $this->router->match($path);
195
196 //Reset context
197 $this->router->setContext($oldContext);
198
199 //Clear old context
200 unset($oldContext);
201
202 //With route name
203 if ($name = $route['_route']) {
204 //Remove route and controller from route defaults
205 unset($route['_route'], $route['_controller'], $route['_canonical_route']);
206
207 //Generate url
208 $url = $this->router->generate($name, $extra+$route);
209
210 //Return redirect to url response
211 return new RedirectResponse($url, 302);
212 }
213 //No route matched
214 } catch (ResourceNotFoundException $e) {
215 //Unset default path, name and route
216 unset($failurePath, $name, $route);
217 }
218 //With route name
219 } else {
220 //Try with login path route
221 try {
222 //Retrieve route matching path
223 $url = $this->router->generate($failurePath, $extra);
224
225 //Return redirect to url response
226 return new RedirectResponse($url, 302);
227 //Route not found, missing parameter or invalid parameter
228 } catch (RouteNotFoundException|MissingMandatoryParametersException|InvalidParameterException $e) {
229 //Unset default path and url
230 unset($failurePath, $url);
231 }
232 }
233 }
234
235 //Without user
236 if (($user = $this->doctrine->getRepository($this->config['class']['user'])->findOneByMail($mail)) === null) {
237 //With index route from config
238 if (!empty($name = $this->config['route']['register']['name']) && is_array($context = $this->config['route']['register']['context'])) {
239 //Try index route
240 try {
241 //Generate url
242 $url = $this->router->generate($name, $extra+$context);
243
244 //Return generated route
245 return new RedirectResponse($url, 302);
246 //No route matched
247 } catch (ResourceNotFoundException $e) {
248 //Unset name and context
249 unset($name, $context);
250 }
251 }
252
253 //With login target path option
254 if (!empty($loginPath = $this->options['login_path'])) {
255 //With path
256 if ($loginPath[0] == '/') {
257 //Create login path request instance
258 $req = Request::create($loginPath);
259
260 //Get login path pathinfo
261 $path = $req->getPathInfo();
262
263 //Remove script name
264 $path = str_replace($request->getScriptName(), '', $path);
265
266 //Try with login path path
267 try {
268 //Save old context
269 $oldContext = $this->router->getContext();
270
271 //Force clean context
272 //XXX: prevent MethodNotAllowedException on GET only routes because our context method is POST
273 //XXX: see vendor/symfony/routing/Matcher/Dumper/CompiledUrlMatcherTrait.php +42
274 $this->router->setContext(new RequestContext());
275
276 //Retrieve route matching path
277 $route = $this->router->match($path);
278
279 //Reset context
280 $this->router->setContext($oldContext);
281
282 //Clear old context
283 unset($oldContext);
284
285 //With route name
286 if ($name = $route['_route']) {
287 //Remove route and controller from route defaults
288 unset($route['_route'], $route['_controller'], $route['_canonical_route']);
289
290 //Generate url
291 $url = $this->router->generate($name, $extra+$route);
292
293 //Return redirect to url response
294 return new RedirectResponse($url, 302);
295 }
296 //No route matched
297 } catch (ResourceNotFoundException $e) {
298 //Unset default path, name and route
299 unset($loginPath, $name, $route);
300 }
301 //With route name
302 } else {
303 //Try with login path route
304 try {
305 //Retrieve route matching path
306 $url = $this->router->generate($loginPath, $extra);
307
308 //Return redirect to url response
309 return new RedirectResponse($url, 302);
310 //Route not found, missing parameter or invalid parameter
311 } catch (RouteNotFoundException|MissingMandatoryParametersException|InvalidParameterException $e) {
312 //Unset default path and url
313 unset($loginPath, $url);
314 }
315 }
316 }
317 //With not enabled user
318 } elseif (empty($user->isEnabled())) {
319 //Add error message account is not enabled
320 $this->addFlash('error', $this->translator->trans('Your account is not enable'));
321
322 //Redirect on the same route with sent=1 to cleanup form
323 return new RedirectResponse($this->router->generate($request->get('_route'), $request->get('_route_params')), 302);
324 //With unactivated user
325 } elseif (empty($user->isActivated())) {
326 //Set context
327 $context = [
328 'recipient_mail' => $user->getMail(),
329 'recipient_name' => $user->getRecipientName()
330 ] + array_replace_recursive(
331 $this->config['context'],
332 $this->config['register']['view']['context'],
333 $this->config['register']['mail']['context']
334 );
335
336 //Generate each route route
337 foreach($this->config['register']['route'] as $route => $tag) {
338 //Only process defined routes
339 if (!empty($this->config['route'][$route])) {
340 //Process for confirm url
341 if ($route == 'confirm') {
342 //Set the url in context
343 $context[$tag] = $this->router->generate(
344 $this->config['route'][$route]['name'],
345 //Prepend subscribe context with tag
346 [
347 'mail' => $smail = $this->slugger->short($context['recipient_mail']),
348 'hash' => $this->slugger->hash($smail)
349 ]+$this->config['route'][$route]['context'],
350 UrlGeneratorInterface::ABSOLUTE_URL
351 );
352 }
353 }
354 }
355
356 //Iterate on keys to translate
357 foreach($this->config['translate'] as $translate) {
358 //Extract keys
359 $keys = explode('.', $translate);
360
361 //Set current
362 $current =& $context;
363
364 //Iterate on each subkey
365 do {
366 //Skip unset translation keys
367 if (!isset($current[current($keys)])) {
368 continue(2);
369 }
370
371 //Set current to subkey
372 $current =& $current[current($keys)];
373 } while(next($keys));
374
375 //Set translation
376 $current = $this->translator->trans($current);
377
378 //Remove reference
379 unset($current);
380 }
381
382 //Translate subject
383 $context['subject'] = $subject = ucfirst(
384 $this->translator->trans(
385 $this->config['register']['mail']['subject'],
386 $this->slugger->flatten($context, null, '.', '%', '%')
387 )
388 );
389
390 //Create message
391 $message = (new TemplatedEmail())
392 //Set sender
393 ->from(new Address($this->config['contact']['address'], $this->config['contact']['name']))
394 //Set recipient
395 //XXX: remove the debug set in vendor/symfony/mime/Address.php +46
396 ->to(new Address($context['recipient_mail'], $context['recipient_name']))
397 //Set subject
398 ->subject($context['subject'])
399
400 //Set path to twig templates
401 ->htmlTemplate($this->config['register']['mail']['html'])
402 ->textTemplate($this->config['register']['mail']['text'])
403
404 //Set context
405 ->context($context);
406
407 //Try sending message
408 //XXX: mail delivery may silently fail
409 try {
410 //Send message
411 $this->mailer->send($message);
412 //Catch obvious transport exception
413 } catch(TransportExceptionInterface $e) {
414 //Add error message mail unreachable
415 $this->addFlash('error', $this->translator->trans('Unable to reach account'));
416 }
417
418 //Add notice
419 $this->addFlash('notice', $this->translator->trans('Your verification mail has been sent, to activate your account you must follow the confirmation link inside'));
420
421 //Add junk warning
422 $this->addFlash('warning', $this->translator->trans('If you did not receive a verification mail, check your Spam or Junk mail folders'));
423
424 //Redirect on the same route with sent=1 to cleanup form
425 return new RedirectResponse($this->router->generate($request->get('_route'), $request->get('_route_params')), 302);
426 }
427 }
428 }
429 }
430
431 //Call parent function
432 return parent::onAuthenticationFailure($request, $exception);
433 }
434
435 /**
436 * {@inheritdoc}
437 */
438 public function getAlias(): string {
439 return RapsysUserBundle::getAlias();
440 }
441 }