使用 Symfony Guard 做用户登录

使用 Symfony Guard 做用户登录

Chris Yue 6 comments
Posts

其实 Symfony Guard 已经发布很久了,很早就想写一篇相关的教程,趁最近一个项目有用到它,正好可以作为一个实例,跟大家展示一下 Guard 的用法。

在 Symfony2 的年代,我就写过一篇文章介绍 Symfony 的用户登录系统。用户登录系统是一个网站开发必须经历,但又不简单的活,从 Symfony 对登录系统的设计就可以看得出来,四个大组件,才能做好用户登录系统。当然 Symfony 开发组也深知原有的设计太『宏大』,估计一开始就会吓跑很多人,所以后来添加了精简版的用户登录系统 Guard。

简而言之,Guard 就是将原本的登录四大组件变成了一个统一的类来处理,从它的接口 GuardAuthenticatorInterface 就能看出来,这里我将按照执行的顺序,依次说明每个接口的意义。

getCredentials(Request $request)

这是每次请求都会执行的方法,让开发者从 $request 对象中获取登录所需要的讯息,比如常见表单登录的用户名和密码,或者微信登录的 code 参数。返回的结果可以是任意的类型,但一般来说用数组形式就够了。如果此方法返回 null,那么 GuardAuthenticatorInterface::start 方法将会被执行;如果不是 null,则 GuardAuthenticatorInterface::getUser 方法将被执行,而 getCredentials 返回的结果将作为 getUser 的第一个参数。

start(Request $request, AuthenticationException $authException = null)

此方法可以理解成传统 Symfony 登录系统的 entry point,即让用户登录的地方。看定义可以发现此方法返回一个 Response,你可以返回带登录表单的页面,或者跳转到微信 OAuth 获取 code 的接口。当用户提交了账号密码,或者微信返回带 code 参数的链接,第二次请求开始,将又从 getCredentials 方法重新开始。

getUser(mixed $credentials, UserProviderInterface $userProvider)

此方法一般来说,都需要利用 UserProviderloadUserByUsername 方法,通过传入 $credentials 里的登录名,或者微信的 openId,返回 User 对象。如果通过用户名找不到对应的 User 对象,既 UserProvider::loadUserByUsername 返回 null,那么 GuardAuthenticatorInterface::onAuthenticationFailure 方法将会被调用;如果 UserProvider::loadUserByUsername 能返回 User 对象,那么 GuardAuthenticatorInterface::checkCredentials 方法将会被调用,而 $user 对象会被作为第二参数被传入,第一参数仍是 $credentials

checkCredentials

此方法将检查用户和 $credentials 是否匹配。比如表单登录,将 $credentials 里的密码信息和 $user 对象里的密码做对比,如果密码不匹配,那么你将在此方法抛出 AuthenticationException 异常或者返回 false 来表示登录失败,从而转向 GuardAuthenticatorInterface::onAuthenticationFailure 方法。

onAuthenticationSuccess

当然,如果 checkCredentials 方法返回 true(即登录成功),那么 GuardAuthenticatorInterface::onAuthenticationSuccess 方法将会被调用,你可以在此方法做一些登录成功之后的事情。此方法需要返回 Response 或者 null,如果返回 null 将继续执行当前路径应当执行的代码;如果返回 Response,则此 Response 会立马发送。比如如果要求用户登录成功都需要返回到首页,那么你就可以在此返回 new RedirectResponse('/')

onAuthenticationFailure

最后是登录失败的处理。类似于登录成功,此方法需要返回 Response,比如显示登录失败的页面,或者继续显示登录表单让用户登录。

还有两个方法,现在先不说。到此是否觉得 Guard 接口的设计让我们对 Symfony 登录思路的理解更加简洁清晰了呢?

把实例给大家展现出来,自己感受感受,利用微信 OAuth2 接口做微信登录。

首先是用户类。跟之前一样,必须实现 Symfony\Component\Security\Core\User\UserInterface

<?php

namespace AppBundle;

use Symfony\Component\Security\Core\User\UserInterface;

class User implements UserInterface
{
    private $openId;

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

    public function getRoles()
    {
    }

    public function getPassword()
    {
    }

    public function getSalt()
    {
    }

    public function getUsername()
    {
        return $this->openId;
    }

    public function eraseCredentials()
    {
    }
}

接下来是 UserProvider

<?php

namespace AppBundle;

use Symfony\Component\Security\Core\User\UserInterface;
use Symfony\Component\Security\Core\User\UserProviderInterface;

class UserProvider implements UserProviderInterface
{
    public function loadUserByUsername($openId)
    {
        return new User($openId);
    }

    public function refreshUser(UserInterface $user)
    {
        return $user; // 关于用户刷新以后专门开一篇说,目前就直接返回 $user
    }

    public function supportsClass($class)
    {
        return User::class === $class;
    }
}

一切准备工作就绪,上 Guard:

<?php

namespace AppBundle\Security\Guard;

use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Exception\AuthenticationException;
use Symfony\Component\Security\Core\User\UserInterface;
use Symfony\Component\Security\Core\User\UserProviderInterface;
use Symfony\Component\Security\Guard\AbstractGuardAuthenticator;

class Authenticator extends AbstractGuardAuthenticator
{
    private $clientId;
    private $clientSecret;

    public function __construct($clientId, $clientSecret)
    {
        $this->clientId = $clientId;
        $this->clientSecret = $clientSecret;
    }

    public function getCredentials(Request $request)
    {
        if ($request->query->has('code')) {
            return ['code' => $request->query->get('code')];
        }
    }

    public function getUser($credentials, UserProviderInterface $userProvider)
    {
        $openId = $this->getOpenIdFromCredentials($credentials);

        return $userProvider->loadUserByUsername($openId);
    }

    public function checkCredentials($credentials, UserInterface $user)
    {
        return true; // 注意:对 OAuth2 来说到此步已经拿到 openId,其实已经算登录成功了,直接返回 true
    }

    public function onAuthenticationFailure(Request $request, AuthenticationException $exception)
    {
        return new Response($exception->getMessage(), Response::HTTP_UNAUTHORIZED);
    }

    public function onAuthenticationSuccess(Request $request, TokenInterface $token, $providerKey)
    {
    }

    public function supportsRememberMe()
    {
        return false;
    }

    public function start(Request $request, AuthenticationException $authException = null)
    {
        $queries = [
            'appid' => $this->clientId,
            'redirect_uri' => $request->getUri(),
            'response_type' => 'code',
            'scope' => 'snsapi_base',
        ];
        $redirectUrl = sprintf('https://open.weixin.qq.com/connect/oauth2/authorize?%s#wechat_redirect', http_build_query($queries));

        return new RedirectResponse($redirectUrl);
    }

    private function getOpenIdFromCredentials(array $credentials)
    {
        $url = sprintf(
            'https://api.weixin.qq.com/sns/oauth2/access_token?appid=%s&secret=%s&code=%s&grant_type=authorization_code',
            $this->clientId,
            $this->clientSecret,
            $credentials['code']
        );

        try {
            $info = json_decode(file_get_contents($url), true);
        } catch (\Exception $ex) {
            throw new AuthenticationException('OAuth server is down', $ex);
        }

        if (empty($info['openid'])) {
            throw new AuthenticationException('OAuth code is not valid');
        }

        return $info['openid'];
    }
}

最后,配置 services 和 firewall 信息,一切就搞定了

# security.yml
security:
    providers:
        main:
            id: AppBundle\UserProvider

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

        main:
            guard:
                authenticators:
                    - AppBundle\Security\Guard\Authenticator
serivces:
    # 这里使用了 Symfony3 的新语法
    AppBundle\Security\Guard\Authenticator:
        arguments:
            $clientId: '%oauth_client_id%'
            $clientSecret: '%oauth_client_secret%'
    # Symfony3 默认开启 autowire 所以此处也不用定义 UserProvider 服务

使用 Symfony Guard 做用户登录 by Chris Yue is licensed under a Creative Commons Attribution-NonCommercial-NoDerivatives 4.0 International License.

微信赞赏码

写作累,服务器还越来越贵
求分担,祝愿好人一生平安
天使打赏人

6 Comments

xiaobai

九月 5, 2019 在 4:02 下午

大佬你好- -我又来问问题了

结合文中和官方文档, 我已经搞定了,但是在checkCredentials 中我直接 return true, 是不能算作登陆吗? 我并没有看到用户登陆的状态- – 我是新手,比较懵

    Chris Yue

    九月 7, 2019 在 6:05 下午

    算作登录啊,然后就该执行 onAuthenticationSuccess 了,只要执行了 onAuthenticationSuccess 就表示已经成功登录了,然后做一些登录成功之后该干的事情,比如保存用户会话啥的,你可以确认一下 onAuthenticationSuccess 到底执行没有。

     

xiaobai

九月 5, 2019 在 1:28 下午

大佬你好,按照官方文档中的来, 设置的请求头,sf接收不到, 如果是这样的话,我是得设置参数来进行是否登陆判断吗

小白

九月 5, 2019 在 12:42 上午

The service “App\Security\TokenAuthenticator” has a dependency on a non-existent parameter “client_id”.
大佬,跟着做为啥提示不存在参数

fsing

五月 23, 2019 在 4:42 下午

感谢您的分享,有一个问题,如果用在后台登录里面,每一次请求都要带上用户名密码吗?能不能在某个地方判断用户是否已经登录,就不用再去执行后面的语句了呢?

    Chris Yue

    五月 24, 2019 在 8:23 上午

    当然可以,credential 不一定非得是单一方式。

    你可以让 credential 既可能返回用户登录信息又可能返回会话信息,再根据 credential 的不同,走不同的身份认证流程就好了。

     

发表评论

7 + 2 =