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!

What PHP libraries does this tutorial use?

// composer.json
{
    "require": {
        "php": "^7.4.1 || ^8.0.0",
        "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.4.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
    }
}