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

https://www.guaosi.com/2019/02/26/laravel-api-initialization-preparation/

 

1. 起因

       随着前后端完全分离,PHP也基本告别了view模板嵌套开发,转而专门写资源接口。Laravel是PHP框架中最优雅的框架,国内也越来越多人告别ThinkPHP选择了LaravelLaravel框架本身对API有支持,但是感觉再工作中还是需要再做一些处理。Lumen用起来不顺手,有些包不能很好地支持。所以,将Laravel框架进行一些配置处理,让其在开发API时更得心应手。

内容划水过长,请谨慎打开

       当然,你也可以点击这里,直接跳到成果~

2. 准备工作

2.1. 环境

1
2
3
PHP > 7.1
MySQL > 5.5
Redis > 2.8

2.2. 工具

1
2
postman
composer

2.3. 使用postman

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

2.4. 安装Laravel

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

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

 

2.5. 创建数据库

1
2
3
4
5
6
7
8
9
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. 初始化数据

3.1. Model移动

在项目的app目录下可以看到,有一个User.php的模型文件。因为Laravel默认把模型文件放在app目录下,如果数据表多的话,这里模型文件就会很多,不便于管理,所以我们先要将模型文件移动到其他文件夹内。

1) 在app目录下新建Models文件夹,然后将User.php文件移动进来。
2) 修改User.php的内容

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
<?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个文件

1
2
3
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目录下。

使用命令行创建控制器

1
php artisan make:controller Api/UserController

 

编写app/Http/Controllers/Api目录下的UserController.php文件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<?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接口的路由,所以我们打开它,填写以下内容,做一个测试.

1
2
3
4
5
6
<?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,最后返回结果是

1
guaosi

 

则成功

3.4. 创建验证器

在创建用户之前,我们先创建验证器,来让我们服务器接收到的数据更安全.当然,我们也要把关于Api验证的放在一个专门的文件夹内。
先创建一个Request的基类

1
php artisan make:request Api/FormRequest

 

因为验证器默认的权限验证是false,导致返回都是403的权限不通过错误。这里我们没有用到权限认证,为了方便处理,我们默认将权限都是通过的状态。所以,每个文件都需要我们将false改成true

1
2
3
4
5
6
public function authorize()
{
//false代表权限验证不通过,返回403错误
//true代表权限认证通过
return true;
}

 

所以我们修改app/Http/Requests/Api 目录下的 FormRequest.php 文件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<?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的专属验证器

1
php artisan make:request Api/UserRequest

 

编辑app/Http/Requests/Api 目录下的 UserRequest.php文件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
<?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

1
2
3
4
5
6
7
8
9
10
11
12
//用户注册
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

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

 

打开postman,用post方式请求你的域名/api/v1/users,在form-data记得填写要创建的用户名和密码。

最后返回结果是

1
用户创建成功。。。

 

则成功。
create_user

如果返回

1
2
3
4
5
6
7
8
9
10
11
{
"message": "The given data was invalid.",
"errors": {
"name": [
"用户名不能为空"
],
"password": [
"密码不能为空"
]
}
}

 

则证明验证失败。

然后验证是否可以正常登录。因为我们认证的字段是namepassword,而Laravel默认认证的是emailpassword。所以我们还要打开app/Http/Controllers/auth 目录下的 LoginController.php,加入如下代码

1
2
3
4
 public function username()
{
return 'name';
}

 

打开postman,用post方式请求你的域名/api/v1/login
最后返回结果是

1
用户登录成功...

 

则成功
login_user

3.6. 创建10个用户

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

3.7. 编写相关资源接口

给出整体控制器信息UserController.php

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
<?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

1
2
3
4
5
6
7
8
9
<?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');
});

 

4. 存在问题

以上所有返回的结果,无论正确或者错误,都没有一个统一格式规范,对开发Api不太友好的,需要我们进行一些修改,让Laravel框架可以更加友好地编写Api。

5. 构造

5.1. 跨域问题

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

5.1.1. 安装medz/cors

1
composer require medz/cors

5.1.2. 发布配置文件

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

5.1.3. 修改配置文件

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

1
2
3
4
5
return [
......
'expose-headers' => ['Authorization'],
......
];

 

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

5.1.4. 增加中间件别名

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

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

 

5.1.5. 修改路由

打开routes/api.php,在路由组中增加使用中间件

1
2
3
4
5
6
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
填入如下内容

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
<?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专门的基类.
填入以下内容

1
2
3
4
5
6
7
8
9
10
11
12
13
<?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

1
2
3
4
5
6
7
8
9
10
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.返回正确信息

1
return $this->success('用户登录成功...');

 

2.返回正确资源信息

1
return $this->success($user);

 

3.返回自定义http状态码的正确信息

1
return $this->setStatusCode(201)->success('用户登录成功...');

 

4.返回错误信息

1
return $this->failed('用户注册失败');

 

5.返回自定义http状态码的错误信息

1
return $this->failed('用户登录失败',401);

 

6.返回自定义http状态码的错误信息,同时也想返回自己内部定义的错误码

1
return $this->failed('用户登录失败',401,10001);

 

默认success返回的状态码是200,failed返回的状态码是400

5.2.5. 修改用户控制器

我们将统一消息封装运用到UserController

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
<?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
    success_user_list_message
  2. 返回单一用户
    请求http://你的域名/api/v1/users/1
    success_simple_user_message
  3. 登陆正确
    请求http://你的域名/api/v1/login
    success_login_user_message
  4. 登陆错误
    请求http://你的域名/api/v1/login
    fail_login_user_message

    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. 创建单一用户资源和列表用户资源

1
php artisan make:resource Api/UserResource

修改app/Http/Resources/Api 目录下的 UserResource.php文件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
<?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. 如何使用

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

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

 

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

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

 

5.3.4. 修改用户控制器

1
2
3
4
5
6
7
8
9
10
//返回用户列表
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. 测试

返回单一用户(单一的资源)
success_simple_user_resource
返回用户列表(资源列表)
success_user_list_resource

5.4. Enum枚举

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

5.4.1. 两个问题

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

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

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

5.4.2. 解决思路

  1. 第一个问题:为什么一段时间后再看就不知道-1 跟 1 具体表示的含义?

       这是因为单纯的数字没有解释说明的作用,变量以及函数这些具有解释说明的作用,可以让我们立刻知道具体含义。

  1. 第二个问题:如何给一个数字就能直接知道它代表的含义?

       提供一个函数,返回这个数字代表的具体含义。

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

5.4.3. 注意

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

5.4.4. 创建枚举

在 app/Models 下新建目录 Enum ,并在目录Enum下新建 UserEnum.php 文件,填写以下内容

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
<?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.表示具体含义

1
2
3
4
5
6
//有可能状态有很多,所以这边就直接用 或 来判断不取反了。
if($user->status==UserEnum::INVALID||$user->status==UserEnum::FREEZE){
// 不允许用户登录逻辑
return
}
//用户正常登录逻辑

 

2.修改UserResource.php

1
2
3
4
5
6
7
8
9
10
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文件中修改

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

 

故意报个错,请求看看结果
debug_true_error_ajax
我们再把设置成ajaxheader头去掉
debug_true_error

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

debug_false_error

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

5.5.2. 需求

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

5.5.3. 创建自定义异常处理

在 app/Api/Helpers 目录下新建 ExceptionReport.php 文件,填入以下内容

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
<?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 文件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
<?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,请求刚刚故意错误的接口。
exception_debug_false_error
2.开启APP_DEBUG,请求刚刚故意错误的接口。
debug_true_error_ajax
3.请求一个不存在的路由,查看返回结果。
exception_debug_false_not_found

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

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. 安装

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文件,添加下面代码

1
2
3
4
5
6
'providers' => [

...

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

 

2.发布配置文件

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

 

此命令会在 config 目录下生成一个 jwt.php 配置文件,你可以在此进行自定义配置。

3.生成密钥

1
php artisan jwt:secret

 

此命令会在你的 .env 文件中新增一行 JWT_SECRET=secret。以此来作为加密时使用的秘钥。

4.配置 Auth guard
打开 config 目录下的 auth.php文件,修改为下面代码

1
2
3
4
5
6
7
8
9
10
11
'guards' => [
'web' => [
'driver' => 'session',
'provider' => 'users',
],

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

 

这样,我们就能让api的用户认证变成使用jwt

5.更改 Model

如果需要使用 jwt-auth 作为用户认证,我们需要对我们的 User 模型进行一点小小的改变,实现一个接口,变更后的 User 模型如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
<?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文件配置详解

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
<?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方法用来获取当前用户的信息。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
//用户登录
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

1
2
//当前用户信息
Route::get('/users/info','UserController@info')->name('users.info');

 

3.接着我们打开postman,请求http://你的域名/api/v1/login.可以看到接口返回的token.

1
2
3
4
5
6
7
{
"status": "success",
"code": 201,
"data": {
"token": "bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJodHRwOlwvXC90ZXN0LmNvbVwvYXBpXC92MVwvbG9naW4iLCJpYXQiOjE1NTEzMzUyNzgsImV4cCI6MTU1MTMzODg3OCwibmJmIjoxNTUxMzM1Mjc4LCJqdGkiOiJrUzZSWHRoQVBkczR6ck4wIiwic3ViIjoxLCJwcnYiOiIyM2JkNWM4OTQ5ZjYwMGFkYjM5ZTcwMWM0MDA4NzJkYjdhNTk3NmY3In0.FLk-JPFBDTWcItPRN8SVGaLI0j2zgiWLLs_MNKxCafQ"
}
}

 

4.此时,我们打开Postman直接访问http://你的域名/api/v1/users/info,你会看到报了如下错误.

1
Trying to get property 'id' of non-object

 

这是我们没有携带token导致的。报错不友好我们将在下面自动刷新用户认证解决。

5.我们在PostmanHeader头部分再加一个keyAuthorizationvalue为登陆成功后返回的token值,然后再次进行请求,可以看到成功返回当前登陆用户的信息。
postman_set_bearer

5.7. 自动刷新用户认证

5.7.1. 需求

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

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

5.7.2. 自定义认证中间件

1
php artisan make:middleware Api/RefreshTokenMiddleware

打开 app/Http/Middleware/Api 目录下的 RefreshTokenMiddleware.php 文件,填写以下内容

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
<?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 文件,添加如下一行

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

 

5.7.4. 路由器修改

接着我们将路由进行修改,添加上我们写好的中间件。
routes/api.php

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
<?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,返回如下错误

1
2
3
4
5
{
"status": "error",
"code": 422,
"message": "未登录或登录状态失效"
}

 

2.那随便输入token又会是怎么样呢?我们也来尝试一下

1
2
3
4
5
{
"status": "error",
"code": 400,
"message": "token不正确"
}

 

3.现在,我们再做一个如果token过期了,但是刷新限制没有过期的情况,看看会有什么结果。我们先将config/jwt.php里的ttl60改成1。意味着重新生成的token将会1分钟后过期。

然后我们重新登录获取到token,替换/api/v1/users/info原有的token,进行访问,可以正常返回用户的信息。

等过了一分钟,我们再进行访问,发现依旧可以返回用户信息,但是我们在返回的HeadersAuthorization可以看到新的token
refresh_token_ttl
此时如果我们再次访问,则报出异常

1
2
3
4
5
{
"status": "error",
"code": 422,
"message": "未登录或登录状态失效"
}

 

我们替换上新的token,再次访问,访问正常通过。

4.现在,我们接着继续做token和刷新时间都过期的情况,会发生什么。我们再将config/jwt.php里的refresh_ttl20160改成2

重新按照3步骤执行一次,当刚过一分钟时,返回结果与3相同,都是正常返回信息并且在Headers携带了新的token。

当2分钟过后,报如下错误信息。

1
2
3
4
5
{
"status": "error",
"code": 422,
"message": "未登录或登录状态失效"
}

 

5.为了后面的方便,我们将修改的ttlrefresh_ttl的时间复原。

5.7.6. 前端逻辑

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

1
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文件,修改如下内容

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
'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/Controllers/Middleware/Api/RefreshAdminTokenMiddleware.php

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
<?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 文件,添加如下一行

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

 

5.8.6. 路由文件

routes/api.php

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
<?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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
<?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(){
$admins = Auth::guard('admin')->user();
return $this->success(new AdminResource($admins));
}
//用户注册
public function store(UserRequest $request){
Admin::create($request->all());
return $this->setStatusCode(201)->success('用户注册成功');x`
}
//用户登录
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的请求用户信息接口,看看会不会串号。结果返回

1
2
3
4
5
6
7
8
9
10
11
{
"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的请求用户信息接口,看看会不会串号。结果返回

1
2
3
4
5
6
7
8
9
10
11
12
13
{
{
"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.当我们编写登陆,退出,获取当前用户信息的时候,都需要

1
Auth::guard('admin')

 

通过制定guard的具体守护是哪一个。因为框架默认的guard默认守护的是web

所以,我希望可以让guard自动化,如果我请求的是users的,我就守护api。如果我请求的是admins的,我就守护admin

接下来,就以admins的为例,users的保持不动

2.新建中间件

1
php artisan make:middleware Api/AdminGuardMiddleware

 

打开app/Http/Middleware/Api/AdminGuardMiddleware.php 文件,填入以下内容

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
<?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 文件,添加如下一行

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

 

4.修改路由
接着我们将路由进行修改,添加上我们写好的中间件。
routes/api.php

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
//返回当前登录用户信息
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. 函数

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

1
2
3
4
5
6
7
8
$this->checkForToken($request)
//这个函数只会检测是否携带token以及token是否能被当前密钥所解析

$this->auth->parseToken()->authenticate()
//将使用token进行登录,如果token过期,则抛出 TokenExpiredException 异常

$this->auth->refresh();
//刷新当前token

 

然后我们再来看一个有趣的函数

1
2
3
4
Auth::check();
//可以根据当前的`guard`来判断这个token是否属于这个 guard ,不是则抛出 TokenInvalidException 异常
//但是,当token过期时,无论是不是属于这个 guard ,它也是都抛出 TokenInvalidException 异常。这导致我们无法正常判断出到底是属于哪种问题
//所以,想要用check()来判断,是不可能的。

 

接着,我们继续看一个有意思的函数

1
2
3
Auth::payload();
//可以输出当前token的载荷信息(也就是token解析后的内容)
//但是,如果你这个token已经过期了,那这个函数将会报错

 

5.9.2. 原理

我们通过Auth::payload()可以看到未过期token的载荷信息

1
2
3
4
5
6
7
8
9
{
"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中加入我们定义的字段。

1
2
3
4
5
6
7
8
9
10
11
//用户登录
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也能读取出里面的信息

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
<?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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
//用户登录
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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
<?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。正常访问。
invalid_token_first
当我们再次请求登陆/api/v1/admin/login,然后继续用原token访问/api/v1/admins/info,提示错误。
refresh_token_second

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

5.11. horizon管理异步队列

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

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

5.11.1. 安装

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

1
composer require laravel/horizon

 

5.11.2. 发布配置文件

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

5.11.3. 修改队列驱动

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

1
QUEUE_CONNECTION=redis

 

5.11.4. 仪表盘权限验证

仪表盘不能通过接口访问。所以我们做验证的时候,可以通过指定的IP才能正常通过进入仪表盘。IP可以写在.env文件里,当IP发生变化时进行修改。

在 .env 最后加上一行

1
2
3
HORIZON_IP=想通过访问的IP地址
比如
HORIZON_IP=127.0.0.1

 

修改改app/Providers/AuthServiceProvider.php 文件 里的 boot 方法

1
2
3
4
5
6
7
8
9
10
11
12
13
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的任务类

1
php artisan make:job Api/SaveLastTokenJob

 

打开 app/Jobs/Api/SaveLastTokenJob.php 文件 ,填写以下内容

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
<?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. 使用任务类

将控制器与中间件里的

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

 

统一替换为

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

 

5.11.7. 运行Horizon

1
php artisan horizon

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

horizon_dashboard

5.11.8. Supervisor守护进程

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

5.11.9. 测试

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

5.11.10. 注意

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

6. 成品

到此,所有修改已经全部完成,如果还有新的更改也会实时更新。同时,本文中的所有修改都已经在正式项目中运行过了。

如果你已经看完了整篇文章,知道了修改的原因,但是不想受累自己修改一遍。我已经将修改后的上传到全球最大的同性交友网站了,可以直接点击这里直接搬走。或者复制下方的链接打开。

项目地址:

https://github.com/guaosi/Laravel_api_init

posted @ 2019-12-23 18:04  onewaa  阅读(1109)  评论(0编辑  收藏  举报