译注:本文由Symfony之父Fabien Potencier撰写于2013年,谈到了Symfony中最难的Security部分。原博清楚表明了在当时就可以有如此强大而简易(但在常人看来可能非常复杂,实际情况是,理论并不简单,但搞懂之后,代码层面真的很省事)的验证架构。然而这种架构在2016年的今天,就愈发强大起来——Security核心组件中的authentication部分经过了再次简化,以Guard姿态登场。本文内容涉及大量代码,某些写法与最新文档略有不同(大家在实践中注意甄别),但由于文章在原理层面仍然具有重大参考意义,我们决定全文翻译,希望各位能够增进对Security的理解,并活用于实践。
你可能已经听说使用Symfony2安全组件时是非常复杂的是吧?我既同意又不同意。一方面,如果你的需求是“标准化”的(经过数据库的会员表单验证,HTTP基本验证,等等),在框架中设置Security是相当容易的——仅仅是配置一些选项而已。(译注:确实如此。“自动”是底层化全覆盖的Symfony根本基因。)
但是另外一面,你需要定制authentication/authorization/user provider系统,此时事情变得有些许复杂,因为你必须理解所有这些概念,以及你的需求如何来凝聚这一切。进入Symfony 2.4以来,这个过程已经被简化了,这要感谢针对Security Layer引入的简易定制方式,毋需再写齐“一大捆”的类(译注:要写满5个,包括Factory等)。本文,我将描述如何写就一些基本功能的代码。
使用自定义User Provider ¶
深入到框架全新的自定义功能之前,我们先简化一些条件,将用户credentials(密钥之类的凭据,不作翻译)存在一个文件中。当然,在多数情况下,用户的credentials是存在数据库中的,Symfony内置了Doctrine和Propel。
为了让例程简明,我们使用一个简单的JSON文件:
用户名是键,值就是密码了,加密方式为md5
,全都按简单的来。
创建一个User Provider再简单不过,实现接口Symfony\Component\Security\Core\User\UserProviderInterface
即可:
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 | use Symfony\Component\Security\Core\User\User;
use Symfony\Component\Security\Core\User\UserProviderInterface;
use Symfony\Component\Security\Core\User\UserInterface;
use Symfony\Component\Security\Core\Exception\UsernameNotFoundException;
use Symfony\Component\Security\Core\Exception\UnsupportedUserException;
class JsonUserProvider implements UserProviderInterface
{
protected $users;
public function __construct()
{
$this->users = json_decode(file_get_contents('/path/to/users.json'), true);
}
public function loadUserByUsername($username)
{
if (isset($this->users[$username])) {
return new User($username, $this->users[$username], array('ROLE_USER'));
}
throw new UsernameNotFoundException(sprintf('Username "%s" does not exist.', $username));
}
public function refreshUser(UserInterface $user)
{
if (!$user instanceof User) {
throw new UnsupportedUserException(sprintf('Instances of "%s" are not supported.', get_class($user)));
}
return $this->loadUserByUsername($user->getUsername());
}
public function supportsClass($class)
{
return 'Symfony\Component\Security\Core\User\User' === $class;
}
} |
我们使用了内置的User
类。然后在配置文件中应用这个provider时是直白的:
1 2 | services:
json_user_provider: { class: JsonUserProvider } |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 | security:
providers:
json:
id: json_user_provider
firewalls:
secured_area:
pattern: ^/admin
provider: json
# ...
encoders:
Symfony\Component\Security\Core\User\User:
algorithm: md5
iterations: 0
encode_as_base64: false |
这里发生了什么?
一个
json_user_provider
被定义。一个
json
user provider被链接到json_user_provider
服务。json
user provider被用于secured_area
防火墙。User
密码加密encoder使用了简单的md5 hash。
这里的user provider被从authentication mechanism(验证架构)中解除藕合,所以你可以使用在下列任意场合:表单、API key、HTTP basic,...
使用自定义的Authenticator ¶
如果我们要创建一个表单来让用户输入他们的credentials,我们可以使用内置的form-login
验证系统:
1 2 3 4 5 6 7 8 | security:
firewalls:
secured_area:
pattern: ^/admin
provider: json
form-login:
check_path: security_check
login_path: login |
有多种方式可以配置
form-login
,这篇Security Configuration Reference对此进行了全面解释。
现在,我们假定,要让用户在下午2点到4点之间(UTC时区)才可以访问我们的网站。如何才能实现呢?很明显,并没有内置的“简易配置”能够满足这样一个需求。所以,我们需要对用户的认证过程进行自定义。
即将进入有趣环节。不同于创建一整套自定义的token、factory、listener、provider,我们使用的是一个全新的Symfony\Component\Security\Core\Authentication\SimpleFormAuthenticatorInterface
接口来替代(为了简化操作,我们在这里扩展了user 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 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 | use Symfony\Component\Security\Core\Authentication\SimpleFormAuthenticatorInterface;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Exception\AuthenticationException;
use Symfony\Component\Security\Core\Authentication\Token\UsernamePasswordToken;
use Symfony\Component\Security\Core\User\UserProviderInterface;
use Symfony\Component\Security\Core\Exception\UsernameNotFoundException;
use Symfony\Component\Security\Core\Encoder\EncoderFactoryInterface;
use Symfony\Component\HttpFoundation\Request;
class TimeAuthenticator extends JsonUserProvider implements SimpleFormAuthenticatorInterface
{
private $encoderFactory;
public function __construct(EncoderFactoryInterface $encoderFactory)
{
$this->encoderFactory = $encoderFactory;
}
public function createToken(Request $request, $username, $password, $providerKey)
{
return new UsernamePasswordToken($username, $password, $providerKey);
}
public function authenticateToken(TokenInterface $token, UserProviderInterface $userProvider, $providerKey)
{
try {
$user = $userProvider->loadUserByUsername($token->getUsername());
} catch (UsernameNotFoundException $e) {
throw new AuthenticationException('Invalid username or password');
}
$passwordValid = $this->encoderFactory->getEncoder($user)->isPasswordValid($user->getPassword(), $token->getCredentials(), $user->getSalt());
if ($passwordValid) {
$currentHour = date('G');
if ($currentHour < 14 || $currentHour > 16) {
throw new AuthenticationException('You can only log in between 2 and 4!', 100);
}
return new UsernamePasswordToken($user, 'bar', $providerKey, $user->getRoles());
}
throw new AuthenticationException('Invalid username or password');
}
public function supportsToken(TokenInterface $token, $providerKey)
{
return $token instanceof UsernamePasswordToken && $token->getProviderKey() === $providerKey;
}
} |
这里发生了太多事:
createToken()
创建了一个Token用于对用户进行验证;authenticateToken
负责检查Token
是否被允许登陆,它依靠的是:第一步,从user provider得到User
;然后,检查密码;最后,比对当前时间。(带有roles的Token
即为已“已验证”,即authenticated Token);supporsToken
只是一种灵活方式,它允许多种不同的验证架构(authentication mechanism)被用于相同的Firewall(译注:防火墙是Symfony安全组件的概念,本文不作翻译)。如此一来,你就可以尝试先行使用API key来验证用户,失败的话再回滚到表单登陆继续验证;加密器(encoder)在密码验证的时候需要,下面是默认提供的加密服务:
1 | $passwordValid = $this->encoderFactory->getEncoder($user)->isPasswordValid($user->getPassword(), $token->getCredentials(), $user->getSalt()); |
现在,怎么才能把这个authenticator类绑到我们的配置文件中呢?因为我们实现的是Symfony\Component\Security\Core\Authentication\SimpleFormAuthenticatorInterface
接口,直接用simple-form
替换掉form-login
,然后设置authenticator
选项为time_authenticator
即可:
1 2 3 4 | services:
time_authenticator:
class: TimeAuthenticator
arguments: [@security.encoder_factory] |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 | security:
providers:
json:
id: time_authenticator
firewalls:
secured_area:
pattern: ^/admin
provider: authenticator
simple-form:
provider: json
authenticator: time_authenticator
check_path: security_check
login_path: login |
正如你所见,毋须创建“security listener”,毋需创建“security configuragion factory”。
自定义的验证失败和验证成功操作 ¶
经历authentication mechanism的验证过程之后,你有机会改变验证的默认行为,添加一些新方法到authenticator类中:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 | use Symfony\Component\Security\Core\Exception\AuthenticationException;
use Symfony\Component\Security\Http\Authentication\AuthenticationFailureHandlerInterface;
use Symfony\Component\Security\Http\Authentication\AuthenticationSuccessHandlerInterface;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
class CustomTimeAuthenticator extends TimeAuthenticator implements AuthenticationFailureHandlerInterface, AuthenticationSuccessHandlerInterface
{
public function onAuthenticationFailure(Request $request, AuthenticationException $exception)
{
error_log('You are out!');
}
public function onAuthenticationSuccess(Request $request, TokenInterface $token)
{
error_log(sprintf('Yep, you are in "%s"!', $token->getUsername()));
}
} |
这里,我们使用了error_log()
函数来记录信息。但你完全可以无视一切默认行为,而只是返回一个Response
实例:
1 2 3 4 5 6 | public function onAuthenticationFailure(Request $request, AuthenticationException $exception)
{
if ($exception->getCode()) {
return new Response('Not the right time to log in, come back later.');
}
} |
通过API key来验证用户 ¶
当今时代,使用API key来验证用户变得再普遍不过(比如要开发一个web service。译注:oauth也属此类,泛称pre_auth)。API key在每次请求时都需要传递进来,通过一个query string参数,或是通过HTTP头信息。
我们续用前面的JSON文件来操作,因此(密钥的)值就变成了API key。基于Request信息来对用户进行验证是通过一种pre-authentication mechanism(预验证架构)来实现的。全新的Symfony\Component\Security\Core\Authentication\SimplePreAuthenticatorInterface
类,令此种场景的认证变得相当容易:
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 | use Symfony\Component\Security\Core\Authentication\SimplePreAuthenticatorInterface;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Exception\AuthenticationException;
use Symfony\Component\Security\Core\Authentication\Token\PreAuthenticatedToken;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Security\Core\User\User;
use Symfony\Component\Security\Core\User\UserProviderInterface;
use Symfony\Component\Security\Core\Exception\UsernameNotFoundException;
use Symfony\Component\Security\Core\Exception\BadCredentialsException;
class TimeAuthenticator extends JsonUserProvider implements SimplePreAuthenticatorInterface
{
protected $apikeys;
public function __construct()
{
parent::__construct();
$this->apikeys = array_flip($this->users);
}
public function createToken(Request $request, $providerKey)
{
if (!$request->query->has('apikey')) {
throw new BadCredentialsException('No API key found');
}
return new PreAuthenticatedToken('anon.', $request->query->get('apikey'), $providerKey);
}
public function authenticateToken(TokenInterface $token, UserProviderInterface $userProvider, $providerKey)
{
$currentHour = date('G');
if ($currentHour < 14 || $currentHour > 16) {
throw new AuthenticationException('You can only log in between 2 and 4!', 100);
}
$apikey = $token->getCredentials();
if (!isset($this->apikeys[$apikey])) {
throw new AuthenticationException(sprintf('API Key "%s" does not exist.', $apikey));
}
$user = new User($this->apikeys[$apikey], $apikey, array('ROLE_USER'));
return new PreAuthenticatedToken($user, $token->getCredentials(), $providerKey, $user->getRoles());
}
public function supportsToken(TokenInterface $token, $providerKey)
{
return $token instanceof PreAuthenticatedToken && $token->getProviderKey() === $providerKey;
}
} |
如你所见,这个类很像前面的自定义表单验证,除了我们使用的是Symfony\Component\Security\Core\Authentication\Token\PreAuthenticatedToken
这个token类。
为了访问到这样一个authenticator所保护的资源,你需要提供一个apikey参数给query string,就像这种http://example.com/admin/foo?apikey=37b51d194a7513e45b56f6524f2d51f2
。
配置过程仍然简单明了(只需把simple-form
改为simple-preauth
):
1 2 3 4 5 6 7 | security:
firewalls:
secured_area:
pattern: ^/admin
simple-preauth:
provider: json
authenticator: time_authenticator |
在Firewall中使用多个Authenticator ¶
如果你的程序能够同时返回一个HTML和一个JSON/XML格式的资源的话,那么同时支持API-key验证架构(程序化的访问)和一个常规表单验证(浏览器人工访问)也许是一个不错的选择。
配置起来真是易如反掌:
1 2 3 4 5 6 7 8 9 10 11 12 | security:
firewalls:
secured_area:
pattern: ^/admin
simple-preauth:
provider: json
authenticator: pre_auth_time_authenticator
simple-form:
provider: json
authenticator: form_time_authenticator
check_path: security_check
login_path: login |
结论 ¶
我希望以这种全新方式来自定义框架的Security功能,能够降低新入行的Symfony开发者之门槛。本功能目前还是实验性的,在2.4版正式发布之前有可能会基于大家的建议而发生变化。因此,请尝试使用它,并告诉我们你的感受。
译注:文中的
simple-xxxx
写法,现已更新为simple_xxxx