[Symfony] 在FOSUserbundle添加Google Recaptcha驗証碼進行登入

這不是一篇Syfmony新手學習的文章####

閱讀這篇文章時,你該具備以下知識:####

  1. Symfony表單的創建
  2. Service的使用以及Container的注入
  3. 對FOSUserBundle有一定的了解

你將會在這篇文章獲得以下知識:####

  1. 寫一個Eventer Listener
  2. 覆寫Fosuser Controller
  3. Php Trait 的使用
  4. 注入個人邏輯至Symfony登入機制

文章開始###

開發 [Symfony] (https://symfony.com/)項目已經有一段時間了,目前 Symfony 很多開源項目的登入系統都會選擇 FOSUserBundle,加上做項目就是要求快呀,所以就硑究起來了,剛開始用時很久結這東西:what the fuck this bundle working on?
這東西很方便,登入、驗証、授權、查找密碼都不用開發者去處理,Fos全家桶已經幫你Handle好了。但問題來了吖吖吖,登入這東西很玄呀,帳號/郵件 +密碼這種東西不能滿足大部份需求呀。 先別說用API拿TOKEN去LOGIN、OAUTH、甚麼JWT,大哥,我只想加個驗証碼怎麼也這麼難呀。這個是基本的吖吖。。。

問題:
1. FOSUserBundle does not handle the login. It is done by the Symfony security component.(官方)
2. If you want to add more field about Authenticate, the most general way is custom authentication system.(官方)
3. 在GITHUB走了二轉,的有很多關於google recaptcha或其它Verification注入到Symfony的FORM中,但是,但是!!這些BUNDLE全家桶都不能INJECT到FOSUserBundle的登入表單呀。

所以,如果你想在Authenticate中加入GOOGLE Reaptcha 或者其它驗証碼方法時,你要怎做?有的司機會說:Build your custom authentication system 就行了呀。
媽的!說了等於沒說。就像Symfony官網所說的:

Creating a custom authentication system is hard####

那麼難道就沒有一種簡單的方法麼?SYMFONY一直標榜說自己有多屌有多屌,強調從架構上提升代碼的複用性同時降低藕合性。所以當然有方法啦,下面就分享一個比較簡單的方法把Google Recaptcha。這個例子很實用,無論你想加入任何的checking,都可以用這個方法。

Step:
1. Install EWZRecaptchaBundle from git and config it.
2. 新增一個表單Service,并注射container到GoogleLoginFormListener,用來渲染Google Recaptcha表單。
3. Override FOSUserBundle 的 LoginAction,并Inject包含了Google Recaptcha的表單(步驟2)。
4. 增加渲染google recapcha到登入頁面。
5. 新增一個名叫GoogleLoginFormListener的listener,它是override SYMFONY的 UsernamePasswordFormAuthenticationListener
6. GoogleLoginFormListener裹面,用 EWZRecaptchaBundle 檢查驗証內容

好了,說到這裹,有沒有一點想法了,如果有,就動一下手吧。如果沒有,請繼續看下去。這裹會一部一部的分享。

Step 1: install EWZRecaptchaBundle from git and config it.

首先,從GIT安裝EWZRecaptchaBundle,并Config。
在這部中,你只需跟著DOCUMENT啦。但是有一點要注意的:

ajax : true

這行千萬不要加,如果加了就會默認用了GOOGLE Recapcha(下稱GR,名稱太長不想打)。

Step 2: 新增一個表單Service,并注射container到GoogleLoginFormListener,用來渲染Google Recaptcha表單。

<?php

namespace App\BackBundle\Helper;

use Symfony\Component\DependencyInjection\ContainerInterface;
use CTM\UserBundle\Form\CustomLoginForm;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;

class GoogleRecaptchaForm
{
    private $container;

    function __construct(ContainerInterface $containerInterface) {
        $this->container = $containerInterface;
    }

    public function createLoginForm(){
        $form = $this->container->get('form.factory')->create(new CustomLoginForm(), null, array(
            'action' => $this->generateUrl( 'fos_user_security_check' ),
            'method' => 'POST',
        ));
        $form->add('submit', 'submit', array('label' => 'Create'));
        return $form;
    }

    public function generateUrl($route, $parameters = array(), $referenceType = UrlGeneratorInterface::ABSOLUTE_PATH)
    {
        return $this->container->get('router')->generate($route, $parameters, $referenceType);
    }

}

這裹的關鍵代碼就是action是

'fos_user_security_check'

雖然有些老司機會知道,這個ACTION FOS是沒有用到啦,這是題外話。

Step 3: Override FOSUserBundle 的 LoginAction,并Inject包含了Google Recaptcha的表單(步驟2)。
關於如何Override FOSUserBundle 的CONTROLLER,這裹不多說,自己看文檔
Override SecurityController 的 LoginAction:

    public function loginAction(Request $request)
    {
        /** @var $session \Symfony\Component\HttpFoundation\Session\Session */
        $session = $request->getSession();

        if (class_exists('\Symfony\Component\Security\Core\Security')) {
            $authErrorKey = Security::AUTHENTICATION_ERROR;
            $lastUsernameKey = Security::LAST_USERNAME;
        } else {
            // BC for SF < 2.6
            $authErrorKey = SecurityContextInterface::AUTHENTICATION_ERROR;
            $lastUsernameKey = SecurityContextInterface::LAST_USERNAME;
        }

        // get the error if any (works with forward and redirect -- see below)
        if ($request->attributes->has($authErrorKey)) {
            $error = $request->attributes->get($authErrorKey);
        } elseif (null !== $session && $session->has($authErrorKey)) {
            $error = $session->get($authErrorKey);
            $session->remove($authErrorKey);
        } else {
            $error = null;
        }

        if (!$error instanceof AuthenticationException) {
            $error = null; // The value does not come from the security component.
        }

        // last username entered by the user
        $lastUsername = (null === $session) ? '' : $session->get($lastUsernameKey);

        if ($this->has('security.csrf.token_manager')) {
            $csrfToken = $this->get('security.csrf.token_manager')->getToken('authenticate')->getValue();
        } else {
            // BC for SF < 2.4
            $csrfToken = $this->has('form.csrf_provider')
                ? $this->get('form.csrf_provider')->generateCsrfToken('authenticate')
                : null;
        }

        $form = $this->get('app.form.google.recaptcha')->createLoginForm();

        return $this->renderLogin(array(
            'last_username' => $lastUsername,
            'error' => $error,
            'csrf_token' => $csrfToken,
            'form' => $form->createView(),
        ));
    }

這裹的關鍵代碼是:

        $form = $this->get('app.form.google.recaptcha')->createLoginForm();

        return $this->renderLogin(array(
            'last_username' => $lastUsername,
            'error' => $error,
            'csrf_token' => $csrfToken,
            'form' => $form->createView(),
        ));

就是渲染我們上一部的表單。

Step 4: 增加渲染google recapcha到登入頁面。

        <form class="form-horizontal" name="{{ form.vars.name }}" action="{{ path('fos_user_security_check') }}"
              method="post">
            <fieldset>

                <input type="hidden" name="_csrf_token" value="{{ csrf_token }}"/>

                <div class="form-group" title="Username">
                    <div class="col-md-12">
                        <input class="form-control" name="_username" id="username" type="text"
                               placeholder="{{ 'Type username' | trans }}" value="{{ last_username }}"/>
                    </div>
                </div>
                <div class="form-group" title="Password">
                    <div class="col-md-12">
                        <input class="form-control" name="_password" id="password" type="password"
                               placeholder="{{ 'Type password' | trans }}"/>
                    </div>
                </div>

                {% if error is not null %}
                    <div class="form-group ">
                        {% if 'recaptcha' in error.message  and '|' in error.message %}
                            {% set key = error.message | split ('|') %}
                                <p class="col-xs-12 col-lg-12 col-md-12 customWarning">{{ key.0 | trans ~ " " ~ "Error" | trans  }} </p>
                                <p class="col-xs-12 col-lg-12 col-md-12 customWarning">{{ key.1 | trans }}</p>
                        {% else %}
                                <p class="col-md-12 col-lg-12 col-md-12 customWarning">{{ error.messageKey|trans(error.messageData, 'security') }}</p>
                                <p class="col-md-12 col-lg-12 col-md-12 customWarning">{{ 'form.errorLogin' | trans }}</p>
                        {% endif %}
                    </div>
                {% endif %}

                {% if form.recaptcha.vars.ewz_recaptcha_enabled == true %}
                    <div class="form-group">
                        <div class="col-xs-12 col-lg--12 col-md-12">
                            {% form_theme form.recaptcha 'EWZRecaptchaBundle:Form:ewz_recaptcha_widget.html.twig' %}
                            {{ form_widget(form.recaptcha ) }}
                        </div>
                    </div>
                {% endif %}

                <div class="form-group">
                    <div class="col-md-6">
                        <button type="submit" id="_submit" name="{{ form.submit.vars.full_name }}"
                                class="btn btn-info btn-block">{{ 'Login' | trans }}</button>
                    </div>

                    <div class="col-md-6">
                        <a class="btn btn-success btn-block"
                           href="{{ path('fos_user_resetting_request') }}">{{ 'Forget password' | trans }}</a>
                    </div>
                </div>

                <div id="recaptcha"></div>

            </fieldset>
        </form>

TWIG的HTML代碼不多說,主要是自己實現一個FORM,INJECT剛才的EWZRecaptchaBundle FORM進去,到這裹都很簡單。看下一步。關鍵。

STEP 5: 新增一個名叫GoogleLoginFormListener的listener,它是override SYMFONY的 UsernamePasswordFormAuthenticationListener

這部份是關鍵,因為我們希望在原有的Symfony登入驗証上增加驗証碼的驗証,但因為FOS是不作出驗証的,其實它是交給SYMFONY作驗証的,所以,我們就要作一個LISTENER,在symfony checking你的帳號/EMAIL/密碼 的ACTION 前/後 做一次驗証碼的CHECK。 這個例子很實用,無論你想加入任何的checking,都可以用這個方法。
當然,如果你想要加入checking的是用戶的資料,當然是要實現一個的Login ACTION 後的LISTENER。

核心部份具體是你要override一個名叫UsernamePasswordFormAuthenticationListener的Listener。這個LISTENER是監聽你帳號密碼驗証前的ACTION:

<?php
/**
 * Created by PhpStorm.
 * User: kimwong
 */

namespace App\BackBundle\EventListener;
use Symfony\Component\Security\Http\Firewall\UsernamePasswordFormAuthenticationListener as BaseListener;
use Symfony\Component\Form\Extension\Csrf\CsrfProvider\CsrfProviderInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\Log\LoggerInterface;
use Symfony\Component\Security\Http\Authentication\AuthenticationFailureHandlerInterface;
use Symfony\Component\Security\Http\Authentication\AuthenticationSuccessHandlerInterface;
use Symfony\Component\Security\Http\Session\SessionAuthenticationStrategyInterface;
use Symfony\Component\Security\Http\HttpUtils;
use Symfony\Component\Security\Core\Authentication\AuthenticationManagerInterface;
use Symfony\Component\Security\Core\Authentication\Token\UsernamePasswordToken;
use Symfony\Component\Security\Core\Exception\InvalidCsrfTokenException;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\Security\Core\Exception\BadCredentialsException;
use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface;
use Symfony\Component\Security\Csrf\CsrfTokenManagerInterface;
use Symfony\Component\Form\Extension\Csrf\CsrfProvider\CsrfProviderAdapter;
use Symfony\Component\Security\Core\Exception\InvalidArgumentException;
use Symfony\Component\Security\Http\ParameterBagUtils;
use Symfony\Component\Security\Core\Security;
use Symfony\Component\Security\Csrf\CsrfToken;
use \Symfony\Component\DependencyInjection\ContainerAwareTrait;
use \Symfony\Component\DependencyInjection\ContainerAwareInterface;


class GoogleLoginFormListener extends BaseListener implements ContainerAwareInterface
{
    use ContainerAwareTrait;

    private $csrfTokenManager;

    public function __construct(
        TokenStorageInterface $tokenStorage,
        AuthenticationManagerInterface $authenticationManager,
        SessionAuthenticationStrategyInterface $sessionStrategy,
        HttpUtils $httpUtils,
        $providerKey,
        AuthenticationSuccessHandlerInterface $successHandler,
        AuthenticationFailureHandlerInterface $failureHandler,
        array $options = array(),
        LoggerInterface $logger = null,
        EventDispatcherInterface $dispatcher = null,
        $csrfTokenManager = null
    )
    {
        if ($csrfTokenManager instanceof CsrfProviderInterface) {
            $csrfTokenManager = new CsrfProviderAdapter($csrfTokenManager);
        }elseif (null !== $csrfTokenManager && !$csrfTokenManager instanceof CsrfTokenManagerInterface) {
            throw new InvalidArgumentException('The CSRF token manager should be an instance of CsrfProviderInterface or CsrfTokenManagerInterface.');
        }

        if (isset($options['intention'])) {
            if (isset($options['csrf_token_id'])) {
                throw new \InvalidArgumentException(sprintf('You should only define an option for one of "intention" or "csrf_token_id" for the "%s". Use the "csrf_token_id" as it replaces "intention".', __CLASS__));
            }

            @trigger_error('The "intention" option for the '.__CLASS__.' is deprecated since version 2.8 and will be removed in 3.0. Use the "csrf_token_id" option instead.', E_USER_DEPRECATED);

            $options['csrf_token_id'] = $options['intention'];
        }

        parent::__construct($tokenStorage, $authenticationManager, $sessionStrategy, $httpUtils, $providerKey, $successHandler, $failureHandler, array_merge(array(
            'username_parameter' => '_username',
            'password_parameter' => '_password',
            'csrf_parameter' => '_csrf_token',
            'csrf_token_id' => 'authenticate',
            'captcha' => 'captcha',
            'post_only' => true,
        ), $options), $logger, $dispatcher);

        $this->csrfTokenManager = $csrfTokenManager;
    }

    /**
     * {@inheritdoc}
     */
    protected function requiresAuthentication(Request $request)
    {
        if ($this->options['post_only'] && !$request->isMethod('POST')) {
            return false;
        }

        return parent::requiresAuthentication($request);
    }

    /**
     * {@inheritdoc}
     */
    protected function attemptAuthentication(Request $request)
    {
        if (null !== $this->csrfTokenManager) {
            $csrfToken = ParameterBagUtils::getRequestParameterValue($request, $this->options['csrf_parameter']);

            if (false === $this->csrfTokenManager->isTokenValid(new CsrfToken($this->options['csrf_token_id'], $csrfToken))) {
                throw new InvalidCsrfTokenException('Invalid CSRF token.');
            }
        }

        $form = $this->container->get('app.form.google.recaptcha')->createLoginForm();

        if ($form->createView()->children['recaptcha']->vars['ewz_recaptcha_enabled']){
            $form->handleRequest($request);
            if (!$form->isValid()) {
                if ( $form->get('recaptcha')->getErrors() ){
                    $error = $form->get('recaptcha')->getErrorsAsString();
                    throw new BadCredentialsException(  'recaptcha' . "|"  .  $error );
                }
                throw new BadCredentialsException( 'Unexpected Error!' );
            }
        }

        if ($this->options['post_only']) {
            $username = trim(ParameterBagUtils::getParameterBagValue($request->request, $this->options['username_parameter']));
            $password = ParameterBagUtils::getParameterBagValue($request->request, $this->options['password_parameter']);
        } else {
            $username = trim(ParameterBagUtils::getRequestParameterValue($request, $this->options['username_parameter']));
            $password = ParameterBagUtils::getRequestParameterValue($request, $this->options['password_parameter']);
        }


        if (strlen($username) > Security::MAX_USERNAME_LENGTH) {
            throw new BadCredentialsException('Invalid username.');
        }

        $request->getSession()->set(Security::LAST_USERNAME, $username);

        return $this->authenticationManager->authenticate(new UsernamePasswordToken($username, $password, $this->providerKey));
    }
}

這裹核心代碼有二個:
1.注入ContainerAwareTrait。 用Trait的方法注入Container到這個Listener。

不要問我這裹為甚麼要用Trait而不用傳統的在construct裹注入,并不是因為Trait很屌很方便很PHP潮流,是因為這裹如果你用construct,我很負責任100%擔保,你會爆ERROR的,詳細自己試,原因有點複雜,這裹先不說了#####

2.覆寫原生attemptAuthentication,加入GR的checking:

        $form = $this->container->get('app.form.google.recaptcha')->createLoginForm();

        if ($form->createView()->children['recaptcha']->vars['ewz_recaptcha_enabled']){
            $form->handleRequest($request);
            if (!$form->isValid()) {
                if ( $form->get('recaptcha')->getErrors() ){
                    $error = $form->get('recaptcha')->getErrorsAsString();
                    throw new BadCredentialsException(  'recaptcha' . "|"  .  $error );
                }
                throw new BadCredentialsException( 'Unexpected Error!' );
            }
        }

因為我們用了EWZBUNDLE,所以這裹的驗証我們不需要自己實作,用handleRequest就好了。主要是做一個配對,錯的話把ERROR MESSAGE RENDER到前端。

整個GR注入到FOSUSERBUNDLE就完成了。

是不是很簡單呢。

話說Angular2已經進入最後階段了,未來有機會會分享一下一些Ng2的經驗。

謝謝觀看

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 216,997评论 6 502
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 92,603评论 3 392
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 163,359评论 0 353
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 58,309评论 1 292
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 67,346评论 6 390
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 51,258评论 1 300
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 40,122评论 3 418
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 38,970评论 0 275
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 45,403评论 1 313
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,596评论 3 334
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,769评论 1 348
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,464评论 5 344
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 41,075评论 3 327
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,705评论 0 22
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,848评论 1 269
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 47,831评论 2 370
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,678评论 2 354

推荐阅读更多精彩内容

  • 为何叫做 shell ? shell prompt(PS1) 与 Carriage Return(CR) 的关系?...
    Zero___阅读 3,150评论 3 49
  • 程序員創業白皮書 作者:Paul Graham Paul Graham是程序員,專欄作家。他在1995年創建了第一...
    刘立山John阅读 1,918评论 0 20
  • 提問的智慧 How To Ask Questions The Smart Way Copyright © 2001...
    Albert陈凯阅读 2,290评论 0 8
  • 哇,93个汤,连煲三个月都吾怕重复,收藏! 广东省中医院药师佘自强教你煲靓汤!家庭必备 1.淮山党参鹌鹑汤 材料:...
    瓶子瘦瘦阅读 529评论 0 0
  • 我猜测过你所有离开的原因, 但每一种都不足以使我们分离。 我喜欢过你了, 你就要走了么? 我的眼泪像一首诗, 它忍...
    爱酱lll阅读 429评论 13 9