支付宝扫一扫付款
微信扫一扫付款
(微信为保护隐私,不显示你的昵称)
创建一个自定义的认证系统很难,本文将引导你完成这一过程。但根据你的需求,可能通过一种更简单的方式即可解决问题,你也可以使用一些社区bundle:
要使用第三方服务 Google, Facebook 或 Twitter 透过OAuth进行认证,尝试使用 HWIOAuthBundle。
如果你已读过 安全(Security) 一文,你就已经理解了Symfony在实现security的过程中,对认证(authentication)和授权(authorization)所做的区分。本文讨论了认证过程所涉及的核心类,以及如何去实现一个自定义的Authentication Provider。由于认证和授权是不同的概念,在这个扩展中,user-provider并不可知,其功能性由你程序中的user provider来完成,它们可能基于内存,数据库,或任何你选择用来保存用户的其他地方。
完全自定义 算上user provider的话,本文需要手写5个文件,因此是完全自定义。同理,simple要写3个,guard要写2个。但如果使用FOSUserBundle,手写文件数量即分别变为4、2、1。
下面的文字将演示如何创建一个自定义的authentication provider以完成WSSE认证。WSSE的安全协议提供了若干安全利益:
用户名/密码的加密
针对replay attack的安全防范
不需要配置web服务器
WSSE对于保护web services来说非常有用,可以是 SOAP 或 REST 等。
目前有很多关于 WSSE 的精美文档,但本文的注意力不在安全协议上,而是把重心放在“将一个自定义协议添加到你的Symfony程序中”。WSSE基础是,一个请求头(request header)被用来检查加了密的凭证(encrypted credentials),使用timestamp时间戳和 nonce 进行检验,并使用一个密码摘要(password digest)来对发起请求的用户进行身份认证。
WSSE还支持application key验证,这对web services很有用,但它超出了本文范畴.
在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对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要做的是检验 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中)将为你定义此函数。
你已经创建一个自定义的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()
getPosition()
pre_auth
, form
, http
或 remember_me
之一。getKey()
addConfiguration()
上例没有用到一个类,即 AbstractFactory
类,它是一个极其有用的基类,为security factory提供了常见需求的功能性。在定义不同类型的 authentication provider时,它是非常有用的。
现在,你创建了一个工厂类,在你的security配置信息中,那个 wsse
键即可作为一个firewall来使用。
你可能会问,“为何需要一个特殊的工厂类,将 listeners 和 providers 添加到依赖注入容器呢?”。这是个非常好的问题。原因是,你可以多次使用你的防火墙,来保护你程序中的多个部分。正因为如此,每次使用防火墙时,都会有一个新服务在DI容器中被创建。工厂就是用来创建这些新服务。
是时候看看你的 authentication provider 的实际运作了。为了让它运行,你需要做一些事。第一件事就是将上面的服务添加到DI容器。factory类中所引用的是尚不存在的service id: wsse.security.authentication.provider
和 wsse.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> |
祝贺你!你已编写出高度自定义的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> |
剩下的就靠你了!在工厂中你可以定义任何相关的配置,然后在容器中使用掉或者传递到别的类中。
Security精校收官之作 powered by SymfonyChina, 最高深文档 翻译水平有限 请学者多包涵, reviewed by xtt1341@2017-02-26
本文,包括例程代码在内,采用的是 Creative Commons BY-SA 3.0 创作共用授权。