首页 > 解决方案 > Symfony 5.1:使用实体用户提供者的 LDAP 身份验证

问题描述

我正在使用 Symfony 5.1 并尝试实现 LDAP 身份验证,而用户属性(用户名、角色等)存储在 MySQL 数据库中。因此,我为 Doctrine 添加了一个用户实体,并配置了与文档对应的 services.yml 和 security.yml。

我还使用了 Maker Bundle 来生成一个LoginFormAuthenticator似乎使用 Guard Authenticator 模块。

当我尝试登录时,它看起来好像没有做任何与 LDAP 相关的事情。我还用 tcpdump 监听了 TCP 包,没有看到任何到 LDAP 服务器的流量。

这是我的代码:

services.yml

services:
    _defaults:
        autowire: true      # Automatically injects dependencies in your services.
        autoconfigure: true # Automatically registers your services as commands, event subscribers, etc.

    App\:
        resource: '../src/*'
        exclude: '../src/{DependencyInjection,Entity,Migrations,Tests,Kernel.php}'

    App\Controller\:
        resource: '../src/Controller'
        tags: ['controller.service_arguments']

    Symfony\Component\Ldap\Ldap:
        arguments: ['@Symfony\Component\Ldap\Adapter\ExtLdap\Adapter']
    Symfony\Component\Ldap\Adapter\ExtLdap\Adapter:
        arguments:
            -   host: <ldap-IP>
                port: 389
                options:
                    protocol_version: 3
                    referrals: false

security.yml

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

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

        my_ldap:
            ldap:
                service: Symfony\Component\Ldap\Ldap
                base_dn: "<base_dn>"
                search_dn: "<search_dn>"
                search_password: "<password>"
                default_roles: ROLE_USER
                uid_key: sAMAccountName

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

            form_login_ldap:
                login_path: login
                check_path: login
                service: Symfony\Component\Ldap\Ldap
                dn_string: 'uid={username},OU=Test,DC=domain,DC=domain'

            guard:
                authenticators:
                    - App\Security\LoginFormAuthenticator
            logout:
                path: app_logout
                # where to redirect after logout
                target: index

    access_control:
        - { path: ^/$, roles: IS_AUTHENTICATED_ANONYMOUSLY }
        - { path: ^/admin, roles: ROLE_ADMIN }
        - { path: ^/profile, roles: ROLE_USER }

LoginFormAuthenticator我想问题出在checkCredentials函数内。我发现LdapBindAuthenticationProvider该类的目的似乎正是针对 LDAP 进行此类用户凭据检查,但我完全不确定我必须如何去做:

<?php

namespace App\Security;

use Psr\Log\LoggerInterface;
use App\Entity\User;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Encoder\UserPasswordEncoderInterface;
use Symfony\Component\Security\Core\Exception\CustomUserMessageAuthenticationException;
use Symfony\Component\Security\Core\Exception\InvalidCsrfTokenException;
use Symfony\Component\Security\Core\Security;
use Symfony\Component\Security\Core\User\UserInterface;
use Symfony\Component\Security\Core\User\UserProviderInterface;
use Symfony\Component\Security\Csrf\CsrfToken;
use Symfony\Component\Security\Csrf\CsrfTokenManagerInterface;
use Symfony\Component\Security\Guard\Authenticator\AbstractFormLoginAuthenticator;
use Symfony\Component\Security\Guard\PasswordAuthenticatedInterface;
use Symfony\Component\Security\Http\Util\TargetPathTrait;
use Symfony\Component\Ldap\Ldap;
use Symfony\Component\Security\Core\Authentication\Provider\LdapBindAuthenticationProvider;

class LoginFormAuthenticator extends AbstractFormLoginAuthenticator implements PasswordAuthenticatedInterface
{
    use TargetPathTrait;

    public const LOGIN_ROUTE = 'app_login';

    private $logger;
    private $entityManager;
    private $urlGenerator;
    private $csrfTokenManager;
    private $passwordEncoder;
    private $ldap;

    public function __construct(LoggerInterface $logger, EntityManagerInterface $entityManager, UrlGeneratorInterface $urlGenerator, CsrfTokenManagerInterface $csrfTokenManager, UserPasswordEncoderInterface $passwordEncoder, Ldap $ldap, LdapBindAuthenticationProvider $form_login_ldap)
    {
        $this->logger = $logger;
        $this->entityManager = $entityManager;
        $this->urlGenerator = $urlGenerator;
        $this->csrfTokenManager = $csrfTokenManager;
        $this->passwordEncoder = $passwordEncoder;
        $this->ldap = $ldap;
    }

    public function supports(Request $request): ?bool
    {
        return self::LOGIN_ROUTE === $request->attributes->get('_route')
            && $request->isMethod('POST');
    }

    public function getCredentials(Request $request)
    {
        $credentials = [
            'username' => $request->request->get('username'),
            'password' => $request->request->get('password'),
            'csrf_token' => $request->request->get('_csrf_token'),
        ];
        $request->getSession()->set(
            Security::LAST_USERNAME,
            $credentials['username']
        );

        return $credentials;
    }

    public function getUser($credentials, UserProviderInterface $userProvider)
    {
        $token = new CsrfToken('authenticate', $credentials['csrf_token']);
        if (!$this->csrfTokenManager->isTokenValid($token)) {
            throw new InvalidCsrfTokenException();
        }

        $user = $this->entityManager->getRepository(User::class)->findOneBy(['email' => $credentials['username']]);

        if (!$user) {
            // user not found in db, but may exist in ldap:
            $user = $userProvider->loadUserByUsername($credentials['username']);
            if (!$user) {
                // user simply doesn't exist
                throw new CustomUserMessageAuthenticationException('Email could not be found.');
            } else {
                // user never logged in before, create user in DB and proceed...
                // TODO
            }
        }

        return $user;
    }

    public function checkCredentials($credentials, UserInterface $user)
    {
        // TODO: how to use the LdapBindAuthenticationProvider here to check the users credentials agains LDAP?
        return $this->passwordEncoder->isPasswordValid($user, $credentials['password']);
    }

    /**
     * Used to upgrade (rehash) the user's password automatically over time.
     */
    public function getPassword($credentials): ?string
    {
        return $credentials['password'];
    }

    public function onAuthenticationSuccess(Request $request, TokenInterface $token, $providerKey)
    {
        if ($targetPath = $this->getTargetPath($request->getSession(), $providerKey)) {
            return new RedirectResponse($targetPath);
        }

        // For example : return new RedirectResponse($this->urlGenerator->generate('some_route'));
        throw new \Exception('TODO: provide a valid redirect inside '.__FILE__);
    }

    protected function getLoginUrl()
    {
        return $this->urlGenerator->generate(self::LOGIN_ROUTE);
    }
}

不幸的是,我没有找到任何示例代码。

更新:

感谢 T. van den Berg 的回答,我终于设法让身份验证部分正常工作。我从 security.yml 中删除了 LoginFormAuthenticator Guard,并稍微调整了 form_login_ldap。

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

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

        my_ldap:
            ldap:
                service: Symfony\Component\Ldap\Ldap
                base_dn: '<baseDN>'
                search_dn: '<bindDN>'
                search_password: '<bindDN password>'
                default_roles: ['ROLE_USER']

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

        main:
            anonymous: true
            lazy: true
            provider: my_ldap

            form_login_ldap:
                login_path: app_login
                check_path: app_login
                service: Symfony\Component\Ldap\Ldap
                dn_string: '<baseDN>'
                query_string: '(sAMAccountName={username})'
                search_dn: '<bindDN>'
                search_password: '<bindDN password>'

            logout:
                path: app_logout
                target: index

    access_control:
        - { path: ^/$, roles: IS_AUTHENTICATED_ANONYMOUSLY }
        - { path: ^/admin, roles: ROLE_ADMIN }
        - { path: ^/profile, roles: ROLE_USER }

它现在使用 LDAPUserProvider 使用 LDAP 服务用户(绑定 DN)通过其登录名(sAMAccountName)获取用户 LDAP 对象,然后在第二个请求中使用此 LDAP 对象的专有名称(DN)进行另一次身份验证使用提供的密码针对 LDAP 服务器。到目前为止很好。

唯一缺少的是数据库存储的用户实体。我的想法如下:

密码未保存在数据库中,但其他应用程序特定信息在 LDAP 中不可用(例如上次活动)。

有谁知道如何实现这个?

标签: phpsymfonyauthenticationldapsymfony5

解决方案


ldaptools/ldaptools-bundle (or Maks3w/FR3DLdapBundle)如果您想在 LDAP 用户登录后将其保存到本地数据库,则可以使用此捆绑包。

有关更多信息,请参阅:https ://github.com/ldaptools/ldaptools-bundle/blob/master/Resources/doc/Save-LDAP-Users-to-the-Database-After-Login.md

更新 :

这就是我让它工作的方式(没有外部捆绑包):

  1. 安全.yaml
security:
    encoders:
        App\Entity\User:
            algorithm: auto

    # 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: username

        ldap_server:
            ldap:
                service: Symfony\Component\Ldap\Ldap
                base_dn: "dc=example,dc=com"
                search_dn: "cn=read-only-admin,dc=example,dc=com"
                search_password: "password"
                default_roles: ROLE_USER
                uid_key: uid

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

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


            form_login:
                login_path: app_login
                check_path: app_login

            form_login_ldap:
                login_path: app_login
                check_path: app_login
                service: Symfony\Component\Ldap\Ldap
                dn_string: 'uid={username},dc=example,dc=com'

            logout:
                path: app_logout

  1. 事件监听器
<?php


namespace App\EventListener;


use App\Entity\User;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Security\Core\Encoder\UserPasswordEncoderInterface;
use Symfony\Component\Security\Http\Event\InteractiveLoginEvent;

class LoginEventListener
{
    /**
     * @var EntityManagerInterface
     */
    protected $em;

    /**
     * @var UserPasswordEncoderInterface
     */
    private $encoder;

    /**
     * LoginEventListener constructor.
     * @param EntityManagerInterface $em
     * @param UserPasswordEncoderInterface $encoder
     */
    public function __construct(EntityManagerInterface $em, UserPasswordEncoderInterface $encoder)
    {
        $this->em = $em;
        $this->encoder = $encoder;
    }

    /**
     * @param InteractiveLoginEvent $event
     */
    public function onLoginSuccess(InteractiveLoginEvent $event)
    {
        $request = $event->getRequest();
        $token = $event->getAuthenticationToken();
        $loggedUser = $token->getUser();

//     If the logged user is not an instance of User (not ldapUser), then it hasn't been saved to the database. So save it..
        if(!($loggedUser instanceof User)) {
            $user = new User();
            $user->setUsername($request->request->get('_username'));
            $user->setPassword($this->encoder->encodePassword($user, $request->request->get('_password')));
            $user->setRoles($loggedUser->getRoles());
            $this->em->persist($user);
            $this->em->flush();
        }

    }

  1. 服务.yaml
# ldap service
    Symfony\Component\Ldap\Ldap:
        arguments: [ '@Symfony\Component\Ldap\Adapter\ExtLdap\Adapter' ]
    Symfony\Component\Ldap\Adapter\ExtLdap\Adapter:
        arguments:
            - host: ldap.forumsys.com
              port: 389
              options:
                  protocol_version: 3
                  referrals: false

    app_bundle.event.login_listener:
        class: App\EventListener\LoginEventListener
        arguments: [ '@doctrine.orm.entity_manager', '@security.user_password_encoder.generic' ]
        tags:
            - { name: kernel.event_listener, event: security.interactive_login, method: onLoginSuccess }


推荐阅读