Flag of Ukraine
SymfonyCasts stands united with the people of Ukraine

Custom User Query & Credentials

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

On the screen, we see a dd() of the password I entered into the login form and the User entity object for the email I entered. Something, somehow knew to take the submitted email and query for the User!

UserBadge & The User Provider

Here's how this works. After we return the Passport object, the security system tries to find the User object from the UserBadge. If you just pass one argument to UserBadge - like we are - then it does this by leveraging our user provider. Remember that thing in security.yaml called providers?

security:
... lines 2 - 7
providers:
# used to reload user from session & other features (e.g. switch_user)
app_user_provider:
entity:
class: App\Entity\User
property: email
... lines 14 - 34

Because our User class is an entity, we're using the entity provider that knows how to load users using the email property. So basically this is an object that's really good at querying the user table via the email property. So when we pass just the email to the UserBadge, the user provider uses that to query for the User.

If a User object is found, Symfony then tries to "check the credentials" on our passport. Because we're using CustomCredentials, this means that it executes this callback... where we're dumping some data. If a User could not be found - because we entered an email that isn't in the database - authentication fails. More on both of these situations soon.

Custom User Query

Anyways, the point is this: if you just pass one argument to UserBadge, the user provider loads the user automatically. That's the easiest thing to do. And you can even customize this query a bit if you need to - search for "Using a Custom Query to Load the User" on the Symfony docs to see how.

Or... you can write your own custom logic to load the user right here. To do that, we're going to need the UserRepository. At the top of the class, add public function __construct()... and autowire a UserRepository argument. I'll hit Alt+Enter and select "Initialize properties" to create that property and set it:

... lines 1 - 5
use App\Repository\UserRepository;
... lines 7 - 17
class LoginFormAuthenticator extends AbstractAuthenticator
{
private UserRepository $userRepository;
public function __construct(UserRepository $userRepository)
{
$this->userRepository = $userRepository;
}
... lines 26 - 73
}

Down in authenticate(), UserBadge has an optional second argument called a user loader. Pass it a callback with one argument: $userIdentifier:

... lines 1 - 17
class LoginFormAuthenticator extends AbstractAuthenticator
{
... lines 20 - 31
public function authenticate(Request $request): PassportInterface
{
... lines 34 - 36
return new Passport(
new UserBadge($email, function($userIdentifier) {
... lines 39 - 46
}),
... lines 48 - 50
);
}
... lines 53 - 73
}

It's pretty simple: if you pass a callable, then when Symfony loads your User, it will call this function instead of your user provider. Our job here is to load the user and return it. The $userIdentifier will be whatever we passed to the first argument of UserBadge... so the email in our case.

Say $user = $this->userRepository->findOneBy() to query for email set to $userIdentifier:

... lines 1 - 17
class LoginFormAuthenticator extends AbstractAuthenticator
{
... lines 20 - 31
public function authenticate(Request $request): PassportInterface
{
... lines 34 - 36
return new Passport(
new UserBadge($email, function($userIdentifier) {
// optionally pass a callback to load the User manually
$user = $this->userRepository->findOneBy(['email' => $userIdentifier]);
... lines 41 - 46
}),
... lines 48 - 50
);
}
... lines 53 - 73
}

This is where you could use whatever custom query you want. If we can't find the user, we need to throw a special exception. So if not $user, throw new UserNotFoundException(). That will cause authentication to fail. At the bottom, return $user:

... lines 1 - 17
class LoginFormAuthenticator extends AbstractAuthenticator
{
... lines 20 - 31
public function authenticate(Request $request): PassportInterface
{
... lines 34 - 36
return new Passport(
new UserBadge($email, function($userIdentifier) {
// optionally pass a callback to load the User manually
$user = $this->userRepository->findOneBy(['email' => $userIdentifier]);
if (!$user) {
throw new UserNotFoundException();
}
return $user;
}),
... lines 48 - 50
);
}
... lines 53 - 73
}

This... is basically identical to what our user provider was doing a minute ago... so it won't change anything. But you can see how we have the power to load the User however we want to.

Let's refresh. Yup! The same dump as before.

Validating the Credentials

Ok, so if a User object is found - either from our custom callback or the user provider - Symfony next checks our credentials, which means something different depending on which credentials object you pass. There are 3 main ones: PasswordCredentials - we'll see that later, a SelfValidatingPassport which is good for API authentication and doesn't need any credentials - and CustomCredentials.

If you use CustomCredentials, Symfony executes the callback... and our job is to "check their credentials"... whatever that means in our app. The $credentials argument will match whatever we passed to the 2nd argument to CustomCredentials. For us, that's the submitted password:

... lines 1 - 17
class LoginFormAuthenticator extends AbstractAuthenticator
{
... lines 20 - 31
public function authenticate(Request $request): PassportInterface
{
... lines 34 - 36
return new Passport(
... lines 38 - 47
new CustomCredentials(function($credentials, User $user) {
... line 49
}, $password)
);
}
... lines 53 - 73
}

Let's pretend that all users have the same password tada! To validate that, return true if $credentials === 'tada':

... lines 1 - 17
class LoginFormAuthenticator extends AbstractAuthenticator
{
... lines 20 - 31
public function authenticate(Request $request): PassportInterface
{
... lines 34 - 36
return new Passport(
... lines 38 - 47
new CustomCredentials(function($credentials, User $user) {
return $credentials === 'tada';
}, $password)
);
}
... lines 53 - 73
}

Air-tight security!

Authentication Failure and Success

If we return true from this function, authentication is successful! Woo! If we return false, authentication fails. To prove this, go down to onAuthenticationSuccess() and dd('success'). Do the same thing inside onAuthenticationFailure():

... lines 1 - 17
class LoginFormAuthenticator extends AbstractAuthenticator
{
... lines 20 - 53
public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $firewallName): ?Response
{
dd('success');
}
public function onAuthenticationFailure(Request $request, AuthenticationException $exception): ?Response
{
dd('failure');
}
... lines 63 - 73
}

We'll put real code into these methods soon... but their purpose is pretty self-explanatory: if authentication is successful, Symfony will call onAuthenticationSuccess(). If authentication fails for any reason - like an invalid email or password - Symfony will call onAuthenticationFailure().

Let's try it! Go directly back to /login. Use the real email again - abraca_admin@example.com with the correct password: tada. Submit and... yes! It hit onAuthenticationSuccess(). Authentication is complete!

I know, it doesn't look like much yet... so next, let's do something on success, like redirect to another page. We're also going to learn about the other critical job of a user provider: refreshing the user from the session at the beginning of each request to keep us logged in.

Leave a comment!

6
Login or Register to join the conversation
akincer Avatar
akincer Avatar akincer | posted 1 year ago

If one wanted to use LDAP to do the password checking but otherwise have a normal symfony user (and not an LdapUser) would you do the password checking in the custom credentials? That's kind of what I took it to mean by "whatever that means in our app".

Reply

Hey @Aaron Kincer!

You got it :). That's exactly what I would do. Let me know if it works out like you want!

Cheers!

Reply
akincer Avatar

Yes this seems like it's going to do what I want. Due to the bind function in the ldap component taking over on invalid credentials I wrote some lower level LDAP auth code using the built-in LDAP facilities so I could trap the invalid credentials myself and return false.

Another thing I did was have the UserBadge callback code create a new symfony user in the database if one didn't exist but the username DOES exist in LDAP so the user could be returned. Since LDAP is the user database registration didn't make sense really.

One oddity is the "Logged in as" on the debug toolbar shows nothing. I feel like I ran in to that once before and you might have even told me the solution. I've eaten and slept since then though. I'll figure it out.

Reply
akincer Avatar

I swear I searched for all instances of "email" in the User entity and didn't see this but nonetheless the answer was indeed simple -- changing the field getUserIdentifier() returns.

Reply
Default user avatar
Default user avatar Aaron Kincer | akincer | posted 1 year ago

Ahh looks like the debug toolbar is determined to use email to identify who's logged on despite me configuring userId in security.yaml under app_user_provider as the identifying property. The getUsername function returns userId so I'm confused here as to why the profiler is determined to use email instead of userId. I'll keep looking.

Thanks for the help. Cheers to you!

Reply

Hey @Aaron Kincer!

Ha! Yes, nice job all around - including debugging :). Your setup makes sense to me - including the part of inserting a User if it's found in LDAP but not (yet) in your local database. That is the proper way to do it (not registration).

Cheers!

Reply
Cat in space

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

This tutorial also works great for Symfony 6!

What PHP libraries does this tutorial use?

// composer.json
{
    "require": {
        "php": ">=8.1",
        "ext-ctype": "*",
        "ext-iconv": "*",
        "babdev/pagerfanta-bundle": "^3.3", // v3.3.0
        "composer/package-versions-deprecated": "^1.11", // 1.11.99.4
        "doctrine/annotations": "^1.0", // 1.13.2
        "doctrine/doctrine-bundle": "^2.1", // 2.6.3
        "doctrine/doctrine-migrations-bundle": "^3.0", // 3.1.1
        "doctrine/orm": "^2.7", // 2.10.1
        "knplabs/knp-markdown-bundle": "^1.8", // 1.9.0
        "knplabs/knp-time-bundle": "^1.11", // v1.16.1
        "pagerfanta/doctrine-orm-adapter": "^3.3", // v3.3.0
        "pagerfanta/twig": "^3.3", // v3.3.0
        "phpdocumentor/reflection-docblock": "^5.2", // 5.2.2
        "scheb/2fa-bundle": "^5.12", // v5.12.1
        "scheb/2fa-qr-code": "^5.12", // v5.12.1
        "scheb/2fa-totp": "^5.12", // v5.12.1
        "sensio/framework-extra-bundle": "^6.0", // v6.2.0
        "stof/doctrine-extensions-bundle": "^1.4", // v1.6.0
        "symfony/asset": "5.3.*", // v5.3.4
        "symfony/console": "5.3.*", // v5.3.7
        "symfony/dotenv": "5.3.*", // v5.3.8
        "symfony/flex": "^1.3.1", // v1.17.5
        "symfony/form": "5.3.*", // v5.3.8
        "symfony/framework-bundle": "5.3.*", // v5.3.8
        "symfony/monolog-bundle": "^3.0", // v3.7.0
        "symfony/property-access": "5.3.*", // v5.3.8
        "symfony/property-info": "5.3.*", // v5.3.8
        "symfony/rate-limiter": "5.3.*", // v5.3.4
        "symfony/runtime": "5.3.*", // v5.3.4
        "symfony/security-bundle": "5.3.*", // v5.3.8
        "symfony/serializer": "5.3.*", // v5.3.8
        "symfony/stopwatch": "5.3.*", // v5.3.4
        "symfony/twig-bundle": "5.3.*", // v5.3.4
        "symfony/ux-chartjs": "^1.3", // v1.3.0
        "symfony/validator": "5.3.*", // v5.3.8
        "symfony/webpack-encore-bundle": "^1.7", // v1.12.0
        "symfony/yaml": "5.3.*", // v5.3.6
        "symfonycasts/verify-email-bundle": "^1.5", // v1.5.0
        "twig/extra-bundle": "^2.12|^3.0", // v3.3.3
        "twig/string-extra": "^3.3", // v3.3.3
        "twig/twig": "^2.12|^3.0" // v3.3.3
    },
    "require-dev": {
        "doctrine/doctrine-fixtures-bundle": "^3.3", // 3.4.0
        "symfony/debug-bundle": "5.3.*", // v5.3.4
        "symfony/maker-bundle": "^1.15", // v1.34.0
        "symfony/var-dumper": "5.3.*", // v5.3.8
        "symfony/web-profiler-bundle": "5.3.*", // v5.3.8
        "zenstruck/foundry": "^1.1" // v1.13.3
    }
}
userVoice