serendipity_hq/bundle-users

一个帮助在Symfony应用程序中管理用户的Symfony扩展包。

安装次数: 189 806

依赖项: 0

建议者: 0

安全性: 0

星标: 3

关注者: 3

分支: 0

开放问题: 14

类型:symfony-bundle

0.2.2 2024-03-15 17:54 UTC

README

Serendipity HQ 用户扩展包

帮助管理Symfony应用程序中的用户。

支持:

测试环境:

当前状态

Coverage Maintainability Rating Quality Gate Status Reliability Rating Security Rating Technical Debt Vulnerabilities

Phan PHPStan PSalm PHPUnit Composer PHP CS Fixer Rector

功能

提供一些实用工具,使得在Symfony应用程序中管理用户更加容易,在Symfony内置用户管理的基础上。

你喜欢这个扩展包吗?
留下一个 ★

或者运行
composer global require symfony/thanks && composer thanks
以感谢你当前项目中使用的所有库,包括这个!

文档

起点总是Symfony的文档。

一旦你配置了UserInterface实体,配置了应用程序的安全性并构建了登录表单,就是时候创建你的第一个用户了,甚至在构建注册表单之前。

为了使用户管理更容易,SerendipityHQ Users Bundle提供了一个命令shq:users:create,允许从命令行创建用户。

它几乎可以即插即用:你只需要稍微调整一下由Symfony自动生成的实体。

安装Serendipity HQ用户扩展包

要安装该扩展包,运行

composer req serendipity_hq/bundle-users

然后在你的bundles.php中激活该扩展包

<?php

// config/bundles.php

return [
    Symfony\Bundle\FrameworkBundle\FrameworkBundle::class => ['all' => true],
    Symfony\Bundle\MonologBundle\MonologBundle::class => ['all' => true],
    Symfony\Bundle\DebugBundle\DebugBundle::class => ['dev' => true, 'test' => true],
    Sensio\Bundle\FrameworkExtraBundle\SensioFrameworkExtraBundle::class => ['all' => true],
    Doctrine\Bundle\DoctrineBundle\DoctrineBundle::class => ['all' => true],
    Doctrine\Bundle\MigrationsBundle\DoctrineMigrationsBundle::class => ['all' => true],
+    SerendipityHQ\Bundle\UsersBundle\SHQUsersBundle::class => ['all' => true],
    Symfony\Bundle\SecurityBundle\SecurityBundle::class => ['all' => true],
    Symfony\Bundle\MakerBundle\MakerBundle::class => ['dev' => true],
    Symfony\Bundle\TwigBundle\TwigBundle::class => ['all' => true],
];

使用shq:users:create命令

此命令非常有用,因为它允许你在创建注册表单之前在你的应用程序中创建用户。

这样你就可以立即测试登录功能,也可以自动化应用程序中的某些任务,比如在开发环境中重置它,而无需每次都从注册表单创建新用户。

要使用该命令,你必须做一件简单的事情:在你的UserInterface实体中实现HasPlainPasswordInterface及其实现特质。

HasPlainPasswordInterface使得可能获得一系列优势:我们将在稍后看到它们。

目前,请确保你永远不会将明文密码保存到数据库中:它仅在UserInterface对象的生命周期中有效,并允许你的应用程序实现一些基本功能。

目前,让我们实现接口和特质。

  1. 打开你的 UserInterface 实体(位于 src/App/Entity/User.php);
  2. 实现接口 \SerendipityHQ\Bundle\UsersBundle\Model\Property\HasPlainPasswordInterface
  3. 使用特质 SerendipityHQ\Bundle\UsersBundle\Model\Property\HasPlainPasswordTrait

这些修改完成后,你的实体应该看起来像这样

<?php

declare(strict_types = 1);

/*
 * This file is part of Trust Back Me.
 *
 * Copyright (c) Adamo Aerendir Crespi <hello@aerendir.me>.
 *
 * This code is to consider private and non disclosable to anyone for whatever reason.
 * Every right on this code is reserved.
 *
 * For the full copyright and license information, please view the LICENSE file that
 * was distributed with this source code.
 */

namespace App\Entity;

use App\Repository\UserRepository;
use Doctrine\ORM\Mapping as ORM;
+ use SerendipityHQ\Bundle\UsersBundle\Property\HasPlainPasswordInterface;
+ use SerendipityHQ\Bundle\UsersBundle\Property\HasPlainPasswordTrait;
use Symfony\Component\Security\Core\User\UserInterface;

/**
 * @ORM\Entity(repositoryClass=UserRepository::class)
 * @ORM\Table(name="tbme_users")
 */
+ class User implements UserInterface, HasPlainPasswordInterface
{
+    use HasPlainPasswordTrait;

    // Here the remaining code of your entity
    // ...
}

现在使用命令行创建你的第一个用户

Aerendir@SerendipityHQ % bin/console shq:user:create Aerendir 1234

Create user
===========

Password for user Aerendir: 1234


 [OK] User Aerendir created.

Aerendir(第一个参数),是主属性值(在文件 config/packages/security.yaml 中的 security.providers.[your_user_provider].entity.property 设置的值);1234(第二个参数),是分配给用户的密码。

你现在可以测试应用的登录功能了:访问 http://your_app_url/login 并提供你刚刚创建的用户凭据。

现在登录功能正常,我们可以进一步了解 HasPlainPasswordInterface 的用途。

HasPlainPasswordInterface 的用途

接口 HasPlainPasswordInterface 激活了由 Serendipity HQ Users Bundle 提供的 Doctrine 监听器。

这个监听器读取明文密码(由特质 HasPlainPasswordTrait 管理)并自动进行编码。

这样你将能够在用户对象的整个生命周期中访问明文密码

  1. 你不需要关心密码的加密;
  2. 你可以使用明文密码来做你喜欢的事情:通过电子邮件发送给用户,在页面上显示,或者做你喜欢的任何事情。

注意:Serendipity HQ Users Bundle 从不会调用 UserInterface::ereaseCredentials() 方法:这是你应用的责任。

实现个人资料展示页面

下一步是让用户能够查看其个人资料。

你需要

  1. 一个 UserProfileController
  2. 一个模板来展示当前的个人资料

1. 创建 UserProfileController

<?php

// src/Controller/UserProfileController.php

declare(strict_types = 1);

/*
 * This file is part of Trust Back Me.
 *
 * Copyright (c) Adamo Aerendir Crespi <hello@aerendir.me>.
 *
 * This code is to consider private and non disclosable to anyone for whatever reason.
 * Every right on this code is reserved.
 *
 * For the full copyright and license information, please view the LICENSE file that
 * was distributed with this source code.
 */

namespace App\Controller;

use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;

/**
 * @Security("is_granted('ROLE_USER')", statusCode=403)
 */
final class UserProfileController extends AbstractController
{
    /**
     * @Route("/me/", name="user_profile")
     */
    public function show(): Response
    {
        return $this->render('user/profile.html.twig', [
            'user' => $this->getUser(),
        ]);
    }
}

2. 创建模板 user/profile.html.twig

以下是渲染个人资料的模板代码

{# templates/user/profile.html.twig #}
{% extends 'base.html.twig' %}

{% block title %}Your profile{% endblock %}

{% block body %}
    <h1 class="text-center">Hello {{ user.username }}</h1>
{% endblock %}

这同样很简单,你可以根据你的应用程序进行定制。

实现个人资料编辑页面

为了创建一个编辑个人资料的页面,我们需要

  1. 一个 UserType 表单类型
  2. UserProfileController 中的一个路由
  3. 一个用于展示表单的模板

创建 UserType 表单类型

使用 MakerBundle 来完成这个任务

Aerendir@Archimede bundle-users % bin/console make:form

 The name of the form class (e.g. VictoriousPizzaType):
 > UserType

 The name of Entity or fully qualified model class name that the new form will be bound to (empty for none):
 > User

 created: src/Form/UserType.php


  Success!


 Next: Add fields to your form and start using it.
 Find the documentation at https://symfony.com.cn/doc/current/forms.html

非常简单,只需要几秒钟!

2. 在 UserProfileController 中创建路由

让我们在 UserProfileController 中使用刚刚创建的表单类型

// src/Controller/UserProfileController.php

+ use App\Form\UserType;

final class UserProfileController extends AbstractController
{
    ...

+    /**
+     * @Route("/profile/edit", name="user_profile_edit")
+     */
+    public function edit(Request $request): Response
+    {
+        /** @var User $user */
+        $user = $this->getUser();
+        $form = $this->getFormFactory()->create(UserType::class, $user, [
+            'action' => $this->generateUrl('user_profile_edit'),
+            'method' => 'POST',
+        ]);
+
+        $form->handleRequest($request);
+
+        if ($form->isSubmitted() && $form->isValid()) {
+            $this->getDoctrine()->getManager()->flush();
+            $url = $this->generateUrl('user_profile');
+
+            return new RedirectResponse($url);
+        }
+
+        return $this->render('user/edit.html.twig', [
+            'form' => $form->createView(),
+        ]);
+    }
...
}

3. 创建模板

创建文件 templates/user/edit.html.twig

{# templates/user/edit.html.twig #}
{% extends 'base.html.twig' %}

{% block title %}Edit your profile{% endblock %}

{% block body %}
    {{ form(form) }}
{% endblock %}

真的非常简单!

实现密码编辑

现在我们将开始看到 Symfony HQ Users Bundle 的有用性!

我们需要实现用户更改其密码的功能。

为了实现这个功能,我们需要

  1. 一个表单,让用户能够更改其密码
  2. 一个 UserPasswordController
  3. 一个用于展示表单的模板

1. 创建表单 ChangePasswordType

嘿,这里你不需要做任何事情!

Serendipity HQ Users Bundle 预置了一个更改密码的表单。

在这里找到它:src/Form/Type/UserPasswordChangeType.php

它提供了三个字段

  1. old_password
  2. plainPassword
  3. plainPassword 的确认

底层,它使用 Symfony 提供的 RepeatedType 来确保新密码和确认密码相等。

多亏了接口 HasPlainPasswordInterface,表单可以自动由 Serendipity HQ Users Bundle 处理。

一切都很简单!

现在我们需要一个路由。

2. 创建 UserPasswordController::changePassword()

像往常一样,使用 make 命令

Aerendir@Archimede bundle-users % symfony console make:controller

 Choose a name for your controller class (e.g. AgreeablePizzaController):
 > UserPasswordController

 created: src/Controller/UserPasswordController.php
 created: templates/user_password/index.html.twig


  Success!


 Next: Open your new controller class and add some pages!

命令创建了控制器及其模板。

打开控制器并移除index()路由。

然后添加路由changePassword()

<?php

namespace App\Controller;

use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\Routing\Annotation\Route;
+ use SerendipityHQ\Bundle\UsersBundle\Manager\PasswordManager;
+ use SerendipityHQ\Bundle\UsersBundle\Property\HasPlainPasswordInterface;
+ use SerendipityHQ\Bundle\UsersBundle\SHQUsersBundle;
+ use Symfony\Component\HttpFoundation\RedirectResponse;
+ use Symfony\Component\HttpFoundation\Request;
+ use Symfony\Component\HttpFoundation\Response;
+ use Symfony\Contracts\Translation\TranslatorInterface;

class UserPasswordController extends AbstractController
{
+    private PasswordManager $passwordManager;
+
+    public function __construct(PasswordManager $passwordManager)
+    {
+        $this->passwordManager = $passwordManager;
+    }

-    /**
-     * @Route("/user/password", name="user_password")
-     */
-    public function index()
-    {
-        return $this->render('user_password_test/index.html.twig', [
-            'controller_name' => 'UserPasswordTestController',
-        ]);
-    }

+    /**
+     * @Route("/profile/password", name="user_password_change")
+     * @Security("is_granted('ROLE_USER')", statusCode=403)
+     */
+    public function changePassword(Request $request, TranslatorInterface $translator): Response
+    {
+        /** @var HasPlainPasswordInterface $user */
+        $user = $this->getUser();
+        $form = $this->passwordManager->getPasswordHelper()->createFormPasswordChange($user);
+
+        $form->handleRequest($request);
+
+        if ($form->isSubmitted() && $form->isValid()) {
+            $this->getDoctrine()->getManager()->flush();
+            $this->addFlash('success', $translator->trans('user.password.change_password.success', [], 'shq_users'));
+            $url = $this->generateUrl('user_profile');
+
+            return new RedirectResponse($url);
+        }
+
+        return $this->render('user/password/password_change.html.twig', [
+            'form' => $form->createView(),
+        ]);
+    }
}

这里有一些需要注意的事项

  1. 使用注解@Security:如果当前用户未登录,该路由将返回一个Symfony\Component\Security\Core\Exception\AccessDeniedException;
  2. 路由的名称必须与Routes::PASSWORD_CHANGE中的相同;
  3. 变量$user被指定为类型HasPlainPasswordInterface:实际上,PasswordHelper::createFormPasswordChange()方法接受类型为HasPlainPasswordInterface的变量;
  4. 如果表单已提交且有效,我们只需调用EntityManagerInterface::flush():实际上,表单将自动使用提供的新明文密码更新$user

一切准备就绪:现在您的用户可以更改他们的密码了!

请记住,在您的应用中某处添加此页面的链接(通常在个人资料页或用户菜单中)。

现在我们需要让用户在忘记密码的情况下重置他们的密码。

实现密码重置

重置密码的流程如下

  1. 显示一个页面,用户可以提供他的/她的主要标识符(用户名、电子邮件、电话号码等.);
  2. 生成一个唯一的令牌并发送给用户(通过电子邮件、短信或其他您喜欢的渠道)
  3. 验证令牌,并向用户显示一个允许设置新密码的表单。

在本例中,我们将假设以下情况

  1. 用户的唯一标识符是电子邮件;
  2. 令牌将通过电子邮件发送。

要实现整个流程,我们需要

  1. 三个路由来管理三个步骤(重置请求、发送确认链接、重置页面)
  2. 相应的模板(用于路由和电子邮件)
  3. 一个表示令牌的实体
  4. 一个发送电子邮件的监听器

让我们开始吧!

创建重置请求

为了能够请求重置令牌,我们需要

  1. 一个表单类型
  2. 一个路由
  3. 一个模板

首先在UserPasswordController中添加方法resetRequest()

// src/Controller/
class UserPasswordController extends AbstractController
{
    /**
     * @Route("password-reset", name="user_password_reset_request")
     */
    public function resetRequest(Request $request): Response
    {
        $form = $this->passwordManager->getPasswordHelper()->createFormPasswordResetRequest();

        $form->handleRequest($request);

        // Listen for the event PasswordResetTokenCreatedEvent or for the event PasswordResetTokenCreationFailedEvent
        // If the user was found, then process the request.
        // If the user is not found, do nothing to avoid disclosing if
        // the user exists or not (for security)
        if (
            $form->isSubmitted() &&
            $form->isValid() &&
            $this->passwordManager->handleResetRequest($request, $form)
        ) {
            $this->getDoctrine()->getManager()->flush();

            return $this->redirectToRoute('user_password_reset_check_email');
        }

        return $this->render('App/user/password/reset_request.html.twig', [
            'form' => $form->createView(),
        ]);
    }
}

如您所见,路由非常简单。

这里有一些需要注意的事项

  1. 路由的名称必须与Routes::PASSWORD_RESET_REQUEST中的相同;
  2. 一旦我们确定表单没有错误,我们就通过Serendipity HQ Users Bundle处理请求,然后我们简单地提交更改到数据库:实际上,该包负责所有操作:我们只需提交它们(记住:该包永远不会提交Doctrine!)。

注意,我们不需要构建任何表单:它是由Serendipity HQ Users Bundle构建的。

现在我们需要创建模板:这是应用程序中非常定制的内容,因此我们需要自己创建它。

{# templates/user/password/reset_request.html.twig #}
<h1>Reset your password</h1>

{{ form_start(form) }}
    {{ form_row(form.primaryEmail) }}
    <div>
        <small>
            Enter your email address and we we will send you a
            link to reset your password.
        </small>
    </div>

    <button class="btn btn-primary">Send password reset email</button>
{{ form_end(form) }}

我们准备好了。

现在是我们处理这个请求的时候了。

创建确认页面

创建方法UserPasswordController::resetRequestReceived()

    /**
     * Confirmation page after a user has requested a password reset.
     *
     * @Route("/password-reset/requested", name="user_password_reset_request_received")
     */
    public function resetRequestReceived(Request $request): Response
    {
        // Prevent users from directly accessing this page
        if (!$this->passwordManager->getPasswordResetHelper()->canAccessPageCheckYourEmail($request)) {
            return $this->redirectToRoute('user_password_reset_request');
        }

        return $this->render('App/user/password/check_email.html.twig', [
            'tokenLifetime' => PasswordResetHelper::RESET_TOKEN_LIFETIME,
        ]);
    }

这个方法也非常简单。

您需要注意到的是对canAccessPageCheckYourEmail的调用:此方法将读取当前(匿名)用户的会话,并检查是否在UserPasswordController::resetRequest()中通过调用PasswordManager::handleResetRequest()预先设置了值。

现在我们需要创建最后一个路由,这个路由将实际上让用户能够设置他们自己的新密码。

创建重置页面

创建方法UserPasswordController::reset()

    /**
     * @Route("/password-reset/reset/{token}", name="user_password_reset_reset_password")
     */
    public function reset(Request $request, EncoderFactoryInterface $passwordEncoderFactory, string $token = null): Response
    {
        if ($token) {
            // We store the token in session and remove it from the URL, to avoid the URL being
            // loaded in a browser and potentially leaking the token to 3rd party JavaScript.
            $this->passwordManager->getPasswordResetHelper()->storeTokenInSession($request, $token);

            return $this->redirectToRoute('user_password_reset_reset_password');
        }

        $token = $this->passwordManager->getPasswordResetHelper()->getTokenFromSession($request);
        if (null === $token) {
            throw $this->createNotFoundException('No reset password token found in the URL or in the session.');
        }

        try {
            /**
             * findUserByPublicToken also validates the token and throws exceptions on failed validation.
             * @var HasPlainPasswordInterface $user
             */
            $user = $this->passwordManager->findUserByPublicToken($token);
        } catch (PasswordResetException $e) {
            $this->addFlash('user_password_reset_error', sprintf(
                'There was a problem validating your reset request - %s',
                $e->getMessage()
            ));

            return $this->redirectToRoute('user_password_reset_request');
        }

        $form = $this->passwordManager->getPasswordHelper()->createFormPasswordReset();

        $form->handleRequest($request);
        if ($form->isSubmitted() && $form->isValid()) {
            $this->passwordManager->handleReset($token, $user, $form, $request);
            $this->getDoctrine()->getManager()->flush();

            return $this->redirectToRoute('auth_login');
        }

        return $this->render('App/user/password/reset_password.html.twig', [
            'form' => $form->createView(),
        ]);
    }

此方法比其他方法要长一些:它需要做很多事情!

代码已注释,所以它所做的是什么很清楚。

需要注意的唯一一点是,再次是调用方法 EntityManager::flush():同样,包永远不会刷新数据库:这是我们的责任。

如果您现在尝试重置密码,您将收到错误,而且实际上没有代码向用户发送令牌。

我们需要实现这两个缺失的部分。

创建 PasswordResetToken 实体

首先,我们需要创建将代表重置令牌的实体。

创建实体 PasswordResetToken

// src/Entity/PasswordResetToken.php

namespace App\Entity;

use Doctrine\ORM\Mapping as ORM;
use SerendipityHQ\Bundle\UsersBundle\Model\Property\PasswordResetTokenInterface;
use SerendipityHQ\Bundle\UsersBundle\Model\Property\PasswordResetTokenTrait;
use SerendipityHQ\Bundle\UsersBundle\Repository\PasswordResetTokenRepository;
use Symfony\Component\Security\Core\User\UserInterface;

/**
 * @ORM\Entity(repositoryClass=PasswordResetTokenRepository::class)
 * @ORM\Table(name="tbme_users_password_reset_tokens")
 */
class PasswordResetToken implements PasswordResetTokenInterface
{
    use PasswordResetTokenTrait;

    /**
     * @ORM\Id()
     * @ORM\GeneratedValue()
     * @ORM\Column(type="integer")
     */
    private $id;

    /**
     * @var UserInterface
     * @ORM\ManyToOne(targetEntity=User::class)
     * @ORM\JoinColumn(nullable=false)
     */
    private $user;

    public function __construct(UserInterface $user)
    {
        $this->user = $user;
    }

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

    public function getUser(): UserInterface
    {
        return $this->user;
    }
}

此类必须实现接口 PasswordResetTokenInterface,并将由 UserPasswordController::resetRequest() 中的 handleResetRequest() 方法使用。

您可以按需自定义它。

您可能想自定义的两件事是

  1. 您在 ManyToOne 关系中的 User 类;
  2. 实体的命名空间。

在这种情况下,包使用的默认命名空间是 App\Entity\PasswordResetToken,但您可以更改它(以及实体类的名称)通过在配置中设置参数 shq_users.token_class

创建订阅者以向用户发送电子邮件

要实际向用户发送电子邮件,我们需要一个订阅者,它监听事件 PasswordResetTokenCreatedEvent

创建它

<?php
// src/Subscriber/SecurityPasswordResetSubscriber.php

namespace App\Subscriber;

use SerendipityHQ\Bundle\UsersBundle\Event\PasswordResetTokenCreatedEvent;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Bridge\Twig\Mime\TemplatedEmail;
use Symfony\Component\Mailer\MailerInterface;
use Symfony\Component\Mime\Address;

/**
 * Send reminders to ask the feedback releasing.
 */
final class SecurityPasswordResetSubscriber implements EventSubscriberInterface
{
    private MailerInterface $mailer;
    public function __construct(MailerInterface $mailer)
    {
        $this->mailer = $mailer;
    }

    /**
     * {@inheritdoc}
     */
    public static function getSubscribedEvents(): array
    {
        return [
            PasswordResetTokenCreatedEvent::class => 'onPasswordResetTokenCreated'
        ];
    }

    public function onPasswordResetTokenCreated(PasswordResetTokenCreatedEvent $event)
    {
        $user = $event->getUser();
        $token = $event->getToken();

        $email = (new TemplatedEmail())
            // @todo Use values from ENV config
            ->from(new Address('hello@trustback.me', 'TrustBack.Me'))
            ->to($user->getEmail())
            // @todo translate this message
            ->subject('Your password reset request')
            ->htmlTemplate('App/user/password/reset_email.html.twig')
            ->context(['token' => $token]);

        $this->mailer->send($email);
    }
}

现在我们完成了!

尝试重置您的密码,所有都应该按预期工作。

如果有什么地方不工作,请打开一个问题。

其他有用功能

  • 如何添加“记住我”功能

  • UsersManager(由 SHQUsersBundle 提供)

  • 事件(由包触发的事件:它们在 src/Events 中)

  • 命令(可用命令的说明)(如何扩展命令)

  • 创建注册表单

  • 创建登录表单

  • 创建密码重置表单

使用管理器创建用户

create() 方法触发 UserCreatedEvent 事件。

您可以监听它,如果您愿意,可以设置 UserCreatedEvent::stopPropagation()

如果 true === UserCreatedEvent::isPropagationStopped(),则管理器不会将用户持久化到数据库中,但仍然会返回它。

这样,您可以决定做什么:创建一个新用户,自行持久化,等等。

管理器永远不会调用 EntityManager::flush():您总是负责调用它并决定何时以及是否这样做。

UserInterface::eraseCredentials() 方法永远不会由包调用:这是您的责任,因为我们不知道您是否还需要它们(例如,向注册用户发送带有纯文本密码的电子邮件)。

创建注册表单

当使用 Symfony 的 make 命令时,生成的表单类型有一个名为 plainPassword 的字段,它具有选项 mapped = false

User 实体中实现特质 HasPlainPassword,然后从表单类型中删除选项。

这样,User 实体将有一个由特质提供的 plainPassword 字段,表单将绑定表单字段到此属性,并且 Doctrine 监听器将自动编码密码。

此外,修改控制器以不再编码密码。

如何创建用于管理用户的命令

管理密码重置

  1. 创建 repo PasswordResetTokenRepository 并实现接口 PasswordResetTokenRepositoryInterface
  2. 创建控制器 PasswordController(使用哪些方法/路由?)

处理垃圾回收

你喜欢这个扩展包吗?
留下一个 ★

或者运行
composer global require symfony/thanks && composer thanks
以感谢你当前项目中使用的所有库,包括这个!