diff --git a/src/Symfony/Bundle/SecurityBundle/DependencyInjection/Security/Factory/JsonLoginFactory.php b/src/Symfony/Bundle/SecurityBundle/DependencyInjection/Security/Factory/JsonLoginFactory.php index f4b9adee93..4e09a3d2f8 100644 --- a/src/Symfony/Bundle/SecurityBundle/DependencyInjection/Security/Factory/JsonLoginFactory.php +++ b/src/Symfony/Bundle/SecurityBundle/DependencyInjection/Security/Factory/JsonLoginFactory.php @@ -20,7 +20,7 @@ use Symfony\Component\DependencyInjection\Reference; * * @author Kévin Dunglas */ -class JsonLoginFactory extends AbstractFactory +class JsonLoginFactory extends AbstractFactory implements AuthenticatorFactoryInterface { public function __construct() { @@ -96,4 +96,18 @@ class JsonLoginFactory extends AbstractFactory return $listenerId; } + + public function createAuthenticator(ContainerBuilder $container, string $id, array $config, string $userProviderId) + { + $authenticatorId = 'security.authenticator.json_login.'.$id; + $options = array_intersect_key($config, $this->options); + $container + ->setDefinition($authenticatorId, new ChildDefinition('security.authenticator.json_login')) + ->replaceArgument(1, new Reference($userProviderId)) + ->replaceArgument(2, isset($config['success_handler']) ? new Reference($this->createAuthenticationSuccessHandler($container, $id, $config)) : null) + ->replaceArgument(3, isset($config['failure_handler']) ? new Reference($this->createAuthenticationFailureHandler($container, $id, $config)) : null) + ->replaceArgument(4, $options); + + return $authenticatorId; + } } diff --git a/src/Symfony/Bundle/SecurityBundle/Resources/config/security_authenticator.xml b/src/Symfony/Bundle/SecurityBundle/Resources/config/security_authenticator.xml index fc21f87e6c..80e9e8e2b9 100644 --- a/src/Symfony/Bundle/SecurityBundle/Resources/config/security_authenticator.xml +++ b/src/Symfony/Bundle/SecurityBundle/Resources/config/security_authenticator.xml @@ -98,6 +98,17 @@ options + + + user provider + authentication success handler + authentication failure handler + options + + + diff --git a/src/Symfony/Component/Security/Http/Authenticator/JsonLoginAuthenticator.php b/src/Symfony/Component/Security/Http/Authenticator/JsonLoginAuthenticator.php new file mode 100644 index 0000000000..f10e330923 --- /dev/null +++ b/src/Symfony/Component/Security/Http/Authenticator/JsonLoginAuthenticator.php @@ -0,0 +1,146 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Security\Http\Authenticator; + +use Symfony\Component\HttpFoundation\JsonResponse; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\HttpKernel\Exception\BadRequestHttpException; +use Symfony\Component\PropertyAccess\Exception\AccessException; +use Symfony\Component\PropertyAccess\PropertyAccess; +use Symfony\Component\PropertyAccess\PropertyAccessorInterface; +use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; +use Symfony\Component\Security\Core\Authentication\Token\UsernamePasswordToken; +use Symfony\Component\Security\Core\Exception\AuthenticationException; +use Symfony\Component\Security\Core\Exception\BadCredentialsException; +use Symfony\Component\Security\Core\Security; +use Symfony\Component\Security\Core\User\UserInterface; +use Symfony\Component\Security\Core\User\UserProviderInterface; +use Symfony\Component\Security\Http\Authentication\AuthenticationFailureHandlerInterface; +use Symfony\Component\Security\Http\Authentication\AuthenticationSuccessHandlerInterface; +use Symfony\Component\Security\Http\HttpUtils; + +/** + * Provides a stateless implementation of an authentication via + * a JSON document composed of a username and a password. + * + * @author Kévin Dunglas + * @author Wouter de Jong + * + * @final + * @experimental in 5.1 + */ +class JsonLoginAuthenticator implements InteractiveAuthenticatorInterface, PasswordAuthenticatedInterface +{ + private $options; + private $httpUtils; + private $userProvider; + private $propertyAccessor; + private $successHandler; + private $failureHandler; + + public function __construct(HttpUtils $httpUtils, UserProviderInterface $userProvider, ?AuthenticationSuccessHandlerInterface $successHandler = null, ?AuthenticationFailureHandlerInterface $failureHandler = null, array $options = [], ?PropertyAccessorInterface $propertyAccessor = null) + { + $this->options = array_merge(['username_path' => 'username', 'password_path' => 'password'], $options); + $this->httpUtils = $httpUtils; + $this->successHandler = $successHandler; + $this->failureHandler = $failureHandler; + $this->userProvider = $userProvider; + $this->propertyAccessor = $propertyAccessor ?: PropertyAccess::createPropertyAccessor(); + } + + public function supports(Request $request): ?bool + { + if (false === strpos($request->getRequestFormat(), 'json') && false === strpos($request->getContentType(), 'json')) { + return false; + } + + if (isset($this->options['check_path']) && !$this->httpUtils->checkRequestPath($request, $this->options['check_path'])) { + return false; + } + + return true; + } + + public function getCredentials(Request $request) + { + $data = json_decode($request->getContent()); + if (!$data instanceof \stdClass) { + throw new BadRequestHttpException('Invalid JSON.'); + } + + $credentials = []; + try { + $credentials['username'] = $this->propertyAccessor->getValue($data, $this->options['username_path']); + + if (!\is_string($credentials['username'])) { + throw new BadRequestHttpException(sprintf('The key "%s" must be a string.', $this->options['username_path'])); + } + + if (\strlen($credentials['username']) > Security::MAX_USERNAME_LENGTH) { + throw new BadCredentialsException('Invalid username.'); + } + } catch (AccessException $e) { + throw new BadRequestHttpException(sprintf('The key "%s" must be provided.', $this->options['username_path']), $e); + } + + try { + $credentials['password'] = $this->propertyAccessor->getValue($data, $this->options['password_path']); + + if (!\is_string($credentials['password'])) { + throw new BadRequestHttpException(sprintf('The key "%s" must be a string.', $this->options['password_path'])); + } + } catch (AccessException $e) { + throw new BadRequestHttpException(sprintf('The key "%s" must be provided.', $this->options['password_path']), $e); + } + + return $credentials; + } + + public function getUser($credentials): ?UserInterface + { + return $this->userProvider->loadUserByUsername($credentials['username']); + } + + public function getPassword($credentials): ?string + { + return $credentials['password']; + } + + public function createAuthenticatedToken(UserInterface $user, string $providerKey): TokenInterface + { + return new UsernamePasswordToken($user, null, $providerKey, $user->getRoles()); + } + + public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $providerKey): ?Response + { + if (null === $this->successHandler) { + return null; // let the original request continue + } + + return $this->successHandler->onAuthenticationSuccess($request, $token); + } + + public function onAuthenticationFailure(Request $request, AuthenticationException $exception): ?Response + { + if (null === $this->failureHandler) { + return new JsonResponse(['error' => $exception->getMessageKey()], JsonResponse::HTTP_UNAUTHORIZED); + } + + return $this->failureHandler->onAuthenticationFailure($request, $exception); + } + + public function isInteractive(): bool + { + return true; + } +} diff --git a/src/Symfony/Component/Security/Http/Tests/Authenticator/JsonLoginAuthenticatorTest.php b/src/Symfony/Component/Security/Http/Tests/Authenticator/JsonLoginAuthenticatorTest.php new file mode 100644 index 0000000000..84ff61781f --- /dev/null +++ b/src/Symfony/Component/Security/Http/Tests/Authenticator/JsonLoginAuthenticatorTest.php @@ -0,0 +1,127 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Security\Http\Tests\Authenticator; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpKernel\Exception\BadRequestHttpException; +use Symfony\Component\Security\Core\Exception\BadCredentialsException; +use Symfony\Component\Security\Core\Security; +use Symfony\Component\Security\Core\User\UserProviderInterface; +use Symfony\Component\Security\Http\Authenticator\JsonLoginAuthenticator; +use Symfony\Component\Security\Http\HttpUtils; + +class JsonLoginAuthenticatorTest extends TestCase +{ + private $userProvider; + /** @var JsonLoginAuthenticator */ + private $authenticator; + + protected function setUp(): void + { + $this->userProvider = $this->createMock(UserProviderInterface::class); + } + + /** + * @dataProvider provideSupportData + */ + public function testSupport($request) + { + $this->setUpAuthenticator(); + + $this->assertTrue($this->authenticator->supports($request)); + } + + public function provideSupportData() + { + yield [new Request([], [], [], [], [], ['HTTP_CONTENT_TYPE' => 'application/json'], '{"username": "dunglas", "password": "foo"}')]; + + $request = new Request([], [], [], [], [], [], '{"username": "dunglas", "password": "foo"}'); + $request->setRequestFormat('json-ld'); + yield [$request]; + } + + /** + * @dataProvider provideSupportsWithCheckPathData + */ + public function testSupportsWithCheckPath($request, $result) + { + $this->setUpAuthenticator(['check_path' => '/api/login']); + + $this->assertSame($result, $this->authenticator->supports($request)); + } + + public function provideSupportsWithCheckPathData() + { + yield [Request::create('/api/login', 'GET', [], [], [], ['HTTP_CONTENT_TYPE' => 'application/json']), true]; + yield [Request::create('/login', 'GET', [], [], [], ['HTTP_CONTENT_TYPE' => 'application/json']), false]; + } + + public function testGetCredentials() + { + $this->setUpAuthenticator(); + + $request = new Request([], [], [], [], [], ['HTTP_CONTENT_TYPE' => 'application/json'], '{"username": "dunglas", "password": "foo"}'); + $this->assertEquals(['username' => 'dunglas', 'password' => 'foo'], $this->authenticator->getCredentials($request)); + } + + public function testGetCredentialsCustomPath() + { + $this->setUpAuthenticator([ + 'username_path' => 'authentication.username', + 'password_path' => 'authentication.password', + ]); + + $request = new Request([], [], [], [], [], ['HTTP_CONTENT_TYPE' => 'application/json'], '{"authentication": {"username": "dunglas", "password": "foo"}}'); + $this->assertEquals(['username' => 'dunglas', 'password' => 'foo'], $this->authenticator->getCredentials($request)); + } + + /** + * @dataProvider provideInvalidGetCredentialsData + */ + public function testGetCredentialsInvalid($request, $errorMessage, $exceptionType = BadRequestHttpException::class) + { + $this->expectException($exceptionType); + $this->expectExceptionMessage($errorMessage); + + $this->setUpAuthenticator(); + + $this->authenticator->getCredentials($request); + } + + public function provideInvalidGetCredentialsData() + { + $request = new Request([], [], [], [], [], ['HTTP_CONTENT_TYPE' => 'application/json']); + yield [$request, 'Invalid JSON.']; + + $request = new Request([], [], [], [], [], ['HTTP_CONTENT_TYPE' => 'application/json'], '{"usr": "dunglas", "password": "foo"}'); + yield [$request, 'The key "username" must be provided']; + + $request = new Request([], [], [], [], [], ['HTTP_CONTENT_TYPE' => 'application/json'], '{"username": "dunglas", "pass": "foo"}'); + yield [$request, 'The key "password" must be provided']; + + $request = new Request([], [], [], [], [], ['HTTP_CONTENT_TYPE' => 'application/json'], '{"username": 1, "password": "foo"}'); + yield [$request, 'The key "username" must be a string.']; + + $request = new Request([], [], [], [], [], ['HTTP_CONTENT_TYPE' => 'application/json'], '{"username": "dunglas", "password": 1}'); + yield [$request, 'The key "password" must be a string.']; + + $username = str_repeat('x', Security::MAX_USERNAME_LENGTH + 1); + $request = new Request([], [], [], [], [], ['HTTP_CONTENT_TYPE' => 'application/json'], sprintf('{"username": "%s", "password": 1}', $username)); + yield [$request, 'Invalid username.', BadCredentialsException::class]; + } + + private function setUpAuthenticator(array $options = []) + { + $this->authenticator = new JsonLoginAuthenticator(new HttpUtils(), $this->userProvider, null, null, $options); + } +}