Symfony Security 组件:实现自定义身份验证提供者(User Provider)的完整流程
大家好!今天我们来深入探讨 Symfony Security 组件中一个非常重要的概念:自定义身份验证提供者(User Provider)。我们将从理论到实践,一步一步地构建一个完整的自定义 User Provider,让你彻底掌握其工作原理和实现方法。
1. 身份验证机制概述
在开始之前,我们先简单回顾一下 Symfony Security 组件的身份验证流程。
- 请求到达: 用户发起一个需要身份验证的请求。
- 防火墙匹配: Symfony 的防火墙配置根据请求的 URL 匹配相应的安全配置。
- 身份验证流程启动: 防火墙配置中指定的身份验证监听器(Authentication Listener)开始工作。
- 凭据提取: 身份验证监听器负责从请求中提取用户的凭据(例如用户名和密码,或者 API 密钥)。
- 身份验证: 身份验证监听器将凭据传递给身份验证管理器(Authentication Manager)。身份验证管理器根据配置的身份验证提供者(Authentication Provider)进行身份验证。
- User Provider 查找用户: Authentication Provider 通过 User Provider 根据凭据中的用户信息(例如用户名)查找用户。
- 用户验证: Authentication Provider 验证用户凭据是否有效(例如,检查密码是否正确)。
- 成功/失败: 如果身份验证成功,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_path和check_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 使你可以灵活地从任何数据源加载用户的信息,并实现自定义的身份验证逻辑。记住,在实际应用中,一定要使用密码哈希算法来保护用户的密码安全。