關於Laravel框架中Guard的底層實現

domain發表於2021-03-18

1. 什麼是Guard

Laravel/Lumen框架中,使用者的登入/註冊的認證基本都已經封裝好了,開箱即用。而登入/註冊認證的核心就是:

  1. 使用者的註冊資訊存入資料庫(登記)
  2. 從資料庫中讀取資料和使用者輸入的對比(認證)

上述兩步是登入/註冊的基本,可以看到都會涉及到資料庫的操作,這兩步框架底層已經幫我們做好了,而且考慮到了很多情況,比如使用者認證的資料表不是user表而是admin_user,認證欄位是phone而不是email,等等一些問題都是Guard所要解決的,通過Guard可以指定使用哪個資料表什麼欄位等,Guard能非常靈活的構建一套自己的認證體系。

通俗地講,就是這樣:Guard就像是小區的門衛大叔,冷酷無情,不認人只認登記資訊。進小區之前大叔需要先檢查你的身份,驗證不通過大叔就不讓你進去。如果是走路/騎車進去,大叔1需要檢查你的門禁卡,他拿出記錄了小區所有業主門禁卡資訊的本子檢視你這個門禁卡資訊有沒有在這個本子上;如果你開車進去,大叔2就從記錄了所有業主車牌號的本子中檢查你的車牌號,所以新業主要小區了需要告知門衛大叔們你的門禁卡資訊或者車牌號,要不然大叔2不讓你進。如果是物業管理員要進小區,門衛大叔3也只認登記資訊,管理員出示他的管理員門禁卡,門衛大叔就會檢查記錄了管理員門禁卡資訊的本子

上面講的對應了框架中的多使用者認證:

  1. 走路/騎車的人 -> 門禁卡
  2. 開車的人 -> 車牌號
  3. 物業管理員 -> 門禁卡

門禁卡和車牌號都是不同的認證方式,而門衛大叔檢視的本子就對應了不同資料庫中的使用者資訊,這樣講是不是更容易理解了。

Lumen/Laravel中以中介軟體(Middleware)的方式提供了非常靈活的認證,通過簡單的配置就可以切換多個認證。

注:本文所講的都是Lumen的程式碼,是Laravel精簡版,內部實現原理都大差不差

本文所使用的是:Laravel 7.29

2. Guard工作流程

說了這麼多,附上一張手工製作的流程圖:

Laravel Guard

從圖中可以看到,一個Guard會涉及到三個部分,分別是:

  1. Guard實現本身
  2. User Provider使用者提供者,指定哪個資料表以什麼方式獲取(eloquent/database
  3. Authenticatable介面規定那些東西可以被認證,就是實現它的介面嘛

2. 從配置說起

深入底層程式碼之前,先從配置檔案講起。認證的配置主要在/config/auth.php中,裡面可以定義各種認證的門衛大叔(guard)

// /config/auth.php

'guards' => [
  'user' => [
    'driver' => 'session',
    'provider' => 'users',
   ],
  'admin' => [
    'driver' => 'token',
    'provider' => 'admin_users',
  ],
],

'providers' => [
  'users' => [
    'driver' => 'eloquent',
    'model' => App\Models\User::class,
//    'table' => 'user'
  ],
  
  'admin_users' => [
    'driver' => 'eloquent',
    'model' => App\Models\AdminUser::class,
  ],
],

配置中定義了兩個門衛useradmindriver欄位設定門衛的認證系統,預設提供兩種sessesiontokenprovider定義的就是上面說的本子,儲存所有的認證使用者,provider下面的drive定義認證使用者如何獲取,有兩種方式databaseeloquent方式,一般都是用第二種,model定義eloquent方式使用的資料模型,如果driverdatabase,就要設定table指定資料庫表。如果沒有程式碼中沒有指定用哪個門衛,就會使用預設的門衛大爺:

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

3. 使用Guard例子

我們以Laravelauth中介軟體例子來簡單說一下:

Route::get('/user/profile', 'UserController@profile')->middleware('auth');

4. 分析

當發起/user/profile這個請求時,在進入UserController::profile方法前,會呼叫auth中介軟體,auth定義在\app\Http\Kernel.php中:

// \app\Http\Kernel.php

protected $routeMiddleware = [
  'auth' => \App\Http\Middleware\Authenticate::class,
  'auth.basic' => \Illuminate\Auth\Middleware\AuthenticateWithBasicAuth::class,
  'bindings' => \Illuminate\Routing\Middleware\SubstituteBindings::class,
  'cache.headers' => \Illuminate\Http\Middleware\SetCacheHeaders::class,
  ...
];

對應處理指令碼是\App\Http\Middleware\Authenticate::class

// \app\Http\Middleware\Authenticate.php

/**
* Handle an incoming request.
*
* @param  \Illuminate\Http\Request  $request
* @param  \Closure  $next
* @param  string[]  ...$guards
* @return mixed
*
* @throws \Illuminate\Auth\AuthenticationException
*/
public function handle($request, Closure $next, ...$guards)
{
  $this->authenticate($request, $guards);

  return $next($request);
}

Laravel中中介軟體的處理入口都是handle方法,引數中會一陣列形式傳過來多個使用的guard,比如這樣:

Route::get('/user/profile', 'UserController@profile')->middleware('auth:session,foo,bar');

middleware()中冒號前後分別是中介軟體和引數。

handle方法很簡單嘛,就是呼叫了authenticate()

// \Illuminate\Auth\Middleware\Authenticate.php

/**
* Determine if the user is logged in to any of the given guards.
*
* @param  \Illuminate\Http\Request  $request
* @param  array  $guards
* @return void
*
* @throws \Illuminate\Auth\AuthenticationException
*/
protected function authenticate($request, array $guards)
{
  if (empty($guards)) {
    $guards = [null];
  }

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

  $this->unauthenticated($request, $guards);
}

authenticate()方法遍歷傳過來的guard,然後check(),只要滿足其中一個,就直接返回,否則就會丟擲AuthenticationException異常。

⚠️注意

$this->auth->guard($guard)->check()

這個是關鍵,它是通過在auth屬性上鍊式呼叫的,我們來「公眾號」(正義的程式猿)一步一步分析下:

// \Illuminate\Auth\Middleware\Authenticate.php

namespace Illuminate\Auth\Middleware;

use Closure;
use Illuminate\Auth\AuthenticationException;
use Illuminate\Contracts\Auth\Factory as Auth;
use Illuminate\Contracts\Auth\Middleware\AuthenticatesRequests;

class Authenticate implements AuthenticatesRequests
{
    /**
     * The authentication factory instance.
     *
     * @var \Illuminate\Contracts\Auth\Factory
     */
    protected $auth;

    /**
     * Create a new middleware instance.
     *
     * @param  \Illuminate\Contracts\Auth\Factory  $auth
     * @return void
     */
    public function __construct(Auth $auth)
    {
        $this->auth = $auth;
    }
  
  ...
}

這裡的$auth其實是\Illuminate\Contracts\Auth\Factory介面的一個例項,通過建構函式注入進來,通過dd($this->auth)方式發現這個其實就是Illuminate\Auth\AuthManager例項,它實現了Illuminate\Contracts\Auth\Factory介面:

// \Illuminate\Contracts\Auth\Factory.php

namespace Illuminate\Contracts\Auth;

interface Factory
{
    /**
     * Get a guard instance by name.
     *
     * @param  string|null  $name
     * @return \Illuminate\Contracts\Auth\Guard|\Illuminate\Contracts\Auth\StatefulGuard
     */
    public function guard($name = null);

    /**
     * Set the default guard the factory should serve.
     *
     * @param  string  $name
     * @return void
     */
    public function shouldUse($name);
}

這個介面有guard()方法,所以上面可以直接鏈式呼叫。

通過介面定義的宣告,我們可以知道guard()返回\Illuminate\Contracts\Auth\Guard或者\Illuminate\Contracts\Auth\StatefulGuard這兩個介面,具體在AuthManager中的實現是這樣的:

// \Illuminate\Auth\AuthManager.php

/**
* Attempt to get the guard from the local cache.
*
* @param  string|null  $name
* @return \Illuminate\Contracts\Auth\Guard|\Illuminate\Contracts\Auth\StatefulGuard
*/
public function guard($name = null)
{
  $name = $name ?: $this->getDefaultDriver();

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

通過我們在middleware()中傳過來的引數建立對應的guard例項,沒有就是預設driver對應的guard,最後check()

這節最後講一下

AuthManager是什麼時候建立的?

Laravel框架初始化時,很多服務都是以服務提供者(ServiceProvider)的形式建立的,AuthManager就是AuthServiceProvider建立的:

// \Illuminate\Auth\AuthServiceProvider.php

namespace Illuminate\Auth;

class AuthServiceProvider extends ServiceProvider
{
    /**
     * Register the service provider.
     *
     * @return void
     */
    public function register()
    {
      $this->registerAuthenticator();
      ....
    }

    /**
     * Register the authenticator services.
     *
     * @return void
     */
    protected function registerAuthenticator()
    {
      $this->app->singleton('auth', function ($app) {
        // Once the authentication service has actually been requested by the developer
        // we will set a variable in the application indicating such. This helps us
        // know that we need to set any queued cookies in the after event later.
        $app['auth.loaded'] = true;

        return new AuthManager($app);
      });
      
      ....
    }
  
  ....
}

AuthServiceProvider中在註冊時呼叫registerAuthenticator(),建立auth單例指向AuthManager例項。

通過上面的一波分析,我們知道guard的建立是受AuthManager管理的,AuthManager在這裡的指責就是解析driver並建立guard

所以現在整個middleware('auth')的流程大致如下:

middleware auth

5. Guard介面

上面說到AuthManager建立了guard,然後呼叫check(),我先現在來分析下Guard。還是那句話,不管上層業務程式碼多麼複雜,底層的介面往往是很簡單的。Lumen/Laravel框架中大部分介面被設計成是一種契約(Contracts)Guard也一樣的,它的程式碼在\vendor\illuminate\contracts\Auth\Guard.php檔案中,只有6個方法

// \Illuminate\Contracts\Auth\Guard.php

namespace Illuminate\Contracts\Auth;

interface Guard
{
  // 判斷當前使用者是否登入
  public function check();
  // 判斷當前使用者是否是遊客(未登入)
  public function guest();
  // 獲取當前認證的使用者
  public function user();
  // 獲取當前認證使用者的 id,嚴格來說不一定是 id,應該是這個模型中的主鍵欄位
  public function id();
  // 使用者驗證
  public function validate(array $credentials = []);
  // 設定當前認證過的使用者
  public function setUser(Authenticatable $user);
}

很簡單,有木有~同樣,還有一個StatefulGuard介面,繼承自Guard介面並加了幾個有狀態的方法,代表有狀態,就是每次請求都帶有使用者的狀態資訊比如session,程式碼如下:

// Illuminate\Contracts\Auth\StatefulGuard.php

namespace Illuminate\Contracts\Auth;

interface StatefulGuard extends Guard
{
  // 指定資料驗證
  public function attempt(array $credentials = [], $remember = false);
  // 將這一次request驗證通過登入,不會儲存session/cookie
  public function once(array $credentials = []);
  // 登入
  public function login(Authenticatable $user, $remember = false);
  // 使用id登入
  public function loginUsingId($id, $remember = false);
  // 和once()一樣,不過是用id
  public function onceUsingId($id);
  // 通過remember cookie登入
  public function viaRemember();
  // 登出
  public function logout();
}

UML圖大致如下:

6. Guard介面的相關實現

底層介面著實簡單,再來分析下上層的實現程式碼,框架中預設實現了幾個Guard,比如Web開發用到的SessionGuard,介面開發用到的TokenGuard,這些都實現自\Illuminate\Contracts\Auth或者\Illuminate\Contracts\Auth\StatefulGuard,已經滿足我們日常所需了。

幾個Guardcheck()方法都是一樣的,都定義在GuardHelpers這個Trait中:

// \Illuminate\Auth\GuardHelpers.php

/**
* Determine if the current user is authenticated.
*
* @return bool
*/
public function check()
{
  return ! is_null($this->user());
}

user()就是在不同的Guard中實現了,後面也主要看這個方法。

GuardHelpers Trait

什麼是Trait:

你可以理解成一系列方法的集合,就是把經常使用到的重複方法整合起來,在class裡面直接use使用,上下文還是引用它的那個class,減少了重複程式碼量,而且比class更輕量,不需要new在使用。

6.1 RequestGuard.php

RequestGuard認證一個http請求,具體怎麼認證,它是通過callback實現的,認證邏輯在callback中直接放到了上層讓使用者自定義,UML圖:

RequestGuard

看程式碼實現也很簡單:

// \Illuminate\Auth\RequestGuard.php

/**
* Get the currently authenticated user.
*
* @return \Illuminate\Contracts\Auth\Authenticatable|null
*/
public function user()
{
  // If we've already retrieved the user for the current request we can just
  // return it back immediately. We do not want to fetch the user data on
  // every call to this method because that would be tremendously slow.
  if (! is_null($this->user)) {
    return $this->user;
  }

  return $this->user = call_user_func(
    $this->callback, $this->request, $this->getProvider()
  );
}

RequestGuard很多文章都是一筆帶過,這【公眾號)裡我說(正義的程式猿)一下,通常我們使用不到RequestGuard,只有在自定義Guard時才用得上。

使用方式如下

  1. AuthServiceProvider中註冊自定義的guard,設定名稱和callback
// App\Providers\AuthServiceProvider.php

use App\User;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;

/**
 * Register any application authentication / authorization services.
 *
 * @return void
 */
public function boot()
{
    $this->registerPolicies();

    Auth::viaRequest('custom-token', function ($request) {
        return User::where('my-token', $request->my_token)->first();
    });
}
  1. auth.php中配置自定義guard
'guards' => [
    'my-api' => [
        'driver' => 'custom-token',
    ],
],
  1. 使用

還是上面的例子:

Route::get('/user/profile', 'UserController@profile')->middleware('auth:my-api');

最後在認證的時候就會直接使用我們設定的callback了。

上面viaRequest()也是定義AuthManager中:

// \Illuminate\Auth\AuthManager.php

/**
* Register a new callback based request guard.
*
* @param  string  $driver
* @param  callable  $callback
* @return $this
*/
public function viaRequest($driver, callable $callback)
{
  return $this->extend($driver, function () use ($callback) {
    $guard = new RequestGuard($callback, $this->app['request'], $this->createUserProvider());

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

    return $guard;
  });
}

6.2 SessionGuard

見名知義,此guard是基於session的,一般最常用的就是(公眾號:)這(正義的程式猿)個了。由於是基於session所以是有狀態的,所以這個類定義的時候實現了StatefulGuard介面,而且加了更多邏輯程式碼和註釋加起來有800+行,

// \Illuminate\Auth\SessionGuard.php

namespace Illuminate\Auth;

use Illuminate\Contracts\Auth\StatefulGuard;
use Illuminate\Contracts\Auth\SupportsBasicAuth;

class SessionGuard implements StatefulGuard, SupportsBasicAuth
{
  ...
}

UML圖:

SessionGuard

使用者認證的程式碼稍微複雜一點,如下:

// \Illuminate\Auth\SessionGuard.php

/**
* Get the currently authenticated user.
*
* @return \Illuminate\Contracts\Auth\Authenticatable|null
*/
public function user()
{
  if ($this->loggedOut) {
    return;
  }

  // If we've already retrieved the user for the current request we can just
  // return it back immediately. We do not want to fetch the user data on
  // every call to this method because that would be tremendously slow.
  if (! is_null($this->user)) {
    return $this->user;
  }

  $id = $this->session->get($this->getName());

  // First we will try to load the user using the identifier in the session if
  // one exists. Otherwise we will check for a "remember me" cookie in this
  // request, and if one exists, attempt to retrieve the user using that.
  if (! is_null($id) && $this->user = $this->provider->retrieveById($id)) {
    $this->fireAuthenticatedEvent($this->user);
  }

  // If the user is null, but we decrypt a "recaller" cookie we can attempt to
  // pull the user data on that cookie which serves as a remember cookie on
  // the application. Once we have a user we can return it to the caller.
  if (is_null($this->user) && ! is_null($recaller = $this->recaller())) {
    $this->user = $this->userFromRecaller($recaller);

    if ($this->user) {
      $this->updateSession($this->user->getAuthIdentifier());

      $this->fireLoginEvent($this->user, true);
    }
  }

  return $this->user;
}

梳理下,大致是先從session獲取使用者的主鍵id,然後通過特定的UserProvider查詢使用者,查詢成功說明驗證成功,如果沒有,就用recaller查詢使用者,這裡就是remember token查詢,就是登入時“記住我”的那個選項,remember token是儲存在cookie當中的,如果remember token查詢成功,就說明驗證成功,否則驗證失敗。

6.3 TokenGuard

TokenGuard也實現了Guard介面,適用於無狀態的api認證,UML圖:

TokenGuard

由於不要維護狀態整個程式碼就簡單很多:

// \Illuminate\Auth\TokenGuard.php

namespace Illuminate\Auth;

use Illuminate\Contracts\Auth\Guard;
use Illuminate\Contracts\Auth\UserProvider;
use Illuminate\Http\Request;

class TokenGuard implements Guard
{
  ...
  
  /**
  * Get the currently authenticated user.
  *
  * @return \Illuminate\Contracts\Auth\Authenticatable|null
  */
  public function user()
  {
    // If we've already retrieved the user for the current request we can just
    // return it back immediately. We do not want to fetch the user data on
    // every call to this method because that would be tremendously slow.
    if (! is_null($this->user)) {
      return $this->user;
    }

    $user = null;

    $token = $this->getTokenForRequest();

    if (! empty($token)) {
      $user = $this->provider->retrieveByCredentials([
        $this->storageKey => $this->hash ? hash('sha256', $token) : $token,
      ]);
    }

    return $this->user = $user;
  }
  
  ...
}

先從請求中獲取api_token,再用api_token從指定的UserProvider查詢api_token對應的使用者資訊。


至此,LaravelGuard相關的分析已經差不多了,通過分析它的原始碼,我們深入瞭解了框架背後的思想,梳理的過程也是學習的過程,對新手而言能快速掌握guard的相關知識並快速上手,對老鳥而言,我覺得這篇文章寫的已經很細了,能更好地瞭解框架背後的精髓寫出更優雅的程式碼。

總結

在深入學習Guard原始碼後,瞭解到底層歸納為兩個核心,一是UserProvider,認證使用者資料來源,通常是本地資料庫,二是認證邏輯,邏輯這塊主要就是Guard來做了。對於自定義Guard,上面也稍微講了一點,通過AuthManagerviaRequest來做,對於使用者資料來源我們也不必拘泥於現有的,我們也可以將資料來源指向redis或者遠端介面,只要實現相關介面,比如這樣:

namespace app\Providers;


use Illuminate\Contracts\Auth\Authenticatable;
use Illuminate\Contracts\Auth\UserProvider;

class RedisUserProvider implements UserProvider
{

    /**
     * Retrieve a user by their unique identifier.
     *
     * @param  mixed $identifier
     * @return \Illuminate\Contracts\Auth\Authenticatable|null
     */
    public function retrieveById($identifier)
    {
        // TODO: 通過id取redis中對應的使用者
    }
  
    ....
}

也可以從遠端介面獲取:

class ApiUserProvider implements UserProvider
{

    /**
     * Retrieve a user by their unique identifier.
     *
     * @param  mixed $identifier
     * @return \Illuminate\Contracts\Auth\Authenticatable|null
     */
    public function retrieveById($identifier)
    {
        // TODO: 通過id構造curl請求結果
    }
}

最後,附上一張我在學習過程中總結的UML圖:

Laravel Guard

文章首發在我自己的部落格:
https://xydida.com/2021/2/24/PHP/Laravel/The-Guard-in-Laravel-framework/

歡迎大佬們賞光,文章如果有誤,還請大家指正。

相關文章