軟體環境
- 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\AuthManager
的 guard()
方法,看看這個 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_tokenupdateRememberToken()
: 更新 remember_tokenretrieveByCredentials()
: 透過憑證獲取使用者,比如使用者名稱、密碼,或者我們這裡要用到的 api_tokenvalidateCredentials()
: 驗證憑證,比如驗證使用者密碼
我們的需求就是 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 協議》,轉載必須註明作者和本文連結