在身份验证上添加约束

时间:2017-07-17 12:04:03

标签: symfony authentication

实际上,我在AuthenticationEvents::AUTHENTICATION_FAILURE上有一个监听器,可以将failedLogin存储在Redis缓存中,例如:

[
  'ip' => [
    'xxx.xxx.xxx.xxx' => [
      'nbAttempts' => 5,
      'lastAttempd' => \DateTime
    ],
  ],
  'username' => [
    'my_login' => [
      'nbAttempts' => 3,
      'lastAttempd' => \DateTime
    ],
    'my_other_login' => [
      'nbAttempts' => 2,
      'lastAttempd' => \DateTime
    ],
  ]
]

但是现在,当用户尝试使用用户名连接时,我需要使用此失败列表来阻止登录在n分钟内尝试超过x次,而对于IP(具有其他比率)则相同。 (稍后,可能在阻止之前添加ReCaptcha)

为此,我需要在登录时添加自定义验证规则。我在文档中找到了它:

但是,在这两个文档中,我需要重写很多东西,但我想保留所有实际行为:在上一页重定向用户(使用referer或在默认页面上),记住我(在gurad中,我被迫返回成功的响应,否则记住我不工作,但我真的不知道哪个响应返回....因为如果我返回null,重定向工作正常),消息等。

我已经搜索但没有找到Symfony默认使用的防护,可以复制/粘贴它,只需添加一条规则。

有人知道另一种方式,只需重写checkCredential

非常感谢

编辑(见最后的答案): 我找到了一个高级防护抽象类:Symfony\Component\Security\Guard\Authenticator\AbstractFormLoginAuthenticator。然后,身份验证工作就像在Symfony中一样,现在,我只需要在checkCredentials中添加我自己的测试(在我的情况下在getUser()中,我更喜欢返回检索用户之前的错误。

2 个答案:

答案 0 :(得分:1)

您可以侦听事件是否失败登录尝试。创建服务:

services:
    app.failed_login_listener:
    class: AppBundle\EventListener\AuthenticationFailureListener
    tags:
        - { name: kernel.event_listener, event: security.authentication.failure, method: onAuthenticationFailure }

然后创建监听器:

<?php

namespace App\EventListener;

use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Security\Core\Exception\AuthenticationException;
use Symfony\Component\Security\Http\Authentication\AuthenticationFailureHandlerInterface;

class AuthenticationFailureListener implements AuthenticationFailureHandlerInterface
{
    public function onAuthenticationFailure(
        Request $request,
        AuthenticationException $exception
    ) {
        // do whatever
    }
}

修改服务定义以注入您可能需要的任何其他服务。

如果要在用户登录后执行操作,可以使用security.interactive_login事件执行此操作。如果您遇到虚拟用户登录的情况,可能会抛出异常,并可能删除他们的安全令牌或您需要的任何其他内容。您甚至可以在Controller的登录操作中执行此操作。

例如:

services:
    app.security_listener:
    class: AppBundle\EventListener\InteractiveLoginListener
    tags:
        - { name: kernel.event_listener, event: security.interactive_login, method: onInteractiveLogin }

然后请你的听众:

<?php

namespace App\EventListener;

use Symfony\Component\Security\Http\Event\InteractiveLoginEvent;

class InteractiveLoginListener
{
    public function onInteractiveLogin(InteractiveLoginEvent $event)
    {
        // do whatever
    }
}

再次根据需要注入依赖项。另请参阅Symfony的creating a custom authentication provider文档。

答案 1 :(得分:1)

最后,我通过扩展这个抽象类找到了一个简单的方法:Symfony\Component\Security\Guard\Authenticator\AbstractFormLoginAuthenticator。这个Authenticator取代了Symfony使用的默认 FormLoginAuthenticator ,但非常简单,我们只是重写了几个方法。

也许只是找到了获取config.yml值的方法,以定义路由(避免在此文件中写入它,因为我们在配置中声明它)。

我的服务声明:

app.security.form_login_authenticator:
    class: AppBundle\Security\FormLoginAuthenticator
    arguments: ["@router", "@security.password_encoder", "@app.login_brute_force"]

我的FormLoginAuthenticator:

<?php

namespace AppBundle\Security;

use AppBundle\Utils\LoginBruteForce;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Router;
use Symfony\Component\Security\Core\Encoder\UserPasswordEncoderInterface;
use Symfony\Component\Security\Core\Exception\AuthenticationException;
use Symfony\Component\Security\Core\Exception\UsernameNotFoundException;
use Symfony\Component\Security\Core\User\UserInterface;
use Symfony\Component\Security\Core\User\UserProviderInterface;
use Symfony\Component\Security\Guard\Authenticator\AbstractFormLoginAuthenticator;

class FormLoginAuthenticator extends AbstractFormLoginAuthenticator
{
    private $router;
    private $encoder;
    private $loginBruteForce;

    public function __construct(Router $router, UserPasswordEncoderInterface $encoder, LoginBruteForce $loginBruteForce)
    {
        $this->router = $router;
        $this->encoder = $encoder;
        $this->loginBruteForce = $loginBruteForce;
    }

    protected function getLoginUrl()
    {
        return $this->router->generate('login');
    }

    protected function getDefaultSuccessRedirectUrl()
    {
        return $this->router->generate('homepage');
    }

    public function getCredentials(Request $request)
    {
        if ($request->request->has('_username')) {
            return [
                'username' => $request->request->get('_username'),
                'password' => $request->request->get('_password'),
            ];
        }

        return;
    }

    public function getUser($credentials, UserProviderInterface $userProvider)
    {
        $username = $credentials['username'];

        // Check if the asked username is under bruteforce attack, or if client process to a bruteforce attack
        $this->loginBruteForce->isBruteForce($username);

        // Catch the UserNotFound execption, to avoid gie informations about users in database
        try {
            $user = $userProvider->loadUserByUsername($username);
        } catch (UsernameNotFoundException $e) {
            throw new AuthenticationException('Bad credentials.');
        }

        return $user;
    }

    public function checkCredentials($credentials, UserInterface $user)
    {
        // check credentials - e.g. make sure the password is valid
        $passwordValid = $this->encoder->isPasswordValid($user, $credentials['password']);

        if (!$passwordValid) {
            throw new AuthenticationException('Bad credentials.');
        }

        return true;
    }
}

而且,如果它是有趣的人,我的LoginBruteForce:

<?php

namespace AppBundle\Utils;

use Symfony\Component\Cache\Adapter\AdapterInterface;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\Security\Core\Event\AuthenticationFailureEvent;
use Symfony\Component\Security\Core\Exception\AuthenticationException;

class LoginBruteForce
{
    // Define constants used to define how many tries we allow per IP and login
    // Here: 20/10 mins (IP); 5/10 mins (username)
    const MAX_IP_ATTEMPTS = 20;
    const MAX_USERNAME_ATTEMPTS = 5;
    const TIME_RANGE = 10; // In minutes

    private $cacheAdapter;
    private $requestStack;

    public function __construct(AdapterInterface $cacheAdapter, RequestStack $requestStack)
    {
        $this->cacheAdapter = $cacheAdapter;
        $this->requestStack = $requestStack;
    }

    private function getFailedLogins()
    {
        $failedLoginsItem = $this->cacheAdapter->getItem('failedLogins');
        $failedLogins = $failedLoginsItem->get();

        // If the failedLogins is not an array, contruct it
        if (!is_array($failedLogins)) {
            $failedLogins = [
                'ip' => [],
                'username' => [],
            ];
        }

        return $failedLogins;
    }

    private function saveFailedLogins($failedLogins)
    {
        $failedLoginsItem = $this->cacheAdapter->getItem('failedLogins');
        $failedLoginsItem->set($failedLogins);
        $this->cacheAdapter->save($failedLoginsItem);
    }

    private function cleanFailedLogins($failedLogins, $save = true)
    {
        $actualTime = new \DateTime('now');
        foreach ($failedLogins as &$failedLoginsCategory) {
            foreach ($failedLoginsCategory as $key => $failedLogin) {
                $lastAttempt = clone $failedLogin['lastAttempt'];
                $lastAttempt = $lastAttempt->modify('+'.self::TIME_RANGE.' minute');

                // If the datetime difference is greatest than 15 mins, delete entry
                if ($lastAttempt <= $actualTime) {
                    unset($failedLoginsCategory[$key]);
                }
            }
        }

        if ($save) {
            $this->saveFailedLogins($failedLogins);
        }

        return $failedLogins;
    }

    public function addFailedLogin(AuthenticationFailureEvent $event)
    {
        $clientIp = $this->requestStack->getMasterRequest()->getClientIp();
        $username = $event->getAuthenticationToken()->getCredentials()['username'];

        $failedLogins = $this->getFailedLogins();

        // Add clientIP
        if (array_key_exists($clientIp, $failedLogins['ip'])) {
            $failedLogins['ip'][$clientIp]['nbAttempts'] += 1;
            $failedLogins['ip'][$clientIp]['lastAttempt'] = new \DateTime('now');
        } else {
            $failedLogins['ip'][$clientIp]['nbAttempts'] = 1;
            $failedLogins['ip'][$clientIp]['lastAttempt'] = new \DateTime('now');
        }

        // Add username
        if (array_key_exists($username, $failedLogins['username'])) {
            $failedLogins['username'][$username]['nbAttempts'] += 1;
            $failedLogins['username'][$username]['lastAttempt'] = new \DateTime('now');
        } else {
            $failedLogins['username'][$username]['nbAttempts'] = 1;
            $failedLogins['username'][$username]['lastAttempt'] = new \DateTime('now');
        }

        $this->saveFailedLogins($failedLogins);
    }

    // This function can be use, when the user reset his password, or when he is successfully logged
    public function resetUsername($username)
    {
        $failedLogins = $this->getFailedLogins();

        if (array_key_exists($username, $failedLogins['username'])) {
            unset($failedLogins['username'][$username]);
            $this->saveFailedLogins($failedLogins);
        }
    }

    public function isBruteForce($username)
    {
        $failedLogins = $this->getFailedLogins();
        $failedLogins = $this->cleanFailedLogins($failedLogins, true);

        $clientIp = $this->requestStack->getMasterRequest()->getClientIp();

        // If the IP is in the list
        if (array_key_exists($clientIp, $failedLogins['ip'])) {
            if ($failedLogins['ip'][$clientIp]['nbAttempts'] >= self::MAX_IP_ATTEMPTS) {
                throw new AuthenticationException('Too many login attempts. Please try again in '.self::TIME_RANGE.' minutes.');
            }
        }
        // If the username is in the list
        if (array_key_exists($username, $failedLogins['username'])) {
            if ($failedLogins['username'][$username]['nbAttempts'] >= self::MAX_USERNAME_ATTEMPTS) {
                throw new AuthenticationException('Maximum number of login attempts exceeded for user: "'.$username.'". Please try again in '.self::TIME_RANGE.' minutes.');
            }
        }

        return;
    }
}