From: Raphaël Gertz <git@rapsys.eu>
Date: Thu, 12 Aug 2021 11:56:41 +0000 (+0200)
Subject: Add user checker
X-Git-Tag: 0.2.0~47
X-Git-Url: https://git.rapsys.eu/userbundle/commitdiff_plain/17869e03521ae16d21e3e7d7998ec23fd5da1686

Add user checker
Add authentication failure handler
Add unactivated exception
---

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 @@
+<?php declare(strict_types=1);
+
+/*
+ * This file is part of the Rapsys UserBundle package.
+ *
+ * (c) Raphaël Gertz <symfony@rapsys.eu>
+ *
+ * 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 @@
+<?php declare(strict_types=1);
+
+/*
+ * This file is part of the Rapsys UserBundle package.
+ *
+ * (c) Raphaël Gertz <symfony@rapsys.eu>
+ *
+ * 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 @@
+<?php declare(strict_types=1);
+
+/*
+ * This file is part of the Rapsys UserBundle package.
+ *
+ * (c) Raphaël Gertz <symfony@rapsys.eu>
+ *
+ * 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();
+	}
+}