使用 Jwt-Auth 實現 API 使用者認證以及無痛重新整理訪問令牌

Seaonys發表於2019-03-03

最近在做一個公司的專案,前端使用 Vue.js,後端使用 Laravel 構建 Api 服務,使用者認證的包本來是想用 Laravel Passport 的,但是感覺有點麻煩,於是使用了 jwt-auth

安裝

jwt-auth 最新版本是 1.0.0 rc.1 版本,已經支援了 Laravel 5.5。如果你使用的是 Laravel 5.5 版本,可以使用如下命令安裝。根據評論區 @tradzero 兄弟的建議,如果你是 Laravel 5.5 以下版本,也推薦使用最新版本,RC.1 前的版本都存在多使用者token認證的安全問題。

$ composer require tymon/jwt-auth 1.0.0-rc.1
複製程式碼

配置

### 新增服務提供商

將下面這行新增至 config/app.php 檔案 providers 陣列中:

app.php

`providers` => [

    ...

    TymonJWTAuthProvidersLaravelServiceProvider::class,
]
複製程式碼

釋出配置檔案

在你的 shell 中執行如下命令釋出 jwt-auth 的配置檔案:

shell

$ php artisan vendor:publish --provider="TymonJWTAuthProvidersLaravelServiceProvider"
複製程式碼

此命令會在 config 目錄下生成一個 jwt.php 配置檔案,你可以在此進行自定義配置。

生成金鑰

jwt-auth 已經預先定義好了一個 Artisan 命令方便你生成 Secret,你只需要在你的 shell 中執行如下命令即可:

shell

$ php artisan jwt:secret
複製程式碼

此命令會在你的 .env 檔案中新增一行 JWT_SECRET=secret

配置 Auth guard

config/auth.php 檔案中,你需要將 guards/driver 更新為 jwt

auth.php

`defaults` => [
    `guard` => `api`,
    `passwords` => `users`,
],

...

`guards` => [
    `api` => [
        `driver` => `jwt`,
        `provider` => `users`,
    ],
],
複製程式碼

只有在使用 Laravel 5.2 及以上版本的情況下才能使用。

更改 Model

如果需要使用 jwt-auth 作為使用者認證,我們需要對我們的 User 模型進行一點小小的改變,實現一個介面,變更後的 User 模型如下:

User.php

<?php

namespace App;

use TymonJWTAuthContractsJWTSubject;
use IlluminateNotificationsNotifiable;
use IlluminateFoundationAuthUser as Authenticatable;

class User extends Authenticatable implements JWTSubject
{
    use Notifiable;

    // Rest omitted for brevity

    /**
     * Get the identifier that will be stored in the subject claim of the JWT.
     *
     * @return mixed
     */
    public function getJWTIdentifier()
    {
        return $this->getKey();
    }

    /**
     * Return a key value array, containing any custom claims to be added to the JWT.
     *
     * @return array
     */
    public function getJWTCustomClaims()
    {
        return [];
    }
}
複製程式碼

配置項詳解

jwt.php

<?php

return [

    /*
    |--------------------------------------------------------------------------
    | JWT Authentication Secret
    |--------------------------------------------------------------------------
    |
    | 用於加密生成 token 的 secret
    |
    */

    `secret` => env(`JWT_SECRET`),

    /*
    |--------------------------------------------------------------------------
    | JWT Authentication Keys
    |--------------------------------------------------------------------------
    |
    | 如果你在 .env 檔案中定義了 JWT_SECRET 的隨機字串
    | 那麼 jwt 將會使用 對稱演算法 來生成 token
    | 如果你沒有定有,那麼jwt 將會使用如下配置的公鑰和私鑰來生成 token
    |
    */

    `keys` => [

        /*
        |--------------------------------------------------------------------------
        | Public Key
        |--------------------------------------------------------------------------
        |
        | 公鑰
        |
        */

        `public` => env(`JWT_PUBLIC_KEY`),

        /*
        |--------------------------------------------------------------------------
        | Private Key
        |--------------------------------------------------------------------------
        |
        | 私鑰
        |
        */

        `private` => env(`JWT_PRIVATE_KEY`),

        /*
        |--------------------------------------------------------------------------
        | Passphrase
        |--------------------------------------------------------------------------
        |
        | 私鑰的密碼。 如果沒有設定,可以為 null。
        |
        */

        `passphrase` => env(`JWT_PASSPHRASE`),

    ],

    /*
    |--------------------------------------------------------------------------
    | JWT time to live
    |--------------------------------------------------------------------------
    |
    | 指定 access_token 有效的時間長度(以分鐘為單位),預設為1小時,您也可以將其設定為空,以產生永不過期的標記
    |
    */

    `ttl` => env(`JWT_TTL`, 60),

    /*
    |--------------------------------------------------------------------------
    | Refresh time to live
    |--------------------------------------------------------------------------
    |
    | 指定 access_token 可重新整理的時間長度(以分鐘為單位)。預設的時間為 2 周。
    | 大概意思就是如果使用者有一個 access_token,那麼他可以帶著他的 access_token 
    | 過來領取新的 access_token,直到 2 周的時間後,他便無法繼續重新整理了,需要重新登入。
    |
    */

    `refresh_ttl` => env(`JWT_REFRESH_TTL`, 20160),

    /*
    |--------------------------------------------------------------------------
    | JWT hashing algorithm
    |--------------------------------------------------------------------------
    |
    | 指定將用於對令牌進行簽名的雜湊演算法。
    |
    */

    `algo` => env(`JWT_ALGO`, `HS256`),

    /*
    |--------------------------------------------------------------------------
    | Required Claims
    |--------------------------------------------------------------------------
    |
    | 指定必須存在於任何令牌中的宣告。
    | 
    |
    */

    `required_claims` => [
        `iss`,
        `iat`,
        `exp`,
        `nbf`,
        `sub`,
        `jti`,
    ],

    /*
    |--------------------------------------------------------------------------
    | Persistent Claims
    |--------------------------------------------------------------------------
    |
    | 指定在重新整理令牌時要保留的宣告金鑰。
    |
    */

    `persistent_claims` => [
        // `foo`,
        // `bar`,
    ],

    /*
    |--------------------------------------------------------------------------
    | Blacklist Enabled
    |--------------------------------------------------------------------------
    |
    | 為了使令牌無效,您必須啟用黑名單。
    | 如果您不想或不需要此功能,請將其設定為 false。
    |
    */

    `blacklist_enabled` => env(`JWT_BLACKLIST_ENABLED`, true),

    /*
    | -------------------------------------------------------------------------
    | Blacklist Grace Period
    | -------------------------------------------------------------------------
    |
    | 當多個併發請求使用相同的JWT進行時,
    | 由於 access_token 的重新整理 ,其中一些可能會失敗
    | 以秒為單位設定請求時間以防止併發的請求失敗。
    |
    */

    `blacklist_grace_period` => env(`JWT_BLACKLIST_GRACE_PERIOD`, 0),

    /*
    |--------------------------------------------------------------------------
    | Providers
    |--------------------------------------------------------------------------
    |
    | 指定整個包中使用的各種提供程式。
    |
    */

    `providers` => [

        /*
        |--------------------------------------------------------------------------
        | JWT Provider
        |--------------------------------------------------------------------------
        |
        | 指定用於建立和解碼令牌的提供程式。
        |
        */

        `jwt` => TymonJWTAuthProvidersJWTNamshi::class,

        /*
        |--------------------------------------------------------------------------
        | Authentication Provider
        |--------------------------------------------------------------------------
        |
        | 指定用於對使用者進行身份驗證的提供程式。
        |
        */

        `auth` => TymonJWTAuthProvidersAuthIlluminate::class,

        /*
        |--------------------------------------------------------------------------
        | Storage Provider
        |--------------------------------------------------------------------------
        |
        | 指定用於在黑名單中儲存標記的提供程式。
        |
        */

        `storage` => TymonJWTAuthProvidersStorageIlluminate::class,

    ],

];
複製程式碼

自定義認證中介軟體

先來說明一下我想要達成的效果,我希望使用者提供賬號密碼前來登入。如果登入成功,那麼我會給前端頒發一個 access _token ,設定在 header 中以請求需要使用者認證的路由。

同時我希望如果使用者的令牌如果過期了,可以暫時通過此次請求,並在此次請求中重新整理該使用者的 access _token,最後在響應頭中將新的 access _token 返回給前端,這樣子可以無痛的重新整理 access _token ,使用者可以獲得一個很良好的體驗,所以開始動手寫程式碼。

執行如下命令以新建一箇中介軟體:

php artisan make:middleware RefreshToken
複製程式碼

中介軟體程式碼如下:

RefreshToken.php

<?php

namespace AppHttpMiddleware;

use Auth;
use Closure;
use TymonJWTAuthExceptionsJWTException;
use TymonJWTAuthHttpMiddlewareBaseMiddleware;
use TymonJWTAuthExceptionsTokenExpiredException;
use SymfonyComponentHttpKernelExceptionUnauthorizedHttpException;

// 注意,我們要繼承的是 jwt 的 BaseMiddleware
class RefreshToken extends BaseMiddleware
{
    /**
     * Handle an incoming request.
     *
     * @param  IlluminateHttpRequest $request
     * @param  Closure $next
     *
     * @throws SymfonyComponentHttpKernelExceptionUnauthorizedHttpException
     *
     * @return mixed
     */
    public function handle($request, Closure $next)
    {
        // 檢查此次請求中是否帶有 token,如果沒有則丟擲異常。 
        $this->checkForToken($request);

       // 使用 try 包裹,以捕捉 token 過期所丟擲的 TokenExpiredException  異常
        try {
            // 檢測使用者的登入狀態,如果正常則通過
            if ($this->auth->parseToken()->authenticate()) {
                return $next($request);
            }
            throw new UnauthorizedHttpException(`jwt-auth`, `未登入`);
        } catch (TokenExpiredException $exception) {
          // 此處捕獲到了 token 過期所丟擲的 TokenExpiredException 異常,我們在這裡需要做的是重新整理該使用者的 token 並將它新增到響應頭中
            try {
                // 重新整理使用者的 token
                $token = $this->auth->refresh();
               // 使用一次性登入以保證此次請求的成功
                Auth::guard(`api`)->onceUsingId($this->auth->manager()->getPayloadFactory()->buildClaimsCollection()->toPlainArray()[`sub`]);
            } catch (JWTException $exception) {
               // 如果捕獲到此異常,即代表 refresh 也過期了,使用者無法重新整理令牌,需要重新登入。
                throw new UnauthorizedHttpException(`jwt-auth`, $exception->getMessage());
            }
        }
		
        // 在響應頭中返回新的 token
        return $this->setAuthenticationHeader($next($request), $token);
    }
}

複製程式碼

設定 Axios 攔截器

我選用的 HTTP 請求套件是 axios。為了達到無痛重新整理 token 的效果,我們需要對 axios 定義一個攔截器,用以接收我們重新整理的 Token,程式碼如下:

app.js

import Vue from `vue`
import router from `./router`
import store from `./store`
import iView from `iview`
import `iview/dist/styles/iview.css`

Vue.use(iView)


new Vue({
    el: `#app`,
    router,
    store,
    created() {
        // 自定義的 axios 響應攔截器
        this.$axios.interceptors.response.use((response) => {
            // 判斷一下響應中是否有 token,如果有就直接使用此 token 替換掉本地的 token。你可以根據你的業務需求自己編寫更新 token 的邏輯
            var token = response.headers.authorization
            if (token) {
                // 如果 header 中存在 token,那麼觸發 refreshToken 方法,替換本地的 token
                this.$store.dispatch(`refreshToken`, token)
            }
            return response
        }, (error) => {
            switch (error.response.status) {
                
                // 如果響應中的 http code 為 401,那麼則此使用者可能 token 失效了之類的,我會觸發 logout 方法,清除本地的資料並將使用者重定向至登入頁面
                case 401:
                    return this.$store.dispatch(`logout`)
                    break
                // 如果響應中的 http code 為 400,那麼就彈出一條錯誤提示給使用者
                case 400:
                    return this.$Message.error(error.response.data.error)
                    break
            }
            return Promise.reject(error)
        })
    }
})

複製程式碼

Vuex 內的程式碼如下:

store/index.js

import Vue from `vue`
import Vuex from `vuex`
import axios from `axios`

Vue.use(Vuex)

export default new Vuex.Store({
    state: {
        name: null,
        avatar: null,
        mobile: null,
        token: null,
        remark: null,
        auth: false,
    },
    mutations: {
        // 使用者登入成功,儲存 token 並設定 header 頭
        logined(state, token) {
            state.auth = true
            state.token = token
            localStorage.token = token
        },
        // 使用者重新整理 token 成功,使用新的 token 替換掉本地的token
        refreshToken(state, token) {
            state.token = token
            localStorage.token = token
            axios.defaults.headers.common[`Authorization`] = state.token
        },
        // 登入成功後拉取使用者的資訊儲存到本地
        profile(state, data) {
            state.name = data.name
            state.mobile = data.mobile
            state.avatar = data.avatar
            state.remark = data.remark
        },
        // 使用者登出,清除本地資料
        logout(state){
            state.name = null
            state.mobile = null
            state.avatar = null
            state.remark = null
            state.auth = false
            state.token = null

            localStorage.removeItem(`token`)
        }
    },
    actions: {
         // 登入成功後儲存使用者資訊
        logined({dispatch,commit}, token) {
            return new Promise(function (resolve, reject) {
                commit(`logined`, token)
                axios.defaults.headers.common[`Authorization`] = token
                dispatch(`profile`).then(() => {
                    resolve()
                }).catch(() => {
                    reject()
                })
            })
        },
        // 登入成功後使用 token 拉取使用者的資訊
        profile({commit}) {
            return new Promise(function (resolve, reject) {
                axios.get(`profile`, {}).then(respond => {
                    if (respond.status == 200) {
                        commit(`profile`, respond.data)
                        resolve()
                    } else {
                        reject()
                    }
                })
            })
        },
        // 使用者登出,清除本地資料並重定向至登入頁面
        logout({commit}) {
            return new Promise(function (resolve, reject) {
                commit(`logout`)
                axios.post(`auth/logout`, {}).then(respond => {
                    Vue.$router.push({name:`login`})
                })
            })
        },
        // 將重新整理的 token 儲存至本地
        refreshToken({commit},token) {
            return new Promise(function (resolve, reject) {
                commit(`refreshToken`, token)
            })
        },
    }
})

複製程式碼

更新異常處理的 Handler

由於我們構建的是 api 服務,所以我們需要更新一下 app/Exceptions/Handler.php 中的 render

方法,自定義處理一些異常。

Handler.php

<?php

namespace AppExceptions;

use Exception;
use IlluminateFoundationExceptionsHandler as ExceptionHandler;
use IlluminateValidationValidationException;
use SymfonyComponentHttpKernelExceptionUnauthorizedHttpException;

class Handler extends ExceptionHandler
{
    ...

    /**
     * Render an exception into an HTTP response.
     *
     * @param  IlluminateHttpRequest $request
     * @param  Exception $exception
     * @return IlluminateHttpResponse
     */
    public function render($request, Exception $exception)
    {
        // 引數驗證錯誤的異常,我們需要返回 400 的 http code 和一句錯誤資訊
        if ($exception instanceof ValidationException) {
            return response([`error` => array_first(array_collapse($exception->errors()))], 400);
        }
        // 使用者認證的異常,我們需要返回 401 的 http code 和錯誤資訊
        if ($exception instanceof UnauthorizedHttpException) {
            return response($exception->getMessage(), 401);
        }

        return parent::render($request, $exception);
    }
}

複製程式碼

更新完此方法後,我們上面自定義的中介軟體裡丟擲的異常和我們下面引數驗證錯誤丟擲的異常都會被轉為指定的格式丟擲。

使用

現在,我們可以在我們的 routes/api.php 路由檔案中新增幾條路由來測試一下了:

api.php

Route::prefix(`auth`)->group(function($router) {
    $router->post(`login`, `AuthController@login`);
    $router->post(`logout`, `AuthController@logout`);


});

Route::middleware(`refresh.token`)->group(function($router) {
    $router->get(`profile`,`UserController@profile`);
});
複製程式碼

在你的 shell 中執行如下命令以新增一個控制器:

$ php artisan make:controller AuthController
複製程式碼

開啟此控制器,寫入如下內容

<?php

namespace AppHttpControllers;

use IlluminateHttpRequest;
use IlluminateSupportFacadesAuth;
use AppTransformersUserTransformer;

class AuthController extends Controller
{

    /**
     * Get a JWT token via given credentials.
     *
     * @param  IlluminateHttpRequest $request
     *
     * @return IlluminateHttpJsonResponse
     */
    public function login(Request $request)
    {
        // 驗證規則,由於業務需求,這裡我更改了一下登入的使用者名稱,使用手機號碼登入
        $rules = [
            `mobile`   => [
                `required`,
                `exists:users`,
            ],
            `password` => `required|string|min:6|max:20`,
         ];
          
        // 驗證引數,如果驗證失敗,則會丟擲 ValidationException 的異常
        $params = $this->validate($request, $rules);

	   // 使用 Auth 登入使用者,如果登入成功,則返回 201 的 code 和 token,如果登入失敗則返回
        return ($token = Auth::guard(`api`)->attempt($params))
            ? response([`token` => `bearer ` . $token], 201)
            : response([`error` => `賬號或密碼錯誤`], 400);
    }

    /**
     * 處理使用者登出邏輯
     *
     * @return IlluminateHttpJsonResponse
     */
    public function logout()
    {
        Auth::guard(`api`)->logout();

        return response([`message` => `退出成功`]);
    }
}
複製程式碼

然後我們進入 tinker

$ php artisan tinker
複製程式碼

執行以下命令來建立一個測試使用者,我這裡的使用者名稱是用的是手機號碼,你可以自行替換為郵箱。別忘了設定名稱空間喲:

>>> namespace AppModels;
>>> User::create([`name` => `Test`,`mobile` => 17623239881,`password` => bcrypt(`123456`)]);
複製程式碼

正確執行結果如下圖:

file

然後開啟 Postman 來進行 api 測試

使用 Jwt-Auth 實現 API 使用者認證以及無痛重新整理訪問令牌

正確的請求結果如下圖:

使用 Jwt-Auth 實現 API 使用者認證以及無痛重新整理訪問令牌

可以看到我們已經成功的拿到了 token,接下來我們就去驗證一下重新整理 token 吧

使用 Jwt-Auth 實現 API 使用者認證以及無痛重新整理訪問令牌

如圖可以看到我們已經拿到了新的 token,接下來的事情便會交由我們前面設定的 axios 攔截器處理,它會將本地的 token 替換為此 token。

版本科普

感覺蠻多人對版本沒什麼概念,所以在這裡科普下常見的版本。

  • α(Alpha)版

    ​ 這個版本表示該 Package 僅僅是一個初步完成品,通常只在開發者內部交流,也有很少一部分發布給專業測試人員。一般而言,該版本軟體的 Bug 較多,普通使用者最好不要安裝。

  • β(Beta)版

    該版本相對於 α(Alpha)版已有了很大的改進,修復了嚴重的錯誤,但還是存在著一些缺陷,需要經過大規模的釋出測試來進一步消除。通過一些專業愛好者的測試,將結果反饋給開發者,開發者們再進行有針對性的修改。該版本也不適合一般使用者安裝。

  • RC/ Preview版

    RC 即 Release Candidate 的縮寫,作為一個固定術語,意味著最終版本準備就緒。一般來說 RC 版本已經完成全部功能並清除大部分的 BUG。一般到了這個階段 Package 的作者只會修復 Bug,不會對軟體做任何大的更改。

  • 普通發行版本

    一般在經歷了上面三個版本後,作者會推出此版本。此版本修復了絕大部分的 Bug,並且會維護一定的時間。(時間根據作者的意願而決定,例如 Laravel 的一般發行版本會提供為期一年的維護支援。)

  • LTS(Long Term Support) 版

    該版本是一個特殊的版本,和普通版本旨在支援比正常時間更長的時間。(例如 Laravel 的 LTS 版本會提供為期三年的 維護支援。)

結語

jwt-auth 確實是一個很棒的使用者認證 Package,配置簡單,使用方便。

文章結束,感謝閱讀。

相關文章