手摸手教你讓 Laravel 開發 API 更得心應手

guaosi發表於2019-03-22

本文搬運自我自己的部落格

       隨著前後端完全分離,PHP也基本告別了view模板巢狀開發,轉而專門寫資源介面。Laravel是PHP框架中最優雅的框架,國內也越來越多人告別ThinkPHP選擇了LaravelLaravel框架本身對API有支援,但是感覺再工作中還是需要再做一些處理。Lumen用起來不順手,有些包不能很好地支援。所以,將Laravel框架進行一些配置處理,讓其在開發API時更得心應手。

       當然,你也可以點選這裡,直接跳到成果~

2.1. 環境

PHP > 7.1
MySQL > 5.5
Redis > 2.8

2.2. 工具

postman
composer

2.3. 使用postman

為了模擬AJAX請求,請將 header頭 設定X-Requested-WithXMLHttpRequest

file

2.4. 安裝Laravel

Laravel只要>=5.5皆可,這裡採用文章編寫時最新的5.7版本

composer create-project laravel/laravel Laravel --prefer-dist "5.7.*"

2.5. 建立資料庫

CREATE TABLE `users` (
    `id` INT UNSIGNED NOT NULL PRIMARY KEY auto_increment COMMENT '主鍵ID',
    `name` VARCHAR ( 12 ) NOT NULL COMMENT '使用者名稱稱',
    `password` VARCHAR ( 80 ) NOT NULL COMMENT '密碼',
    `last_token` text COMMENT '登陸時的token',
    `status` TINYINT NOT NULL DEFAULT 0 COMMENT '使用者狀態 -1代表已刪除 0代表正常 1代表凍結',
    `created_at` TIMESTAMP NULL DEFAULT NULL COMMENT '建立時間',
`updated_at` TIMESTAMP NULL DEFAULT NULL COMMENT '修改時間' 
) ENGINE = INNODB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_general_ci;

3.1. Model移動

在專案的app目錄下可以看到,有一個User.php的模型檔案。因為Laravel預設把模型檔案放在app目錄下,如果資料表多的話,這裡模型檔案就會很多,不便於管理,所以我們先要將模型檔案移動到其他資料夾內。

1) 在app目錄下新建Models資料夾,然後將User.php檔案移動進來。
2) 修改User.php的內容

<?php

namespace App\Models; //這裡從App改成了App\Models

use Illuminate\Notifications\Notifiable;
use Illuminate\Foundation\Auth\User as Authenticatable;

class User extends Authenticatable
{
    use Notifiable;
    protected $table = 'users';

     //去掉我建立的資料表沒有的欄位
    protected $fillable = [
        'name', 'password'
    ];

     //去掉我建立的資料表沒有的欄位
    protected $hidden = [
        'password'
    ];
    //將密碼進行加密
    public function setPasswordAttribute($value)
    {
        $this->attributes['password'] = bcrypt($value);
    }
}

3) 因為有關於User的名稱空間發生了改變,所以我們全域性搜尋App\User,將其替換為App\Models\User.我一共搜尋到4個檔案

app/Http/Controllers/Auth 目錄下的 RegisterController.php
config 目錄下的 services.php
config 目錄下的 auth.php
database/factories 目錄下的 UserFactory.php

3.2. 控制器

因為是專門做API的,所以我們要把是API的控制器都放到app\Http\Controllers\Api目錄下。

使用命令列建立控制器

php artisan make:controller Api/UserController

編寫app/Http/Controllers/Api目錄下的UserController.php檔案

<?php

namespace App\Http\Controllers\Api;

use Illuminate\Http\Request;
use App\Http\Controllers\Controller;

class UserController extends Controller
{
    //
    public function index(){
        return 'guaosi';
    }
}

這裡寫了index函式,用來下面建立路由後的測試,檢視是否可以正常訪問。

3.3. 路由

routes目錄下的api.php是專門用來寫Api介面的路由,所以我們開啟它,填寫以下內容,做一個測試.

<?php
use Illuminate\Http\Request;

Route::namespace('Api')->prefix('v1')->group(function () {
        Route::get('/users','UserController@index')->name('users.index');
});

因為我們Api控制器的名稱空間是App\Http\Controllers\Api,而Laravel預設只會在名稱空間App\Http\Controllers下查詢控制器,所以需要我們給出namespace

同時,新增一個prefix是為了版本號,方便後期介面升級區分。

開啟postman,用get方式請求你的域名/api/v1/users,最後返回結果是

guaosi

則成功

3.4. 建立驗證器

在建立使用者之前,我們先建立驗證器,來讓我們伺服器接收到的資料更安全.當然,我們也要把關於Api驗證的放在一個專門的資料夾內。
先建立一個Request的基類

php artisan make:request Api/FormRequest

因為驗證器預設的許可權驗證是false,導致返回都是403的許可權不通過錯誤。這裡我們沒有用到許可權認證,為了方便處理,我們預設將許可權都是通過的狀態。所以,每個檔案都需要我們將false改成true

public function authorize()
{
//false代表許可權驗證不通過,返回403錯誤
//true代表許可權認證通過
return true;
}

所以我們修改app/Http/Requests/Api 目錄下的 FormRequest.php 檔案

<?php

namespace App\Http\Requests\Api;

use Illuminate\Foundation\Http\FormRequest as BaseFormRequest;

class FormRequest extends BaseFormRequest
{
    public function authorize()
    {
        //false代表許可權驗證不通過,返回403錯誤
        //true代表許可權認證通過
        return true;
    }
}

這樣這個名稱空間下的驗證器都會預設通過許可權驗證。當然,如果你需要許可權驗證,可以通過直接覆蓋方法。

接著我們開始建立關於UserController的專屬驗證器

php artisan make:request Api/UserRequest

編輯app/Http/Requests/Api 目錄下的 UserRequest.php檔案

<?php

namespace App\Http\Requests\Api;

class UserRequest extends FormRequest
{
    public function rules()
    {

        switch ($this->method()) {
            case 'GET':
                {
                    return [
                        'id' => ['required,exists:shop_user,id']
                    ];
                }
            case 'POST':
                {
                    return [
                        'name' => ['required', 'max:12', 'unique:users,name'],
                        'password' => ['required', 'max:16', 'min:6']
                    ];
                }
            case 'PUT':
            case 'PATCH':
            case 'DELETE':
            default:
                {
                    return [

                    ];
                }
        }
    }

    public function messages()
    {
        return [
            'id.required'=>'使用者ID必須填寫',
            'id.exists'=>'使用者不存在',
            'name.unique' => '使用者名稱已經存在',
            'name.required' => '使用者名稱不能為空',
            'name.max' => '使用者名稱最大長度為12個字元',
            'password.required' => '密碼不能為空',
            'password.max' => '密碼長度不能超過16個字元',
            'password.min' => '密碼長度不能小於6個字元'
        ];
    }
}

3.5. 建立使用者

現在我們來編寫建立使用者介面,製作一些虛擬資料。(就不使用seeder來填充了)
開啟UserController.php

//使用者註冊
public function store(UserRequest $request){
    User::create($request->all());
    return '使用者註冊成功。。。';
}
//使用者登入
public function login(Request $request){
    $res=Auth::guard('web')->attempt(['name'=>$request->name,'password'=>$request->password]);
    if($res){
        return '使用者登入成功...';
     }
    return '使用者登入失敗';
}

然後我們建立路由,編輯api.php

Route::post('/users','UserController@store')->name('users.store');
Route::post('/login','UserController@login')->name('users.login');

開啟postman,用post方式請求你的域名/api/v1/users,在form-data記得填寫要建立的使用者名稱和密碼。

最後返回結果是

使用者建立成功。。。

則成功。

file

如果返回

{
    "message": "The given data was invalid.",
    "errors": {
        "name": [
            "使用者名稱不能為空"
        ],
        "password": [
            "密碼不能為空"
        ]
    }
}

則證明驗證失敗。

然後驗證是否可以正常登入。因為我們認證的欄位是namepassword,而Laravel預設認證的是emailpassword。所以我們還要開啟app/Http/Controllers/auth 目錄下的 LoginController.php,加入如下程式碼

public function username()
{
    return 'name';
}

開啟postman,用post方式請求你的域名/api/v1/login
最後返回結果是

使用者登入成功...

則成功

file

3.6. 建立10個使用者

為了測試使用,請自行通過介面建立10個使用者。

3.7. 編寫相關資源介面

給出整體控制器資訊UserController.php

<?php

namespace App\Http\Controllers\Api;

use App\Http\Requests\Api\UserRequest;
use App\Models\User;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
class UserController extends Controller
{

    //返回使用者列表
    public function index(){
        //3個使用者為一頁
        $users = User::paginate(3);
        return $users;
    }
    //返回單一使用者資訊
    public function show(User $user){
        return $user;
    }
    //使用者註冊
    public function store(UserRequest $request){
        User::create($request->all());
        return '使用者註冊成功。。。';
    }
    //使用者登入
    public function login(Request $request){
        $res=Auth::guard('web')->attempt(['name'=>$request->name,'password'=>$request->password]);
        if($res){
            return '使用者登入成功...';
        }
        return '使用者登入失敗';
    }
}

3.8. 編寫路由

給出整體路由資訊api.php

<?php
use Illuminate\Http\Request;

Route::namespace('Api')->prefix('v1')->group(function () {
        Route::get('/users','UserController@index')->name('users.index');
        Route::get('/users/{user}','UserController@show')->name('users.show');
        Route::post('/users','UserController@store')->name('users.store');
        Route::post('/login','UserController@login')->name('users.login');
});

以上所有返回的結果,無論正確或者錯誤,都沒有一個統一格式規範,對開發Api不太友好的,需要我們進行一些修改,讓Laravel框架可以更加友好地編寫Api。

5.1. 跨域問題

所有問題,跨域先行。跨域問題沒有解決,一切處理都是紙老虎。這裡我們使用medz做的cors擴充套件包

5.1.1. 安裝medz/cors

composer require medz/cors

5.1.2. 釋出配置檔案

php artisan vendor:publish --provider="Medz\Cors\Laravel\Providers\LaravelServiceProvider" --force

5.1.3. 修改配置檔案

開啟config/cors.php,在expose-headers新增值Authorization

return [
    ......
    'expose-headers'     => ['Authorization'],
    ......
];

這樣跨域請求時,才能返回header頭為Authorization的內容,否則在重新整理使用者token時不會返回重新整理後的token

5.1.4. 增加中介軟體別名

開啟app/Http/Kernel.php,增加一行

protected $routeMiddleware = [
        ...... //前面的中介軟體
        'cors'=> \Medz\Cors\Laravel\Middleware\ShouldGroup::class,
];

5.1.5. 修改路由

開啟routes/api.php,在路由組中增加使用中介軟體

Route::namespace('Api')->prefix('v1')->middleware('cors')->group(function () {
        Route::get('/users','UserController@index')->name('users.index');
        Route::get('/users/{user}','UserController@show')->name('users.show');
        Route::post('/users','UserController@store')->name('users.store');
        Route::post('/login','UserController@login')->name('users.login');
});

5.2. 統一Response響應處理

介面主流返回json格式,其中包含http狀態碼status請求狀態data請求資源結果等等。需要我們有一個API介面全域性都能有統一的格式和對應的資料處理。參考於這裡

5.2.1. 封裝返回的統一訊息

app/Api/Helpers 目錄(不存在目錄自己新建)下新建 ApiResponse.php
填入如下內容

<?php
namespace App\Api\Helpers;
use Symfony\Component\HttpFoundation\Response as FoundationResponse;
use Response;

trait ApiResponse
{
    /**
     * @var int
     */
    protected $statusCode = FoundationResponse::HTTP_OK;

    /**
     * @return mixed
     */
    public function getStatusCode()
    {
        return $this->statusCode;
    }

    /**
     * @param $statusCode
     * @return $this
     */
    public function setStatusCode($statusCode,$httpCode=null)
    {
        $httpCode = $httpCode ?? $statusCode;
        $this->statusCode = $statusCode;
        return $this;
    }

    /**
     * @param $data
     * @param array $header
     * @return mixed
     */
    public function respond($data, $header = [])
    {

        return Response::json($data,$this->getStatusCode(),$header);
    }

    /**
     * @param $status
     * @param array $data
     * @param null $code
     * @return mixed
     */
    public function status($status, array $data, $code = null){

        if ($code){
            $this->setStatusCode($code);
        }
        $status = [
            'status' => $status,
            'code' => $this->statusCode
        ];

        $data = array_merge($status,$data);
        return $this->respond($data);

    }

    /**
     * @param $message
     * @param int $code
     * @param string $status
     * @return mixed
     */
    /*
     * 格式
     * data:
     *  code:422
     *  message:xxx
     *  status:'error'
     */
    public function failed($message, $code = FoundationResponse::HTTP_BAD_REQUEST,$status = 'error'){

        return $this->setStatusCode($code)->message($message,$status);
    }

    /**
     * @param $message
     * @param string $status
     * @return mixed
     */
    public function message($message, $status = "success"){

        return $this->status($status,[
            'message' => $message
        ]);
    }

    /**
     * @param string $message
     * @return mixed
     */
    public function internalError($message = "Internal Error!"){

        return $this->failed($message,FoundationResponse::HTTP_INTERNAL_SERVER_ERROR);
    }

    /**
     * @param string $message
     * @return mixed
     */
    public function created($message = "created")
    {
        return $this->setStatusCode(FoundationResponse::HTTP_CREATED)
            ->message($message);

    }

    /**
     * @param $data
     * @param string $status
     * @return mixed
     */
    public function success($data, $status = "success"){

        return $this->status($status,compact('data'));
    }

    /**
     * @param string $message
     * @return mixed
     */
    public function notFond($message = 'Not Fond!')
    {
        return $this->failed($message,Foundationresponse::HTTP_NOT_FOUND);
    }
}

5.2.2. 新建Api控制器基類

app/Http/Controller/Api 目錄下新建一個Controller.php作為Api專門的基類.
填入以下內容

<?php

namespace App\Http\Controllers\Api;

use App\Api\Helpers\ApiResponse;
use App\Http\Controllers\Controller as BaseController;

class Controller extends BaseController
{

    use ApiResponse;
    // 其他通用的Api幫助函式
}

5.2.3. 繼承Api控制器基類

讓Api的控制器繼承這個基類即可。
開啟UserController.php檔案,去掉名稱空間use App\Http\Controllers\Controller

namespace App\Http\Controllers\Api;

use App\Http\Requests\Api\UserRequest;
use App\Models\User;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
class UserController extends Controller
{
    ......
}

5.2.4. 如何使用

得益於前面統一訊息的封裝,使用起來非常容易。
1.返回正確資訊

return $this->success('使用者登入成功...');

2.返回正確資源資訊

return $this->success($user);

3.返回自定義http狀態碼的正確資訊

return $this->setStatusCode(201)->success('使用者登入成功...');

4.返回錯誤資訊

return $this->failed('使用者註冊失敗');

5.返回自定義http狀態碼的錯誤資訊

return $this->failed('使用者登入失敗',401);

6.返回自定義http狀態碼的錯誤資訊,同時也想返回自己內部定義的錯誤碼

return $this->failed('使用者登入失敗',401,10001);

預設success返回的狀態碼是200,failed返回的狀態碼是400

5.2.5. 修改使用者控制器

我們將統一訊息封裝運用到UserController

<?php

namespace App\Http\Controllers\Api;

use App\Http\Requests\Api\UserRequest;
use App\Models\User;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
class UserController extends Controller
{

    //返回使用者列表
    public function index(){
        //3個使用者為一頁
        $users = User::paginate(3);
        return $this->success($users);
    }
    //返回單一使用者資訊
    public function show(User $user){
        return $this->success($user);
    }
    //使用者註冊
    public function store(UserRequest $request){
        User::create($request->all());
        return $this->setStatusCode(201)->success('使用者註冊成功');
    }
    //使用者登入
    public function login(Request $request){
        $res=Auth::guard('web')->attempt(['name'=>$request->name,'password'=>$request->password]);
        if($res){
            return $this->setStatusCode(201)->success('使用者登入成功...');
        }
        return $this->failed('使用者登入失敗',401);
    }
}

5.2.6. 測試

  1. 返回使用者列表
    請求http://你的域名/api/v1/users
    file
  2. 返回單一使用者
    請求http://你的域名/api/v1/users/1
    file
  3. 登陸正確
    請求http://你的域名/api/v1/login
    file
  4. 登陸錯誤
    請求http://你的域名/api/v1/login
    file

    5.3. Api-Resource資源

在上面請求返回使用者列表和返回單一使用者時,返回的欄位都是資料庫裡所有的欄位,當然,不包含我們在User模型中去除的password欄位。

5.3.1. 需求

此時,我們如果想控制返回的欄位有哪些,可以使用select或者使用User模型中的hidden陣列來限制欄位。

這2種辦法雖然可以,但是擴充套件性太差。並且我想對status返回的形式進行修改,比如0的時候顯示正常,1顯示凍結,此時就需要遍歷資料進行修改了。此時,Laravel提供的API 資源就可以很好地解決我們的問題。

當構建 API 時,你往往需要一個轉換層來聯結你的 Eloquent 模型和實際返回給使用者的 JSON 響應。Laravel 的資源類能夠讓你以更直觀簡便的方式將模型和模型集合轉化成 JSON。

也就是在C層輸出V層時,中間再來一層來專門處理欄位問題,我們可以稱之為ViewModel層。

詳細可以檢視手冊如何使用。

5.3.2. 建立單一使用者資源和列表使用者資源

php artisan make:resource Api/UserResource

修改app/Http/Resources/Api 目錄下的 UserResource.php檔案

<?php

namespace App\Http\Resources\Api;

use Illuminate\Http\Resources\Json\JsonResource;

class UserResource extends JsonResource
{
    /**
     * Transform the resource into an array.
     *
     * @param  \Illuminate\Http\Request  $request
     * @return array
     */
    public function toArray($request)
    {
        switch ($this->status){
            case -1:
                $this->status = '已刪除';
                break;
            case 0:
                $this->status = '正常';
                break;
            case 1:
                $this->status = '凍結';
                break;
        }
        return [
            'id'=>$this->id,
            'name' => $this->name,
            'status' => $this->status,
            'created_at'=>(string)$this->created_at,
            'updated_at'=>(string)$this->updated_at
        ];
    }
}

5.3.3. 如何使用

返回單一使用者(單一的資源)

return $this->success(new UserResource($user));

返回使用者列表(資源列表)

return UserResource::collection($users);
//這裡不能用$this->success(UserResource::collection($users))
//否則不能返回分頁標籤資訊

5.3.4. 修改使用者控制器

//返回使用者列表
public function index(){
    //3個使用者為一頁
    $users = User::paginate(3);
    return UserResource::collection($users);
}
//返回單一使用者資訊
public function show(User $user){
    return $this->success(new UserResource($user));
}

5.3.5. 測試

返回單一使用者(單一的資源)
file
返回使用者列表(資源列表)
file

5.4. Enum列舉

我們常常會使用數字來代表狀態,比如使用者表,我們使用 -1 代表已刪除 0 代表正常 1 代表凍結。

5.4.1. 兩個問題

  1. 當我們判斷一個使用者,如果是刪除或者凍結狀態就不讓其登陸了。判斷程式碼這樣寫
    //有可能狀態有很多,所以這邊就直接用 或 來判斷不取反了。
    if($user->status==-1||$user->status==1){
    // 不允許使用者登入邏輯
    return
    }
    //使用者正常登入邏輯

上面邏輯和編寫沒有什麼問題。因為是現在看,可以很明白的知道-1 代表已刪除,1 代表凍結。但是如果一個月後再來看這行程式碼,早已經忘記了 -11 具體表示的含義。

  1. 參考上面UserResource.php編寫時,判斷status具體狀態函式,我們是使用switch語句。這樣太不美觀,而且地方用多了還容易冗餘,每次編寫都需要去檢視每個數字代表的具體意思。

5.4.2. 解決思路

  1. 第一個問題:為什麼一段時間後再看就不知道-11 具體表示的含義?

       這是因為單純的數字沒有解釋說明的作用,變數以及函式這些具有解釋說明的作用,可以讓我們立刻知道具體含義。

  1. 第二個問題:如何給一個數字就能直接知道它代表的含義?

       提供一個函式,返回這個數字代表的具體含義。

而這些,都可以使用Enum列舉可以解決。

5.4.3. 注意

PHPLaravel框架本身是不支援Enum列舉的,不過我們可以模擬列舉的功能

5.4.4. 建立列舉

app/Models 下新建目錄 Enum ,並在目錄Enum下新建 UserEnum.php 檔案,填寫以下內容

<?php

namespace App\Models\Enum;
class UserEnum
{
    // 狀態類別
    const INVALID = -1; //已刪除
    const NORMAL = 0; //正常
    const FREEZE = 1; //凍結

    public static function getStatusName($status){
        switch ($status){
            case self::INVALID:
                return '已刪除';
            case self::NORMAL:
                return '正常';
            case self::FREEZE:
                return '凍結';
            default:
                return '正常';
        }
    }
}

5.4.5. 使用

1.表示具體含義

//有可能狀態有很多,所以這邊就直接用 或 來判斷不取反了。
if($user->status==UserEnum::INVALID||$user->status==UserEnum::FREEZE){
    // 不允許使用者登入邏輯
    return
}
//使用者正常登入邏輯

2.修改UserResource.php

public function toArray($request)
{
    return [
        'id'=>$this->id,
        'name' => $this->name,
        'status' => UserEnum::getStatusName($this->status),
        'created_at'=>(string)$this->created_at,
        'updated_at'=>(string)$this->updated_at
    ];
}

再請求單一使用者和使用者列表介面,返回結果和之前一樣。

5.5. 異常自定義處理

5.5.1. 再發現一個問題

我們在UserController.php檔案中修改

//返回單一使用者資訊
public function show(User $user){
    3/0;
    return $this->success(new UserResource($user));
}

故意報個錯,請求看看結果
file
我們再把設定成ajaxheader頭去掉
file

報錯非常詳細,並且把我們隱私設定都暴露出來了,這是由於我們.envAPP_DEBUGtrue狀態。我們不希望這些資訊被其他訪問者看到。我們改為false,再請求看看結果。

file

嗯。很好,不僅別人看不到了,連我們自己都看不到了

5.5.2. 需求

  1. 所有的異常資訊都以統一json格式輸出
  2. 因為我們是開發者,並且.env檔案預設是不加入git上傳線上的,我們希望可以當APP_DEBUGtrue(本地)的時候可以繼續顯示詳細的錯誤資訊,false(線上)的時候就顯示簡要json資訊,比如500。

5.5.3. 建立自定義異常處理

app/Api/Helpers 目錄下新建 ExceptionReport.php 檔案,填入以下內容

<?php

namespace App\Api\Helpers;

use Exception;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Auth\AuthenticationException;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Http\Request;
use Illuminate\Validation\ValidationException;
use Symfony\Component\HttpKernel\Exception\MethodNotAllowedHttpException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException;
use Tymon\JWTAuth\Exceptions\TokenInvalidException;

class ExceptionReport
{
    use ApiResponse;

    /**
     * @var Exception
     */
    public $exception;
    /**
     * @var Request
     */
    public $request;

    /**
     * @var
     */
    protected $report;

    /**
     * ExceptionReport constructor.
     * @param Request $request
     * @param Exception $exception
     */
    function __construct(Request $request, Exception $exception)
    {
        $this->request = $request;
        $this->exception = $exception;
    }

    /**
     * @var array
     */
    //當丟擲這些異常時,可以使用我們定義的錯誤資訊與HTTP狀態碼
    //可以把常見異常放在這裡
    public $doReport = [
        AuthenticationException::class => ['未授權',401],
        ModelNotFoundException::class => ['該模型未找到',404],
        AuthorizationException::class => ['沒有此許可權',403],
        ValidationException::class => [],
        UnauthorizedHttpException::class=>['未登入或登入狀態失效',422],
        TokenInvalidException::class=>['token不正確',400],
        NotFoundHttpException::class=>['沒有找到該頁面',404],
        MethodNotAllowedHttpException::class=>['訪問方式不正確',405],
        QueryException::class=>['引數錯誤',401],
    ];

    public function register($className,callable $callback){

        $this->doReport[$className] = $callback;
    }

    /**
     * @return bool
     */
    public function shouldReturn(){
    //只有請求包含是json或者ajax請求時才有效
//        if (! ($this->request->wantsJson() || $this->request->ajax())){
//
//            return false;
//        }
        foreach (array_keys($this->doReport) as $report){
            if ($this->exception instanceof $report){
                $this->report = $report;
                return true;
            }
        }

        return false;

    }

    /**
     * @param Exception $e
     * @return static
     */
    public static function make(Exception $e){

        return new static(\request(),$e);
    }

    /**
     * @return mixed
     */
    public function report(){
        if ($this->exception instanceof ValidationException){
            $error = array_first($this->exception->errors());
            return $this->failed(array_first($error),$this->exception->status);
        }
        $message = $this->doReport[$this->report];
        return $this->failed($message[0],$message[1]);
    }
    public function prodReport(){
        return $this->failed('伺服器錯誤','500');
    }
}

5.5.4. 捕捉異常

修改 app/Exceptions 目錄下的 Handler.php 檔案

<?php

namespace App\Exceptions;
use App\Api\Helpers\ExceptionReport;
use Exception;
use Illuminate\Foundation\Exceptions\Handler as ExceptionHandler;

class Handler extends ExceptionHandler
{

    public function render($request, Exception $exception)
    {
        //ajax請求我們才捕捉異常
        if ($request->ajax()){
            // 將方法攔截到自己的ExceptionReport
            $reporter = ExceptionReport::make($exception);
            if ($reporter->shouldReturn()){
                return $reporter->report();
            }
            if(env('APP_DEBUG')){
                //開發環境,則顯示詳細錯誤資訊
                return parent::render($request, $exception);
            }else{
                //線上環境,未知錯誤,則顯示500
                return $reporter->prodReport();
            }
        }
        return parent::render($request, $exception);
    }
}

5.5.5. 測試

繼續開啟設定AJAXheader

1.關閉APP_DEBUG,請求剛剛故意錯誤的介面。
file
2.開啟APP_DEBUG,請求剛剛故意錯誤的介面。
file
3.請求一個不存在的路由,檢視返回結果。
file

其他的異常顯示,自行測試啦~

5.6. jwt-auth

在傳統web中,我們一般是使用session來判定一個使用者的登陸狀態。而在API開發中,我們使用的是tokenjwt-tokenLaravel開發API用的比較多的。

JWT 全稱 JSON Web Tokens ,是一種規範化的 token。可以理解為對 token 這一技術提出一套規範,是在 RFC 7519 中提出的。

jwt-auth的詳細介紹分析可以看JWT超詳細分析這篇文章,具體使用可以看JWT完整使用詳解 這篇文章。

5.6.1. 安裝

composer require tymon/jwt-auth 1.0.0-rc.3

如果是Laravel5.5版本,則安裝rc.1。如果是Laravel5.6版本,則安裝rc.2

5.6.2. 配置

配置參考來自使用 Jwt-Auth 實現 API 使用者認證以及無痛重新整理訪問令牌

1.新增服務提供商
開啟 config 目錄下的 app.php檔案,新增下面程式碼

'providers' => [

    ...

    Tymon\JWTAuth\Providers\LaravelServiceProvider::class,
]

2.釋出配置檔案

php artisan vendor:publish --provider="Tymon\JWTAuth\Providers\LaravelServiceProvider"

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

3.生成金鑰

php artisan jwt:secret

此命令會在你的 .env 檔案中新增一行 JWT_SECRET=secret。以此來作為加密時使用的祕鑰。

4.配置 Auth guard
開啟 config 目錄下的 auth.php檔案,修改為下面程式碼

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

    'api' => [
       'driver' => 'jwt',
       'provider' => 'users',
    ],
],

這樣,我們就能讓api的使用者認證變成使用jwt

5.更改 Model

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

<?php

namespace App\Models;

use Illuminate\Notifications\Notifiable;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Tymon\JWTAuth\Contracts\JWTSubject;

class User extends Authenticatable implements JWTSubject
{
    use Notifiable;

    public function getJWTIdentifier()
    {
        return $this->getKey();
    }

    public function getJWTCustomClaims()
    {
        return [];
    }
    ......

6.配置項詳解
config目錄下的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' => Tymon\JWTAuth\Providers\JWT\Namshi::class,

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

        'auth' => Tymon\JWTAuth\Providers\Auth\Illuminate::class,

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

        'storage' => Tymon\JWTAuth\Providers\Storage\Illuminate::class,

    ],

];

5.6.3. 測試

1.我們在UserController控制器中將login方法進行修改以及新增一個logout方法用來退出登入還有info方法用來獲取當前使用者的資訊。

//使用者登入
public function login(Request $request){
    $token=Auth::guard('api')->attempt(['name'=>$request->name,'password'=>$request->password]);
    if($token) {
        return $this->setStatusCode(201)->success(['token' => 'bearer ' . $token]);
    }
    return $this->failed('賬號或密碼錯誤',400);
}
//使用者退出
public function logout(){
    Auth::guard('api')->logout();
    return $this->success('退出成功...');
}
//返回當前登入使用者資訊
public function info(){
    $user = Auth::guard('api')->user();
    return $this->success(new UserResource($user));
}

2.新增一下路由
routes/api.php

//當前使用者資訊
Route::get('/users/info','UserController@info')->name('users.info');

3.接著我們開啟postman,請求http://你的域名/api/v1/login.可以看到介面返回的token.

{
    "status": "success",
    "code": 201,
    "data": {
        "token": "bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJodHRwOlwvXC90ZXN0LmNvbVwvYXBpXC92MVwvbG9naW4iLCJpYXQiOjE1NTEzMzUyNzgsImV4cCI6MTU1MTMzODg3OCwibmJmIjoxNTUxMzM1Mjc4LCJqdGkiOiJrUzZSWHRoQVBkczR6ck4wIiwic3ViIjoxLCJwcnYiOiIyM2JkNWM4OTQ5ZjYwMGFkYjM5ZTcwMWM0MDA4NzJkYjdhNTk3NmY3In0.FLk-JPFBDTWcItPRN8SVGaLI0j2zgiWLLs_MNKxCafQ"
    }
}

4.此時,我們開啟Postman直接訪問http://你的域名/api/v1/users/info,你會看到報瞭如下錯誤.

Trying to get property 'id' of non-object

這是我們沒有攜帶token導致的。報錯不友好我們將在下面自動重新整理使用者認證解決。

5.我們在PostmanHeader頭部分再加一個keyAuthorizationvalue為登陸成功後返回的token值,然後再次進行請求,可以看到成功返回當前登陸使用者的資訊。
file

5.7. 自動重新整理使用者認證

5.7.1. 需求

現在我想使用者登入後,為了保證安全性,每個小時該使用者的token都會自動重新整理為全新的,用舊的token請求不會通過。我們知道,使用者如果token不對,就會退到當前介面重新登入來獲得新的token,我同時希望雖然重新整理了token,但是能否不要重新登入,就算重新登入也是一週甚至一個月之後呢?給使用者一種無感知的體驗。

看著感覺很神奇,我們一起手摸手來實現。

5.7.2. 自定義認證中介軟體

php artisan make:middleware Api/RefreshTokenMiddleware

開啟 app/Http/Middleware/Api 目錄下的 RefreshTokenMiddleware.php 檔案,填寫以下內容

<?php

namespace App\Http\Middleware\Api;

use Auth;
use Closure;
use Tymon\JWTAuth\Exceptions\JWTException;
use Tymon\JWTAuth\Facades\JWTAuth;
use Tymon\JWTAuth\Http\Middleware\BaseMiddleware;
use Tymon\JWTAuth\Exceptions\TokenExpiredException;
use Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException;

// 注意,我們要繼承的是 jwt 的 BaseMiddleware
class RefreshTokenMiddleware extends BaseMiddleware
{
    /**
     * Handle an incoming request.
     *
     * @param  \Illuminate\Http\Request $request
     * @param  \Closure $next
     *
     * @throws \Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException
     *
     * @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);
    }
}

5.7.3. 增加中介軟體別名

開啟 app/Http 目錄下的 Kernel.php 檔案,新增如下一行

protected $routeMiddleware = [
    ......
    'api.refresh'=>\App\Http\Middleware\Api\RefreshTokenMiddleware::class,
];

5.7.4. 路由器修改

接著我們將路由進行修改,新增上我們寫好的中介軟體。
routes/api.php

<?php

use Illuminate\Http\Request;

Route::namespace('Api')->prefix('v1')->middleware('cors')->group(function () {
        //使用者註冊
        Route::post('/users','UserController@store')->name('users.store');
        //使用者登入
        Route::post('/login','UserController@login')->name('users.login');
        Route::middleware('api.refresh')->group(function () {
            //當前使用者資訊
            Route::get('/users/info','UserController@info')->name('users.info');
            //使用者列表
            Route::get('/users','UserController@index')->name('users.index');
            //使用者資訊
            Route::get('/users/{user}','UserController@show')->name('users.show');
            //使用者退出
            Route::get('/logout','UserController@logout')->name('users.logout');
        });
});

5.7.5. 測試

1.此時我們再次不攜帶token,使用Postman直接訪問http://你的域名/api/v1/users/info,返回如下錯誤

{
    "status": "error",
    "code": 422,
    "message": "未登入或登入狀態失效"
}

2.那隨便輸入token又會是怎麼樣呢?我們也來嘗試一下

{
    "status": "error",
    "code": 400,
    "message": "token不正確"
}

3.現在,我們再做一個如果token過期了,但是重新整理限制沒有過期的情況,看看會有什麼結果。我們先將config/jwt.php裡的ttl60改成1。意味著重新生成的token將會1分鐘後過期。

然後我們重新登入獲取到token,替換/api/v1/users/info原有的token,進行訪問,可以正常返回使用者的資訊。

等過了一分鐘,我們再進行訪問,發現依舊可以返回使用者資訊,但是我們在返回的HeadersAuthorization可以看到新的token
file
此時如果我們再次訪問,則報出異常

{
    "status": "error",
    "code": 422,
    "message": "未登入或登入狀態失效"
}

我們替換上新的token,再次訪問,訪問正常通過。

4.現在,我們接著繼續做token和重新整理時間都過期的情況,會發生什麼。我們再將config/jwt.php裡的refresh_ttl20160改成2

重新按照3步驟執行一次,當剛過一分鐘時,返回結果與3相同,都是正常返回資訊並且在Headers攜帶了新的token。

當2分鐘過後,報如下錯誤資訊。

{
    "status": "error",
    "code": 422,
    "message": "未登入或登入狀態失效"
}

5.為了後面的方便,我們將修改的ttlrefresh_ttl的時間復原。

5.7.6. 前端邏輯

上面可以看出,當token過期或者無效以及亂寫,返回的HTTP狀態碼都是422。這是因為這個異常被我們上面自定義異常捕捉了

UnauthorizedHttpException::class=>['未登入或登入狀態失效',422],

所以,可以跟前端小夥伴商量一個狀態碼,專門表示接收到這個狀態碼就要退回重新登入了。當Header頭攜帶Authorization時,就要及時自動替換新的token,不需要回到重新登入介面。這樣使用者就能完全無感知啦~

5.8. 多角色認證

如果我們的系統不僅僅只有一種角色身份,還有其他的角色身份需要認證呢?目前我們的角色認證是認證Users表的,如果我們再加入一個Admins表,也要角色認證要如何操作?

5.8.1. Admin使用者表

我們將資料庫的Users表複製一份,將其命名為Admins表,並且將其中的一個使用者名稱進行修改,以示區別。

5.8.2. 框架檔案

我們分別將User.php模型檔案,UserEnum.php列舉檔案,UserResource.php資原始檔,UserRequest.php驗證器檔案UserController.php控制器檔案各複製一份,更改為Admin的,並將其中內容也改為Admin相關。因為就是複製貼上,把user改成admin,由於篇幅問題具體修改過程我就不放程式碼了。具體的可以看下面的成品

5.8.3. 使用者認證檔案

開啟config/auth.php檔案,修改如下內容

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

        'api' => [
            'driver' => 'jwt',
            'provider' => 'users',
        ],

        'admin' => [
            'driver' => 'jwt',
            'provider' => 'admins',
        ],
],
'providers' => [
        'users' => [
            'driver' => 'eloquent',
            'model' => App\Models\User::class,
        ],
        'admins' => [
            'driver' => 'eloquent',
            'model' => App\Models\Admin::class,
        ],
        // 'users' => [
        //     'driver' => 'database',
        //     'table' => 'users',
        // ],
    ],

此時,guard守護就多了一個admin,當Auth::guard('admin')時,就會自動查詢Admin模型檔案,這樣就能跟上面的User模型認證分開了。

5.8.4. 重新整理使用者認證中介軟體

我們需要再複製一個重新整理使用者認證的中介軟體,專門為admin認證以及重新整理token.
app/Http/Middleware/Api/RefreshAdminTokenMiddleware.php

<?php

namespace App\Http\Middleware\Api;

use Auth;
use Closure;
use Tymon\JWTAuth\Exceptions\JWTException;
use Tymon\JWTAuth\Facades\JWTAuth;
use Tymon\JWTAuth\Http\Middleware\BaseMiddleware;
use Tymon\JWTAuth\Exceptions\TokenExpiredException;
use Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException;

// 注意,我們要繼承的是 jwt 的 BaseMiddleware
class RefreshAdminTokenMiddleware extends BaseMiddleware
{
    /**
     * Handle an incoming request.
     *
     * @param  \Illuminate\Http\Request $request
     * @param  \Closure $next
     *
     * @throws \Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException
     *
     * @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('admin')->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);
    }
}

5.8.5. 增加中介軟體別名

開啟 app/Http 目錄下的 Kernel.php 檔案,新增如下一行

protected $routeMiddleware = [
    ......
    'admin.refresh'=>\App\Http\Middleware\Api\RefreshAdminTokenMiddleware::class,
];

5.8.6. 路由檔案

routes/api.php

<?php

use Illuminate\Http\Request;

Route::namespace('Api')->prefix('v1')->middleware('cors')->group(function () {
    //使用者註冊
    Route::post('/users', 'UserController@store')->name('users.store');
    //使用者登入
    Route::post('/login', 'UserController@login')->name('users.login');
    Route::middleware('api.refresh')->group(function () {
        //當前使用者資訊
        Route::get('/users/info', 'UserController@info')->name('users.info');
        //使用者列表
        Route::get('/users', 'UserController@index')->name('users.index');
        //使用者資訊
        Route::get('/users/{user}', 'UserController@show')->name('users.show');
        //使用者退出
        Route::get('/logout', 'UserController@logout')->name('users.logout');
    });

    //管理員註冊
    Route::post('/admins', 'AdminController@store')->name('admins.store');
    //管理員登入
    Route::post('/admin/login', 'AdminController@login')->name('admins.login');
    Route::middleware('admin.refresh')->group(function () {
        //當前管理員資訊
        Route::get('/admins/info', 'AdminController@info')->name('admins.info');
        //管理員列表
        Route::get('/admins', 'AdminController@index')->name('admins.index');
        //管理員資訊
        Route::get('/admins/{user}', 'AdminController@show')->name('admins.show');
        //管理員退出
        Route::get('/admins/logout', 'AdminController@logout')->name('admins.logout');
    });

});

5.8.7. 控制器檔案

app/Http/Controllers/Api/AdminController.php

<?php

namespace App\Http\Controllers\Api;

use App\Http\Requests\Api\UserRequest;
use App\Http\Resources\Api\AdminResource;
use App\Models\Admin;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;

class AdminController extends Controller
{

    //返回使用者列表
    public function index(){
        //3個使用者為一頁
        $admins = Admin::paginate(3);
        return AdminResource::collection($admins);
    }
    //返回單一使用者資訊
    public function show(Admin $admin){
        return $this->success(new AdminResource($admin));
    }
    //返回當前登入使用者資訊
    public function info(){
        Auth::guard('admin')->user();
        return $this->success(new AdminResource($admins));
    }
    //使用者註冊
    public function store(UserRequest $request){
        Admin::create($request->all());
        return $this->setStatusCode(201)->success('使用者註冊成功');
    }
    //使用者登入
    public function login(Request $request){
        $token=Auth::guard('admin')->attempt(['name'=>$request->name,'password'=>$request->password]);
        if($token) {
            return $this->setStatusCode(201)->success(['token' => 'bearer ' . $token]);
        }
        return $this->failed('賬號或密碼錯誤',400);
    }
    //使用者退出
    public function logout(){
        Auth::guard('admin')->logout();
        return $this->success('退出成功...');
    }
}

5.8.8. 測試

我們將admin這邊登陸返回的token放在admin的請求使用者資訊介面,看看會不會串號。結果返回

{
    "status": "success",
    "code": 200,
    "data": {
        "id": 1,
        "name": "guaosi123",
        "status": "正常",
        "created_at": "2019-02-26 08:12:31",
        "updated_at": "2019-02-26 08:12:31"
    }
}

我們再將token放在user的請求使用者資訊介面,看看會不會串號。結果返回

{
{
    "status": "success",
    "code": 200,
    "data": {
        "id": 1,
        "name": "guaosi123",
        "status": "正常",
        "created_at": "2019-02-26 08:12:31",
        "updated_at": "2019-03-01 01:48:12"
    }
}
}

看來jwt-auth真的串號了,這個問題我們下面再開一個標題進行解決。

5.8.9. 自動區分guard

1.當我們編寫登陸,退出,獲取當前使用者資訊的時候,都需要

Auth::guard('admin')

通過制定guard的具體守護是哪一個。因為框架預設的guard預設守護的是web

所以,我希望可以讓guard自動化,如果我請求的是users的,我就守護api。如果我請求的是admins的,我就守護admin

接下來,就以admins的為例,users的保持不動

2.新建中介軟體

php artisan make:middleware Api/AdminGuardMiddleware

開啟app/Http/Middleware/Api/AdminGuardMiddleware.php 檔案,填入以下內容

<?php

namespace App\Http\Middleware\Api;
use Closure;
class AdminGuardMiddleware
{
    /**
     * Handle an incoming request.
     *
     * @param  \Illuminate\Http\Request $request
     * @param  \Closure $next
     *
     * @throws \Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException
     *
     * @return mixed
     */
    public function handle($request, Closure $next)
    {
        config(['auth.defaults.guard'=>'admin']);
        return $next($request);
    }
}

3.新增中介軟體別名
開啟 app/Http 目錄下的 Kernel.php 檔案,新增如下一行

protected $routeMiddleware = [
    ......
    'admin.guard'=>\App\Http\Middleware\Api\AdminGuardMiddleware::class,
];

4.修改路由
接著我們將路由進行修改,新增上我們寫好的中介軟體。
routes/api.php

Route::middleware('admin.guard')->group(function () {
        //管理員註冊
        Route::post('/admins', 'AdminController@store')->name('admins.store');
        //管理員登入
        Route::post('/admin/login', 'AdminController@login')->name('admins.login');
        Route::middleware('admin.refresh')->group(function () {
            //當前管理員資訊
            Route::get('/admins/info', 'AdminController@info')->name('admins.info');
            //管理員列表
            Route::get('/admins', 'AdminController@index')->name('admins.index');
            //管理員資訊
            Route::get('/admins/{user}', 'AdminController@show')->name('admins.show');
            //管理員退出
            Route::get('/admins/logout', 'AdminController@logout')->name('admins.logout');
        });
    });

5.修改控制器
app/Http/Controllers/Api/AdminController.php

//返回當前登入使用者資訊
public function info(){
    $admins = Auth::user();
    return $this->success(newAdminResource($admins));
}

//使用者登入
public function login(Request $request){
    $token=Auth::attempt(['name'=>$request->name,'password'=>$request->password]);
    if($token) {
        return $this->setStatusCode(201)->success(['token' => 'bearer ' . $token]);
    }
    return $this->failed('賬號或密碼錯誤',400);
}
//使用者退出
public function logout(){
    Auth::logout();
    return $this->success('退出成功...');
}

6.測試結果
admin登陸後的token再次攜帶訪問/api/v1/admins/info,依舊可以正常輸出當前使用者資訊。

user的自動區分請自己填寫,這裡就不再囉嗦一遍了。

5.9. 修復角色認證串號問題

首先,我們需要知道一個問題,jwt-auth頒發的token裡面是不包含模型驅動的。也就是說,通過這個令牌,我們不知道它到底是屬於api還是屬於admin的。

折騰了一晚上,百度了很多資料,想找找有沒有解決辦法。結果找到的都是沒什麼作用的,或者是讓自動重新整理失效了。最後自己追原始碼,找到了這種比較完美的方式。

5.9.1. 函式

我們先來看幾個我們在中介軟體中用的函式

$this->checkForToken($request)
//這個函式只會檢測是否攜帶token以及token是否能被當前金鑰所解析

$this->auth->parseToken()->authenticate()
//將使用token進行登入,如果token過期,則丟擲 TokenExpiredException 異常

$this->auth->refresh(); 
//重新整理當前token

然後我們再來看一個有趣的函式

Auth::check();
//可以根據當前的`guard`來判斷這個token是否屬於這個 guard ,不是則丟擲 TokenInvalidException 異常
//但是,當token過期時,無論是不是屬於這個 guard ,它也是都丟擲 TokenInvalidException 異常。這導致我們無法正常判斷出到底是屬於哪種問題
//所以,想要用check()來判斷,是不可能的。

接著,我們繼續看一個有意思的函式

Auth::payload();
//可以輸出當前token的載荷資訊(也就是token解析後的內容)
//但是,如果你這個token已經過期了,那這個函式將會報錯

5.9.2. 原理

我們通過Auth::payload()可以看到未過期token的載荷資訊

{
  "sub": "1",
  "iss": "http://test.com/api/v1/admin/login",
  "iat": 1551407332,
  "exp": 1551407392,
  "nbf": 1551407332,
  "jti": "f9zwcMHaXBr5kQYp",
  "prv": "df883db97bd05ef8ff85082d686c45e832e593a9"
}

我們其實是可以拿到這些荷載資訊的。同時,我們也可以加入自己的資訊,這樣在中介軟體時候進行解析,拿到我們的負載,就可以進行判斷是否是屬於當前guard的token了。

5.9.3. 實現

修改 app\Http\Controllers\Api\AdminController.php 中的 login方法,在token中加入我們定義的欄位。

//使用者登入
public function login(Request $request)
{
    //獲取當前守護的名稱
    $present_guard =Auth::getDefaultDriver();
    $token = Auth::claims(['guard'=>$present_guard])->attempt(['name' => $request->name, 'password' => $request->password]);
    if ($token) {
        return $this->setStatusCode(201)->success(['token' => 'bearer ' . $token]);
    }
    return $this->failed('賬號或密碼錯誤', 400);
}

再修改中介軟體app/Http/Middleware/Api/RefreshAdminTokenMiddleware.php ,讓其就算過期token也能讀取出裡面的資訊

<?php

namespace App\Http\Middleware\Api;

use Auth;
use Closure;
use Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException;
use Tymon\JWTAuth\Exceptions\JWTException;
use Tymon\JWTAuth\Exceptions\TokenExpiredException;
use Tymon\JWTAuth\Exceptions\TokenInvalidException;
use Tymon\JWTAuth\Http\Middleware\BaseMiddleware;

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

        //1. 格式通過,驗證是否是專屬於這個的token

        //獲取當前守護的名稱
        $present_guard = Auth::getDefaultDriver();

        //獲取當前token
        $token=Auth::getToken();

        //即使過期了,也能獲取到token裡的 載荷 資訊。
        $payload = Auth::manager()->getJWTProvider()->decode($token->get());

        //如果不包含guard欄位或者guard所對應的值與當前的guard守護值不相同
        //證明是不屬於當前guard守護的token
        if(empty($payload['guard'])||$payload['guard']!=$present_guard){
            throw new TokenInvalidException();
        }
        //使用 try 包裹,以捕捉 token 過期所丟擲的 TokenExpiredException  異常
        //2. 此時進入的都是屬於當前guard守護的token
        try {
            // 檢測使用者的登入狀態,如果正常則通過
            if ($this->auth->parseToken()->authenticate()) {
                return $next($request);
            }
            throw new UnauthorizedHttpException('jwt-auth', '未登入');
        } catch (TokenExpiredException $exception) {
            // 3. 此處捕獲到了 token 過期所丟擲的 TokenExpiredException 異常,我們在這裡需要做的是重新整理該使用者的 token 並將它新增到響應頭中
            try {
                // 重新整理使用者的 token
                $token = $this->auth->refresh();
                // 使用一次性登入以保證此次請求的成功
                Auth::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);
    }
}

這個中介軟體是通用的,可以直接替換User的重新整理使用者認證中介軟體噢

5.9.4. 測試

此時再次進行測試是否串號,最後結果可以成功阻止之前的串號問題,暫未發現其他BUG。

user的修復串號問題請自己修改,這裡就不再囉嗦一遍了。

5.10. 單一裝置登陸

5.10.1. 提出需求

同一時間只允許登入唯一一臺裝置。例如裝置 A 中使用者如果已經登入,那麼使用裝置 B 登入同一賬戶,裝置 A 就無法繼續使用了。

5.10.2. 原理

我們在登陸,token過期自動更換的時候,都會產生一個新的token

我們將token都存到表中的last_token欄位。在登陸介面,獲取到last_token裡的值,將其加入黑名單。

這樣,只要我們無論在哪裡登陸,之前的token一定會被拉黑失效,必須重新登陸,我們的目的也就達到了。

5.10.3. 實現

修改 app\Http\Controllers\Api\AdminController.php 中的 login方法,在登陸的時候,拉黑上一個token

//使用者登入
public function login(Request $request)
{
    //獲取當前守護的名稱
    $present_guard =Auth::getDefaultDriver();
    $token = Auth::claims(['guard'=>$present_guard])->attempt(['name' => $request->name, 'password' => $request->password]);
    if ($token) {
        //如果登陸,先檢查原先是否有存token,有的話先失效,然後再存入最新的token
        $user = Auth::user();
        if ($user->last_token) {
            try{
                Auth::setToken($user->last_token)->invalidate();
            }catch (TokenExpiredException $e){
                //因為讓一個過期的token再失效,會丟擲異常,所以我們捕捉異常,不需要做任何處理
            }
        }
        $user->last_token = $token;
        $user->save();        
        return $this->setStatusCode(201)->success(['token' => 'bearer ' . $token]);
    }
    return $this->failed('賬號或密碼錯誤', 400);
}

再修改中介軟體app/Http/Middleware/Api/RefreshAdminTokenMiddleware.php ,更新的token加到last_token

<?php

namespace App\Http\Middleware\Api;

use Auth;
use Closure;
use Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException;
use Tymon\JWTAuth\Exceptions\JWTException;
use Tymon\JWTAuth\Exceptions\TokenExpiredException;
use Tymon\JWTAuth\Exceptions\TokenInvalidException;
use Tymon\JWTAuth\Http\Middleware\BaseMiddleware;

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

        //1. 格式通過,驗證是否是專屬於這個的token

        //獲取當前守護的名稱
        $present_guard = Auth::getDefaultDriver();

        //獲取當前token
        $token=Auth::getToken();

        //即使過期了,也能獲取到token裡的 載荷 資訊。
        $payload = Auth::manager()->getJWTProvider()->decode($token->get());

        //如果不包含guard欄位或者guard所對應的值與當前的guard守護值不相同
        //證明是不屬於當前guard守護的token
        if(empty($payload['guard'])||$payload['guard']!=$present_guard){
            throw new TokenInvalidException();
        }
        //使用 try 包裹,以捕捉 token 過期所丟擲的 TokenExpiredException  異常
        //2. 此時進入的都是屬於當前guard守護的token
        try {
            // 檢測使用者的登入狀態,如果正常則通過
            if ($this->auth->parseToken()->authenticate()) {
                return $next($request);
            }
            throw new UnauthorizedHttpException('jwt-auth', '未登入');
        } catch (TokenExpiredException $exception) {
            // 3. 此處捕獲到了 token 過期所丟擲的 TokenExpiredException 異常,我們在這裡需要做的是重新整理該使用者的 token 並將它新增到響應頭中
            try {
                // 重新整理使用者的 token
                $token = $this->auth->refresh();
                // 使用一次性登入以保證此次請求的成功
                Auth::onceUsingId($this->auth->manager()->getPayloadFactory()->buildClaimsCollection()->toPlainArray()['sub']);
                //重新整理了token,將token存入資料庫
                $user = Auth::user();
                $user->last_token = $token;
                $user->save();
            } catch (JWTException $exception) {
                // 如果捕獲到此異常,即代表 refresh 也過期了,使用者無法重新整理令牌,需要重新登入。
                throw new UnauthorizedHttpException('jwt-auth', $exception->getMessage());
            }
        }

        // 在響應頭中返回新的 token
        return $this->setAuthenticationHeader($next($request), $token);
    }
}

5.10.4. 測試

我們先登陸一次/api/v1/admin/login,將獲取到token攜帶訪問/api/v1/admins/info。正常訪問。
file
當我們再次請求登陸/api/v1/admin/login,然後繼續用原token訪問/api/v1/admins/info,提示錯誤。
file

user的請自行新增,自行測試結果

5.11. horizon管理非同步佇列

開發中,我們也經常需要使用非同步佇列,來加快我們的響應速度。比如傳送簡訊,傳送驗證碼等。但是佇列執行結果的成功或者失敗只能通過日誌來檢視。這裡,我們使用horizonl來管理非同步佇列,完成登陸和重新整理token時,將token存入last_token的因為放在非同步完成。

Horizon 提供了一個漂亮的儀表盤,並且可以通過程式碼配置你的 Laravel Redis 佇列,同時它允許你輕易的監控你的佇列系統中諸如任務吞吐量,執行時間和失敗任務等關鍵指標。

5.11.1. 安裝

horizon的詳細介紹可以檢視手冊

composer require laravel/horizon

5.11.2. 釋出配置檔案

php artisan vendor:publish --provider="Laravel\Horizon\HorizonServiceProvider"

5.11.3. 修改佇列驅動

開啟 .env 檔案,將QUEUE_CONNECTIONsync改成redis

QUEUE_CONNECTION=redis

5.11.4. 儀表盤許可權驗證

儀表盤不能通過介面訪問。所以我們做驗證的時候,可以通過指定的IP才能正常通過進入儀表盤。IP可以寫在.env檔案裡,當IP發生變化時進行修改。

.env 最後加上一行

HORIZON_IP=想通過訪問的IP地址
比如
HORIZON_IP=127.0.0.1

修改改app/Providers/AuthServiceProvider.php 檔案 裡的 boot 方法

public function boot()
{
    $this->registerPolicies();
    Horizon::auth(function($request){
    if(env('APP_ENV','local') =='local'{
           return true;
    }else{
           $get_ip=$request->getClientIp();
           $can_ip=en('HORIZON_IP''127.0.0.1');
           return $get_ip == $can_ip;
       }
    });
}

5.11.5. 編寫任務類

建立一個專門負責儲存last_token的任務類

php artisan make:job Api/SaveLastTokenJob

開啟 app/Jobs/Api/SaveLastTokenJob.php 檔案 ,填寫以下內容

<?php

namespace App\Jobs\Api;

use Illuminate\Bus\Queueable;
use Illuminate\Queue\SerializesModels;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;

class SaveLastTokenJob implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
    protected $model;
    protected $token;
    /**
     * Create a new job instance.
     *
     * @return void
     */

    public function __construct($model,$token)
    {
        //
        $this->model=$model;
        $this->token=$token;
    }

    /**
     * Execute the job.
     *
     * @return void
     */
    public function handle()
    {
        //
        $this->model->last_token = $this->token;
        $this->model->save();
    }
}

5.11.6. 使用任務類

將控制器與中介軟體裡的

$user->last_token = $token;
$user->save();

統一替換為

SaveLastTokenJob::dispatch($user,$token);

5.11.7. 執行Horizon

php artisan horizon

此時,程式處於阻塞狀態。
開啟瀏覽器輸入http://你的域名/horizon,可以看到Horizon儀表盤。

file

5.11.8. Supervisor守護程式

我們可以使用Supervisor來守護我們的horizon阻塞程式。具體方法可以看我之前寫的文章:安裝和使用守護程式--Supervisor

5.11.9. 測試

確認horizon已經正常啟動。然後我們訪問/api/v1/admin/login這個登陸介面。開啟資料庫可以發現,last_token與返回結果的token相同。我們也可以再開啟儀表盤,看任務完成情況

file

5.11.10. 注意

如果修改了job類的原始碼,需要將horizon重新啟動,否則程式碼還是未改動前的。(應該是horzion是將所有任務類常駐記憶體的原因)

到此,所有修改已經全部完成,如果還有新的更改也會實時更新。同時,本文中的所有修改都已經在正式專案中執行過了。

如果你已經看完了整篇文章,知道了修改的原因,但是不想受累自己修改一遍。我已經將修改後的上傳到全球最大的同性交友網站了,可以直接點選這裡直接搬走。或者複製下方的連結開啟。

專案地址:

https://github.com/guaosi/Laravel_api_init

相關文章