译注本文由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被定义。

  • 一个jsonuser provider被链接到json_user_provider服务。

  • jsonuser provider被用于secured_area防火墙。

  • User密码加密encoder使用了简单的md5 hash。

你可以在book中了解更多,也可以通过cookbook学习到如何创建你自己的的User Provider。

这里的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