I'm trying to upgrade a Symfony 4.3.6 application to v4.4.
The app uses oauth2-client-bundle to authenticate users with Keycloak. Consequently, users are never persisted in database.
Here is the security config:
security:
providers:
oauth:
id: knpu.oauth2.user_provider
firewalls:
main:
logout:
path: app.logout
anonymous: true
guard:
authenticators:
- App\Security\KeycloakAuthenticator
access_control:
- { path: ^/connect, roles: IS_AUTHENTICATED_ANONYMOUSLY }
-
path: ^/
allow_if: "'127.0.0.1' == request.getClientIp() or is_granted('IS_AUTHENTICATED_FULLY')"
And here is the KeycloakAuthenticator service:
<?php
namespace App\Security;
use App\Entity\User; // extends KnpU\OAuth2ClientBundle\Security\User\OAuthUser
use KnpU\OAuth2ClientBundle\Client\ClientRegistry;
use KnpU\OAuth2ClientBundle\Client\Provider\KeycloakClient;
use KnpU\OAuth2ClientBundle\Security\Authenticator\SocialAuthenticator;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\RouterInterface;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Exception\AuthenticationException;
use Symfony\Component\Security\Core\User\UserProviderInterface;
class KeycloakAuthenticator extends SocialAuthenticator
{
private $clientRegistry;
private $router;
public function __construct(ClientRegistry $clientRegistry, RouterInterface $router)
{
$this->clientRegistry = $clientRegistry;
$this->router = $router;
}
public function supports(Request $request)
{
return 'connect_keycloak_check' === $request->attributes->get('_route');
}
public function getCredentials(Request $request)
{
return $this->fetchAccessToken($this->getKeycloakClient());
}
public function getUser($credentials, UserProviderInterface $userProvider)
{
$keycloakUser = $this->getKeycloakClient()->fetchUserFromToken($credentials);
$user = new User($keycloakUser->getName(), ['IS_AUTHENTICATED_FULLY', 'ROLE_USER']);
$user->setEmail($keycloakUser->getEmail())
->setName($keycloakUser->getName())
->setLocale($keycloakUser->toArray()['locale'] ?? 'fr')
;
return $user;
}
public function onAuthenticationSuccess(Request $request, TokenInterface $token, $providerKey)
{
// On success, let the request continue
return null;
}
public function onAuthenticationFailure(Request $request, AuthenticationException $exception)
{
$message = strtr($exception->getMessageKey(), $exception->getMessageData());
return new Response($message, Response::HTTP_FORBIDDEN);
}
public function start(Request $request, AuthenticationException $authException = null)
{
return new RedirectResponse($this->router->generate('app.keycloak_start'), Response::HTTP_TEMPORARY_REDIRECT);
}
private function getKeycloakClient(): KeycloakClient
{
return $this->clientRegistry->getClient('keycloak');
}
}
Functional tests are developped according to the official documentation:
public function testHomePage()
{
$client = static::createClient();
$session = $client->getContainer()->get('session');
$firewallName = 'main';
$token = new PostAuthenticationGuardToken(new User('foo', ['ROLE_USER']), $firewallName, $roles);
$session->set('_security_'.$firewallName, serialize($token));
$session->save();
$cookie = new Cookie($session->getName(), $session->getId());
$client->getCookieJar()->set($cookie);
$client->request('GET', '/');
$this->assertTrue($client->getResponse()->isSuccessful());
}
Until now, tests pass, everything is fine.
But since I upgraded Symfony to 4.4, the method ControllerTrait::getUser() returns null and I'm facing the following error when running functional tests:
Error : Call to a member function getUsername() on null
I except to get the current user as usual when calling $this->getUser().
I tried to manually set the token in the security.token_storage but the error still persists.
I also tried to force authentication by removing the part "'127.0.0.1' == request.getClientIp() in the allow_if section of "security.yaml", and the response is now a 307 Temporary Redirect to the Keycloak service.
Did the behavior of $this->getUser() change between this 2 versions ?
Thank you for your help
Related
I migrating a symfony 5.1 api to symfony 6 with api-platform.
My app has it's own user and password logic different to common user database so I had to create my UserRepository and UserProvider.
I have created a controller with Login functionality that checks credentials and returns a token.
On symfony 5, I had implemented an AbstractGuardAuthenticator to verify the token and load the user.
On symfony 6 I use the new system implementing an AbstractAuthenticator (Personal opinion: less clear than guard).
security:
enable_authenticator_manager: true
# [...]
providers:
# used to reload user from session & other features (e.g. switch_user)
api_user_provider:
id: App\Security\UserProvider
firewalls:
# [...]
api:
pattern: ^/api/
stateless: true
provider: api_user_provider
custom_authenticators:
- App\Security\TokenAuthenticator
<?php
namespace App\Security;
// usings
class TokenAuthenticator extends AbstractAuthenticator
{
// [...]
public function supports(Request $request): ?bool
{
if ( $request->headers->has('Authorization') ) {
return true;
} else {
throw new AuthenticationException('Authorization header is missing');
}
}
public function authenticate(Request $request): Passport
{
$token = $this->getToken($request);
return new SelfValidatingPassport(
new UserBadge($token, function ($token): UserInterface {
return $this->getUserFromToken($token);
}), []
);
}
public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $firewallName): ?Response
{
return New JsonResponse(["result"=> "ok"]);
}
public function onAuthenticationFailure(Request $request, AuthenticationException $exception): ?Response
{
return new JsonResponse(["result" => "error"], Response::HTTP_UNAUTHORIZED);
}
}
When I make a simple call to an Endpoint that requires the user to be logged, e.g:
GET http://localhost:8000/api/categories
Accept: application/json
Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9.eyJpYXQi[...]
I expect a list of categories, but I receive a json from onAuthenticationSuccess:
{"result":"ok"}
So I think I'm missunderstanding the security system. Please help me.
What I'm doing wrong?
It's simple, you almost had it.
onAuthenticationSuccess must return null to let your request continue.
Your are interrupting your original request when returning a json: {"result": "ok"}.
public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $firewallName): ?Response
{
return null;
}
By default, it shows error "Invalid credentials.". I've already seen answers like "Go to translations, create security.en.yaml and type this:
# translations/security.en.yaml
'Invalid credentials.': 'Invalid email or password'
But how to create different errors? For example, "Invalid password" when password is wrong and "Email does not exists" when email is wrong. How to do it?
you must create custom authorization and exceptions.
example:
config/packages/security.yaml
security:
enable_authenticator_manager: true
...
providers:
...
firewalls:
...
client:
pattern: ^/
custom_authenticators:
- App\Security\ClientLoginFormAuthenticator
logout:
path: store.account.logout
target: store.home
access_control:
...
src/Security/ClientLoginFormAuthenticator.php
<?php
declare(strict_types=1);
namespace App\Security;
use App\Repository\UserRepository;
use App\Security\Exception\CustomException;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Exception\BadCredentialsException;
use Symfony\Component\Security\Http\Authenticator\AbstractLoginFormAuthenticator;
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\CsrfTokenBadge;
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge;
use Symfony\Component\Security\Http\Authenticator\Passport\Credentials\PasswordCredentials;
use Symfony\Component\Security\Http\Authenticator\Passport\Passport;
use function in_array;
class ClientLoginFormAuthenticator extends AbstractLoginFormAuthenticator
{
private const LOGIN_ROUTE = 'store.account.login';
public function __construct(private UserRepository $userRepository, private UrlGeneratorInterface $urlGenerator)
{}
public function supports(Request $request): bool
{
return self::LOGIN_ROUTE === $request->attributes->get('_route')
&& $request->isMethod('POST');
}
public function authenticate(Request $request): Passport
{
$password = $request->request->get('_password');
$username = $request->request->get('_username');
$csrfToken = $request->request->get('_csrf_token');
return new Passport(
new UserBadge($username, function ($userIdentifier) {
$user = $this->userRepository->findOneBy(['email' => $userIdentifier]);
if ($user && in_array('ROLE_CLIENT', $user->getRoles(), true)) {
return $user;
}
//next condition
if($user && $user->getEmail() === 'superadmin#example.com') {
throw new CustomException();
}
throw new BadCredentialsException(); //default exception
}),
new PasswordCredentials($password),
[new CsrfTokenBadge('authenticate', $csrfToken)]
);
}
public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $firewallName): ?Response
{
return null;
}
protected function getLoginUrl(Request $request): string
{
return $this->urlGenerator->generate(self::LOGIN_ROUTE);
}
}
src/Security/Exception/CustomException.php
<?php
namespace App\Security\Exception;
use Symfony\Component\Security\Core\Exception\AuthenticationException;
class CustomException extends AuthenticationException
{
/**
* {#inheritdoc}
*/
public function getMessageKey()
{
return 'My Message.';
}
}
It works for me! :) Good luck!
According to this "Old" article Is there any sort of "pre login" event or similar? I can extend UsernamePasswordFormAuthenticationListener to add some code pre-login.
In symfony3 seems that there's no security.authentication.listener.form.class parameter, so how can I reach the same result without changing symfony security_listener.xml config file?
To perform some pre/post-login checks (that means before/after the user authentication) one of the most simple and flexible solutions, offered by the Symfony framework, is to learn How to Create and Enable Custom User Checkers.
If you need more control and flexibility the best option is to learn How to Create a Custom Authentication System with Guard.
Take a look at the simple implementation example below:
security.yml
firewall_name:
guard:
authenticators:
- service_name_for_guard_authenticator
entry_point: service_name_for_guard_authenticator <-- important to add a default one (as described in the docs) if you have many custom authenticators (facebook...)
service.xml
<service id="service_name_for_guard_authenticator"
class="AppBundle\ExampleFolderName\YourGuardAuthClassName">
<argument type="service" id="router"/>
<argument type="service" id="security.password_encoder"/>
</service>
YourGuardAuthClassName.php
use Symfony\Component\Security\Guard\AbstractGuardAuthenticator;
use use Symfony\Bundle\FrameworkBundle\Routing\Router;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\Security\Core\Exception\AuthenticationException;
use Symfony\Component\Security\Core\Exception\BadCredentialsException;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\User\UserProviderInterface;
use Symfony\Component\Security\Core\Encoder\UserPasswordEncoder;
class YourGuardAuthClassName extends AbstractGuardAuthenticator
{
private $router;
private $passwordEncoder;
public function __construct(
Router $router,
UserPasswordEncoder $passwordEncoder)
{
$this->router = $router;
$this->passwordEncoder = $passwordEncoder;
}
public function start(Request $request, AuthenticationException $authException = null)
{
$response = new RedirectResponse($this->router->generate('your_user_login_route_name'));
return $response;
}
public function getCredentials(Request $request)
{
# CHECK IF IT'S THE CHECK LOGIN ROUTE
if ($request->attributes->get('_route') !== 'your_user_login_route_name'
|| !$request->isMethod('POST')) {
return null;
}
# GRAB ALL REQUEST PARAMETERS
$params = $request->request->all();
# SET LOGIN CREDENTIALS
return array(
'email' => $params['email'],
'password' => $params['password'],
);
}
public function getUser($credentials, UserProviderInterface $userProvider)
{
$email = $credentials['email'];
$user = $userProvider->loadUserByUsername($email);
if (! $user){
throw new UsernameNotFoundException();
}
return $user;
}
public function checkCredentials($credentials, UserInterface $user)
{
# YOU CAN ADD YOUR CHECKS HERE!
if (! $this->passwordEncoder->isPasswordValid($user, $credentials['password'])) {
throw new BadCredentialsException();
}
return true;
}
public function onAuthenticationFailure(Request $request, AuthenticationException $exception)
{
# OYU CAN ALSO USE THE EXCEPTIONS TO ADD A FLASH MESSAGE (YOU HAVE TO INJECT YOUR OWN FLASH MESSAGE SERVICE!)
if ($exception instanceof UsernameNotFoundException){
$this->flashMessage->error('user.login.exception.credentials_invalid');
}
if ($exception instanceof BadCredentialsException){
$this->flashMessage->error('user.login.exception.credentials_invalid');
}
return new RedirectResponse($this->router->generate('your_user_login_route_name'));
}
public function onAuthenticationSuccess(Request $request, TokenInterface $token, $providerKey)
{
return new RedirectResponse($this->router->generate('your_success_login_route_name'));
}
public function supportsRememberMe()
{
return false;
}
}
I have problem with symfony2 (I have tried the same with Symfony 2.0 & Symfony 2.3 just to see if its a Symfony bug), I am loosing the security token in the next page load / redirect after authentication.
I have created custom authenticator for Symfony 2.3 to authenticate with a 3rd party service as specified here: http://symfony.com/doc/current/cookbook/security/custom_authentication_provider.html
The application authenticates with an external service and sets the token in the callback URL '/success' and i can see from the debug bar that user is authenticated but when i go to '/' (which is under the same firewall) i am getting "A Token was not found in the SecurityContext." Error and user is no longer authenticated.
Here are the files:
security.yml
security:
encoders:
Symfony\Component\Security\Core\User\User: plaintext
role_hierarchy:
ROLE_ADMIN: ROLE_USER
ROLE_SUPER_ADMIN: [ROLE_USER, ROLE_ADMIN, ROLE_ALLOWED_TO_SWITCH]
providers:
users:
entity: { class: AcmeStoreBundle:User, property: email }
firewalls:
login:
pattern: ^/login$
security: false
noa:
pattern: ^/
provider: users
noa: true
logout:
path: /logout
target: /login
access_control:
- { path: ^/success, roles: IS_AUTHENTICATED_ANONYMOUSLY }
NoaUserToken.php
<?php
namespace Acme\StoreBundle\Security\Authentication\Token;
use Symfony\Component\Security\Core\Authentication\Token\AbstractToken;
class NoaUserToken extends AbstractToken
{
public $expires;
public $mobile;
public $email;
public function __construct(array $roles = array())
{
parent::__construct($roles);
parent::setAuthenticated(true);
}
public function getCredentials()
{
return '';
}
}
NoaProvider.php
<?php
namespace Acme\StoreBundle\Security\Authentication\Provider;
use Symfony\Component\Security\Core\Authentication\Provider\AuthenticationProviderInterface;
use Symfony\Component\Security\Core\User\UserProviderInterface;
use Symfony\Component\Security\Core\Exception\AuthenticationException;
use Symfony\Component\Security\Core\Exception\NonceExpiredException;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Acme\StoreBundle\Security\Authentication\Token\NoaUserToken;
class NoaProvider implements AuthenticationProviderInterface
{
private $userProvider;
private $cacheDir;
public function __construct(UserProviderInterface $userProvider, $cacheDir)
{
$this->userProvider = $userProvider;
$this->cacheDir = $cacheDir;
}
public function authenticate(TokenInterface $token)
{
$userEmail = $token->getUser();
$user = $this->userProvider->loadUserByUsername($userEmail);
if ($user && $this->validateToken($token->expires) && !$user->getHidden()) {
$authenticatedToken = new NoaUserToken($user->getRoles());
$authenticatedToken->expires = $token->expires;
$authenticatedToken->mobile = $token->mobile;
$authenticatedToken->email = $token->email;
$authenticatedToken->setUser($user);
$authenticatedToken->setAuthenticated(true);
return $authenticatedToken;
}
throw new AuthenticationException('The NOA authentication failed.');
}
protected function validateToken($expires)
{
// Check if the token has expired.
if (strtotime($expires) <= time()) {
return false;
}
}
public function supports(TokenInterface $token)
{
return $token instanceof NoaUserToken;
}
}
NoaListener.php
<?php
namespace Acme\StoreBundle\Security\Firewall;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Event\GetResponseEvent;
use Symfony\Component\Security\Http\Firewall\ListenerInterface;
use Symfony\Component\Security\Core\Exception\AuthenticationException;
use Symfony\Component\Security\Core\SecurityContextInterface;
use Symfony\Component\Security\Core\Authentication\AuthenticationManagerInterface;
use Acme\StoreBundle\Security\Authentication\Token\NoaUserToken;
class NoaListener implements ListenerInterface
{
protected $securityContext;
protected $authenticationManager;
public function __construct(SecurityContextInterface $securityContext, AuthenticationManagerInterface $authenticationManager)
{
$this->securityContext = $securityContext;
$this->authenticationManager = $authenticationManager;
}
public function handle(GetResponseEvent $event)
{
$request = $event->getRequest();
if (! preg_match('/^\/app_dev.php\/success/', $request->getRequestUri())) {
return;
}
if( $this->securityContext->getToken() ){
return;
}
try {
\NOA_Sso_Web::getInstance()->createSession();
}
catch (Exception $e) {
// Handle error situation here
}
if (isset($_SESSION['userInfo'])) {
$token = new NoaUserToken();
$token->setUser($_SESSION['userInfo']['email']);
$token->mobile = $_SESSION['userInfo']['mobileVerified'] ? $_SESSION['userInfo']['mobile'] : null;
$token->email = $_SESSION['userInfo']['emailVerified'] ? $_SESSION['userInfo']['email'] : null;
$token->expires = $_SESSION['tokenInfo']['expires'];
try {
$authToken = $this->authenticationManager->authenticate($token);
$this->securityContext->setToken($authToken);
return;
} catch (AuthenticationException $failed) {
// Do nothing and go for the default 403
}
}
$this->securityContext->setToken(null);
// Deny authentication with a '403 Forbidden' HTTP response
$response = new Response();
$response->setStatusCode(403);
$event->setResponse($response);
}
}
NoaFactory.php
<?php
namespace Acme\StoreBundle\DependencyInjection\Security\Factory;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Reference;
use Symfony\Component\DependencyInjection\DefinitionDecorator;
use Symfony\Component\Config\Definition\Builder\NodeDefinition;
use Symfony\Bundle\SecurityBundle\DependencyInjection\Security\Factory\SecurityFactoryInterface;
class NoaFactory implements SecurityFactoryInterface
{
public function create(ContainerBuilder $container, $id, $config, $userProvider, $defaultEntryPoint)
{
$providerId = 'security.authentication.provider.noa.'.$id;
$container
->setDefinition($providerId, new DefinitionDecorator('noa.security.authentication.provider'))
->replaceArgument(0, new Reference($userProvider))
;
$listenerId = 'security.authentication.listener.noa.'.$id;
$listener = $container->setDefinition($listenerId, new DefinitionDecorator('noa.security.authentication.listener'));
return array($providerId, $listenerId, $defaultEntryPoint);
}
public function getPosition()
{
return 'pre_auth';
}
public function getKey()
{
return 'noa';
}
public function addConfiguration(NodeDefinition $node)
{
}
}
DefaultController.php
<?php
namespace Acme\StoreBundle\Controller;
use Symfony\Bundle\FrameworkBundle\Controller\Controller;
use Symfony\Component\Security\Http\Event\InteractiveLoginEvent;
use Symfony\Component\Security\Http\SecurityEvents;
use Symfony\Component\HttpFoundation\Response;
class DefaultController extends Controller
{
public function indexAction()
{
$token = $this->container->get('security.context')->getToken();
// Not reached
print '<pre>';
print_r($token->getUser());
print '</pre>';
return $this->render('AcmeStoreBundle:Default:index.html.twig', array('name' => $token->getUser()->gerUsername()));
}
public function loginAction()
{
return $this->render('AcmeStoreBundle:Default:login.html.twig', array());
}
public function successAction()
{
$token = $this->container->get('security.context')->getToken();
$this->container->get('event_dispatcher')->dispatch(
SecurityEvents::INTERACTIVE_LOGIN,
new InteractiveLoginEvent($this->container->get('request'), $token)
);
// This prints the user object
print '<pre>';
print_r($token->getUser());
print '</pre>';
return new Response('<script>//window.top.refreshPage();</script>');
}
}
I have checked all similar questions in stackoverflow and spent around a week to solve this issue, any help is greatly appreciated.
Rather than using $_SESSION in NoaListener, you ought to be using the session interface on the request object. Symfony does its own session management and may ignore or overwrite your session (e.g. it's common to migrate sessions upon successful login to prevent session fixation attacks).
Use $request = $event->getRequest() as you already have, then $request->getSesssion()->get('userInfo'), etc.
My problem is capture user logout. the code what i have is:
public function onAuthenticationFailure(Request $request, AuthenticationException $exception){
return new Response($this->translator->trans($exception->getMessage()));
}
public function logout(Request $request, Response $response, TokenInterface $token)
{
$empleado = $token->getUser();
$log = new Log();
$log->setFechalog(new \DateTime('now'));
$log->setTipo("Out");
$log->setEntidad("");
$log->setEmpleado($empleado);
$this->em->persist($log);
$this->em->flush();
}
public function onLogoutSuccess(Request $request) {
return new RedirectResponse($this->router->generate('login'));
}
The problem is I can not access the user token TokenInterface when you are running the logout function?
To get token, you must inject with security context.
1. Create class Logout listener, something like this:
namespace Yourproject\Yourbundle\Services;
...
use Symfony\Component\Security\Http\Logout\LogoutSuccessHandlerInterface;
use Symfony\Component\Security\Core\SecurityContext;
class LogoutListener implements LogoutSuccessHandlerInterface {
private $security;
public function __construct(SecurityContext $security) {
$this->security = $security;
}
public function onLogoutSuccess(Request $request) {
$user = $this->security->getToken()->getUser();
//add code to handle $user here
//...
$response = RedirectResponse($this->router->generate('login'));
return $response;
}
}
2. And then in service.yml, add this line:
....
logout_listener:
class: Yourproject\Yourbundle\Services\LogoutListener
arguments: [#security.context]
That's it, may it helps.
See http://symfony.com/doc/current/reference/configuration/security.html
security.yml
secured_area:
logout:
path: /logout
**success_handler: logout_listener**
Take a look here were you can overwrite any controller of the bundle:
http://symfony.com/doc/current/cookbook/bundles/inheritance.html