Symfony 3.4 基于姓名、登录名和密码的认证实现咨询
嘿,我完全懂你现在的纠结——官方文档只覆盖了登录名+密码的认证,你自己写的loadUserByUsername直接硬取$_POST参数的方式确实太别扭了,不仅耦合了请求上下文,测试起来麻烦,还不符合Symfony认证组件的设计原则。扩展Token类其实是非常合适的方案,这也是Symfony认证系统设计时预留的扩展点,咱们一步步来捋清楚正确的实现方式:
为什么你的现有实现不够好?
直接在loadUserByUsername里调用$_POST["_name"]会带来几个问题:
- 耦合HTTP请求:这个方法只能在Web请求场景下用,没法在CLI或者其他非HTTP环境中复用;
- 违反单一职责:
loadUserByUsername的职责应该是根据用户名加载用户,而不是处理请求参数; - 测试困难:没法单独测试这个方法,必须模拟HTTP请求才能验证逻辑。
正确的实现思路:扩展Token与认证组件
Symfony的认证流程核心是Token(凭证载体)和AuthenticationProvider(凭证验证者),默认的UsernamePasswordToken只存了登录名和密码,所以咱们需要扩展它来携带姓名信息,再配合自定义的Provider完成三重验证。
1. 创建自定义Token类
继承默认的UsernamePasswordToken,添加姓名属性并实现序列化(因为Token需要被序列化存储到Session):
namespace AppBundle\Security; use Symfony\Component\Security\Core\Authentication\Token\UsernamePasswordToken; class NameLoginPasswordToken extends UsernamePasswordToken { private $name; public function __construct($user, $credentials, $providerKey, array $roles = [], string $name) { parent::__construct($user, $credentials, $providerKey, $roles); $this->name = $name; } public function getName(): string { return $this->name; } // 必须实现序列化/反序列化,确保Token能在Session中正确存储 public function serialize() { return serialize([$this->name, parent::serialize()]); } public function unserialize($serialized) { list($this->name, $parentSerialized) = unserialize($serialized); parent::unserialize($parentSerialized); } }
2. 自定义AuthenticationProvider
默认的DaoAuthenticationProvider只处理UsernamePasswordToken,咱们需要自定义Provider来处理新的Token,同时用登录名+姓名查询用户:
namespace AppBundle\Security; use Symfony\Component\Security\Core\Authentication\Provider\DaoAuthenticationProvider; use Symfony\Component\Security\Core\Exception\UsernameNotFoundException; class NameLoginAuthenticationProvider extends DaoAuthenticationProvider { protected function retrieveUser($login, \Symfony\Component\Security\Core\Authentication\Token\TokenInterface $token) { /** @var NameLoginPasswordToken $token */ $userProvider = $this->getUserProvider(); try { // 调用UserRepository中新增的查询方法 $user = $userProvider->loadUserByLoginAndName($login, $token->getName()); } catch (UsernameNotFoundException $e) { $e->setUsername($login); throw $e; } if (!$user) { throw new UsernameNotFoundException(sprintf('未找到登录名为"%s"且姓名为"%s"的用户', $login, $token->getName())); } return $user; } }
3. 完善UserRepository的查询方法
把之前硬取POST参数的逻辑改成参数传入的方式,符合依赖注入和单一职责:
// AppBundle/Repository/UserRepository.php use Symfony\Component\Security\Core\Exception\UsernameNotFoundException; public function loadUserByLoginAndName(string $login, string $name) { $user = $this->createQueryBuilder('u') ->where('u.login = :login AND u.name = :name') ->setParameter('login', $login) ->setParameter('name', $name) ->setMaxResults(1) ->getQuery() ->getOneOrNullResult(); if (null === $user) { throw new UsernameNotFoundException(sprintf('未找到登录名为"%s"且姓名为"%s"的用户', $login, $name)); } return $user; }
记得确保你的UserRepository实现了UserProviderInterface,或者在自定义UserProvider中调用这个方法。
4. 自定义表单认证监听器
默认的UsernamePasswordFormAuthenticationListener只会创建UsernamePasswordToken,咱们需要重写它来创建自定义Token,从请求中获取姓名参数:
namespace AppBundle\Security; use Symfony\Component\Security\Http\Firewall\UsernamePasswordFormAuthenticationListener; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\Security\Core\Security; class NameLoginFormAuthenticationListener extends UsernamePasswordFormAuthenticationListener { protected function attemptAuthentication(Request $request) { // 从请求中获取表单参数 $login = trim($request->request->get($this->options['username_parameter'])); $password = $request->request->get($this->options['password_parameter']); $name = trim($request->request->get('_name')); // 对应登录表单的姓名字段name属性 // 记录最后尝试的登录名到Session $request->getSession()->set(Security::LAST_USERNAME, $login); // 创建自定义Token,暂不加载用户(交给Provider处理) $token = new NameLoginPasswordToken(null, $password, $this->providerKey, [], $name); $token->setUser($login); // 交给认证管理器处理验证逻辑 return $this->authenticationManager->authenticate($token); } }
5. 配置Security.yml和服务
最后在配置文件中替换默认的组件,让Symfony使用咱们的自定义类:
# app/config/security.yml security: providers: app_user_provider: entity: class: AppBundle:User property: login firewalls: main: pattern: ^/ form_login: login_path: login check_path: login_check username_parameter: _login # 你的登录表单登录名字段name password_parameter: _password # 密码字段name listener: app.name_login_form_authentication_listener # 使用自定义监听器 logout: ~ access_control: - { path: ^/login, roles: IS_AUTHENTICATED_ANONYMOUSLY } - { path: ^/, roles: ROLE_USER } # app/config/services.yml services: # 自定义认证Provider app.name_login_authentication_provider: class: AppBundle\Security\NameLoginAuthenticationProvider arguments: - '@security.user.provider.concrete.app_user_provider' - '@security.user_checker' - 'app_user_provider' - '@security.password_encoder' tags: - { name: security.authentication_provider } # 自定义表单认证监听器 app.name_login_form_authentication_listener: class: AppBundle\Security\NameLoginFormAuthenticationListener arguments: - '@security.authentication.manager' - '@security.session_strategy' - '@security.http_utils' - 'app_user_provider' - '@security.authentication.success_handler' - '@security.authentication.failure_handler' - { username_parameter: '_login', password_parameter: '_password' } - '@logger' - '@security.csrf.token_manager' tags: - { name: monolog.logger, channel: security }
总结
扩展Token类是Symfony中实现多字段认证的标准方式,这样的实现完全遵循了Symfony认证系统的设计原则:
- 解耦请求与用户查询逻辑,代码更易复用和测试;
- 各个组件职责单一,Token负责携带凭证,Provider负责验证,Listener负责处理请求;
- 完全兼容Symfony的现有认证流程,不需要重构大量代码。
内容的提问来源于stack exchange,提问作者pierzcha

