Flag of Ukraine
SymfonyCasts stands united with the people of Ukraine

form_login: The Built-in Authenticator

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

Custom authenticator classes like this give us tons of control. Like, imagine that, in addition to email and password fields, you needed a third field - like a "company" dropdown menu... and you use that value - along with the email - to query for the User. Doing that in here would be... pretty darn simple! Grab the company POST field, use it in your custom query and celebrate with nachos.

But a login form is a pretty common thing. And so, Symfony comes with a built-in login form authenticator that we can... just use!

Checking out the Core FormLoginAuthenticator

Let's open it up and check it out. Hit Shift+Shift and look for FormLoginAuthenticator.

The first thing to notice is that this extends the same base class that we do. And if you look at the methods - it references a bunch of options - but ultimately... it does the same stuff that our class does: getLoginUrl() generates a URL to the login page... and authenticate() creates a Passport with UserBadge, PasswordCredentials, a RememberMeBadge and a CsrfTokenBadge.

Both onAuthenticationSuccess and onAuthenticationFailure offload their work to another object... but if you looked inside of those, you would see that they're basically doing the same thing that we are.

Using form_login

So let's use this instead of our custom authenticator... which I would do in a real project unless I need the flexibility of a custom authenticator.

In security.yaml, comment-out our customer authenticator... and also comment-out the entry_point config:

security:
... lines 2 - 16
firewalls:
... lines 18 - 20
main:
... lines 22 - 23
#entry_point: App\Security\LoginFormAuthenticator
... lines 25 - 27
custom_authenticator:
# - App\Security\LoginFormAuthenticator
... lines 30 - 50

Replace it with a new key form_login. This activates that authenticator. Below, this has a ton of options - I'll show you them in a minute. But there are two important ones we need: login_path: set to the route to your login page... so for us that's app_login... and also the check_path, which is the route that the login form submits to... which for us is also app_login: we submit to the same URL:

security:
... lines 2 - 16
firewalls:
... lines 18 - 20
main:
... lines 22 - 24
form_login:
login_path: app_login
check_path: app_login
... lines 28 - 50

Setting the entry_point to form_login

And... that's it to start! Let's go try it! Refresh any page and... error! An error that we've seen:

Because you have multiple authenticators on firewall "main", you need to set "entry_point" to one of them: either DummyAuthenticator, or form_login.

I mentioned earlier that some authenticators provide an entry point and some don't. The remember_me authenticator does not provide one... but our DummyAuthenticator does and so does form_login. Its entry point redirects to the login page.

So since we have multiple, we need to choose one. Set entry_point: to form_login:

security:
... lines 2 - 16
firewalls:
... lines 18 - 20
main:
... lines 22 - 23
entry_point: form_login
... lines 25 - 50

Customizing the Login Form Field Names

Now if we refresh... cool: no error. So let's try to log in. Actually... I'll log out first... that still works... then go log in with abraca_admin@example.com password tada. And... ah! Another error!

The key "_username" must be a string, NULL given.

And it's coming from FormLoginAuthenticator::getCredentials(). Ok, so when you use the built-in form_login, you need to make sure a few things are lined up. Open the login template: templates/security/login.html.twig. Our two fields are called email... and password:

... lines 1 - 4
{% block body %}
<div class="container">
<div class="row">
<div class="login-form bg-light mt-4 p-4">
<form method="post" class="row g-3">
... lines 10 - 15
<div class="col-12">
... line 17
<input type="email" name="email" id="inputEmail" class="form-control" required autofocus>
</div>
<div class="col-12">
... line 21
<input type="password" name="password" id="inputPassword" class="form-control" required>
</div>
... lines 24 - 34
</div>
</div>
</div>
{% endblock %}

Whelp, it turns out that Symfony expects these fields to be called _username and _password... that's why we get this error: it's looking for an _username POST parameter... but it's not there. Fortunately, this is the type of thing you can configure.

Find your favorite terminal and run:

symfony console debug:config security

to see all of our current security configuration. Scroll up... and look for form_login... here it is. There are a bunch of options that allow you to control the form_login behavior. Two of the most important ones are username_parameter and password_parameter. Let's configure these to match our field names.

So, in security.yaml add username_parameter: email and password_parameter: password:

security:
... lines 2 - 16
firewalls:
... lines 18 - 20
main:
... lines 22 - 24
form_login:
... lines 26 - 27
username_parameter: email
password_parameter: password
... lines 30 - 53

This tells it to read the email POST parameter... and then it will pass that string to our user provider... which will handle querying the database.

Let's test it. Refresh to resubmit and... got it! We're logged in!

The moral of the story is this: using form_login lets you have a login form with less code. But while using a custom authenticator class is more work... it has infinite flexibility. So, it's your choice.

Next: let's see a few other things that we can configure on the login form and add a totally new-feature: pre-filling the email field when we fail login.

Leave a comment!

18
Login or Register to join the conversation
Georg H. Avatar
Georg H. Avatar Georg H. | posted 1 month ago

Hi there,
thanks for this wonderful introduction to the security system.

Everything works as expected with symfony's test server. As soon as I move the code to the hosting provider all redirects (after login or logout) go to "http" instead of "https". The `.htaccess`-file contains this snippet to send requests to `index.php`:

RewriteCond %{REQUEST_FILENAME} !-f
RewriteRule ^ %{ENV:BASE}/public/index.php [L]

Then in `framework.yaml` I have to set

assets:
base_path: 'public'

The following in `.htaccess` does not work

RewriteCond %{HTTP:X-Forwarded-Proto} !https
RewriteRule ^.*$ https://%{SERVER_NAME}%{REQUEST_URI}

It uses the full path as URL (including 'public/index.php') which looks ugly in the browser address-line, and the images are missing.
Thanks for a hint!

Reply
Tomáš S. Avatar
Tomáš S. Avatar Tomáš S. | posted 6 months ago

Hello,

could anybody help me please with using multiple authentifications providers? I need authenticate users first from local user database (App\Entity\User) and then from LDAP. When the user is not found in local database, then check from LDAP.

I have this security.yml
security:
enable_authenticator_manager: true

# https://symfony.com/doc/current/security.html#registering-the-user-hashing-passwords
password_hashers:
Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface: 'auto'
App\Entity\User:
algorithm: auto

providers:
app_user_provider:
entity:
class: App\Entity\User
property: email

ldap_user_provider:
ldap:
service: Symfony\Component\Ldap\Ldap
base_dn: 'dc=ipp,dc=local'
search_dn: 'cn=searchuser,cn=Users,dc=example,dc=com'
search_password: 'password'
default_roles: ROLE_USER
uid_key: userPrincipalName
#extra_fields: ['mail', 'cn']

chain_provider:
chain:
providers: ['app_user_provider', 'ldap_user_provider']

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

main:
lazy: true
provider: chain_provider

form_login:
login_path: login
check_path: login
enable_csrf: true

form_login_ldap:
service: Symfony\Component\Ldap\Ldap
login_path: login
check_path: login
dn_string: '{username}'

logout:
path: logout
target: login

# Easy way to control access for large sections of your site
# Note: Only the *first* access control that matches will be used
access_control:
# allow unauthenticated users to access the login form
- { path: ^/login, roles: PUBLIC_ACCESS }

- { path: ^/admin, roles: ROLE_ADMIN }

- { path: ^/app, roles: ROLE_USER }

Actually, users from form_login are successfully authenticated but, from LDAP not. When I comment form_login from main firewall, users should authenticate from LDAP, but not from local database.

Cheers

Reply

Hey Tomáš Skočdopole

You need to create a custom authenticator where you'll check the database first for the user's credentials, and in case it was not found, you'll use your Ldap service to fetch the user. Here are the details of how to write a custom authenticator: https://symfony.com/doc/cur...

Cheers!

Reply
Tomáš S. Avatar

Hello Diego,

thank you for your advice. Before when I start to creating a custom authenticator. Could you please explain me purpose of chain provider?

I have expected, when I set chain_provider like this:

chain_provider:
chain:
providers: ['app_user_provider', 'ldap_user_provider']

then the application try to authenticate with app_user_provider first and when user is not found, then try second ldap_user_provider. Or I am wrong?

Thank you
Tomas

Reply

Yes, that's exactly the behavior of the ChainProvider, you can read more about it here: https://symfony.com/doc/cur...

Do you have multiple authenticators as well? If that's the case, then, I think you don't need a ChainProvider because each authenticator can implement the specific logic for finding users

Reply
Tomáš S. Avatar

Thank you for confirming ChainProvider behavior.

Multiple authenticators? I thnink yes, I have...

form_login:
login_path: login
check_path: login
enable_csrf: true

form_login_ldap:
service: Symfony\Component\Ldap\Ldap
login_path: login
check_path: login
dn_string: '{username}'

So what did you thing I have wrong in security.yml?

Reply

Oh, I think I see the problem. You're using the same login/check route on both cases. You need to use different ones, otherwise, Symfony will always use the first service declared (in this case, form_login)

Reply
Tomáš S. Avatar

Hey Diego,

Thank you for look at my problem. I try to change security.yml this way:form_login:
login_path: login
check_path: login
enable_csrf: true

form_login_ldap:
service: Symfony\Component\Ldap\Ldap
login_path: login_ldap
check_path: login_ldap
dn_string: '{username}'

but without success. The users are still validated only from local database, but not from LDAP.

There must be somewhere any different error...

Tomas

Reply

Hmmm, that's odd. Are you using different routes on each authenticator, right? I think I need to see your security.yaml file (preserving formatting). Is there a place where you can upload it?

Reply
Tomáš S. Avatar

Hello Diego,

Here is my security.yml uploaded...
http://ftp.intevia.cz/secur...

I you need, I can prepare new symfony applcation with this issue only and give you access to repo...

Cheers
Tomas

Reply

Hey man, I think I found your problem! and it's quite sneaky. Under your "access_control" key, you have defined a few paths, but those actually work as a REGEX, and the first path that matches is the winner. In your case, you have ^/login at the top, then you have your Ldap route ^loginldap/ - Can you see the problem here? Your first login route will always win (because of the REGEX thing)

Cheers!

Reply
Tomáš S. Avatar

Hello,
OK, I change paths to login

            form_login:
login_path: login
check_path: login
enable_csrf: true

form_login_ldap:
service: Symfony\Component\Ldap\Ldap
login_path: ldaplogin
check_path: ldaplogin
search_dn: 'cn=ldapuser,cn=Users,dc=example,dc=com'
search_password: 'ldapuser_password'
dn_string: '{username}'

And under "access_control" like this:

        - { path: ^/login, roles: PUBLIC_ACCESS }
- { path: ^/ldaplogin, roles: PUBLIC_ACCESS }

But when I open the page /login, I cannot login from local database and from ldap too (any method does not work)
When I open the /ldaplogin page, I am able to log with local database user only, not from ldap.

Could I upload messages from profiler? I can make some printscreen...

Reply

That's interesting, I was not expecting that behavior. I believe your case fits into having multiple firewalls enabled, one for your login form, and another one for the Ldap form. Here's an example: https://symfony.com/doc/cur...

Reply
Tomáš S. Avatar

Hello,

In this example are two firewalls...But I have define entry points in one firewall.


# config/packages/security.yaml
security:
# ...
firewalls:
api:
pattern: ^/api/
custom_authenticators:
- App\Security\ApiTokenAuthenticator
main:
lazy: true
form_login: ~

access_control:
- { path: '^/login', roles: PUBLIC_ACCESS }
- { path: '^/api', roles: ROLE_API_USER }
- { path: '^/', roles: ROLE_USER }

My goal is to have ONE page for login and use chain_provider. So if the user is not found in local database, then try ldap.

I try to make example application.

Cheers
Tomas

Reply
Tomáš S. Avatar

Hey Diego,

I have created a repository where is everything described. Here is https://github.com/skocdopo...

Now it should be everything clear for you.

Thank you

Reply

Hey Tomáš Skočdopole!

I noticed your message before Diego did today ;). Thanks for. the repository, that's very helpful. I understand what you're trying to do... and it's oddly complex. With the setup in the repo, what will happen is when you submit the login form, either form_login OR form_login_ldap will FIRST try to authenticate the request. Let's assume form_login is first. If you log in using a user in your database, I bet it will work.

But if you login with a user that's only in LDAP, it will fail, throw an authentication error and NEVER try form_login_ldap.

So... does having the "chain provider" help with this? MAYBE, and this is where my limited knowledge of LDAP comes into play. In theory, when form_login is processing, if it can't find the user in the database, it would then go find it in ldap thanks to the chain provider. Then, effectively, Symfony will call $user->getPassword() on that LdapUser and compare it to the submitted password. And so, in theory, if the password is being loaded from LDAP, this should work. However, I don't think this is the standard way of checking LDAP passwords (I believe this is the standard way: https://github.com/symfony/... ).

So... it might work with a chain provider and form_login only? But I'm not sure... and it seems weird to me. Also, even if you got this working, it would mean that, if the user logs in and is in the database, then the logged in user is App\Entity\User. If the user is in Ldap, it is Symfony\Component\Ldap\Security\LdapUser... which gets weird, because you can't use any custom methods that you have inside of App\Entity\User reliably.

A more common flow that I've seen works like this:

A) You ALWAYS authenticate against LDAP... because that's where user passwords are stored.
B) After login, you load or create a User entity to correspond to this LDAP user.
C) Then, you. are finally logged in AS the Entity User.

I would probably do this via a custom authenticator using https://github.com/symfony/... as inspiration for doing the LDAP logic.

But, i'm assuming a lot, so that's enough for now. Let me know if this is helpful :).

Cheers!

Reply
Tomáš S. Avatar

Hi Ryan,

Thank you for your very comprehensive answer.

The LDAP authentication is performed using two connections. First connection is created (using the search_dn and search_password parameters from the configuration) to query whether the user exists in the LDAP database. If the given user is found, a second connection is established (using the given user and his password) for check a password. If connection is successfully established, it means that the password is correct. Then is the user successfully authenticated!

I know it's probably best to do the mechanism you're proposing, but in this case I don't need to store logged-in users in a local database...

But I have lot of questions about chain_provider. Why is it implemented at all? What is its purpose?

I mean, isn't this a symfony bug?

Sincerely, Thomas

Reply

Hey @Thomas!

I actually very much appreciate that description of LDAP. I get questions about it somewhat frequently, but have never needed to use it directly myself. Great explanation!

> But I have lot of questions about chain_provider. Why is it implemented at all? What is its purpose?

The user provider, in general, has 2 purposes:

A) It's used to "load the user" for some features like switch_user and remember_me (e.g. take the "username" from the cookie and find that User).

B) Most "authentication mechanisms" (though not a requirement) use this to "load the user" during authentication. For example, form_login and, I think, form_login_ldap use this. However, different authentication mechanisms then have totally different ways for checking the password. And THAT is the part that's causing you trouble.

> I mean, isn't this a symfony bug?

Things are working as designed. However, I DO think that what you're trying to accomplish should be less trouble.

> I know it's probably best to do the mechanism you're proposing, but in this case I don't need to store logged-in users in a local database...

Ah, that's no problem! I was guessing some things about your implementation before, but let me rephrase them. Create a custom authenticator that:

A) Pass the email/username to the UserPassport. That's all your need to do. If you're using the chain provider, then the User will be found in your normal user provider or ldap.
B) For credentials, pass a CustomCredentials. In the callback you pass this, the 2nd argument will be the User object that was found (first will be the submitted password). Based on whether the user was found in the database vs in LDAP, you will have one of two different classes (your entity or a core LdapUser). If normal User, check the password (inject the user password checker service). If Ldap, try to bind the password as you described.

You're still going to be building some things by hand and doing more work than you should need to, but this general plan should work. Let me know what you think - I'm still doing some guessing on your requirements. Also, it IS weird that some users will be logged in via one User class and others via a different LdapUser. That's allowed... but you won't be able to call any methods that both of these do not share.

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": "^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.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
    }
}