手摸手教你让 Laravel 开发 API 更得心应手

准备工作

环境

PHP > 7.1
MySQL > 5.5
Redis > 2.8

工具

postman
composer

使用postman

为了模拟AJAX请求,请将 header头 设置 X-Requested-WithXMLHttpRequest

安装Laravel

Laravel 只要 >=5.5 皆可,这里采用文章编写时最新的 5.7 版本

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

创建数据库

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;

初始化数据

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.我一共搜索到3个文件

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

控制器

因为是专门做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函数,用来下面建立路由后的测试,查看是否可以正常访问。

路由

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

则成功

创建验证器

在创建用户之前,我们先创建验证器,来让我们服务器接收到的数据更安全.当然,我们也要把关于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个字符'
        ];
    }
}

创建用户

现在我们来编写创建用户接口,制作一些虚拟数据。(就不使用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 记得填写要创建的用户名和密码。

最后返回结果是

用户创建成功。。。

则成功。

如果返回

{
    "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
最后返回结果是

用户登录成功...

则成功

创建10个用户

为了测试使用,请自行通过接口创建10个用户。

编写相关资源接口

给出整体控制器信息 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 '用户登录失败';
    }
}

编写路由

给出整体路由信息 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。

构造

跨域问题

所有问题,跨域先行。跨域问题没有解决,一切处理都是纸老虎。这里我们使用medz做的 cors扩展包

安装medz/cors

composer require medz/cors

发布配置文件

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

修改配置文件

打开 config/cors.php,在 expose-headers 添加值 Authorization

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

这样跨域请求时,才能返回 header 头为 Authorization 的内容,否则在刷新用户 token 时不会返回刷新后的 token

增加中间件别名

打开 app/Http/Kernel.php,增加一行

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

修改路由

打开 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');
});

统一Response响应处理

接口主流返回 json 格式,其中包含 http状态码status请求状态data请求资源结果 等等。需要我们有一个API接口全局都能有统一的格式和对应的数据处理。参考于 这里

封装返回的统一消息

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);
    }
}

新建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帮助函数
}

继承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
{
    ......
}

如何使用

得益于前面统一消息的封装,使用起来非常容易。
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

修改用户控制器

我们将统一消息封装运用到 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);
    }
}

测试

  1. 返回用户列表
  2. 请求 http://你的域名/api/v1/users

  3. 返回单一用户

  4. 请求 http://你的域名/api/v1/users/1

  5. 登陆正确

  6. 请求 http://你的域名/api/v1/login

  7. 登陆错误

  8. 请求 http://你的域名/api/v1/login

    5.3. Api-Resource资源

在上面请求返回用户列表和返回单一用户时,返回的字段都是数据库里所有的字段,当然,不包含我们在 User 模型中去除的 password 字段。

需求

此时,我们如果想控制返回的字段有哪些,可以使用 select 或者使用 User 模型中的 hidden 数组来限制字段。

这2种办法虽然可以,但是扩展性太差。并且我想对 status 返回的形式进行修改,比如0的时候显示正常,1显示冻结,此时就需要遍历数据进行修改了。此时,Laravel提供的 API 资源 就可以很好地解决我们的问题。

当构建 API 时,你往往需要一个转换层来联结你的 Eloquent 模型和实际返回给用户的 JSON 响应。Laravel 的资源类能够让你以更直观简便的方式将模型和模型集合转化成 JSON。

也就是在C层输出V层时,中间再来一层来专门处理字段问题,我们可以称之为 ViewModel 层。

详细可以查看 手册 如何使用。

创建单一用户资源和列表用户资源

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
        ];
    }
}

如何使用

返回单一用户(单一的资源)

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

返回用户列表(资源列表)

return UserResource::collection($users);
//这里不能用$this->success(UserResource::collection($users))
//否则不能返回分页标签信息

修改用户控制器

//返回用户列表
public function index(){
    //3个用户为一页
    $users = User::paginate(3);
    return UserResource::collection($users);
}
//返回单一用户信息
public function show(User $user){
    return $this->success(new UserResource($user));
}

测试

返回单一用户(单一的资源)

返回用户列表(资源列表)

Enum枚举

我们常常会使用数字来代表状态,比如用户表,我们使用 -1 代表已删除 0 代表正常 1 代表冻结。

两个问题

  1. 当我们判断一个用户,如果是删除或者冻结状态就不让其登陆了。判断代码这样写
//有可能状态有很多,所以这边就直接用 或 来判断不取反了。
if($user->status==-1||$user->status==1){
// 不允许用户登录逻辑
return
}
//用户正常登录逻辑

上面逻辑和编写没有什么问题。因为是现在看,可以很明白的知道 -1 代表已删除, 1 代表冻结。但是如果一个月后再来看这行代码,早已经忘记了 -11 具体表示的含义。

  1. 参考上面 UserResource.php 编写时,判断 status 具体状态函数,我们是使用 switch 语句。这样太不美观,而且地方用多了还容易冗余,每次编写都需要去查看每个数字代表的具体意思。

    5.4.2. 解决思路

  2. 第一个问题:为什么一段时间后再看就不知道 -11 具体表示的含义?
    这是因为单纯的数字没有解释说明的作用,变量以及函数这些具有解释说明的作用,可以让我们立刻知道具体含义。

  3. 第二个问题:如何给一个数字就能直接知道它代表的含义?
    提供一个函数,返回这个数字代表的具体含义。

而这些,都可以使用 Enum枚举 可以解决。

注意

PHPLaravel 框架本身是不支持 Enum枚举 的,不过我们可以模拟枚举的功能

创建枚举

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 '正常';
        }
    }
}

使用

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
    ];
}

再请求单一用户和用户列表接口,返回结果和之前一样。

异常自定义处理

再发现一个问题

我们在 UserController.php 文件中修改

//返回单一用户信息
public function show(User $user){
    3/0;
    return $this->success(new UserResource($user));
}

故意报个错,请求看看结果

我们再把设置成 ajaxheader 头去掉
报错非常详细,并且把我们隐私设置都暴露出来了,这是由于我们 .envAPP_DEBUGtrue 状态。我们不希望这些信息被其他访问者看到。我们改为 false ,再请求看看结果。

嗯。很好,不仅别人看不到了,连我们自己都看不到了

需求

  1. 所有的异常信息都以统一 json 格式输出
  2. 因为我们是开发者,并且 .env 文件默认是不加入 git 上传线上的,我们希望可以当 APP_DEBUGtrue (本地)的时候可以继续显示详细的错误信息, false (线上)的时候就显示简要 json 信息,比如500。

创建自定义异常处理

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');
    }
}

捕捉异常

修改 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);
    }
}

测试

继续打开设置 AJAXheader

1.关闭 APP_DEBUG ,请求刚刚故意错误的接口。

2.开启 APP_DEBUG ,请求刚刚故意错误的接口。

3.请求一个不存在的路由,查看返回结果。

其他的异常显示,自行测试啦~

jwt-auth

在传统web中,我们一般是使用 session 来判定一个用户的登陆状态。而在 API 开发中,我们使用的是 tokenjwt-tokenLaravel 开发 API 用的比较多的。

JWT 全称 JSON Web Tokens ,是一种规范化的 token。可以理解为对 token 这一技术提出一套规范,是在 RFC 7519 中提出的。

jwt-auth 的详细介绍分析可以看 JWT超详细分析 这篇文章,具体使用可以看 JWT完整使用详解 这篇文章。

安装

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

如果是 Laravel5.5 版本,则安装 rc.1 。如果是 Laravel5.6 版本,则安装 rc.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,

    ],

];

测试

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 值,然后再次进行请求,可以看到成功返回当前登陆用户的信息。

自动刷新用户认证

需求

现在我想用户登录后,为了保证安全性,每个小时该用户的token都会自动刷新为全新的,用旧的token请求不会通过。我们知道,用户如果token不对,就会退到当前界面重新登录来获得新的token,我同时希望虽然刷新了token,但是能否不要重新登录,就算重新登录也是一周甚至一个月之后呢?给用户一种无感知的体验。

看着感觉很神奇,我们一起手摸手来实现。

自定义认证中间件

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);
    }
}

增加中间件别名

打开 app/Http 目录下的 Kernel.php 文件,添加如下一行

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

路由器修改

接着我们将路由进行修改,添加上我们写好的中间件。
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');
        });
});

测试

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

此时如果我们再次访问,则报出异常

{
    "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 的时间复原。

前端逻辑

上面可以看出,当token过期或者无效以及乱写,返回的 HTTP状态码 都是 422 。这是因为这个异常被我们上面自定义异常捕捉了

UnauthorizedHttpException::class=>['未登录或登录状态失效',422],

所以,可以跟前端小伙伴商量一个状态码,专门表示接收到这个状态码就要退回重新登录了。当 Header 头携带 Authorization 时,就要及时自动替换新的token,不需要回到重新登录界面。这样用户就能完全无感知啦~

多角色认证

如果我们的系统不仅仅只有一种角色身份,还有其他的角色身份需要认证呢?目前我们的角色认证是认证 Users 表的,如果我们再加入一个 Admins 表,也要角色认证要如何操作?

Admin用户表

我们将数据库的 Users 表复制一份,将其命名为 Admins 表,并且将其中的一个用户名进行修改,以示区别。

框架文件

我们分别将 User.php 模型文件, UserEnum.php 枚举文件,UserResource.php 资源文件, UserRequest.php 验证器文件 UserController.php 控制器文件各复制一份,更改为 Admin 的,并将其中内容也改为 Admin 相关。因为就是复制粘贴,把 user 改成 admin,由于篇幅问题具体修改过程我就不放代码了。具体的可以看下面的 成品

用户认证文件

打开 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 模型认证分开了。

刷新用户认证中间件

我们需要再复制一个刷新用户认证的中间件,专门为 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);
    }
}

增加中间件别名

打开 app/Http 目录下的 Kernel.php 文件,添加如下一行

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

路由文件

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');
    });

});

控制器文件

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('退出成功...');
    }
}

测试

我们将 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 真的串号了,这个问题我们下面再开一个标题进行解决。

自动区分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的自动区分请自己填写,这里就不再啰嗦一遍了。

修复角色认证串号问题

首先,我们需要知道一个问题, jwt-auth 颁发的 token 里面是不包含 模型驱动 的。也就是说,通过这个令牌,我们不知道它到底是属于 api 还是属于 admin 的。

折腾了一晚上,百度了很多资料,想找找有没有解决办法。结果找到的都是没什么作用的,或者是让自动刷新失效了。最后自己追源码,找到了这种比较完美的方式。

函数

我们先来看几个我们在中间件中用的函数

$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已经过期了,那这个函数将会报错

原理

我们通过 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了。

实现

修改 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的刷新用户认证中间件噢

测试

此时再次进行测试是否串号,最后结果可以成功阻止之前的串号问题,暂未发现其他BUG。

user的修复串号问题请自己修改,这里就不再啰嗦一遍了。

单一设备登陆

提出需求

同一时间只允许登录唯一一台设备。例如设备 A 中用户如果已经登录,那么使用设备 B 登录同一账户,设备 A 就无法继续使用了。

原理

我们在登陆, token 过期自动更换的时候,都会产生一个新的 token

我们将 token 都存到表中的 last_token 字段。在登陆接口,获取到 last_token 里的值,将其加入黑名单。

这样,只要我们无论在哪里登陆,之前的 token 一定会被拉黑失效,必须重新登陆,我们的目的也就达到了。

实现

修改 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);
    }
}

测试

我们先登陆一次 /api/v1/admin/login ,将获取到 token 携带访问 /api/v1/admins/info 。正常访问。

当我们再次请求登陆 /api/v1/admin/login ,然后继续用原 token 访问 /api/v1/admins/info ,提示错误。

user的请自行添加,自行测试结果

horizon管理异步队列

开发中,我们也经常需要使用异步队列,来加快我们的响应速度。比如发送短信,发送验证码等。但是队列执行结果的成功或者失败只能通过日志来查看。这里,我们使用 horizonl 来管理异步队列,完成登陆和刷新 token 时,将 token 存入 last_token 的因为放在异步完成。

Horizon 提供了一个漂亮的仪表盘,并且可以通过代码配置你的 Laravel Redis 队列,同时它允许你轻易的监控你的队列系统中诸如任务吞吐量,运行时间和失败任务等关键指标。

安装

horizon 的详细介绍可以 查看手册

composer require laravel/horizon

发布配置文件

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

修改队列驱动

打开 .env 文件,将 QUEUE_CONNECTIONsync 改成 redis

QUEUE_CONNECTION=redis

仪表盘权限验证

仪表盘不能通过接口访问。所以我们做验证的时候,可以通过指定的 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;
       }
    });
}

编写任务类

创建一个专门负责保存 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();
    }
}

使用任务类

将控制器与中间件里的

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

统一替换为

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

运行Horizon

php artisan horizon

此时,进程处于阻塞状态。
打开浏览器输入 http://你的域名/horizon,可以看到 Horizon 仪表盘。

Supervisor守护进程

我们可以使用Supervisor来守护我们的horizon阻塞进程。具体方法可以看我之前写的文章:安装和使用守护进程–Supervisor

测试

确认 horizon 已经正常启动。然后我们访问 /api/v1/admin/login 这个登陆接口。打开数据库可以发现, last_token 与返回结果的 token 相同。我们也可以再打开仪表盘,看任务完成情况

注意

如果修改了 job 类的源码,需要将 horizon 重新启动,否则代码还是未改动前的。(应该是 horzion 是将所有任务类常驻内存的原因)

原文地址: 手摸手教你让 Laravel 开发 API 更得心应手


 上一篇
如欲采蜜,勿蹴蜂房 如欲采蜜,勿蹴蜂房
文章开头以“双枪手”科洛雷、“头号公敌”阿尔卡普案例来表述,即使犯下如此罪大恶极的恶魔,也从不认为自己是坏人,他们依然坚称自己是好人,有意识地以一种错误的逻辑来为自己的反社会行为作辩护。在我们的生活中,类似这样的人太多太多了,如果我们发现身
下一篇 
There are no packages available for installation问题 There are no packages available for installation问题
解决sublime text 3使用Install Package时出现There are no packages available for installation问题。 分析原因发现: 在利用sublime进行插件下载时,sub
2019-03-18
  目录