Symfony Security组件:实现自定义身份验证提供者(User Provider)的完整流程

Symfony Security 组件:实现自定义身份验证提供者(User Provider)的完整流程

大家好!今天我们来深入探讨 Symfony Security 组件中一个非常重要的概念:自定义身份验证提供者(User Provider)。我们将从理论到实践,一步一步地构建一个完整的自定义 User Provider,让你彻底掌握其工作原理和实现方法。

1. 身份验证机制概述

在开始之前,我们先简单回顾一下 Symfony Security 组件的身份验证流程。

  1. 请求到达: 用户发起一个需要身份验证的请求。
  2. 防火墙匹配: Symfony 的防火墙配置根据请求的 URL 匹配相应的安全配置。
  3. 身份验证流程启动: 防火墙配置中指定的身份验证监听器(Authentication Listener)开始工作。
  4. 凭据提取: 身份验证监听器负责从请求中提取用户的凭据(例如用户名和密码,或者 API 密钥)。
  5. 身份验证: 身份验证监听器将凭据传递给身份验证管理器(Authentication Manager)。身份验证管理器根据配置的身份验证提供者(Authentication Provider)进行身份验证。
  6. User Provider 查找用户: Authentication Provider 通过 User Provider 根据凭据中的用户信息(例如用户名)查找用户。
  7. 用户验证: Authentication Provider 验证用户凭据是否有效(例如,检查密码是否正确)。
  8. 成功/失败: 如果身份验证成功,Authentication Manager 将创建一个 Token 对象,其中包含用户的信息和角色。这个 Token 对象会被存储在 SecurityContext 中,以便后续的请求可以访问用户的信息。如果身份验证失败,Authentication Manager 会抛出一个 AuthenticationException。

User Provider 在整个流程中扮演着至关重要的角色,它负责从数据源(例如数据库、LDAP 服务器、API)中加载用户的信息,并返回一个 UserInterface 接口的实现。

2. UserInterface 接口

Symfony Security 组件使用 SymfonyComponentSecurityCoreUserUserInterface 接口来表示用户。你的用户类必须实现这个接口。这个接口定义了以下方法:

方法名 返回值类型 描述
getUserIdentifier() string 返回用户的唯一标识符(例如用户名、邮箱地址)。这个方法在 Symfony 5.3 之前是 getUsername(),推荐使用 getUserIdentifier()
getRoles() array 返回用户的角色列表。角色用于授权,决定用户可以访问哪些资源。
getPassword() string 返回用户的密码。
getSalt() ?string 返回用户的盐值(如果使用)。在现代密码存储中,通常不需要盐值,可以返回 null
eraseCredentials() void 清除用户敏感信息,例如密码。这个方法在用户被存储到 Session 之前被调用。
isAccountNonExpired() bool 检查用户账户是否过期。
isAccountNonLocked() bool 检查用户账户是否被锁定。
isCredentialsNonExpired() bool 检查用户凭据是否过期。
isEnabled() bool 检查用户账户是否启用。

3. 实现 UserInterface

假设我们有一个简单的 AppEntityUser 实体类,它包含以下属性:

  • id: 用户 ID (int)
  • username: 用户名 (string)
  • email: 邮箱地址 (string)
  • password: 密码 (string)
  • roles: 角色 (array)
  • isActive: 是否启用 (bool)

我们需要让这个实体类实现 UserInterface 接口。

<?php

namespace AppEntity;

use SymfonyComponentSecurityCoreUserUserInterface;
use SymfonyComponentSecurityCoreUserPasswordAuthenticatedUserInterface;

class User implements UserInterface, PasswordAuthenticatedUserInterface
{
    private ?int $id = null;

    private string $username;

    private string $email;

    private string $password;

    private array $roles = [];

    private bool $isActive = true;

    public function getId(): ?int
    {
        return $this->id;
    }

    public function getUsername(): string
    {
        return $this->username;
    }

    public function setUsername(string $username): self
    {
        $this->username = $username;

        return $this;
    }

    public function getEmail(): string
    {
        return $this->email;
    }

    public function setEmail(string $email): self
    {
        $this->email = $email;

        return $this;
    }

    public function getPassword(): string
    {
        return $this->password;
    }

    public function setPassword(string $password): self
    {
        $this->password = $password;

        return $this;
    }

    public function getRoles(): array
    {
        $roles = $this->roles;
        // 保证用户至少拥有 ROLE_USER 角色
        $roles[] = 'ROLE_USER';

        return array_unique($roles);
    }

    public function setRoles(array $roles): self
    {
        $this->roles = $roles;

        return $this;
    }

    public function isActive(): bool
    {
        return $this->isActive;
    }

    public function setIsActive(bool $isActive): self
    {
        $this->isActive = $isActive;

        return $this;
    }

    public function getSalt(): ?string
    {
        // 不使用盐值,返回 null
        return null;
    }

    public function eraseCredentials(): void
    {
        // 清除密码,防止密码泄露
        // $this->password = null;  // 可以选择性地清除密码
    }

    public function getUserIdentifier(): string
    {
        return (string) $this->username;
    }

    public function isAccountNonExpired(): bool
    {
        return true; // 账户永不过期
    }

    public function isAccountNonLocked(): bool
    {
        return true; // 账户不锁定
    }

    public function isCredentialsNonExpired(): bool
    {
        return true; // 凭据永不过期
    }

    public function isEnabled(): bool
    {
        return $this->isActive; // 账户是否启用
    }
}

注意:

  • getRoles() 方法确保用户至少拥有 ROLE_USER 角色。
  • getSalt() 方法返回 null,因为我们不使用盐值。
  • eraseCredentials() 方法可以选择性地清除密码。
  • 实现了 PasswordAuthenticatedUserInterface 接口,该接口只有一个方法 getPassword(),用于获取密码。
  • getUserIdentifier() 方法返回用户的用户名。

4. 实现 UserProviderInterface

Symfony 提供了一个 SymfonyComponentSecurityCoreUserUserProviderInterface 接口,用于定义 User Provider。这个接口定义了以下方法:

方法名 返回值类型 描述
loadUserByIdentifier(string $identifier) UserInterface 根据用户标识符(例如用户名、邮箱地址)加载用户。如果找不到用户,应该抛出 UsernameNotFoundException 异常。
refreshUser(UserInterface $user) UserInterface 刷新用户。当用户被存储到 Session 之后,Symfony 会尝试调用这个方法来刷新用户的信息。如果用户不再存在,应该抛出 UsernameNotFoundException 异常。如果用户类型不匹配,应该抛出 UnsupportedUserException 异常。
supportsClass(string $class) bool 判断当前 User Provider 是否支持指定的 User 类。

5. 创建自定义 User Provider

现在,我们创建一个自定义的 User Provider,命名为 AppSecurityUserProviderCustomUserProvider。这个 User Provider 将从数据库中加载用户。

<?php

namespace AppSecurityUserProvider;

use AppEntityUser;
use DoctrineORMEntityManagerInterface;
use SymfonyComponentSecurityCoreExceptionUnsupportedUserException;
use SymfonyComponentSecurityCoreExceptionUserNotFoundException;
use SymfonyComponentSecurityCoreUserUserInterface;
use SymfonyComponentSecurityCoreUserUserProviderInterface;

class CustomUserProvider implements UserProviderInterface
{
    private EntityManagerInterface $entityManager;

    public function __construct(EntityManagerInterface $entityManager)
    {
        $this->entityManager = $entityManager;
    }

    /**
     * Loads the user based on the identifier (username or email).
     *
     * @param string $identifier The user identifier
     *
     * @throws UserNotFoundException if the user is not found
     */
    public function loadUserByIdentifier(string $identifier): UserInterface
    {
        $user = $this->entityManager->getRepository(User::class)->findOneBy(['username' => $identifier]);

        if (null === $user) {
            $user = $this->entityManager->getRepository(User::class)->findOneBy(['email' => $identifier]);

            if (null === $user) {
                throw new UserNotFoundException(sprintf('User with identifier "%s" not found.', $identifier));
            }
        }

        return $user;
    }

    /**
     * Refreshes the user.
     *
     * It is loaded from the database and then re-authenticated.
     *
     * @param UserInterface $user
     *
     * @throws UnsupportedUserException if the account is not supported
     * @throws UserNotFoundException     if the account is not found
     */
    public function refreshUser(UserInterface $user): UserInterface
    {
        if (!$user instanceof User) {
            throw new UnsupportedUserException(sprintf('Invalid user class "%s".', get_class($user)));
        }

        // Reload the user from the database
        $reloadedUser = $this->entityManager->getRepository(User::class)->find($user->getId());

        if (null === $reloadedUser) {
            throw new UserNotFoundException(sprintf('User with ID "%s" not found.', $user->getId()));
        }

        return $reloadedUser;
    }

    /**
     * Whether this provider supports the given user class.
     *
     * @param string $class
     *
     * @return bool
     */
    public function supportsClass(string $class): bool
    {
        return User::class === $class || is_subclass_of($class, User::class);
    }

    /**
     * Upgrades the hashed password of a user, typically for using a better hash algorithm.
     */
    public function upgradePassword(UserInterface $user, string $newHashedPassword): void
    {
        // TODO: when hashed passwords are in use, this method should:
        // 1. persist the new password in the user storage
        // 2. update the $user object with the new password
    }
}

注意:

  • 我们在构造函数中注入了 EntityManagerInterface,用于访问数据库。
  • loadUserByIdentifier() 方法首先尝试通过用户名查找用户,如果找不到,则尝试通过邮箱地址查找。如果仍然找不到,则抛出 UserNotFoundException 异常。
  • refreshUser() 方法从数据库中重新加载用户的信息。
  • supportsClass() 方法判断当前 User Provider 是否支持 AppEntityUser 类。
  • upgradePassword() 方法用于升级密码哈希算法。

6. 配置 Security 组件

现在,我们需要配置 Symfony Security 组件,告诉它使用我们的自定义 User Provider。打开 config/packages/security.yaml 文件,并进行如下修改:

security:
    # ...

    providers:
        app_user_provider:
            id: AppSecurityUserProviderCustomUserProvider

    firewalls:
        dev:
            pattern: ^/(_(profiler|wdt)|css|images|js)/
            security: false
        main:
            lazy: true
            provider: app_user_provider
            custom_authenticator: AppSecurityAuthenticatorLoginFormAuthenticator #如果使用自定义的 Authenticator
            #form_login:
            #    login_path: app_login
            #    check_path: app_login
            logout:
                path: app_logout
                # where to redirect after logout
                # target: app_any_route

    # ...

注意:

  • 我们在 providers 节点下定义了一个名为 app_user_provider 的 User Provider,并指定了它的 ID 为 AppSecurityUserProviderCustomUserProvider
  • firewalls -> main 节点下,我们将 provider 设置为 app_user_provider,表示使用我们自定义的 User Provider。
  • 这里使用了 custom_authenticator 来指定自定义的 Authenticator,这是 Symfony 5.3 之后推荐的方式。如果使用传统的 form_login,你需要取消注释 form_login 节点,并配置 login_pathcheck_path
  • 配置了 logout 节点,指定登出路径。

7. 创建 Authenticator (可选)

如果你使用传统的 form_login 方式,可以跳过这一步。但是,Symfony 5.3 之后推荐使用 Authenticator。Authenticator 负责处理用户认证的逻辑。

创建一个名为 AppSecurityAuthenticatorLoginFormAuthenticator 的 Authenticator 类:

<?php

namespace AppSecurityAuthenticator;

use AppEntityUser;
use DoctrineORMEntityManagerInterface;
use SymfonyComponentHttpFoundationRedirectResponse;
use SymfonyComponentHttpFoundationRequest;
use SymfonyComponentHttpFoundationResponse;
use SymfonyComponentRoutingGeneratorUrlGeneratorInterface;
use SymfonyComponentSecurityCoreAuthenticationTokenTokenInterface;
use SymfonyComponentSecurityCoreSecurity;
use SymfonyComponentSecurityCoreUserUserInterface;
use SymfonyComponentSecurityCoreUserUserProviderInterface;
use SymfonyComponentSecurityCsrfCsrfTokenManagerInterface;
use SymfonyComponentSecurityHttpAuthenticatorAbstractLoginFormAuthenticator;
use SymfonyComponentSecurityHttpAuthenticatorPassportBadgeCsrfTokenBadge;
use SymfonyComponentSecurityHttpAuthenticatorPassportBadgeRememberMeBadge;
use SymfonyComponentSecurityHttpAuthenticatorPassportBadgeUserBadge;
use SymfonyComponentSecurityHttpAuthenticatorPassportCredentialsPasswordCredentials;
use SymfonyComponentSecurityHttpAuthenticatorPassportPassport;
use SymfonyComponentSecurityHttpSecurityRequestAttributes;
use SymfonyComponentSecurityHttpUtilTargetPathTrait;
use SymfonyComponentPasswordHasherHasherUserPasswordHasherInterface;

class LoginFormAuthenticator extends AbstractLoginFormAuthenticator
{
    use TargetPathTrait;

    public const LOGIN_ROUTE = 'app_login';

    private UrlGeneratorInterface $urlGenerator;
    private CsrfTokenManagerInterface $csrfTokenManager;
    private UserPasswordHasherInterface $passwordHasher;
    private EntityManagerInterface $entityManager;

    public function __construct(
        UrlGeneratorInterface $urlGenerator,
        CsrfTokenManagerInterface $csrfTokenManager,
        UserPasswordHasherInterface $passwordHasher,
        EntityManagerInterface $entityManager
    ) {
        $this->urlGenerator = $urlGenerator;
        $this->csrfTokenManager = $csrfTokenManager;
        $this->passwordHasher = $passwordHasher;
        $this->entityManager = $entityManager;
    }

    public function authenticate(Request $request): Passport
    {
        $username = $request->request->get('username', '');

        $request->getSession()->set(SecurityRequestAttributes::LAST_USERNAME, $username);

        return new Passport(
            new UserBadge($username, function ($username) {
                return $this->entityManager->getRepository(User::class)->findOneBy(['username' => $username]);
            }),
            new PasswordCredentials($request->request->get('password', '')),
            [
                new CsrfTokenBadge('authenticate', $request->request->get('_csrf_token')),
                new RememberMeBadge(),
            ]
        );
    }

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

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

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

注意:

  • authenticate() 方法负责从请求中提取用户名和密码,并创建一个 Passport 对象。Passport 对象包含用户的凭据和认证徽章(badges)。
  • onAuthenticationSuccess() 方法在认证成功后被调用,用于重定向用户到目标页面。
  • getLoginUrl() 方法返回登录页面的 URL。

8. 创建登录表单 (可选)

如果你使用传统的 form_login 方式,或者使用了自定义的 Authenticator,你需要创建一个登录表单。

创建一个名为 LoginFormType 的表单类:

<?php

namespace AppForm;

use SymfonyComponentFormAbstractType;
use SymfonyComponentFormExtensionCoreTypePasswordType;
use SymfonyComponentFormExtensionCoreTypeTextType;
use SymfonyComponentFormFormBuilderInterface;
use SymfonyComponentOptionsResolverOptionsResolver;
use SymfonyComponentSecurityCsrfCsrfTokenManagerInterface;
use SymfonyComponentFormExtensionCoreTypeHiddenType;

class LoginFormType extends AbstractType
{
    private CsrfTokenManagerInterface $csrfTokenManager;

    public function __construct(CsrfTokenManagerInterface $csrfTokenManager)
    {
        $this->csrfTokenManager = $csrfTokenManager;
    }

    public function buildForm(FormBuilderInterface $builder, array $options): void
    {
        $builder
            ->add('username', TextType::class, [
                'label' => 'Username',
                'attr' => ['class' => 'form-control'],
            ])
            ->add('password', PasswordType::class, [
                'label' => 'Password',
                'attr' => ['class' => 'form-control'],
            ])
            ->add('_csrf_token', HiddenType::class, [
                'data' => $this->csrfTokenManager->getToken('authenticate')->getValue(),
            ]);
    }

    public function configureOptions(OptionsResolver $resolver): void
    {
        $resolver->setDefaults([
            // Configure your form options here
        ]);
    }
}

9. 创建登录控制器 (可选)

如果你使用传统的 form_login 方式,或者使用了自定义的 Authenticator,你需要创建一个登录控制器。

创建一个名为 SecurityController 的控制器类:

<?php

namespace AppController;

use AppFormLoginFormType;
use SymfonyBundleFrameworkBundleControllerAbstractController;
use SymfonyComponentHttpFoundationResponse;
use SymfonyComponentRoutingAnnotationRoute;
use SymfonyComponentSecurityHttpAuthenticationAuthenticationUtils;

class SecurityController extends AbstractController
{
    #[Route('/login', name: 'app_login')]
    public function login(AuthenticationUtils $authenticationUtils): Response
    {
        // if ($this->getUser()) {
        //     return $this->redirectToRoute('target_path');
        // }

        // get the login error if there is one
        $error = $authenticationUtils->getLastAuthenticationError();
        // last username entered by the user
        $lastUsername = $authenticationUtils->getLastUsername();

        $form = $this->createForm(LoginFormType::class, [
            'username' => $lastUsername,
        ]);

        return $this->render('security/login.html.twig', [
            'loginForm' => $form->createView(),
            'last_username' => $lastUsername,
            'error' => $error,
        ]);
    }

    #[Route('/logout', name: 'app_logout')]
    public function logout(): void
    {
        throw new LogicException('This method can be blank - it will be intercepted by the logout key on your firewall.');
    }
}

10. 创建登录模板 (可选)

创建一个名为 security/login.html.twig 的模板文件:

{% extends 'base.html.twig' %}

{% block title %}Log in!{% endblock %}

{% block body %}
<form method="post">
    {% if error %}
        <div class="alert alert-danger">{{ error.messageKey|trans(error.messageData, 'security') }}</div>
    {% endif %}

    {% if app.user %}
        <div class="mb-3">
            You are logged in as {{ app.user.userIdentifier }}, <a href="{{ path('app_logout') }}">Logout</a>
        </div>
    {% endif %}

    <h1 class="h3 mb-3 font-weight-normal">Please sign in</h1>

    {{ form_start(loginForm) }}
        {{ form_row(loginForm.username) }}
        {{ form_row(loginForm.password) }}
        <input type="hidden" name="_target_path" value="{{ path('app_home') }}">
        <button class="btn btn-lg btn-primary" type="submit">
            Sign in
        </button>
    {{ form_end(loginForm) }}
</form>
{% endblock %}

11. 测试

现在,你可以尝试访问你的应用程序,并使用你在数据库中创建的用户进行登录。

12. 密码哈希

在实际应用中,绝对不要以明文形式存储密码。你应该使用密码哈希算法来存储密码。Symfony 提供了 SymfonyComponentPasswordHasherHasherUserPasswordHasherInterface 接口和 SymfonyComponentPasswordHasherHasherPasswordHasherFactory 类来处理密码哈希。

你需要配置 security.yaml 文件,指定使用的密码哈希算法:

security:
    # ...

    encoders:
        AppEntityUser:
            algorithm: auto
            # cost: 12 # 仅适用于 bcrypt 算法
            # time_cost: 3 # 仅适用于 argon2i 算法
            # memory_cost: 10 # 仅适用于 argon2i 算法

然后,在注册用户时,使用 UserPasswordHasherInterface 对密码进行哈希:

use SymfonyComponentPasswordHasherHasherUserPasswordHasherInterface;

// ...

public function register(Request $request, UserPasswordHasherInterface $passwordHasher): Response
{
    $user = new User();
    // ...
    $hashedPassword = $passwordHasher->hashPassword(
        $user,
        $form->get('plainPassword')->getData()
    );
    $user->setPassword($hashedPassword);

    // ...
}

LoginFormAuthenticator 中,Symfony 会自动使用配置的密码哈希算法来验证用户输入的密码是否正确。

总结

通过以上步骤,我们成功地实现了一个自定义的 User Provider,并将其集成到 Symfony Security 组件中。自定义 User Provider 使你可以灵活地从任何数据源加载用户的信息,并实现自定义的身份验证逻辑。记住,在实际应用中,一定要使用密码哈希算法来保护用户的密码安全。

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注