Larave Auth Token 认证使用自定义 Redis UserProvider

Larave Auth Token 认证使用自定义 Redis UserProvider

软件环境

  • PHP: 7.2
  • Larave 5.6

需求

用 Laravel 做一套接口,需要用到 token 认证。
接口调用频繁,有心跳链接,如果 token 在数据库中,数据库压力会很大,所以用 Redis 保存用户 Token 。

问题

但是 Larave 自带的获取用户的 Provider 只支持 eloquent 或者 database ,并不支持使用 Redis ,所以需要自己写一个支持 Redis 的 Provider。
怎么写这个 Provider ,并且能无缝的融入到 Laravel 自己的 Auth 认证呢?只能从源码开始分析了。

源码分析

Larave 已经搭好 api 的框架,在 routes/api.php 里已经写了一个路由:

...

Route::middleware('auth:api')->get('/user', function (Request $request) {
    return $request->user();
});

...

这里使用了 auth 中间件,并传入 api 参数。

auth 中间件在 app/Http/Kernel.php 中可以找到定义:

    ...

    protected $routeMiddleware = [
        'auth' => \Illuminate\Auth\Middleware\Authenticate::class,
        'auth.basic' => \Illuminate\Auth\Middleware\AuthenticateWithBasicAuth::class,
        'bindings' => \Illuminate\Routing\Middleware\SubstituteBindings::class,
        'cache.headers' => \Illuminate\Http\Middleware\SetCacheHeaders::class,
        'can' => \Illuminate\Auth\Middleware\Authorize::class,
        'guest' => \App\Http\Middleware\RedirectIfAuthenticated::class,
        'signed' => \Illuminate\Routing\Middleware\ValidateSignature::class,
        'throttle' => \Illuminate\Routing\Middleware\ThrottleRequests::class,
    ];

    ...

可以看到 auth 中间件对应的是 \Illuminate\Auth\Middleware\Authenticate 这个类,这里需要关注的代码:

    ...

    public function __construct(Auth $auth)
    {
        $this->auth = $auth;
    }

    ...

    public function handle($request, Closure $next, ...$guards)
    {
        $this->authenticate($guards);

        return $next($request);
    }

    ...

    protected function authenticate(array $guards)
    {
        if (empty($guards)) {
            return $this->auth->authenticate();
        }

        foreach ($guards as $guard) {
            if ($this->auth->guard($guard)->check()) {
                return $this->auth->shouldUse($guard);
            }
        }

        throw new AuthenticationException('Unauthenticated.', $guards);
    }

    ...

Authenticate 在初始化的时候,由系统自动注入 Illuminate\Contracts\Auth\Factory 的对象,这里实例化之后是 Illuminate\Auth\AuthManager 的对象,再赋值给 $this->auth

handel 方法调用自己的 authenticate() 方法,并传入中间件参数 $guards,这里传入的参数就是 api

如果没有传入 $guards 参数,就调用 $this->auth->authenticate() 进行验证。

这个 authenticate() 方法在 Illuminate\Auth\AuthManager 里找不到,但是 AuthManager 定义了一个魔术方法 __call()

    ...

    public function __call($method, $parameters)
    {
        return $this->guard()->{$method}(...$parameters);
    }

    ...

如果当前类没有改方法,就调用 $this->guard() 返回的对象的方法,这里 $this->guard() 又是啥:

    ...

    public function guard($name = null)
    {
        $name = $name ?: $this->getDefaultDriver();

        return $this->guards[$name] ?? $this->guards[$name] = $this->resolve($name);
    }

    ...

这里暂时先不展开,只需要知道方法返回在 auth 配置里配置的 guard

    ...

    'guards' => [
        'web' => [
            'driver' => 'session',
            'provider' => 'users',
        ],

        'api' => [
            'driver' => 'token',
            'provider' => 'token-user',
        ],
    ],

    ...

这里的 guard 是 api,使用的 driver 是 token,对应 Illuminate\Auth\TokenGuard

TokenGuard 类里使用了 GuardHelpers 这个 trait ,authenticate() 方法就定义在这个 trait 里:

    ...

    public function authenticate()
    {
        if (! is_null($user = $this->user())) {
            return $user;
        }

        throw new AuthenticationException;
    }

    ...

这里判断 $this->user() 如果为空,就抛出 \Illuminate\Auth\AuthenticationException 异常,登陆失败;

再来看看 $this->user() 是什么鬼,定义在 Illuminate\Auth\TokenGuard 里:

    ...

    public function user()
    {
        if (! is_null($this->user)) {
            return $this->user;
        }

        $user = null;

        $token = $this->getTokenForRequest();

        if (! empty($token)) {
            $user = $this->provider->retrieveByCredentials(
                [$this->storageKey => $token]
            );
        }

        return $this->user = $user;
    }

    ...

user() 方法里,首先判断 $this->user 是否存在,如果存在,直接返回;

如果 $this->user 不存在,调用 $this->getTokenForRequest() 方法获取 token:

    ...

    public function getTokenForRequest()
    {
        $token = $this->request->query($this->inputKey);

        if (empty($token)) {
            $token = $this->request->input($this->inputKey);
        }

        if (empty($token)) {
            $token = $this->request->bearerToken();
        }

        if (empty($token)) {
            $token = $this->request->getPassword();
        }

        return $token;
    }

    ...

这里的 $this->inputKey 在构造函数里给的 api_token

    ...

    public function __construct(UserProvider $provider, Request $request, $inputKey = 'api_token', $storageKey = 'api_token')
    {
        $this->request = $request;
        $this->provider = $provider;
        $this->inputKey = $inputKey;
        $this->storageKey = $storageKey;
    }

    ...

再回到 getTokenForRequest() 里:

首先在传入的查询字符串里获取 api_token 的值;

如果不存在,在 $request->input() 里找;

还是不存在,通过 $request->bearerToken() 在请求头里获取,相应的代码在 Illuminate\Http\Concerns\InteractsWithInput 里:

    ...

    public function bearerToken()
    {
        $header = $this->header('Authorization', '');

        if (Str::startsWith($header, 'Bearer ')) {
            return Str::substr($header, 7);
        }
    }

    ...

请求头里的字段是 Authorization ,需要注意的是,这里的 token 要以字符串 Bearer 开头,Laravel 会自动将前面的 Bearer 去掉并返回。

还是回到 getTokenForRequest() 里:

如果以上途径都没有获取到 token 那么就把请求传入的密码作为 token 返回。

回到 Illuminate\Auth\TokenGuard::user() 方法,使用获取的 token 调用 $this->provider->retrieveByCredentials() 方法获取用户。

我们再来看一下 $this->provider 是从哪里来的,在 Illuminate\Auth\TokenGuard 的构造函数里:

    ...

    public function __construct(UserProvider $provider, Request $request, $inputKey = 'api_token', $storageKey = 'api_token')
    {
        $this->request = $request;
        $this->provider = $provider;
        $this->inputKey = $inputKey;
        $this->storageKey = $storageKey;
    }

    ...

第一个参数就是 $provider,我们再回到 Illuminate\Auth\AuthManagerguard() 方法,看看这个 guard 是怎么创建的:

    ...

    public function guard($name = null)
    {
        $name = $name ?: $this->getDefaultDriver();

        return $this->guards[$name] ?? $this->guards[$name] = $this->resolve($name);
    }

    ...

如果没有传入 $guard 参数,就调用 $this->getDefaultDriver() 获取默认的 guard 驱动

    ...

    public function getDefaultDriver()
    {
        return $this->app['config']['auth.defaults.guard'];
    }

    ...

返回配置文件 config/auth.php 中的值 web

    ...

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

    ...

然后再判断 $this->guards[] 里是否存在这个 guard ,如果不存在,通过 $this->resolve($name) 生成 guard 并返回。

再看看这里的 resolve() 方法:

    ...

    protected function resolve($name)
    {
        $config = $this->getConfig($name);

        if (is_null($config)) {
            throw new InvalidArgumentException("Auth guard [{$name}] is not defined.");
        }

        if (isset($this->customCreators[$config['driver']])) {
            return $this->callCustomCreator($name, $config);
        }

        $driverMethod = 'create'.ucfirst($config['driver']).'Driver';

        if (method_exists($this, $driverMethod)) {
            return $this->{$driverMethod}($name, $config);
        }

        throw new InvalidArgumentException("Auth driver [{$config['driver']}] for guard [{$name}] is not defined.");
    }

    ...

首先获取 guard 的配置,如果配置不存在,直接抛出异常;

再看是否存在自定义的 guard 创建方法 $this->customCreators(这里的 $this->customCreator 通过 extend() 方法配置),如果存在,就调用自定义创建 guard 的方法;

没有自定义 guard 方法,就调用类里写好的 createXXXXDriver() 方法创建 guard ,这里就是 createTokenDriver()

    ...

    public function createTokenDriver($name, $config)
    {
        $guard = new TokenGuard(
            $this->createUserProvider($config['provider'] ?? null),
            $this->app['request']
        );

        $this->app->refresh('request', $guard, 'setRequest');

        return $guard;
    }

    ...

总算找到创建 guard 的地方了,这里又调用了 $this->createUserProvider() 方法创建 Provider ,并传入 guard 的构造函数,创建 guard ,这个 createUserProvider() 方法是写在 Illuminate\Auth\CreatesUserProviders 这个 trait 里的:

    ...

    public function createUserProvider($provider = null)
    {
        if (is_null($config = $this->getProviderConfiguration($provider))) {
            return;
        }

        if (isset($this->customProviderCreators[$driver = ($config['driver'] ?? null)])) {
            return call_user_func(
                $this->customProviderCreators[$driver], $this->app, $config
            );
        }

        switch ($driver) {
            case 'database':
                return $this->createDatabaseProvider($config);
            case 'eloquent':
                return $this->createEloquentProvider($config);
            default:
                throw new InvalidArgumentException(
                    "Authentication user provider [{$driver}] is not defined."
                );
        }
    }

    ...

首先获取 auth.providers 里的配置,如果存在 $this->customProviderCreators 自定义 Provider 创建方法,调用该方法创建 Provider;否则就根据传入的 $provider 参数创建内建的 Provider。

这里的 $this->customProviderCreators 就是我们创建自定义 Provider 的关键了。

查看代码,发现在 Illuminate\Auth\AuthManager 里的 provider() 方法对这个数组进行了赋值:

    ...

    public function provider($name, Closure $callback)
    {
        $this->customProviderCreators[$name] = $callback;

        return $this;
    }

    ...

传入两个参数: $name , Provider 的标识; $callback , Provider 创建闭包。

就是通过调用这个方法,传入自定义 Provider 创建方法,就会可以把自定义的 Provider 放入使用的 guard 中,以达到我们的目的。

代码

首先是使用 Redis 的 Provider。

供 Auth 使用的 Provider 必须实现 Illuminate\Contracts\Auth\UserProvider 接口:


interface UserProvider
{
    public function retrieveById($identifier);

    public function retrieveByToken($identifier, $token);

    public function updateRememberToken(Authenticatable $user, $token);

    public function retrieveByCredentials(array $credentials);

    public function validateCredentials(Authenticatable $user, array $credentials);
}

这个接口给出了几个方法

  • retrieveById() : 通过 id 获取用户
  • retrieveByToken() : 通过 token 获取用户。注意,这里的 token 不是我们要用的 api_token ,是 remember_token
  • updateRememberToken() : 更新 remember_token
  • retrieveByCredentials() : 通过凭证获取用户,比如用户名、密码,或者我们这里要用到的 api_token
  • validateCredentials() : 验证凭证,比如验证用户密码

我们的需求就是 api 传入 api_token ,再通过存在 Redis 里的 api_token 来获取用户,并交给 guard 使用。

上面我们看到了,在 Illuminate\Auth\TokenGuard 里:

    ...

    public function user()
    {
        if (! is_null($this->user)) {
            return $this->user;
        }

        $user = null;

        $token = $this->getTokenForRequest();

        if (! empty($token)) {
            $user = $this->provider->retrieveByCredentials(
                [$this->storageKey => $token]
            );
        }

        return $this->user = $user;
    }

    ...

是调用 $this->provider->retrieveByCredentials() 根据凭证获取用户的,我们用 api_token 换用户的操作在这个方法里实现。

根据需求,最方便的方法是复用 Illuminate\Auth\EloquentUserProvider 类,并重载 retrieveByCredentials() 方法,就可以实现我们的需求了


namespace App\Extensions;

use Illuminate\Support\Facades\Redis;
use Illuminate\Auth\EloquentUserProvider;

class RedisUserProvider extends EloquentUserProvider
{
    /**
     * Retrieve a user by the given credentials.
     *
     * @param  array  $credentials
     * @return \Illuminate\Contracts\Auth\Authenticatable|null
     */
    public function retrieveByCredentials(array $credentials)
    {
        if (!isset($credentials['api_token'])) {
            return;
        }

        $userId = Redis::get($credentials['api_token']);

        return $this->retrieveById($userId);
    }
}

这里简单起见,我在 Redis 里只存储了用户 id ,再调用 retrieveById() 获取用户,真正的应用中,可以根据需要,在 Redis 里存入需要的数据,直接取出,提高效率。

Provider 写好了,剩下要做的就是在 TokenGuard 里使用这个 Provider 了。

前面我们提到在 Illuminate\Auth\AuthManager::provider() 里设置 customProviderCreators 可以达成这个目的。

找到位置就好办,我们在 App\Providers\AppServiceProvider 注册这个方法:

    ...

    use App\Extensions\RedisUserProvider;

    ...

    public function register()
    {
        $this->app->make('auth')->provider('redis', function ($app, $config) {
            return new RedisUserProvider($app['hash'], $config['model']);
        });
    }

    ...

至此,完成。

测试

我们在 Redis 存入一个以 token 为 key 的用户 id,在浏览器里输入 http://localhost/api/user?api_token=XXXX 可以看到返回用户信息。

本作品采用《CC 协议》,转载必须注明作者和本文链接
《L01 基础入门》
我们将带你从零开发一个项目并部署到线上,本课程教授 Web 开发中专业、实用的技能,如 Git 工作流、Laravel Mix 前端工作流等。
《L04 微信小程序从零到发布》
从小程序个人账户申请开始,带你一步步进行开发一个微信小程序,直到提交微信控制台上线发布。
讨论数量: 0
(= ̄ω ̄=)··· 暂无内容!

讨论应以学习和精进为目的。请勿发布不友善或者负能量的内容,与人为善,比聪明更重要!