If you liked what you've learned so far, dive in!
Subscribe to get access to this tutorial plus
video, code and script downloads.
With a Subscription, click any sentence in the script to jump to that part of the video!
Login SubscribeOn 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!
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.
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.
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!
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.
Hey @Aaron Kincer!
You got it :). That's exactly what I would do. Let me know if it works out like you want!
Cheers!
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.
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.
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!
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!
// 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
}
}
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".