diff --git a/src/Symfony/Component/Security/Core/Authentication/Authenticator/HttpBasicAuthenticator.php b/src/Symfony/Component/Security/Core/Authentication/Authenticator/HttpBasicAuthenticator.php new file mode 100644 index 0000000000..9ba11d0ddb --- /dev/null +++ b/src/Symfony/Component/Security/Core/Authentication/Authenticator/HttpBasicAuthenticator.php @@ -0,0 +1,91 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Security\Core\Authentication\Authenticator; + +use Psr\Log\LoggerInterface; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; +use Symfony\Component\Security\Core\Encoder\EncoderFactoryInterface; +use Symfony\Component\Security\Core\Exception\AuthenticationException; +use Symfony\Component\Security\Core\User\UserInterface; +use Symfony\Component\Security\Core\User\UserProviderInterface; +use Symfony\Component\Security\Guard\AuthenticatorInterface; + +/** + * @author Wouter de Jong + */ +class HttpBasicAuthenticator implements AuthenticatorInterface +{ + use UserProviderTrait, UsernamePasswordTrait { + UserProviderTrait::getUser as getUserTrait; + } + + private $realmName; + private $userProvider; + private $encoderFactory; + private $logger; + + public function __construct(string $realmName, UserProviderInterface $userProvider, EncoderFactoryInterface $encoderFactory, ?LoggerInterface $logger = null) + { + $this->realmName = $realmName; + $this->userProvider = $userProvider; + $this->encoderFactory = $encoderFactory; + $this->logger = $logger; + } + + public function start(Request $request, AuthenticationException $authException = null) + { + $response = new Response(); + $response->headers->set('WWW-Authenticate', sprintf('Basic realm="%s"', $this->realmName)); + $response->setStatusCode(401); + + return $response; + } + + public function supports(Request $request): bool + { + return $request->headers->has('PHP_AUTH_USER'); + } + + public function getUser($credentials, UserProviderInterface $userProvider): ?UserInterface + { + return $this->getUserTrait($credentials, $this->userProvider); + } + + public function getCredentials(Request $request) + { + return [ + 'username' => $request->headers->get('PHP_AUTH_USER'), + 'password' => $request->headers->get('PHP_AUTH_PW', ''), + ]; + } + + public function onAuthenticationSuccess(Request $request, TokenInterface $token, $providerKey): ?Response + { + return null; + } + + public function onAuthenticationFailure(Request $request, AuthenticationException $exception): ?Response + { + if (null !== $this->logger) { + $this->logger->info('Basic authentication failed for user.', ['username' => $request->headers->get('PHP_AUTH_USER'), 'exception' => $exception]); + } + + return $this->start($request, $exception); + } + + public function supportsRememberMe(): bool + { + return false; + } +} diff --git a/src/Symfony/Component/Security/Core/Authentication/Authenticator/UserProviderTrait.php b/src/Symfony/Component/Security/Core/Authentication/Authenticator/UserProviderTrait.php new file mode 100644 index 0000000000..b0bad3844e --- /dev/null +++ b/src/Symfony/Component/Security/Core/Authentication/Authenticator/UserProviderTrait.php @@ -0,0 +1,26 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Security\Core\Authentication\Authenticator; + +use Symfony\Component\Security\Core\User\UserInterface; +use Symfony\Component\Security\Core\User\UserProviderInterface; + +/** + * @author Wouter de Jong + */ +trait UserProviderTrait +{ + public function getUser($credentials, UserProviderInterface $userProvider): ?UserInterface + { + return $userProvider->loadUserByUsername($credentials['username']); + } +} diff --git a/src/Symfony/Component/Security/Core/Authentication/Authenticator/UsernamePasswordTrait.php b/src/Symfony/Component/Security/Core/Authentication/Authenticator/UsernamePasswordTrait.php new file mode 100644 index 0000000000..e791d52405 --- /dev/null +++ b/src/Symfony/Component/Security/Core/Authentication/Authenticator/UsernamePasswordTrait.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 Symfony\Component\Security\Core\Authentication\Authenticator; + +use Symfony\Component\Security\Core\Authentication\Token\UsernamePasswordToken; +use Symfony\Component\Security\Core\Encoder\EncoderFactoryInterface; +use Symfony\Component\Security\Core\Exception\BadCredentialsException; +use Symfony\Component\Security\Core\User\UserInterface; +use Symfony\Component\Security\Guard\Token\GuardTokenInterface; + +/** + * @author Wouter de Jong + * + * @property EncoderFactoryInterface $encoderFactory + */ +trait UsernamePasswordTrait +{ + public function checkCredentials($credentials, UserInterface $user): bool + { + if (!$this->encoderFactory instanceof EncoderFactoryInterface) { + throw new \LogicException(\get_class($this).' uses the '.__CLASS__.' trait, which requires an $encoderFactory property to be initialized with an '.EncoderFactoryInterface::class.' implementation.'); + } + + if ('' === $credentials['password']) { + throw new BadCredentialsException('The presented password cannot be empty.'); + } + + if (!$this->encoderFactory->getEncoder($user)->isPasswordValid($user->getPassword(), $credentials['password'], null)) { + throw new BadCredentialsException('The presented password is invalid.'); + } + + return true; + } + + public function createAuthenticatedToken(UserInterface $user, $providerKey): GuardTokenInterface + { + return new UsernamePasswordToken($user, null, $providerKey, $user->getRoles()); + } +} diff --git a/src/Symfony/Component/Security/Core/Authentication/Token/UsernamePasswordToken.php b/src/Symfony/Component/Security/Core/Authentication/Token/UsernamePasswordToken.php index b9eaa68246..b751bde7f1 100644 --- a/src/Symfony/Component/Security/Core/Authentication/Token/UsernamePasswordToken.php +++ b/src/Symfony/Component/Security/Core/Authentication/Token/UsernamePasswordToken.php @@ -12,13 +12,14 @@ namespace Symfony\Component\Security\Core\Authentication\Token; use Symfony\Component\Security\Core\User\UserInterface; +use Symfony\Component\Security\Guard\Token\GuardTokenInterface; /** * UsernamePasswordToken implements a username and password token. * * @author Fabien Potencier */ -class UsernamePasswordToken extends AbstractToken +class UsernamePasswordToken extends AbstractToken implements GuardTokenInterface { private $credentials; private $providerKey; diff --git a/src/Symfony/Component/Security/Core/Tests/Authentication/Authenticator/HttpBasicAuthenticatorTest.php b/src/Symfony/Component/Security/Core/Tests/Authentication/Authenticator/HttpBasicAuthenticatorTest.php new file mode 100644 index 0000000000..9e923364ea --- /dev/null +++ b/src/Symfony/Component/Security/Core/Tests/Authentication/Authenticator/HttpBasicAuthenticatorTest.php @@ -0,0 +1,114 @@ +userProvider = $this->getMockBuilder(UserProviderInterface::class)->getMock(); + $this->encoderFactory = $this->getMockBuilder(EncoderFactoryInterface::class)->getMock(); + $this->encoder = $this->getMockBuilder(PasswordEncoderInterface::class)->getMock(); + $this->encoderFactory + ->expects($this->any()) + ->method('getEncoder') + ->willReturn($this->encoder); + } + + public function testValidUsernameAndPasswordServerParameters() + { + $request = new Request([], [], [], [], [], [ + 'PHP_AUTH_USER' => 'TheUsername', + 'PHP_AUTH_PW' => 'ThePassword', + ]); + + $guard = new HttpBasicAuthenticator('test', $this->userProvider, $this->encoderFactory); + $credentials = $guard->getCredentials($request); + $this->assertEquals([ + 'username' => 'TheUsername', + 'password' => 'ThePassword', + ], $credentials); + + $mockedUser = $this->getMockBuilder(UserInterface::class)->getMock(); + $mockedUser->expects($this->any())->method('getPassword')->willReturn('ThePassword'); + + $this->userProvider + ->expects($this->any()) + ->method('loadUserByUsername') + ->with('TheUsername') + ->willReturn($mockedUser); + + $user = $guard->getUser($credentials, $this->userProvider); + $this->assertSame($mockedUser, $user); + + $this->encoder + ->expects($this->any()) + ->method('isPasswordValid') + ->with('ThePassword', 'ThePassword', null) + ->willReturn(true); + + $checkCredentials = $guard->checkCredentials($credentials, $user); + $this->assertTrue($checkCredentials); + } + + /** @dataProvider provideInvalidPasswords */ + public function testInvalidPassword($presentedPassword, $exceptionMessage) + { + $guard = new HttpBasicAuthenticator('test', $this->userProvider, $this->encoderFactory); + + $this->encoder + ->expects($this->any()) + ->method('isPasswordValid') + ->willReturn(false); + + $this->expectException(BadCredentialsException::class); + $this->expectExceptionMessage($exceptionMessage); + + $guard->checkCredentials([ + 'username' => 'TheUsername', + 'password' => $presentedPassword, + ], $this->getMockBuilder(UserInterface::class)->getMock()); + } + + public function provideInvalidPasswords() + { + return [ + ['InvalidPassword', 'The presented password is invalid.'], + ['', 'The presented password cannot be empty.'], + ]; + } + + /** @dataProvider provideMissingHttpBasicServerParameters */ + public function testHttpBasicServerParametersMissing(array $serverParameters) + { + $request = new Request([], [], [], [], [], $serverParameters); + + $guard = new HttpBasicAuthenticator('test', $this->userProvider, $this->encoderFactory); + $this->assertFalse($guard->supports($request)); + } + + public function provideMissingHttpBasicServerParameters() + { + return [ + [[]], + [['PHP_AUTH_PW' => 'ThePassword']], + ]; + } +}