From 17869e03521ae16d21e3e7d7998ec23fd5da1686 Mon Sep 17 00:00:00 2001 From: =?utf8?q?Rapha=C3=ABl=20Gertz?= Date: Thu, 12 Aug 2021 13:56:41 +0200 Subject: [PATCH 1/1] Add user checker Add authentication failure handler Add unactivated exception --- Checker/UserChecker.php | 48 +++++ Exception/UnactivatedException.php | 28 +++ Handler/AuthenticationFailureHandler.php | 261 +++++++++++++++++++++++ 3 files changed, 337 insertions(+) create mode 100644 Checker/UserChecker.php create mode 100644 Exception/UnactivatedException.php create mode 100644 Handler/AuthenticationFailureHandler.php diff --git a/Checker/UserChecker.php b/Checker/UserChecker.php new file mode 100644 index 0000000..1240c8a --- /dev/null +++ b/Checker/UserChecker.php @@ -0,0 +1,48 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Rapsys\UserBundle\Checker; + +use Symfony\Component\Security\Core\User\UserChecker as BaseUserChecker; +use Symfony\Component\Security\Core\Exception\DisabledException; +use Symfony\Component\Security\Core\User\UserInterface; + +use Rapsys\UserBundle\Entity\User; +use Rapsys\UserBundle\Exception\UnactivatedException; + +/** + * {@inheritdoc} + */ +class UserChecker extends BaseUserChecker { + /** + * {@inheritdoc} + */ + public function checkPostAuth(UserInterface $user): void { + //Without User instance + if (!$user instanceof User) { + return; + } + + //With not activated user + if (!$user->isActivated()) { + $ex = new UnactivatedException('Account is not activated'); + $ex->setUser($user); + throw $ex; + } + + //With disabled user + if ($user->isDisabled()) { + $ex = new DisabledException('Account is disabled'); + $ex->setUser($user); + throw $ex; + } + } +} diff --git a/Exception/UnactivatedException.php b/Exception/UnactivatedException.php new file mode 100644 index 0000000..cbf367d --- /dev/null +++ b/Exception/UnactivatedException.php @@ -0,0 +1,28 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Rapsys\UserBundle\Exception; + +use Symfony\Component\Security\Core\Exception\AccountStatusException; + +/** + * UnactivatedException is thrown when the user account is unactivated. + * + * {@inheritdoc} + */ +class UnactivatedException extends AccountStatusException { + /** + * {@inheritdoc} + */ + public function getMessageKey(): string { + return 'Account is not activated'; + } +} diff --git a/Handler/AuthenticationFailureHandler.php b/Handler/AuthenticationFailureHandler.php new file mode 100644 index 0000000..5945644 --- /dev/null +++ b/Handler/AuthenticationFailureHandler.php @@ -0,0 +1,261 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Rapsys\UserBundle\Handler; + +use Psr\Log\LoggerInterface; +use Symfony\Component\DependencyInjection\ContainerInterface; +use Symfony\Component\HttpFoundation\RedirectResponse; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\HttpKernel\HttpKernelInterface; +use Symfony\Component\Routing\Exception\ResourceNotFoundException; +use Symfony\Component\Routing\RequestContext; +use Symfony\Component\Routing\RouterInterface; +use Symfony\Component\Security\Core\Exception\AuthenticationException; +use Symfony\Component\Security\Core\Exception\BadCredentialsException; +use Symfony\Component\Security\Core\Exception\DisabledException; +use Symfony\Component\Security\Http\Authentication\DefaultAuthenticationFailureHandler; +use Symfony\Component\Security\Http\HttpUtils; +use Symfony\Component\Security\Http\ParameterBagUtils; + +use Rapsys\PackBundle\Util\SluggerUtil; +use Rapsys\UserBundle\Exception\UnactivatedException; +use Rapsys\UserBundle\RapsysUserBundle; + +/** + * {@inheritdoc} + */ +class AuthenticationFailureHandler extends DefaultAuthenticationFailureHandler { + /** + * Config array + */ + protected $config; + protected $options; + protected $defaultOptions = [ + 'failure_path' => null, + 'failure_forward' => false, + 'login_path' => '/login', + 'failure_path_parameter' => '_failure_path', + ]; + + /** + * Router instance + */ + protected $router; + + /** + * Slugger instance + */ + protected $slugger; + + /** + * {@inheritdoc} + */ + public function __construct(HttpKernelInterface $httpKernel, HttpUtils $httpUtils, array $options = [], LoggerInterface $logger, ContainerInterface $container, RouterInterface $router, SluggerUtil $slugger) { + //Set config + $this->config = $container->getParameter(self::getAlias()); + + //Set router + $this->router = $router; + + //Set slugger + $this->slugger = $slugger; + + //Call parent constructor + parent::__construct($httpKernel, $httpUtils, $options, $logger); + } + + /** + * This is called when an interactive authentication attempt fails + * + * User may retrieve mail + field + hash for each unactivated/locked accounts + * + * {@inheritdoc} + */ + public function onAuthenticationFailure(Request $request, AuthenticationException $exception): Response { + //With bad credential exception + if ($exception instanceof BadCredentialsException) { + //With parent exception + if ($parent = $exception->getPrevious()) { + //Retrieve login + //TODO: check form _token validity ??? + if ( + $request->request->has('login') && + !empty($login = $request->request->get('login')) && + !empty($mail = $login['mail']) + ) { + //Redirect on register + if ($parent instanceof UnactivatedException || $parent instanceof DisabledException) { + //Set extra parameters + $extra = ['mail' => $smail = $this->slugger->short($mail), 'field' => $sfield = $this->slugger->serialize([]), 'hash' => $this->slugger->hash($smail.$sfield)]; + + //With failure target path option + if (!empty($failurePath = $this->options['failure_path'])) { + //With path + if ($failurePath[0] == '/') { + //Create login path request instance + $req = Request::create($failurePath); + + //Get login path pathinfo + $path = $req->getPathInfo(); + + //Remove script name + $path = str_replace($request->getScriptName(), '', $path); + + //Try with login path path + try { + //Save old context + $oldContext = $this->router->getContext(); + + //Force clean context + //XXX: prevent MethodNotAllowedException on GET only routes because our context method is POST + //XXX: see vendor/symfony/routing/Matcher/Dumper/CompiledUrlMatcherTrait.php +42 + $this->router->setContext(new RequestContext()); + + //Retrieve route matching path + $route = $this->router->match($path); + + //Reset context + $this->router->setContext($oldContext); + + //Clear old context + unset($oldContext); + + //With route name + if ($name = $route['_route']) { + //Remove route and controller from route defaults + unset($route['_route'], $route['_controller'], $route['_canonical_route']); + + //Generate url + $url = $this->router->generate($name, $extra+$route); + + //Return redirect to url response + return new RedirectResponse($url, 302); + } + //No route matched + } catch (ResourceNotFoundException $e) { + //Unset default path, name and route + unset($failurePath, $name, $route); + } + //With route name + } else { + //Try with login path route + try { + //Retrieve route matching path + $url = $this->router->generate($failurePath, $extra); + + //Return redirect to url response + return new RedirectResponse($url, 302); + //Route not found, missing parameter or invalid parameter + } catch (RouteNotFoundException|MissingMandatoryParametersException|InvalidParameterException $e) { + //Unset default path and url + unset($failurePath, $url); + } + } + } + + //With index route from config + if (!empty($name = $this->config['route']['register']['name']) && is_array($context = $this->config['route']['register']['context'])) { + //Try index route + try { + //Generate url + $url = $this->router->generate($name, $extra+$context); + + //Return generated route + return new RedirectResponse($url, 302); + //No route matched + } catch (ResourceNotFoundException $e) { + //Unset name and context + unset($name, $context); + } + } + + //With login target path option + if (!empty($loginPath = $this->options['login_path'])) { + //With path + if ($loginPath[0] == '/') { + //Create login path request instance + $req = Request::create($loginPath); + + //Get login path pathinfo + $path = $req->getPathInfo(); + + //Remove script name + $path = str_replace($request->getScriptName(), '', $path); + + //Try with login path path + try { + //Save old context + $oldContext = $this->router->getContext(); + + //Force clean context + //XXX: prevent MethodNotAllowedException on GET only routes because our context method is POST + //XXX: see vendor/symfony/routing/Matcher/Dumper/CompiledUrlMatcherTrait.php +42 + $this->router->setContext(new RequestContext()); + + //Retrieve route matching path + $route = $this->router->match($path); + + //Reset context + $this->router->setContext($oldContext); + + //Clear old context + unset($oldContext); + + //With route name + if ($name = $route['_route']) { + //Remove route and controller from route defaults + unset($route['_route'], $route['_controller'], $route['_canonical_route']); + + //Generate url + $url = $this->router->generate($name, $extra+$route); + + //Return redirect to url response + return new RedirectResponse($url, 302); + } + //No route matched + } catch (ResourceNotFoundException $e) { + //Unset default path, name and route + unset($loginPath, $name, $route); + } + //With route name + } else { + //Try with login path route + try { + //Retrieve route matching path + $url = $this->router->generate($loginPath, $extra); + + //Return redirect to url response + return new RedirectResponse($url, 302); + //Route not found, missing parameter or invalid parameter + } catch (RouteNotFoundException|MissingMandatoryParametersException|InvalidParameterException $e) { + //Unset default path and url + unset($loginPath, $url); + } + } + } + } + } + } + } + + //Call parent function + return parent::onAuthenticationFailure($request, $exception); + } + + /** + * {@inheritdoc} + */ + public function getAlias(): string { + return RapsysUserBundle::getAlias(); + } +} -- 2.41.1