如何创建一个自定义的Authentication Provider

3.4 版本
维护中的版本

创建一个自定义的认证系统很难,本文将引导你完成这一过程。但根据你的需求,可能通过一种更简单的方式即可解决问题,你也可以使用一些社区bundle:

译注

上面三个链接中,Guard(第一个链接)可以做simple_form(第二个链接)和simple_preauth(第三个链接)的事。但小编xtt1341的建议是,根据需求分别行事,使用simple authenticator系列先掌握一下要领(其实simple系列接口相对于本文已经很简单了),最后即可活用Guard(继承抽象类更简单,直达所有方法)。

超高难文档 本文需要极为扎实的Symfony框架之Security背景知识。学者应通过实际代码加深理解,可自写github同步登录,而不是用 HWI

如果你已读过 安全(Security) 一文,你就已经理解了Symfony在实现security的过程中,对认证(authentication)和授权(authorization)所做的区分。本文讨论了认证过程所涉及的核心类,以及如何去实现一个自定义的Authentication Provider。由于认证和授权是不同的概念,在这个扩展中,user-provider并不可知,其功能性由你程序中的user provider来完成,它们可能基于内存,数据库,或任何你选择用来保存用户的其他地方。

完全自定义 算上user provider的话,本文需要手写5个文件,因此是完全自定义。同理,simple要写3个,guard要写2个。但如果使用FOSUserBundle,手写文件数量即分别变为4、2、1。

了解WSSE 

下面的文字将演示如何创建一个自定义的authentication provider以完成WSSE认证。WSSE的安全协议提供了若干安全利益:

  1. 用户名/密码的加密

  2. 针对replay attack的安全防范

  3. 不需要配置web服务器

WSSE对于保护web services来说非常有用,可以是 SOAP 或 REST 等。

目前有很多关于 WSSE 的精美文档,但本文的注意力不在安全协议上,而是把重心放在“将一个自定义协议添加到你的Symfony程序中”。WSSE基础是,一个请求头(request header)被用来检查加了密的凭证(encrypted credentials),使用timestamp时间戳和 nonce 进行检验,并使用一个密码摘要(password digest)来对发起请求的用户进行身份认证。

WSSE还支持application key验证,这对web services很有用,但它超出了本文范畴.

Token 

在Symfony的security context中,token中的role非常重要。一个token,呈现了包含在请求中的用户认证数据。一旦请求被认证,token会保留用户的数据,并把这些数据在security context中进行传递。首先,创建你的token类。它可以把相关的全部信息传入你的authentication provider。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// src/AppBundle/Security/Authentication/Token/WsseUserToken.php
namespace AppBundle\Security\Authentication\Token;
 
use Symfony\Component\Security\Core\Authentication\Token\AbstractToken;
 
class WsseUserToken extends AbstractToken
{
    public $created;
    public $digest;
    public $nonce;
 
    public function __construct(array $roles = array())
    {
        parent::__construct($roles);
 
        // If the user has roles, consider it authenticated
        // 如果用户持有roles,视其为已认证
        $this->setAuthenticated(count($roles) > 0);
    }
 
    public function getCredentials()
    {
        return '';
    }
}

WsseUserToken 类继承自Security组件的 AbstractToken 抽象类,用以提供基本的token功能。在任何类中实现 TokenInterface即可作为一个token来使用。

Listener 

接下来,你需要一个listener对firewall进行监听。监听,负责守备着“指向防火墙的请求”,并调用authentication provider。一个listener,必须是 ListenerInterface 的一个实例。一个security listener ,应能处理 GetResponseEvent 事件,并在认证成功时,于token storage中设置一个authenticated token(已认证token)。

listener 这个单词不译。必要时译做“监听”。请大家依据语义和上下文自行判断其词性是动还是名。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
// src/AppBundle/Security/Firewall/WsseListener.php
namespace AppBundle\Security\Firewall;
 
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Event\GetResponseEvent;
use Symfony\Component\Security\Core\Authentication\AuthenticationManagerInterface;
use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface;
use Symfony\Component\Security\Core\Exception\AuthenticationException;
use Symfony\Component\Security\Http\Firewall\ListenerInterface;
use AppBundle\Security\Authentication\Token\WsseUserToken;
 
class WsseListener implements ListenerInterface
{
    protected $tokenStorage;
    protected $authenticationManager;
 
    public function __construct(TokenStorageInterface $tokenStorage, AuthenticationManagerInterface $authenticationManager)
    {
        $this->tokenStorage = $tokenStorage;
        $this->authenticationManager = $authenticationManager;
    }
 
    public function handle(GetResponseEvent $event)
    {
        $request = $event->getRequest();
 
        $wsseRegex = '/UsernameToken Username="([^"]+)", PasswordDigest="([^"]+)", Nonce="([a-zA-Z0-9+\/]+={0,2})", Created="([^"]+)"/';
        if (!$request->headers->has('x-wsse') || 1 !== preg_match($wsseRegex, $request->headers->get('x-wsse'), $matches)) {
            return;
        }
 
        $token = new WsseUserToken();
        $token->setUser($matches[1]);
 
        $token->digest   = $matches[2];
        $token->nonce    = $matches[3];
        $token->created  = $matches[4];
 
        try {
            $authToken = $this->authenticationManager->authenticate($token);
            $this->tokenStorage->setToken($authToken);
 
            return;
        } catch (AuthenticationException $failed) {
            // ... you might log something here / 可于此处做些日志
 
            // To deny the authentication clear the token. This will redirect to the login page.
            // Make sure to only clear your token, not those of other authentication listeners.
            // 要拒绝认证应清除token。这会重定向到登录页。
            // 确保只清除你的token,而不是其他authentication listeners的token。
            // $token = $this->tokenStorage->getToken();
            // if ($token instanceof WsseUserToken && $this->providerKey === $token->getProviderKey()) {
            //     $this->tokenStorage->setToken(null);
            // }
            // return;
        }
 
        // By default deny authorization / 默认时,拒绝授权
        $response = new Response();
        $response->setStatusCode(Response::HTTP_FORBIDDEN);
        $event->setResponse($response);
    }
}

监听会对请求(request)检查预期的 X-WSSE 头,并对返回给“预期的WSSE信息”的值进行比对,创建一个使用此信息的token,然后把token传入authentication manager。如果没有提供正确的信息,或身authentication manager抛出了一个 AuthenticationException,会返回一个403响应。

上例没有用到一个类,即 AbstractAuthenticationListener 类,它是个极其有用的基类,为security扩展提供了常见需求的功能性。包括在 session 中管理 token,提供了 success / failure handlers、 登录表单的 URL,等等更多。因为 WSSE 毋须维护认证相关的 sessions 或是登录表单,所以本例没有使用它。

从监听中提前返回(例如要允许匿名用户),仅在你希望链起(chain)authentication providers时,才有意义。如果你要禁止匿名用户访问,并带有一个美观的 403 错误,则应在响应被返回之前设置其状态码(status code)。

authentication provider 

authentication provider要做的是检验 WsseUserToken。 也就是说,provider 要去检验 Created 头的值是在五分钟有效期之内,Nonce 头的值在五分钟之内是唯一的,并且 PasswordDigest 头的值要能匹配上用户的密码。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
// src/AppBundle/Security/Authentication/Provider/WsseProvider.php
namespace AppBundle\Security\Authentication\Provider;
 
use Psr\Cache\CacheItemPoolInterface;
use Symfony\Component\Security\Core\Authentication\Provider\AuthenticationProviderInterface;
use Symfony\Component\Security\Core\User\UserProviderInterface;
use Symfony\Component\Security\Core\Exception\AuthenticationException;
use Symfony\Component\Security\Core\Exception\NonceExpiredException;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use AppBundle\Security\Authentication\Token\WsseUserToken;
 
class WsseProvider implements AuthenticationProviderInterface
{
    private $userProvider;
    private $cachePool;
 
    public function __construct(UserProviderInterface $userProvider, CacheItemPoolInterface $cachePool)
    {
        $this->userProvider = $userProvider;
        $this->cachePool = $cachePool;
    }
 
    public function authenticate(TokenInterface $token)
    {
        $user = $this->userProvider->loadUserByUsername($token->getUsername());
 
        if ($user && $this->validateDigest($token->digest, $token->nonce, $token->created, $user->getPassword())) {
            $authenticatedToken = new WsseUserToken($user->getRoles());
            $authenticatedToken->setUser($user);
 
            return $authenticatedToken;
        }
 
        throw new AuthenticationException('The WSSE authentication failed.');
    }
 
    /**
     * This function is specific to Wsse authentication and is only used to help this example
     * 本函数仅作用于 Wsse authentication,并且仅对本例有用
     *
     * For more information specific to the logic here, see
     * 此处逻辑的更多特定信息,参考:
     * https://github.com/symfony/symfony-docs/pull/3134#issuecomment-27699129
     */
    protected function validateDigest($digest, $nonce, $created, $secret)
    {
        // Check created time is not in the future
        // 检查创建时间小于当前
        if (strtotime($created) > time()) {
            return false;
        }
 
        // Expire timestamp after 5 minutes
        // 5分钟之后释放时间戳
        if (time() - strtotime($created) > 300) {
            return false;
        }
 
        // Try to fetch the cache item from pool
        // 尝试从池中取出缓存元素
        $cacheItem = $this->cachePool->getItem(md5($nonce));
 
        // Validate that the nonce is *not* in cache
        // if it is, this could be a replay attack
        // 验证 nonce *不* 在缓存中
        // 否则,这可能是一个回放攻击
        if ($cacheItem->isHit()) {
            throw new NonceExpiredException('Previously used nonce detected');
        }
 
        // Store the item in cache for 5 minutes
        // 把元素存放在缓存中5分钟
        $cacheItem->set(null)->expiresAfter(300);
        $this->cachePool->save($cacheItem);
 
        // Validate Secret / 验证密码
        $expected = base64_encode(sha1(base64_decode($nonce).$created.$secret, true));
 
        return hash_equals($expected, $digest);
    }
 
    public function supports(TokenInterface $token)
    {
        return $token instanceof WsseUserToken;
    }
}

AuthenticationProviderInterface 需要用户token中的 authenticate 方法,以及 supports 方法,后者告诉 authentication manager 是否对给定的 token 使用这个 provider。在多个 providers 条件下,authentication manager 会移动到 provider 列表中的下一个。

虽然 hash_equals 函数在 PHP 5.6 中被引入,你可以在S任何 PHP 版本的 Symfony 程序中安全使用它。在 PHP 5.6 之前的版本中,Symfony Polyfill(已包含在Symfony中)将为你定义此函数。

Factory 

你已经创建一个自定义的token,一个自定义的监听,以及一个自定义的provider。现在你要将它们组织到一起。你如何才能为每个防火墙都提供一个唯一的provider?答案是使用 factory。factory(工厂),是你打入Security组件内部的一个场所,告诉组件你的provider名称,以及任何它可用的配置选项。首先,你必须创建一个实现了 SecurityFactoryInterface 接口的类。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
// src/AppBundle/DependencyInjection/Security/Factory/WsseFactory.php
namespace AppBundle\DependencyInjection\Security\Factory;
 
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Reference;
use Symfony\Component\DependencyInjection\DefinitionDecorator;
use Symfony\Component\Config\Definition\Builder\NodeDefinition;
use Symfony\Bundle\SecurityBundle\DependencyInjection\Security\Factory\SecurityFactoryInterface;
 
class WsseFactory implements SecurityFactoryInterface
{
    public function create(ContainerBuilder $container, $id, $config, $userProvider, $defaultEntryPoint)
    {
        $providerId = 'security.authentication.provider.wsse.'.$id;
        $container
            ->setDefinition($providerId, new DefinitionDecorator('wsse.security.authentication.provider'))
            ->replaceArgument(0, new Reference($userProvider))
        ;
 
        $listenerId = 'security.authentication.listener.wsse.'.$id;
        $listener = $container->setDefinition($listenerId, new DefinitionDecorator('wsse.security.authentication.listener'));
 
        return array($providerId, $listenerId, $defaultEntryPoint);
    }
 
    public function getPosition()
    {
        return 'pre_auth';
    }
 
    public function getKey()
    {
        return 'wsse';
    }
 
    public function addConfiguration(NodeDefinition $node)
    {
    }
}

SecurityFactoryInterface 接口需要以下方法:

create()
该方法把监听和authentication provider添加到DI容器,以形成合适的security context。
getPosition()
在provider被调用时返回。可以是 pre_auth, form, httpremember_me 之一。
getKey()
该方法定义的配置键,用来引用firewall配置信息中的provider。
addConfiguration()
该方法用于定义你的security配置信息中的子键所对应的选项。设置配置选项将在本文后面解释。

上例没有用到一个类,即 AbstractFactory 类,它是一个极其有用的基类,为security factory提供了常见需求的功能性。在定义不同类型的 authentication provider时,它是非常有用的。

现在,你创建了一个工厂类,在你的security配置信息中,那个 wsse 键即可作为一个firewall来使用。

你可能会问,“为何需要一个特殊的工厂类,将 listeners 和 providers 添加到依赖注入容器呢?”。这是个非常好的问题。原因是,你可以多次使用你的防火墙,来保护你程序中的多个部分。正因为如此,每次使用防火墙时,都会有一个新服务在DI容器中被创建。工厂就是用来创建这些新服务。

配置 

是时候看看你的 authentication provider 的实际运作了。为了让它运行,你需要做一些事。第一件事就是将上面的服务添加到DI容器。factory类中所引用的是尚不存在的service id: wsse.security.authentication.providerwsse.security.authentication.listener。现在定义这些服务。

1
2
3
4
5
6
7
8
9
10
11
12
13
# app/config/services.yml
services:
    wsse.security.authentication.provider:
        class: AppBundle\Security\Authentication\Provider\WsseProvider
        arguments:
            - '' # User Provider
            - '@cache.app'
        public: false

    wsse.security.authentication.listener:
        class: AppBundle\Security\Firewall\WsseListener
        arguments: ['@security.token_storage', '@security.authentication.manager']
        public: false
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
<!-- app/config/services.xml -->
<?xml version="1.0" encoding="UTF-8" ?>
<container xmlns="http://symfony.com/schema/dic/services"
    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    xsi:schemaLocation="http://symfony.com/schema/dic/services http://symfony.com/schema/dic/services/services-1.0.xsd">
 
    <services>
        <service id="wsse.security.authentication.provider"
            class="AppBundle\Security\Authentication\Provider\WsseProvider"
            public="false"
        >
            <argument /> <!-- User Provider -->
            <argument type="service" id="cache.app"></argument>
        </service>
 
        <service id="wsse.security.authentication.listener"
            class="AppBundle\Security\Firewall\WsseListener"
            public="false"
        >
            <argument type="service" id="security.token_storage"/>
            <argument type="service" id="security.authentication.manager" />
        </service>
    </services>
</container>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// app/config/services.php
use Symfony\Component\DependencyInjection\Definition;
use Symfony\Component\DependencyInjection\Reference;
 
$definition = new Definition(
    'AppBundle\Security\Authentication\Provider\WsseProvider',
    array(
        '', // User Provider
        new Reference('cache.app'),
    )
);
$definition->setPublic(false);
$container->setDefinition('wsse.security.authentication.provider', $definition)
 
$definition = new Definition(
    'AppBundle\Security\Firewall\WsseListener',
    array(
        new Reference('security.token_storage'),
        new Reference('security.authentication.manager'),
    )
);
$definition->setPublic(false);
$container->setDefinition('wsse.security.authentication.listener', $definition);

现在,服务已经定义好了,在你的bundle类向security context通报你的factory:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// src/AppBundle/AppBundle.php
namespace AppBundle;
 
use AppBundle\DependencyInjection\Security\Factory\WsseFactory;
use Symfony\Component\HttpKernel\Bundle\Bundle;
use Symfony\Component\DependencyInjection\ContainerBuilder;
 
class AppBundle extends Bundle
{
    public function build(ContainerBuilder $container)
    {
        parent::build($container);
 
        $extension = $container->getExtension('security');
        $extension->addSecurityListenerFactory(new WsseFactory());
    }
}

齐活!现在可以在程序级别(的security配置文件中)定义 WSSE (防火墙)保护之下的各个部分了。

1
2
3
4
5
6
7
8
9
# app/config/security.yml
security:
    # ...

    firewalls:
        wsse_secured:
            pattern:   ^/api/
            stateless: true
            wsse:      true
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
<!-- app/config/security.xml -->
<?xml version="1.0" encoding="UTF-8"?>
<srv:container xmlns="http://symfony.com/schema/dic/security"
    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    xmlns:srv="http://symfony.com/schema/dic/services"
    xsi:schemaLocation="http://symfony.com/schema/dic/services
        http://symfony.com/schema/dic/services/services-1.0.xsd">
 
    <config>
        <!-- ... -->
 
        <firewall
            name="wsse_secured"
            pattern="^/api/"
            stateless="true"
            wsse="true"
        />
    </config>
</srv:container>
1
2
3
4
5
6
7
8
9
10
11
12
// app/config/security.php
$container->loadFromExtension('security', array(
    // ...
 
    'firewalls' => array(
        'wsse_secured' => array(
            'pattern'   => '^/api/',
            'stateless' => true,
            'wsse'      => true,
        ),
    ),
));

祝贺你!你已编写出高度自定义的security authentication provider!

一点补充 

为什么不让你的 WSSE authentication provider 更加精彩呢?有无限可能。何不从添加一些闪光点开始?

配置 

你可以在security配置信息中的 wsse 键下添加自定义选项。例如,允许 Created 头元素过期之前所允许的时间,默认是5分钟。若令其成为“可配置项”,则可以让不同的防火墙有不同的超时时间。

首先你需要编辑 WsseFactory 并在 addConfiguration 方法中定义新的选项;

1
2
3
4
5
6
7
8
9
10
11
12
class WsseFactory implements SecurityFactoryInterface
{
    // ...
 
    public function addConfiguration(NodeDefinition $node)
    {
      $node
        ->children()
        ->scalarNode('lifetime')->defaultValue(300)
        ->end();
    }
}

现在,在工厂的 create 方法中,$config参数包含了一个 lifetime 键,被设置成5分钟(300秒),除非在配置文件中设置了其他时间。将此参数传入 authentication provider 以付诸使用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class WsseFactory implements SecurityFactoryInterface
{
    public function create(ContainerBuilder $container, $id, $config, $userProvider, $defaultEntryPoint)
    {
        $providerId = 'security.authentication.provider.wsse.'.$id;
        $container
            ->setDefinition($providerId,
              new DefinitionDecorator('wsse.security.authentication.provider'))
            ->replaceArgument(0, new Reference($userProvider))
            ->replaceArgument(2, $config['lifetime']);
        // ...
    }
 
    // ...
}

DIC最重要文档 上例中的方法,可到本站的 DI组件相关文档 中寻找答案。

你还需要为 wsse.security.authentication.provider 服务配置添加第三个参数,它可以为空,但必须在工厂的生存期之内填实。WsseProvider 类的构造函数也需要第三个参数 - 即 lifetime – 它是用来替代写死的300秒的。这两个步骤未在本文展现出来。

每一次WSSE请求的生命周期现在都是可配置的,并且可以逐防火墙地设置为所期望的值。

1
2
3
4
5
6
7
8
9
# app/config/security.yml
security:
    # ...

    firewalls:
        wsse_secured:
            pattern:   ^/api/
            stateless: true
            wsse:      { lifetime: 30 }
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<!-- app/config/security.xml -->
<?xml version="1.0" encoding="UTF-8"?>
<srv:container xmlns="http://symfony.com/schema/dic/security"
    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    xmlns:srv="http://symfony.com/schema/dic/services"
    xsi:schemaLocation="http://symfony.com/schema/dic/services
        http://symfony.com/schema/dic/services/services-1.0.xsd">
 
    <config>
        <!-- ... -->
 
        <firewall name="wsse_secured" pattern="^/api/" stateless="true">
            <wsse lifetime="30" />
        </firewall>
    </config>
</srv:container>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
// app/config/security.php
$container->loadFromExtension('security', array(
    // ...
 
    'firewalls' => array(
        'wsse_secured' => array(
            'pattern'   => '^/api/',
            'stateless' => true,
            'wsse'      => array(
                'lifetime' => 30,
            ),
        ),
    ),
));

剩下的就靠你了!在工厂中你可以定义任何相关的配置,然后在容器中使用掉或者传递到别的类中。

Security精校收官之作 powered by SymfonyChina, 最高深文档 翻译水平有限 请学者多包涵, reviewed by xtt1341@2017-02-26

本文,包括例程代码在内,采用的是 Creative Commons BY-SA 3.0 创作共用授权。

登录symfonychina 发表评论或留下问题(我们会尽量回复)