Larave Auth Token 認證使用自定義 Redis UserProvider

realghost發表於2018-07-04

軟體環境

  • 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 協議》,轉載必須註明作者和本文連結

相關文章