月度归档:2018年06月

Laravel 实践 – 验证码 mews/captcha

验证码原理:随机产生一段字符串,用这个字符串生成图片,对这个字符串进行签名(哈希),把签名记录到会话,把图片返回给客户端,客户端根据图片识别图片内容,提交到服务端,服务端根据提交的字符进行哈希比对,如果匹配,则验证码正确,否则验证失败。

另外,对于API,由于没有状态,所以产生了图片和签名后,需要把图片换成Base64编码后一起发送给客户端,客户端识别图片后,把识别的图片对应的字符串和签名回传到服务器端,服务器端根据签名和验证码进行比对。

这里面,产生图片是一个关键,这个直接决定了验证码的质量。

软件包mews/captcha提供了一个验证码方案,产生的图片有一定的干扰性,不过质量不高,对于普通应用来说,还是能满足要求的。

地址:
https://github.com/mewebstudio/captcha

安装:
composer require mews/captcha

以下研究一下这个软件包实现细节:
1 composer.json

"require": {
		"php": ">=5.4",
		"ext-gd": "*",
		"illuminate/config": "~5.0",
		"illuminate/filesystem": "~5.0",
		"illuminate/support": "~5.0",
		"illuminate/hashing": "~5.0",
		"intervention/image": "~2.2"
	},
	"autoload": {
		"psr-4": {
			"Mews\\Captcha\\": "src/"
		},
		"files": [
			"src/helpers.php"
		]
	},

依赖ext-gd库,依赖intervention/image,全局加载helpers.php。

对于针对Laravel的扩展包,首先需要看看它提供的ServiceProvider:

<?php

namespace Mews\Captcha;

use Illuminate\Support\ServiceProvider;

/**
 * Class CaptchaServiceProvider
 * @package Mews\Captcha
 */
class CaptchaServiceProvider extends ServiceProvider {

    /**
     * Boot the service provider.
     *
     * @return null
     */
    public function boot()
    {
        // Publish configuration files
        $this->publishes([
            __DIR__.'/../config/captcha.php' => config_path('captcha.php')
        ], 'config');

        // HTTP routing
        if (strpos($this->app->version(), 'Lumen') !== false) {
	        $this->app->get('captcha[/api/{config}]', 'Mews\Captcha\LumenCaptchaController@getCaptchaApi');
	        $this->app->get('captcha[/{config}]', 'Mews\Captcha\LumenCaptchaController@getCaptcha');
        } else {
            if ((double) $this->app->version() >= 5.2) {
	            $this->app['router']->get('captcha/api/{config?}', '\Mews\Captcha\CaptchaController@getCaptchaApi')->middleware('web');
	            $this->app['router']->get('captcha/{config?}', '\Mews\Captcha\CaptchaController@getCaptcha')->middleware('web');
            } else {
	            $this->app['router']->get('captcha/api/{config?}', '\Mews\Captcha\CaptchaController@getCaptchaApi');
	            $this->app['router']->get('captcha/{config?}', '\Mews\Captcha\CaptchaController@getCaptcha');
            }
        }

	    // Validator extensions
	    $this->app['validator']->extend('captcha', function($attribute, $value, $parameters)
	    {
		    return captcha_check($value);
	    });

	    // Validator extensions
	    $this->app['validator']->extend('captcha_api', function($attribute, $value, $parameters)
	    {
		    return captcha_api_check($value, $parameters[0]);
	    });
    }

    /**
     * Register the service provider.
     *
     * @return void
     */
    public function register()
    {
        // Merge configs
        $this->mergeConfigFrom(
            __DIR__.'/../config/captcha.php', 'captcha'
        );

        // Bind captcha
        $this->app->bind('captcha', function($app)
        {
            return new Captcha(
                $app['Illuminate\Filesystem\Filesystem'],
                $app['Illuminate\Config\Repository'],
                $app['Intervention\Image\ImageManager'],
                $app['Illuminate\Session\Store'],
                $app['Illuminate\Hashing\BcryptHasher'],
                $app['Illuminate\Support\Str']
            );
        });
    }
}

首先是绑定路由(flat是配置的key):
captcha/api/flat
captcha/api
captcha/flat
captcha

然后注册一个验证规则:captcha,它验证值,内部实际调用了captcha_check(),从会话中取回签名哈希比对;如果是API则有点差别。

最后就是向容器中注册一个绑定:captch。它最终会产生一个Mews\Captcha\Captcha类实例。从构造函数可见,实现一个验证码方案,实际并不简单。需要读取文件,需要动态产生图片,需要读取配置,需要哈希比对,需要产生随机字符串。

验证码所有的内容都集中在Mews\Captcha\Captcha类中,只需要看几个方法:

    public function create($config = 'default', $api = false)
    {
        $this->backgrounds = $this->files->files(__DIR__ . '/../assets/backgrounds');
        $this->fonts = $this->files->files(__DIR__ . '/../assets/fonts');
        
        if (app()->version() >= 5.5){
            $this->fonts = array_map(function($file) {
                return $file->getPathName();
            }, $this->fonts);
        }
        
        $this->fonts = array_values($this->fonts); //reset fonts array index

        $this->configure($config);

        $generator = $this->generate();
        $this->text = $generator['value'];

        $this->canvas = $this->imageManager->canvas(
            $this->width,
            $this->height,
            $this->bgColor
        );

        if ($this->bgImage)
        {
            $this->image = $this->imageManager->make($this->background())->resize(
                $this->width,
                $this->height
            );
            $this->canvas->insert($this->image);
        }
        else
        {
            $this->image = $this->canvas;
        }

        if ($this->contrast != 0)
        {
            $this->image->contrast($this->contrast);
        }

        $this->text();

        $this->lines();

        if ($this->sharpen)
        {
            $this->image->sharpen($this->sharpen);
        }
        if ($this->invert)
        {
            $this->image->invert($this->invert);
        }
        if ($this->blur)
        {
            $this->image->blur($this->blur);
        }

        return $api ? [
	        'sensitive' => $generator['sensitive'],
	        'key'       => $generator['key'],
        	'img'       => $this->image->encode('data-url')->encoded
        ] : $this->image->response('png', $this->quality);
    }

/**
     * Generate captcha text
     *
     * @return string
     */
    protected function generate()
    {
        $characters = str_split($this->characters);

        $bag = '';
        for($i = 0; $i < $this->length; $i++)
        {
            $bag .= $characters[rand(0, count($characters) - 1)];
        }

        $bag = $this->sensitive ? $bag : $this->str->lower($bag);

        $hash = $this->hasher->make($bag);
        $this->session->put('captcha', [
            'sensitive' => $this->sensitive,
            'key'       => $hash
        ]);

        return [
        	'value'     => $bag,
	        'sensitive' => $this->sensitive,
	        'key'       => $hash
        ];
    }

这个是主要逻辑:读取背景图,字体,读取配置,产生随机字符串并生成签名并且如果是签名记录到当前会话(generate()方法完成),然后产生画布,插入图片文件,插入干扰等,最终生成一张图片,根据类型,返回一个数组(API方式)或直接返回一个图片响应。

这里已经很情况,随机字符串的哈希写入了会话,接下只需要比对即可。提供的方法:

public function check($value)
	{
		if ( ! $this->session->has('captcha'))
		{
			return false;
		}

		$key = $this->session->get('captcha.key');
		$sensitive = $this->session->get('captcha.sensitive');

		if ( ! $sensitive)
		{
			$value = $this->str->lower($value);
		}

		$this->session->remove('captcha');

		return $this->hasher->check($value, $key);
	}

最后的问题是如何呈现给客户端:

   public function src($config = null)
    {
        return url('captcha' . ($config ? '/' . $config : '/default')) . '?' . $this->str->random(8);
    }

    /**
     * Generate captcha image html tag
     *
     * @param null $config
     * @param array $attrs HTML attributes supplied to the image tag where key is the attribute
     * and the value is the attribute value
     * @return string
     */
    public function img($config = null, $attrs = [])
    {
        $attrs_str = '';
        foreach($attrs as $attr => $value){
            if ($attr == 'src'){
                //Neglect src attribute
                continue;
            }
            $attrs_str .= $attr.'="'.$value.'" ';
        }
        return '<img src="' . $this->src($config) . '" '. trim($attrs_str).'>';
    }

产生一个URL,实际就是ServiceProvider中动态注册的路由。这个路由调用create()方法,返回图片。

最后,是关于如何使用:
在登录视图中添加captcha字段,并且输出图片:

@if ($errors->has('captcha'))
                            <br />
                            <strong>{{ $errors->first('captcha') }}</strong>
                        @endif


<input id="captcha" name="captcha" type="text" class="form-control" required="" placeholder="验证码">
                            <br />
                            {!! captcha_img('flat') !!}

然后在Auth\LoginController.php中,添加:

    protected function validateLogin(Request $request)
    {
        $this->validate($request, [
            $this->username() => 'required|string',
            'password' => 'required|string',
            'captcha' => 'required|captcha'
        ]);
    }

这里是验证captcha字段,必填并且要符合captcha规则。captcha规则实际就是比对验证码是否正确。由于captcha验证规则是新增的,验证不通过时的提示语没有定义(没有定义执行显示key,比如validation.captcha),所以需要进入resources/lang/zh-CN/validation.php添加:

'captcha' => '验证码不正确'

另外,产生验证码和图片是可以自定义的,主要是修改配置:

#直接从包中拷贝一个配置记录,也可以publish
cp mews/captcha/config/captcha.php conf/captcha.php

<?php

return [

    'characters' => '2346789abcdefghjmnpqrtuxyzABCDEFGHJMNPQRTUXYZ',

    'default'   => [
        'length'    => 5,
        'width'     => 120,
        'height'    => 36,
        'quality'   => 90,
    ],

    'flat'   => [
        'length'    => 6,
        'width'     => 160,
        'height'    => 46,
        'quality'   => 90,
        'lines'     => 6,
        'bgImage'   => false,
        'bgColor'   => '#ecf2f4',
        'fontColors'=> ['#2c3e50', '#c0392b', '#16a085', '#c0392b', '#8e44ad', '#303f9f', '#f57c00', '#795548'],
        'contrast'  => -5,
    ],

    'mini'   => [
        'length'    => 3,
        'width'     => 60,
        'height'    => 32,
    ],

    'inverse'   => [
        'length'    => 5,
        'width'     => 120,
        'height'    => 36,
        'quality'   => 90,
        'sensitive' => true,
        'angle'     => 12,
        'sharpen'   => 10,
        'blur'      => 2,
        'invert'    => true,
        'contrast'  => -5,
    ]

];

如果没有指定配置,那就是default。 比如这里可以指定flat。

验证码看不清楚时,可以点击验证码更换一个验证,由于验证码是请求一个地址动态产生的,所以客户端只需要重新让img标签发起请求即可:

Laravel 实践 – 自定义认证

Laravle认证原理:http://blog.ifeeline.com/2726.html

从一个旧系统迁移数据,密码取了哈希,为了兼容认证,比如修改认证方式。

Laravel中可以新增Guard,也可以新增Provider,所以一般都是通过新增的方式实现更换原有的Guard和Provider。

vi config/auth.php

<?php

return [
    'defaults' => [
        'guard' => 'web',
        'passwords' => 'users',
    ],

    'guards' => [
        'web' => [
            'driver' => 'session',
            //'provider' => 'users',
            // 这里的compatible_users对应providers中的compatible_users,名字随意
            'provider' => 'compatible_users'
        ],

        'api' => [
            'driver' => 'token',
            'provider' => 'users',
        ],
    ],

    'providers' => [
        'users' => [
            'driver' => 'eloquent',
            'model' => App\User::class,
        ],
        // 自定义Provider,driver对应的值必须和定义时一直
        'compatible_users' => [
            'driver' => 'compatible',
            'model' => App\User::class,
        ],
    ],

    'passwords' => [
        'users' => [
            'provider' => 'users',
            'table' => 'password_resets',
            'expire' => 60,
        ],
    ],

];
[/php
这里设置了一个自定义的Provider,他对应的driver叫compatible,通过\Auth::provider添加这个“compatible”

vi app/Providers/AuthServiceProvider.php
<?php

namespace App\Providers;

use Illuminate\Support\Facades\Gate;
use Illuminate\Foundation\Support\Providers\AuthServiceProvider as ServiceProvider;
use App\Extensions\CompatibleUserProvider;

class AuthServiceProvider extends ServiceProvider
{
    protected $policies = [
        'App\Model' => 'App\Policies\ModelPolicy',
    ];

    public function boot()
    {
        $this->registerPolicies();

        \Auth::provider('compatible', function($app, array $config) {
            return new CompatibleUserProvider($app, $config['model']);
        });
    }
}

这里需要定义App\Extensions\CompatibleUserProvider,它集成了原来的类:

vi app/Extensions/CompatibleUserProvider.php

<?php

namespace App\Extensions;

use Illuminate\Support\Str;
use Illuminate\Contracts\Auth\UserProvider;
use Illuminate\Contracts\Hashing\Hasher as HasherContract;
use Illuminate\Contracts\Auth\Authenticatable as UserContract;
use Illuminate\Auth\EloquentUserProvider;

class CompatibleUserProvider extends EloquentUserProvider
{
    /**
     * Validate a user against the given credentials.
     *
     * @param  \Illuminate\Contracts\Auth\Authenticatable  $user
     * @param  array  $credentials
     * @return bool
     */
    public function validateCredentials(UserContract $user, array $credentials)
    {
        $plain = $credentials['password'];
        $password = $user->getAuthPassword();
        if (strlen($password) <= 32) {
            return hash_equals(md5($plain), $password);
        }
        return $this->hasher->check($plain, $user->getAuthPassword());
    }
}

覆盖了父类方法:validateCredentials(),通过这个方法注入自己的验证逻辑。

另外,为了validateCredentials()方法需要得到用户实例,如果没有用户实例,那就没有验证这个说法了,用户实例是通过retrieveByCredentials获取的:

//Illuminate\Auth\EloquentUserProvider

/**
     * Retrieve a user by the given credentials.
     *
     * @param  array  $credentials
     * @return \Illuminate\Contracts\Auth\Authenticatable|null
     */
    public function retrieveByCredentials(array $credentials)
    {
        if (empty($credentials) ||
           (count($credentials) === 1 &&
            array_key_exists('password', $credentials))) {
            return;
        }

        // First we will add each credential element to the query as a where clause.
        // Then we can execute the query and, if we found a user, return it in a
        // Eloquent User "model" that will be utilized by the Guard instances.
        $query = $this->createModel()->newQuery();

        foreach ($credentials as $key => $value) {
            if (! Str::contains($key, 'password')) {
                $query->where($key, $value);
            }
        }

        return $query->first();
    }

可以通过修改这个方法来修改取回用户实例的逻辑,不过仔细看就知道它不过是根据$credentials来查询用户表而已,所以只需要定义$credentials即可:

#Illuminate\Foundation\Auth是一个trait,它被App\Http\Controllers\Auth\LoginController使用
#用来从请求中抽取需要认证的信息
    /**
     * Get the needed authorization credentials from the request.
     *
     * @param  \Illuminate\Http\Request  $request
     * @return array
     */
    protected function credentials(Request $request)
    {
        return $request->only($this->username(), 'password');
    }

可以添加需要验证的字段和对应的值。这样的条件会被用来查找用户实例。如果找不到用户实例,最终会返回验证不通过。