]> Raphaël G. Git Repositories - userbundle/blob - Handler/AuthenticationFailureHandler.php
Remove mail and hash possible leak from failure_path context
[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\DisabledException;
33 use Symfony\Component\Security\Core\Exception\UserNotFoundException;
34 use Symfony\Component\Security\Http\Authentication\DefaultAuthenticationFailureHandler;
35 use Symfony\Component\Security\Http\HttpUtils;
36 use Symfony\Contracts\Translation\TranslatorInterface;
37
38 use Rapsys\PackBundle\Util\SluggerUtil;
39 use Rapsys\UserBundle\Exception\UnactivatedException;
40 use Rapsys\UserBundle\RapsysUserBundle;
41
42 /**
43 * {@inheritdoc}
44 */
45 class AuthenticationFailureHandler extends DefaultAuthenticationFailureHandler {
46 /**
47 * Config array
48 */
49 protected array $config;
50 protected array $options;
51 protected array $defaultOptions = [
52 'failure_path' => null,
53 'failure_forward' => false,
54 'login_path' => '/login',
55 'failure_path_parameter' => '_failure_path',
56 ];
57
58 /**
59 * Doctrine instance
60 */
61 protected ManagerRegistry $doctrine;
62
63 /**
64 * MailerInterface
65 */
66 protected MailerInterface $mailer;
67
68 /**
69 * Router instance
70 */
71 protected RouterInterface $router;
72
73 /**
74 * Slugger instance
75 */
76 protected SluggerUtil $slugger;
77
78 /**
79 * RequestStack instance
80 */
81 protected RequestStack $stack;
82
83 /**
84 * Translator instance
85 */
86 protected TranslatorInterface $translator;
87
88 /**
89 * @xxx Second argument will be replaced by security.firewalls.main.logout.target
90 * @see vendor/symfony/security-bundle/DependencyInjection/SecurityExtension.php +360
91 *
92 * @param HttpKernelInterface $httpKernel The http kernel
93 * @param HttpUtils $httpUtils The http utils
94 * @param array $options The options
95 * @param LoggerInterface $logger The logger instance
96 * @param ContainerInterface $container The container instance
97 * @param ManagerRegistry $doctrine The doctrine instance
98 * @param MailerInterface $mailer The mailer instance
99 * @param RouterInterface $router The router instance
100 * @param SluggerUtil $slugger The slugger instance
101 * @param RequestStack $stack The stack instance
102 * @param TranslatorInterface $translator The translator instance
103 *
104 * {@inheritdoc}
105 */
106 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) {
107 //Set config
108 $this->config = $container->getParameter(self::getAlias());
109
110 //Set doctrine
111 $this->doctrine = $doctrine;
112
113 //Set mailer
114 $this->mailer = $mailer;
115
116 //Set router
117 $this->router = $router;
118
119 //Set slugger
120 $this->slugger = $slugger;
121
122 //Set stack
123 $this->stack = $stack;
124
125 //Set translator
126 $this->translator = $translator;
127
128 //Call parent constructor
129 parent::__construct($httpKernel, $httpUtils, $options, $logger);
130 }
131
132 /**
133 * Adds a flash message to the current session for type.
134 *
135 * @throws \LogicException
136 */
137 protected function addFlash(string $type, mixed $message): void {
138 try {
139 $session = $this->stack->getSession();
140 } catch (SessionNotFoundException $e) {
141 throw new \LogicException('You cannot use the addFlash method if sessions are disabled. Enable them in "config/packages/framework.yaml".', 0, $e);
142 }
143
144 if (!$session instanceof FlashBagAwareSessionInterface) {
145 throw new \LogicException(sprintf('You cannot use the addFlash method because class "%s" doesn\'t implement "%s".', get_debug_type($session), FlashBagAwareSessionInterface::class));
146 }
147
148 $session->getFlashBag()->add($type, $message);
149 }
150
151 /**
152 * This is called when an interactive authentication attempt fails
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 /** Disabled to prevent user mail + hash retrieval for each unactivated/locked accounts
162
163 //Get user identifier
164 $mail = $parent->getUserIdentifier();
165
166 //Set extra parameters
167 $extra = ['mail' => $smail = $this->slugger->short($mail), 'hash' => $this->slugger->hash($smail)];*/
168
169 //With failure target path option
170 if (!empty($failurePath = $this->options['failure_path'])) {
171 //With path
172 if ($failurePath[0] == '/') {
173 //Create login path request instance
174 $req = Request::create($failurePath);
175
176 //Get login path pathinfo
177 $path = $req->getPathInfo();
178
179 //Remove script name
180 $path = str_replace($request->getScriptName(), '', $path);
181
182 //Try with login path path
183 try {
184 //Save old context
185 $oldContext = $this->router->getContext();
186
187 //Force clean context
188 //XXX: prevent MethodNotAllowedException on GET only routes because our context method is POST
189 //XXX: see vendor/symfony/routing/Matcher/Dumper/CompiledUrlMatcherTrait.php +42
190 $this->router->setContext(new RequestContext());
191
192 //Retrieve route matching path
193 $route = $this->router->match($path);
194
195 //Reset context
196 $this->router->setContext($oldContext);
197
198 //Clear old context
199 unset($oldContext);
200
201 //With route name
202 if ($name = $route['_route']) {
203 //Remove route and controller from route defaults
204 unset($route['_route'], $route['_controller'], $route['_canonical_route']);
205
206 //Generate url
207 $url = $this->router->generate($name, /*$extra+*/$route);
208
209 //Return redirect to url response
210 return new RedirectResponse($url, 302);
211 }
212 //No route matched
213 } catch (ResourceNotFoundException $e) {
214 //Unset default path, name and route
215 unset($failurePath, $name, $route);
216 }
217 //With route name
218 } else {
219 //Try with login path route
220 try {
221 //Retrieve route matching path
222 $url = $this->router->generate($failurePath/*, $extra*/);
223
224 //Return redirect to url response
225 return new RedirectResponse($url, 302);
226 //Route not found, missing parameter or invalid parameter
227 } catch (RouteNotFoundException|MissingMandatoryParametersException|InvalidParameterException $e) {
228 //Unset default path and url
229 unset($failurePath, $url);
230 }
231 }
232 }
233 //With not enabled user
234 } elseif ($parent instanceof DisabledException) {
235 //Add error message account is not enabled
236 $this->addFlash('error', $this->translator->trans('Your account is not enabled'));
237
238 //Redirect on the same route with sent=1 to cleanup form
239 return new RedirectResponse($this->router->generate($request->get('_route'), $request->get('_route_params')), 302);
240 //With not activated user
241 } elseif ($parent instanceof UnactivatedException) {
242 //Set user
243 $user = $parent->getUser();
244
245 //Set context
246 $context = [
247 'recipient_mail' => $user->getMail(),
248 'recipient_name' => $user->getRecipientName()
249 ] + array_replace_recursive(
250 $this->config['context'],
251 $this->config['register']['view']['context'],
252 $this->config['register']['mail']['context']
253 );
254
255 //Generate each route route
256 foreach($this->config['register']['route'] as $route => $tag) {
257 //Only process defined routes
258 if (!empty($this->config['route'][$route])) {
259 //Process for confirm url
260 if ($route == 'confirm') {
261 //Set the url in context
262 $context[$tag] = $this->router->generate(
263 $this->config['route'][$route]['name'],
264 //Prepend confirm context with tag
265 [
266 'mail' => $smail = $this->slugger->short($context['recipient_mail']),
267 'hash' => $this->slugger->hash($smail)
268 ]+$this->config['route'][$route]['context'],
269 UrlGeneratorInterface::ABSOLUTE_URL
270 );
271 }
272 }
273 }
274
275 //Iterate on keys to translate
276 foreach($this->config['translate'] as $translate) {
277 //Extract keys
278 $keys = explode('.', $translate);
279
280 //Set current
281 $current =& $context;
282
283 //Iterate on each subkey
284 do {
285 //Skip unset translation keys
286 if (!isset($current[current($keys)])) {
287 continue(2);
288 }
289
290 //Set current to subkey
291 $current =& $current[current($keys)];
292 } while(next($keys));
293
294 //Set translation
295 $current = $this->translator->trans($current);
296
297 //Remove reference
298 unset($current);
299 }
300
301 //Translate subject
302 $context['subject'] = $subject = ucfirst(
303 $this->translator->trans(
304 $this->config['register']['mail']['subject'],
305 $this->slugger->flatten($context, null, '.', '%', '%')
306 )
307 );
308
309 //Create message
310 $message = (new TemplatedEmail())
311 //Set sender
312 ->from(new Address($this->config['contact']['address'], $this->config['contact']['name']))
313 //Set recipient
314 //XXX: remove the debug set in vendor/symfony/mime/Address.php +46
315 ->to(new Address($context['recipient_mail'], $context['recipient_name']))
316 //Set subject
317 ->subject($context['subject'])
318
319 //Set path to twig templates
320 ->htmlTemplate($this->config['register']['mail']['html'])
321 ->textTemplate($this->config['register']['mail']['text'])
322
323 //Set context
324 ->context($context);
325
326 //Try sending message
327 //XXX: mail delivery may silently fail
328 try {
329 //Send message
330 $this->mailer->send($message);
331 //Catch obvious transport exception
332 } catch(TransportExceptionInterface $e) {
333 //Add error message mail unreachable
334 $this->addFlash('error', $this->translator->trans('Unable to reach account'));
335 }
336
337 //Add notice
338 $this->addFlash('notice', $this->translator->trans('Your verification mail has been sent, to activate your account you must follow the confirmation link inside'));
339
340 //Add junk warning
341 $this->addFlash('warning', $this->translator->trans('If you did not receive a verification mail, check your Spam or Junk mail folders'));
342
343 //Redirect on the same route with sent=1 to cleanup form
344 return new RedirectResponse($this->router->generate($request->get('_route'), $request->get('_route_params')), 302);
345 }
346 }
347
348 //Call parent function
349 return parent::onAuthenticationFailure($request, $exception);
350 }
351
352 /**
353 * {@inheritdoc}
354 */
355 public function getAlias(): string {
356 return RapsysUserBundle::getAlias();
357 }
358 }