Flag of Ukraine
SymfonyCasts stands united with the people of Ukraine

API Token Authenticator Part 2!

Keep on Learning!

If you liked what you've learned so far, dive in!
Subscribe to get access to this tutorial plus
video, code and script downloads.

Start your All-Access Pass
Buy just this tutorial for $12.00

With a Subscription, click any sentence in the script to jump to that part of the video!

Login Subscribe

When the request sends us a valid API token, our authenticator code is working! At least all the way to checkCredentials(). But before we finish that, I want to see what happens if a client sends us a bad key. So let's see... the last number in the token is six. Let's add a space: that will be enough to mess things up.

Hit send again. Woh! It redirects us to /login? I did not see that coming.

Sometimes the hardest part of security is figuring out what's happening when something unexpected occurs. So, let's figure out exactly what's going on here.

When authentication fails, this onAuthenticationFailure() method is called:

... lines 1 - 12
class ApiTokenAuthenticator extends AbstractGuardAuthenticator
{
... lines 15 - 54
public function onAuthenticationFailure(Request $request, AuthenticationException $exception)
{
// todo
}
... lines 59 - 73
}

Our job is to return a Response that should be sent back to the client. Right now... we're doing nothing.! So, instead of sending an error back to the user, the request continues like normal to the controller. But, the request is still anonymous. So when it hits our security check in AccountController, Symfony activates the "entry point", which redirects the user to /login.

onAuthenticationFailure()

But... that's not what we want at all! If an API client sends a bad API token, we need to tell them! Bad API client! Let's return a new JsonResponse() with a message key that describes what went wrong. Earlier, I mentioned that whenever authentication fails - for any reason - it's because, internally, some sort of AuthenticationException is thrown. That's important because this exception is passed to us as an argument:

... lines 1 - 12
class ApiTokenAuthenticator extends AbstractGuardAuthenticator
{
... lines 15 - 54
public function onAuthenticationFailure(Request $request, AuthenticationException $exception)
{
... line 57
}
... lines 59 - 73
}

And it has a method - getMessageKey() - that holds a message about what went wrong. Set the status code to 401:

... lines 1 - 5
use Symfony\Component\HttpFoundation\JsonResponse;
... lines 7 - 13
class ApiTokenAuthenticator extends AbstractGuardAuthenticator
{
... lines 16 - 55
public function onAuthenticationFailure(Request $request, AuthenticationException $exception)
{
return new JsonResponse([
'message' => $exception->getMessageKey()
], 401);
}
... lines 62 - 76
}

Custom Error Messages with CustomUserMessageAuthenticationException

Let's try it again! Send the request. Yes! A 401 Unauthorized response. But, oh. That message isn't right at all!

Username could not be found?

This is because Symfony creates a different error message based on where authentication fails inside your authenticator. If you fail to return a User from getUser(), you get this "Username could not be found" error.

For our login form, we render this exact messageKey field in the template. But we also pass it through the translator:

... lines 1 - 10
{% block body %}
<form class="form-signin" method="post">
{% if error %}
<div class="alert alert-danger">{{ error.messageKey|trans(error.messageData, 'security') }}</div>
{% endif %}
... lines 16 - 34
</form>
{% endblock %}

That allowed us to translate that into a better message:

"Username could not be found.": "Oh no! It doesn't look like that email exists!"

We could do the same here: inject the translator service into ApiTokenAuthenticator and translate the message key. But... hmmm, the message still wouldn't be right - it would use the "It doesn't look like that email exists!" message from the translation file.

No problem: there is a second way to control error messages in an authenticator, and it's super flexible. At any point in your authenticator, you can throw a new CustomUserMessageAuthenticationException() that will cause authentication to fail and accepts any custom error message you want, like, "Invalid API Token":

... lines 1 - 9
use Symfony\Component\Security\Core\Exception\CustomUserMessageAuthenticationException;
... lines 11 - 14
class ApiTokenAuthenticator extends AbstractGuardAuthenticator
{
... lines 17 - 38
public function getUser($credentials, UserProviderInterface $userProvider)
{
... lines 41 - 44
if (!$token) {
throw new CustomUserMessageAuthenticationException(
'Invalid API Token'
);
}
... lines 50 - 51
}
... lines 53 - 79
}

That's it! This exception will be passed to onAuthenticationFailure() and its getMessageKey() method will return that message.

Go back to Postman to try it: send! We got it! So much better!

Checking Token Expiration

Oh, while we're talking about tokens failing, we should definitely check to make sure the token hasn't expired. Inside ApiToken, we created this nice expiresAt property:

... lines 1 - 9
class ApiToken
{
... lines 12 - 23
/**
* @ORM\Column(type="datetime")
*/
private $expiresAt;
... lines 28 - 60
}

Go down to the bottom of the class and add a new helper function: isExpired() that returns a bool. Return $this->getExpiresAt() is less than or equal to new \DateTime():

... lines 1 - 9
class ApiToken
{
... lines 12 - 61
public function isExpired(): bool
{
return $this->getExpiresAt() <= new \DateTime();
}
}

Nice! Back in ApiTokenAuthenticator, in getUser(), if $token->isExpired(), then throw new CustomUserMessageAuthenticationException() with Token Expired:

... lines 1 - 9
use Symfony\Component\Security\Core\Exception\CustomUserMessageAuthenticationException;
... lines 11 - 14
class ApiTokenAuthenticator extends AbstractGuardAuthenticator
{
... lines 17 - 38
public function getUser($credentials, UserProviderInterface $userProvider)
{
... lines 41 - 44
if (!$token) {
throw new CustomUserMessageAuthenticationException(
'Invalid API Token'
);
}
if ($token->isExpired()) {
throw new CustomUserMessageAuthenticationException(
'Token expired'
);
}
... lines 56 - 57
}
... lines 59 - 85
}

We're killin' it! Oh, but, why are we putting this code here and not in checkCredentials()? Answer: no reason! These two methods are called one after the other and you can really put any code inside either of these methods. Actually, I chose getUser() just because we have access to the $token object there.

Head back to Postman. Let's remove that extra space so our API token is valid once again. Send! Success! Now, go back to the ApiToken class and, temporarily, return true from isExpired() so we can see the error:

class ApiToken
{
    // ...

    public function isExpired(): bool
    {
        return true;
        return $this->getExpiresAt() <= new \DateTime();
    }
}

And... send it again! Got it! Token Expired. Remove that dummy code.

onAuthenticationSuccess()

At this point... we're basically done! In checkCredentials(), there is no password to check. And so, it's perfectly ok for us to return true:

... lines 1 - 14
class ApiTokenAuthenticator extends AbstractGuardAuthenticator
{
... lines 17 - 59
public function checkCredentials($credentials, UserInterface $user)
{
return true;
}
... lines 64 - 85
}

Finally, in onAuthenticationSuccess(), hmm. What should we do when authentication is successful? With a login form, we redirect the user after success. But with an API token system we, well, want to do... nothing! Yep! We want to allow the request to continue so that it can hit the controller and return the JSON response:

... lines 1 - 14
class ApiTokenAuthenticator extends AbstractGuardAuthenticator
{
... lines 17 - 71
public function onAuthenticationSuccess(Request $request, TokenInterface $token, $providerKey)
{
// allow the authentication to continue
}
... lines 76 - 85
}

start() & supportsRememberMe()

So what about start()? Because we chose LoginFormAuthenticator as the entry_point, this will never be called. To prove it, I'll throw an exception that says:

Not used: entry_point from other authenticator is used:

... lines 1 - 14
class ApiTokenAuthenticator extends AbstractGuardAuthenticator
{
... lines 17 - 76
public function start(Request $request, AuthenticationException $authException = null)
{
throw new \Exception('Not used: entry_point from other authentication is used');
}
... lines 81 - 85
}

And, finally, supportsRememberMe(). Return false:

... lines 1 - 14
class ApiTokenAuthenticator extends AbstractGuardAuthenticator
{
... lines 17 - 81
public function supportsRememberMe()
{
return false;
}
}

If you return true from this method, it just means that the "remember me" system is activated and looking for that _remember_me checkbox to be checked. Because that makes absolutely no sense for an API, just turn it off.

That's it! Find a stranger to high-five! Cheers your coffee with a co-worker! And find Postman! Brace yourself... send! Yes! It executes our controller and we are definitely authenticated because we see the info for spacebar9@example.com.

People - we now have two valid ways to authenticate in our system! The super cool thing is that, inside of our controller, we don't care which method is used! We just say $this->getUser()... never caring whether the user was authenticated via the login form or with an API token.

Next: let's set up a registration form and learn how we can manually authenticate the user after success.

Leave a comment!

46
Login or Register to join the conversation

I understand this section isn't quite finished yet. But, I need this for a project I am working so I am working my way through it.

I downloaded the code to make sure that everything was the same in security.yaml, SecurityController, ApiTokenAuthenticator, LoginFormAuthenticator, etc.

Everything works except if you send a request with Postman and do not include a Bearer Token (e.g. no auth header, basic auth, etc.).

If you do not include a Bearer Token, the response recieved is the login page code. What I would like to do is return a JsonResponse.

Any ideas about how to do this? I am not getting it to work.

I don't think it matters. But, just in case, I am doing this in a new project using API Platform.

Thanks in advance!

1 Reply

Hey Scott!

Sorry for the slow reply. But yea, I'd be happy to help you with that :). So, when you don't send the Bearer token, it means that both of your authenticators do nothing (i.e. supports() returns false). This means that your request continues anonymously, then it hits some security check. At this point, Symfony calls the "entry point" for the firewall. In practice, that means it calls the "start" method for one of your two authenticators. You choose WHICH start() method (which entry point) should be used via the entry_point config under "guard" in security.yaml.

So, change the entry_point config to point to the ApiTokenAuthenticator class instead. Then, add some code in its start() method to return a JsonResponse.

The only downside is that if your site also supports normal users, if they try to access any page anonymously, they'll see a JsonResponse :). For the best of both worlds, in the start() method of your ApiTokenAuthenticator, you will need to (somehow) determine if the current request is an API request (then return JSON) or a web request (then redirect to the login page). How? Well, if all your URLs start with /api, that's one thing you could check for. Or, if your API exists only to power your own JavaScript, you could check if $request->isXmlHttpRequest(). It depends on your app :).

I hope this helps & reaches you in time!

Cheers!

1 Reply

I appreciate your help.

Your response was very helpful and pointed me in the right direction.

I only have a self-imposed deadline as this is a personal project for freelancing jobs. I am also looking for a new job (hopefully staying in Europe) . So, brushing up on some things.

Anyways, here is my solution in case it may help someone else.

security.yaml


main:
anonymous: true
guard:
authenticators:
- App\Security\LoginFormAuthenticator
- App\Security\ApiTokenAuthenticator

entry_point: App\Security\ApiTokenAuthenticator

logout:
success_handler: App\Service\LogoutSuccessService

remember_me:
secret: '%kernel.secret%'
lifetime: 2592000 # 30 days in seconds

In ApiTokenAuthenticator


public function start(Request $request, AuthenticationException $authException = null)
{
if('json' === $request->getContentType()) {
$data = array(
"@context" => "/contexts/Error",
"@type" => "Error",
"hydra:title" => "An error occurred",
"hydra:description" => "Authentication Required.",
"hydra:status" => Response::HTTP_UNAUTHORIZED
);

return new JsonResponse($data, Response::HTTP_UNAUTHORIZED);
} else {
return new RedirectResponse($this->router->generate('form_login'));
}
}

To be able to log out, API should get a JsonResponse and website should be redirected to login page. To do this, I had to do the following:

LogoutSuccessService


class LogoutSuccessService implements LogoutSuccessHandlerInterface
{

private $router;

public function __construct(RouterInterface $router)
{
$this->router = $router;
}

/**
* Creates a Response object to send upon a successful logout.
*
* @param Request $request
*
* @return JsonResponse|RedirectResponse never null
*/
public function onLogoutSuccess(Request $request)
{
if(null === $request->getContentType()) {
return new RedirectResponse($this->router->generate('form_login'));
} else {
$data = array(
"@context" => "/contexts/Message",
"@type" => "Message",
"hydra:title" => "Success",
"hydra:description" => "You have successfully been logged out.",
"hydra:status" => Response::HTTP_ACCEPTED
);

return new JsonResponse($data, Response::HTTP_ACCEPTED);
}
}
}

This is reference by the logout success_handler in secrity.yaml.

I haven't had time to test this with Behat or PHPUnit. But, seems to work when testing using PostMan and using a browser.

6 Reply

Awesome job Scott, thanks for sharing your solution :)

I hope you find the job you are looking for. Cheers!

Reply
Gaetano S. Avatar
Gaetano S. Avatar Gaetano S. | posted 2 years ago

Hello guys,
I need your help. I'm trying to learn using your knpu_oauth2 with Google. When I try to log in, I have this error:
Error 400: redirect_uri_mismatch
Theredirect URI in the request, http://localhost.com/connec...,
does not match the ones authorized for the OAuth client. To update the
authorized redirect URIs, visit:......

When I created my Google credentials, I used the follow URIs: http://localhost.com
I created this GooglController:


class GoogleController extends AbstractController
{
public function connectAction(ClientRegistry $clientRegistry): RedirectResponse
{


// key is name of client in knpu_outh...yaml conf


return $clientRegistry


->getClient('google')


->redirect([


'profile', 'email' // the scopes you want to access


]);


}

public function connectCheckAction(Request $request): RedirectResponse
{
return $this->redirectToRoute('app_account');
}
}

and this authenticator:


class MyGoogleAuthenticator extends SocialAuthenticator
{
private $clientRegistry;
private $em;
private $router;


public function __construct(ClientRegistry $clientRegistry, EntityManagerInterface $em, RouterInterface $router)
{
$this->clientRegistry = $clientRegistry;
$this->em = $em;
$this->router = $router;
}


/**
* @param Request $request
* @return bool
*/
public function supports(Request $request): bool
{
// continue ONLY if the current ROUTE matches the check ROUTE
return $request->attributes->get('_route') === 'connect_google_check' && $request->isMethod('GET');
}


public function getCredentials(Request $request)
{
// this method is only called if supports() returns true
return $this->fetchAccessToken($this->getGoogleClient());
}


public function getUser($credentials, UserProviderInterface $userProvider)
{
dd($credentials);
/** @var GoogleUser $googleUser */
$googleUser = $this->getGoogleClient()
->fetchUserFromToken($credentials);


$email = $googleUser->getEmail();
// 1) have they logged in with Google before? Easy!
$existingUser = $this->em->getRepository(User::class)
->findOneBy(['googleId' => $googleUser->getId()]);
if ($existingUser) {
return $existingUser;
}


// 2) do we have a matching user by email?
$user = $this->em->getRepository(User::class)
->findOneBy(['email' => $email]);


if (!$user) {
/** @var User $user */
$user->setEmail($googleUser->getEmail());
$user->setFirstName($googleUser->getName());
// $user->setCreatedAt(new \DateTime(date('Y-m-d H:i:s')));
$this->em->persist($user);
$this->em->flush();
}


// 3) Maybe you just want to "register" them by creating
// a User object
// $user->setGoogleId($googleUser->getId());
// $this->em->persist($user);
// $this->em->flush();


return $user;
}


/**
* @return OAuth2ClientInterface
*/
private function getGoogleClient(): OAuth2ClientInterface
{
return $this->clientRegistry
// "google" is the key used in config/packages/knpu_oauth2_client.yaml
->getClient('google');
}


/**
* @param Request $request
* @param TokenInterface $token
* @param string $providerKey
* @return Response|null
*/
public function onAuthenticationSuccess(Request $request, TokenInterface $token, $providerKey): ?Response
{
// change "app_homepage" to some route in your app
// $targetUrl = $this->router->generate('app_homepage');
// return new RedirectResponse($targetUrl);


// or, on success, let the request continue to be handled by the controller
return null;
}


public function onAuthenticationFailure(Request $request, AuthenticationException $exception): ?Response
{
$message = strtr($exception->getMessageKey(), $exception->getMessageData());


return new Response($message, Response::HTTP_FORBIDDEN);


}


/**
* Called when authentication is needed, but it's not sent.
* This redirects to the 'login'.
*
* @param Request $request
* @param AuthenticationException|null $authException
*
* @return RedirectResponse
*/
public function start(Request $request, AuthenticationException $authException = null): RedirectResponse
{
return new RedirectResponse(
'/connect/', // might be the site, where users choose their oauth provider
Response::HTTP_TEMPORARY_REDIRECT
);
}
}

Thanks a lot for your help

Reply

Hey Gaetano,

The error you see: "Error 400: redirect_uri_mismatch" - clearly says that the redirect URL you're using mismatch the one you set in your Google Application. It also says "visit ..." some URL, try to follow that link and check what redirect URL you have there and compare it with URL you're actually redirecting. Even scheme might cause this error I think, so please, double check it - use HTTP or HTTPS in both.

I hope this helps!

Cheers!

Reply
Gaetano S. Avatar
Gaetano S. Avatar Gaetano S. | victor | posted 2 years ago

Finally I did it...just a little of patience more..:)

Reply

Hey Gaetano,

Glad you were able to get it working, well done! :)

Cheers!

Reply
Gaetano S. Avatar
Gaetano S. Avatar Gaetano S. | victor | posted 2 years ago

Hi,
thanks for your help. I changed a little bit my code and now I have another problem: 401 invalid credentials. I tried with the oauth of facebook but I have the follow error: something went wrong, try again later.... I think I have to leave for the moment and go on to finish this course. I am quite angry about it. :(

Reply
Amy anuszewski Avatar
Amy anuszewski Avatar Amy anuszewski | posted 3 years ago

I was interrupted while working on the tutorial and came back after an hour, which meant all my tokens had expired. No biggie, just loaded the fixtures again. BUT, I got to wondering, how easy would it be to call the renewExpiresAt function whenever the user's session is modified? For example, every time they load a page? (I would also probably renew it after every successful API call and shorten the life of the token)

Reply

Hey Amy anuszewski

I don't know if it is a good or bad practice but if you want to refresh the token life, probably after a succesful API call is the best moment. You would only need to update the "expireAt" field of such token.

Cheers!

Reply
Shaun T. Avatar
Shaun T. Avatar Shaun T. | posted 3 years ago

If there is an authentication failure, should the Symfony app be responsible for redirecting the user to the login page? Or should this be done by the front end receiving the authentication failure response?

Reply

Hey Shaun T.!

Hmm. Well, I suppose like everything, it depends :). In this app, Symfony *is* our front-end and our back-end - some pages are HTML pages (a frontend) and some pages are API endpoints (a back-end). For the front-end part, Symfony *does* need to know where to redirect the user to when authentication fails. But, when Symfony is being treated like an API, then if authentication fails, it should simply return a 401 JSON response back to the API client. It would then be up to the frontend (e.g. JavaScript) to figure out what to do.

That's a long way of saying: if you are really building a frontend in JavaScript, then it should be capable of understanding the 401 JSON response sent back from Symfony and then it should redirect the user (or open some login dialog) itself.

Let me know if that answers your question!

Cheers!

Reply
Jorge Avatar
Jorge Avatar Jorge | posted 3 years ago | edited

Hi there, congratulations for the course, it's really useful.

By the way, I'm using this approach on one app with Symfony 4, everything works as expected. But when I test it with phpunit, on the getUser method of my JwtGuardAuthenticator service, it's quering the "dev" DB instead of my test database.

I followed the steps described here (https://symfony.com/doc/cur..., and I have a phpunit.xml file with the DATABASE_URL configured. However the EntityManager is connecting to the wrong database.

Any idea about what could be wrong?

Thanks!

Reply

Hey Jorge

Yeah, testing in Symfony4 got a bit weirder... but anyways, you only have to tweak your Doctrine config for your test environment, like this:


// config/packages/test/doctrine.yaml

doctrine:
dbal:
url: '%env(resolve:DATABASE_URL)%_test'

Cheers!

Reply
Jorge Avatar
Jorge Avatar Jorge | MolloKhan | posted 3 years ago | edited

Hi MolloKhan , thanks for your answer.

I already did what you suggested, but it still takes the wrong DB URL, still using dev DB instead of test DB, really weird!

Cheers!

Reply

Hmm, that's odd. How are you testing it? Can you double check that you are actually running on "test" environment?

Reply
Jorge Avatar

I'm running my tests with "./vendor/bin/simple-phpunit". The thing is that the authentication of the endpoints I'm testing, uses a JwtGuardAuthenticator class, that is getting the user using the repository. I've verified that it's querying the dev DB instead of my tests DB.

I've checked everything but still with the same problem.

Reply

Is there a reason to run "simple-phpunit"? Try running "phpunit" bin


./vendor/bin/phpunit
Reply
Jorge Avatar

I'm using Php Unit Bridge:

https://symfony.com/doc/cur...

That's the way to run it. It shouldn't be the problem.

Reply

Hey Jorge!

Yea, as Diego mentioned, testing got a little strange in Symfony 4. It will be fixed in Symfony 4.2, with a small new feature and a change to the recipe. There are two issues:

1) The .env file is not read in the test environment. And so, all the environment variables need to be duplicated in phpunit.xml.dist. Diego's suggestion above to override the doctrine.dbal.url config setting via that special doctrine.yaml file in the test environment takes care of this part (though we will have a more robust solution soon).

2) Even after you've fixed the above, when you're running your tests, you need to make sure that you're actually in the test environment! That should be handled thanks to the APP_ENV environment variable in your phpunit.xml.dist file. However, if you are making real web requests from your test (e.g. via Guzzle), then those requests will execute your public/index.php file, which, just like when you load it in a browser, will load in the "dev" environment. That's the second piece that needs to be made more smooth. One easy way to fix this is to create an index_test.php file that is a duplicate of index.php, except that it defaults to the "test" environment.

Let me know if this helps! I think part (2) might be your issue - but I could also be totally wrong ;).

Cheers!

Reply
Jorge Avatar
Jorge Avatar Jorge | weaverryan | posted 3 years ago | edited

Hi weaverryan !

Thanks for your answer. Makes sense, I did all the suggested steps, but as you said, I'm using Guzzle and I was able to verify that the repositories where using my dev database instead of the tests one. I'll try that solution.

Thank you very much in advance.

Cheers!

Reply

BTW, you can use Symfony's client to make your http requests instead of Guzzle. I'm recommending it because it hits the test environment by default without having to create a special front controller (index_text.php as Ryan suggested)

Docs: https://symfony.com/doc/cur...

Cheers!

Reply
Jorge Avatar
Jorge Avatar Jorge | MolloKhan | posted 3 years ago | edited

You are right MolloKhan , in fact, I started to do my tests with the Symfony's client, but later I adjusted those test using the Guzzle client. Now I have too much tests with this client so I don't want to spend more time change it. But it's good to know it for the future, thanks!

Cheers!

1 Reply
Yahya E. Avatar
Yahya E. Avatar Yahya E. | posted 3 years ago

I am getting following depreciation message. How can we get rid of it? Because it is a little bit annoying shown in all pages (authenticcated ones) :)


User Deprecated: Doctrine\Common\ClassLoader is deprecated.
Hide context Hide trace
[▼
"exception" => ErrorException {#453 ▶}
]
{▼
/var/www/symfony/vendor/doctrine/common/lib/Doctrine/Common/ClassLoader.php:7 {▶}
/var/www/symfony/vendor/symfony/debug/DebugClassLoader.php:143 {▶}
Symfony\Component\Debug\DebugClassLoader->loadClass() {}
/var/www/symfony/vendor/doctrine/orm/lib/Doctrine/ORM/Mapping/ClassMetadataInfo.php:1028 {▶}
/var/www/symfony/vendor/doctrine/orm/lib/Doctrine/ORM/Mapping/ClassMetadataFactory.php:266 {▶}
/var/www/symfony/vendor/doctrine/orm/lib/Doctrine/ORM/Mapping/ClassMetadataFactory.php:245 {▶}
/var/www/symfony/vendor/doctrine/persistence/lib/Doctrine/Common/Persistence/Mapping/AbstractClassMetadataFactory.php:305 {▶}
/var/www/symfony/vendor/doctrine/orm/lib/Doctrine/ORM/Mapping/ClassMetadataFactory.php:78 {▶}
/var/www/symfony/vendor/doctrine/persistence/lib/Doctrine/Common/Persistence/Mapping/AbstractClassMetadataFactory.php:183 {▶}
/var/www/symfony/vendor/doctrine/orm/lib/Doctrine/ORM/EntityManager.php:283 {▶}
/var/www/symfony/vendor/doctrine/doctrine-bundle/Repository/ServiceEntityRepository.php:31 {▶}
/var/www/symfony/src/Repository/UserRepository.php:19 {▼
› {
› parent::__construct($registry, User::class);
› }
}
Reply

Hey Yahya E.

That's a known deprecation that will be fix (if it's not fixed yed) soon. Check this thread: https://github.com/symfony/...

Cheers!

Reply
Yahya E. Avatar
Yahya E. Avatar Yahya E. | posted 3 years ago

I might found a bug. In your security.yaml with two authenticator and and one entry point (even if you indent entry_point correctly), if you want to run

php bin/console debug:config doctrine

or any other service, you will get below message:


In CheckExceptionOnInvalidReferenceBehaviorPass.php line 31:

The service "security.firewall.map" has a dependency on a non-existent service ".security.request_matcher.zfHj2lW".

Even if you change your security.yaml to:


api:
pattern: ^/api/
guard:
authenticators:
- App\Security\ApiTokenAuthenticator
main:
anonymous: true
guard:
authenticators:
- App\Security\LoginFormAuthenticator

You will still get exact same error.

How to fix this?

Reply

Hey Yahya E.!

Hmm, yea, it could be. That is, at the very least, a very unhelpful error :). Can you post the exact security.yaml config that you have that gives you the error? You mentioned that the security.yaml you printed on the bottom also causes the error, but I would love to see the original one.

Thanks!

Reply
Yahya E. Avatar

Dear Ryan,

This is latest security.yaml of the latest tutorial for Symfony Security in SyymfonyCasts.


security:
encoders:
App\Entity\User:
algorithm: bcrypt

role_hierarchy:
ROLE_ADMIN: [ROLE_ADMIN_COMMENT, ROLE_ADMIN_ARTICLE, ROLE_ALLOWED_TO_SWITCH]

# https://symfony.com/doc/current/security.html#where-do-users-come-from-user-providers
providers:
# used to reload user from session & other features (e.g. switch_user)
app_user_provider:
entity:
class: App\Entity\User
property: email
# used to reload user from session & other features (e.g. switch_user)

firewalls:
dev:
pattern: ^/(_(profiler|wdt)|css|images|js)/
security: false
main:
anonymous: true

guard:
authenticators:
- App\Security\LoginFormAuthenticator
- App\Security\ApiTokenAuthenticator

# redirect anonymous users to the login page
entry_point: App\Security\LoginFormAuthenticator

logout:
path: app_logout

remember_me:
secret: '%kernel.secret%'
lifetime: 2592000 # 30 days in seconds

switch_user: true

# activate different ways to authenticate

# http_basic: true
# https://symfony.com/doc/current/security.html#a-configuring-how-your-users-will-authenticate

# form_login: true
# https://symfony.com/doc/current/security/form_login_setup.html

# Easy way to control access for large sections of your site
# Note: Only the *first* access control that matches will be used
access_control:
# but, definitely allow /login to be accessible anonymously
#- { path: ^/login, roles: IS_AUTHENTICATED_ANONYMOUSLY }
# require the user to fully login to change password
#- { path: ^/change-password, roles: IS_AUTHENTICATED_FULLY }
# if you wanted to force EVERY URL to be protected
#- { path: ^/, roles: IS_AUTHENTICATED_REMEMBERED }

# - { path: ^/admin, roles: ROLE_ADMIN }
# - { path: ^/profile, roles: ROLE_USER }

This will give you an error saying that a problem with entry point. It will ask you indent entry_point to guard level. If you indent as requested, it will give you the previous error I wrote about security.firewall.map.

And this is the yaml I alternately wrote without entry point.


security:
encoders:
App\Entity\User:
algorithm: bcrypt

role_hierarchy:
ROLE_ADMIN: [ROLE_ADMIN_COMMENT, ROLE_ADMIN_ARTICLE, ROLE_ALLOWED_TO_SWITCH]

# https://symfony.com/doc/current/security.html#where-do-users-come-from-user-providers
providers:
# used to reload user from session & other features (e.g. switch_user)
app_user_provider:
entity:
class: App\Entity\User
property: email
# used to reload user from session & other features (e.g. switch_user)

firewalls:
dev:
pattern: ^/(_(profiler|wdt)|css|images|js)/
security: false
api:
pattern: ^/api/
guard:
authenticators:
- App\Security\ApiTokenAuthenticator
main:
anonymous: true
guard:
authenticators:
- App\Security\LoginFormAuthenticator
# redirects anonymous users to the login page

logout:
path: app_logout

remember_me:
secret: '%kernel.secret%'
lifetime: 2592000 # 30 days in seconds

switch_user: true

# activate different ways to authenticate

# http_basic: true
# https://symfony.com/doc/current/security.html#a-configuring-how-your-users-will-authenticate

# form_login: true
# https://symfony.com/doc/current/security/form_login_setup.html

# Easy way to control access for large sections of your site
# Note: Only the *first* access control that matches will be used
access_control:
# but, definitely allow /login to be accessible anonymously
#- { path: ^/login, roles: IS_AUTHENTICATED_ANONYMOUSLY }
# require the user to fully login to change password
#- { path: ^/change-password, roles: IS_AUTHENTICATED_FULLY }
# if you wanted to force EVERY URL to be protected
#- { path: ^/, roles: IS_AUTHENTICATED_REMEMBERED }

# - { path: ^/admin, roles: ROLE_ADMIN }
# - { path: ^/profile, roles: ROLE_USER }

Still gives same error.

Hope this helps.

Reply

Hey Yahya E.!

Hmm, very interesting. Basically, what you're seeing should not happen :). So yes, it seems like a bug... in Symfony or some weird setup issue. Basically, that ".security.request_matcher.zfHj2lW" service is an internal service that's created to support your firewall (https://github.com/symfony/... and https://github.com/symfony/.... It's consumed directly by the internal security.firewall.map service. This is all low-level plumbing that's handled automatically for you by the container.

So, you have hit a Symfony bug... or something similar indeed :). Here are some things we can do:

A) Manually clear your cache to see if it fixes things: rm -rf var/cache/*

B) Post your `composer.lock` file (a good option is to https://gist.github.com/). Basically, we can't reproduce this error. And, if it IS a symfony bug, it is probably only something that happens on a very specific version. If you post your composer.lock file, we can try to reproduce it using that.

Sorry you hit this! This type of thing is very strange and rare. But, unless I'm looking at the code wrong, I can't think of anything that you're doing in your code that would cause this - this looks like bad Symfony behavior.

Cheers!

Reply
Yahya E. Avatar

Clearing cache rm -rf var/cache/* did not change the status.

Here is the gist contains 3 files:

composer.lock, symfony.lock and a custom file named .history - as my own log of the composer installations during the project:

https://gist.github.com/yah...

That bad behaviour prevent me to use debug:config for all :(

3 Reply
Marcin Avatar

I had the same problem. Right now I can't remember for sure what was the cause but I think my dependencies (security bundle???) were not up to date. Try that.

Reply

Hey guys!

Hmm. I am frustratingly stumped! I've used your composer.lock file so that I have the EXACT dependencies as you have, and have used the EXACT code from the tutorial up to this point. No matter what I do (I've tried making many different mistakes/typos) I cannot get the same error that you're getting. And, as I mentioned before, I can't even see how this is possible! But, if 2 people have gotten the error, then it's definitely looks like it could be legit.

Two last questions:

1) If you rm -rf vendor/* and composer install, same result?
2) Would it be possible to push your entire app to GitHub so I could reproduce the error myself? I thought composer.lock would be enough, but apparently not. I think it may be something somewhat unrelated that is causing the weird issue.

Cheers!

Reply
Marcin Avatar

Sorry, I should have been more clear. I had this problem with security.request_matcher.***** in my business project, not in this tutorial.
However, I was curious and I've just executed bin/console debug:config doctrine in the root of this tutorial and here is what I've experienced:
Step 1: Yes, I did get: "In GuardAuthenticationFactory.php line 100: The guard authentication provider cannot use the "App\Security\LoginFormAuthenticator" entry_point because another entry point is already configured by another provider! Either remove the other provider or move the entry_point configuration as a root key under your firewall (i.e. at the same level as "guard")."
And yes, I did get problem with security.request_matcher.***** when moved to the "guard" level but it doesnt matter right now.
Step 2: Opened GuardAuthenticationFactory and put `dd($defaultEntryPointId, $config['entry_point'])` after `if ($defaultEntryPointId) {`, executed debug:config again and got "App\Security\LoginFormAuthenticator" TWICE!!!
Step 3: Executed debug:config - 1, 2, 3 ... n times and IT WORKS!
Stop 4: rm -rf var/cache/*
Step 5: Deleted line with `dd` and executed debug:config --- BLOW!!! Error from Step 1.

But, but, but. After the steps I had written out above I randomly run debug:config doctrine three times in a row and I did get the config at the third time and the every following one!!!

Answering your question: when I remove both: cache and vendor, then composer install I do get this error with another entry point but only on the first comman run, then I'm good :)

Ryan, I will upload my repo to github and send you a link via email on ryan@knpuniversity.com. Give me a moment.

P.S. I would change the Exception message so it includes the name of this "another entry point". It is more explicit and a developer can see that both entry points are the same ones (like in out case both are App\Security\LoginFormAuthenticator)

21 Reply
Yahya E. Avatar
Yahya E. Avatar Yahya E. | weaverryan | posted 3 years ago | edited

weaverryan

rm -Rf vendor/*; composer install did the work. Now I can run ./bin/console debug:config doctrine in ease. Regarding my app, it is 100% copied from finish folder of Symfony Security track.

Reply

Hey Yahya E.!

Sorry for the slow delay! And... congrats! I'm 99% sure that you found a Symfony bug :). And it took me quite some time to track it down. Here is the issue report: https://github.com/symfony/...

The tl;dr; is this: there is a bug that causes the container cache to be rebuilt too often, when you're using the debug:config command. It doesn't affect anything else in the system. Basically, under the *right* situation, debug:config will break. But, nothing else in the system should be affected.

I hope this helps! Oh, also, at least when I was testing it, I would get the error the first time I ran debug:config, but not afterwards (basically it would only occur if the command had to be the container). I know you got it working, but I have a feeling that this might come back at some point - and I want you to know what's going on.

Cheers!

Reply
Yahya E. Avatar
Yahya E. Avatar Yahya E. | posted 3 years ago

Excellent tutorial. Thank you. One question (with two leaf :)) - How we can make Custom exceptions messages translated, and in generic how to access translator in a Service?

Reply

> how to access translator in a Service?

Using translator: https://symfony.com/doc/cur...

Dependency Injection: https://symfonycasts.com/sc...

DI for services in Symfony 4: https://symfonycasts.com/sc...

And putting it all together:

use Symfony\Component\Translation\TranslatorInterface;

class SomeService
{
private $translator;

public __construct(TranslatorInterface $translator)
{
$this->translator = $translator;
}

public someAction()
{
$this->translator->trans('some text');
}
}

> How we can make Custom exceptions messages translated

The simplest way I see, is just translate the text before you pass it to the exception's constructor.

1 Reply

Hey Ivan,

Thank you for your help!

Cheers!

Reply
Yahya E. Avatar

Thank you Ivan. It seems all about finding the correct interface as gate to service. Maybe one more question. If we create a trait, which we can use in several services, how we do it in a Trait? Because traits has no constructor?

Reply

Hey Yahya E.!

Ah! So, there are 2 options here:

1) (the cleaner, but less fancy solution): add the shortcut methods in your trait (e.g. private function translate()</code),>$this->translator property (maybe throw an exception that this is needed if it's not there). This means you still need to do the work of creating a constructor and setting the property... which is actually a good thing - because that's good clean coding.

2) Use this tricky: https://symfonycasts.com/sc... - but I don't *love* this solution for "required" dependencies: objects that you actually need to make your code work.

Cheers!

2 Reply
Yahya E. Avatar
Yahya E. Avatar Yahya E. | weaverryan | posted 3 years ago | edited

weaverryan Can you a little bit elaborate the first solution please?

Reply

It would be something like this:


...
class SomeService
{
use TranslatorTrait;

public function __construct(TranslatorInterface $translator)
{
$this->setTranslator($translator);
}
...
}


...
trait TranslatorTrait
{
private $translator;

protected function translate($message)
{
...
}

protected function setTranslator(TranslatorInterface $translator)
{
$this->translator = $translator;
}
}

Cheers!

Reply

Hey Yahya,

Good question! You can execute "bin/console debug:autowiring" and find a proper translation service to know what type hint you need to use to get it. So, as you can see if you ran the command, you can type hint with "Symfony\Component\Translation\TranslatorInterface" in your ApiTokenAuthenticator's constructor. So, first of all, translate your message first and then create a custom exception with already translated message. Basically, the same as Ivan suggested to you below :)

Cheers!

Reply
Default user avatar

Am I missing code blocks or the videos always come few days earlier?

Reply

Hey @Marcin

Sometimes it take us a couple of days to release the code blocks, sorry about that!

Reply
Cat in space

"Houston: no signs of life"
Start the conversation!

What PHP libraries does this tutorial use?

// composer.json
{
    "require": {
        "php": "^7.1.3",
        "ext-iconv": "*",
        "composer/package-versions-deprecated": "^1.11", // 1.11.99
        "knplabs/knp-markdown-bundle": "^1.7", // 1.7.0
        "knplabs/knp-paginator-bundle": "^2.7", // v2.8.0
        "knplabs/knp-time-bundle": "^1.8", // 1.8.0
        "nexylan/slack-bundle": "^2.0,<2.2.0", // v2.0.0
        "php-http/guzzle6-adapter": "^1.1", // v1.1.1
        "sensio/framework-extra-bundle": "^5.1", // v5.2.0
        "stof/doctrine-extensions-bundle": "^1.3", // v1.3.0
        "symfony/asset": "^4.0", // v4.1.4
        "symfony/console": "^4.0", // v4.1.4
        "symfony/flex": "^1.0", // v1.17.6
        "symfony/framework-bundle": "^4.0", // v4.1.4
        "symfony/lts": "^4@dev", // dev-master
        "symfony/orm-pack": "^1.0", // v1.0.6
        "symfony/security-bundle": "^4.0", // v4.1.4
        "symfony/serializer-pack": "^1.0", // v1.0.1
        "symfony/twig-bundle": "^4.0", // v4.1.4
        "symfony/web-server-bundle": "^4.0", // v4.1.4
        "symfony/yaml": "^4.0", // v4.1.4
        "twig/extensions": "^1.5" // v1.5.2
    },
    "require-dev": {
        "doctrine/doctrine-fixtures-bundle": "^3.0", // 3.0.2
        "easycorp/easy-log-handler": "^1.0.2", // v1.0.7
        "fzaninotto/faker": "^1.7", // v1.8.0
        "symfony/debug-bundle": "^3.3|^4.0", // v4.1.4
        "symfony/dotenv": "^4.0", // v4.1.4
        "symfony/maker-bundle": "^1.0", // v1.7.0
        "symfony/monolog-bundle": "^3.0", // v3.3.0
        "symfony/phpunit-bridge": "^3.3|^4.0", // v4.1.4
        "symfony/profiler-pack": "^1.0", // v1.0.3
        "symfony/var-dumper": "^3.3|^4.0" // v4.1.4
    }
}