https://www.guaosi.com/2019/02/26/laravel-api-initialization-preparation/
1. 起因
随着前后端完全分离,PHP
也基本告别了view
模板嵌套开发,转而专门写资源接口。Laravel
是PHP框架中最优雅的框架,国内也越来越多人告别ThinkPHP
选择了Laravel
。Laravel
框架本身对API
有支持,但是感觉再工作中还是需要再做一些处理。Lumen
用起来不顺手,有些包不能很好地支持。所以,将Laravel
框架进行一些配置处理,让其在开发API
时更得心应手。
内容划水过长,请谨慎打开
当然,你也可以点击这里,直接跳到成果~
2. 准备工作
2.1. 环境
123 |
PHP > 7.1MySQL > 5.5Redis > 2.8 |
2.2. 工具
12 |
postmancomposer |
2.3. 使用postman
为了模拟AJAX请求,请将 header头
设置X-Requested-With
为 XMLHttpRequest
2.4. 安装Laravel
Laravel
只要>=5.5
皆可,这里采用文章编写时最新的5.7
版本
1 |
composer create-project laravel/laravel Laravel --prefer-dist "5.7.*" |
2.5. 创建数据库
123456789 |
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
的内容
123456789101112131415161718192021222324252627 |
<?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个文件
1234 |
app/Http/Controllers/Auth 目录下的 RegisterController.phpconfig 目录下的 services.phpconfig 目录下的 auth.phpdatabase/factories 目录下的 UserFactory.php |
3.2. 控制器
因为是专门做API的,所以我们要把是API的控制器都放到app\Http\Controllers\Api
目录下。
使用命令行创建控制器
1 |
php artisan make:controller Api/UserController |
编写app/Http/Controllers/Api
目录下的UserController.php
文件
1234567891011121314 |
<?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接口的路由,所以我们打开它,填写以下内容,做一个测试.
123456 |
<?phpuse Illuminate\Http\Request; Route::namespace(‘Api‘)->prefix(‘v1‘)->group(function () { Route::get(‘/users‘,‘[email protected]‘)->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
。
123456 |
public function authorize(){ //false代表权限验证不通过,返回403错误 //true代表权限认证通过 return true;} |
所以我们修改app/Http/Requests/Api
目录下的 FormRequest.php
文件
123456789101112131415 |
<?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
文件
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849 |
<?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
123456789101112 |
//用户注册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
12 |
Route::post(‘/users‘,‘[email protected]‘)->name(‘users.store‘);Route::post(‘/login‘,‘[email protected]‘)->name(‘users.login‘); |
打开postman
,用post
方式请求你的域名/api/v1/users
,在form-data
记得填写要创建的用户名和密码。
最后返回结果是
1 |
用户创建成功。。。 |
则成功。
如果返回
1234567891011 |
{ "message": "The given data was invalid.", "errors": { "name": [ "用户名不能为空" ], "password": [ "密码不能为空" ] }} |
则证明验证失败。
然后验证是否可以正常登录。因为我们认证的字段是name
跟password
,而Laravel
默认认证的是email
跟password
。所以我们还要打开app/Http/Controllers/auth
目录下的 LoginController.php
,加入如下代码
1234 |
public function username() { return ‘name‘;} |
打开postman
,用post
方式请求你的域名/api/v1/login
最后返回结果是
1 |
用户登录成功... |
则成功
3.6. 创建10个用户
为了测试使用,请自行通过接口创建10个用户。
3.7. 编写相关资源接口
给出整体控制器信息UserController.php
12345678910111213141516171819202122232425262728293031323334353637 |
<?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
123456789 |
<?phpuse Illuminate\Http\Request; Route::namespace(‘Api‘)->prefix(‘v1‘)->group(function () { Route::get(‘/users‘,‘[email protected]‘)->name(‘users.index‘); Route::get(‘/users/{user}‘,‘[email protected]‘)->name(‘users.show‘); Route::post(‘/users‘,‘[email protected]‘)->name(‘users.store‘); Route::post(‘/login‘,‘[email protected]‘)->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
12345 |
return [ ...... ‘expose-headers‘ => [‘Authorization‘], ......]; |
这样跨域请求时,才能返回
header
头为Authorization
的内容,否则在刷新用户token
时不会返回刷新后的token
5.1.4. 增加中间件别名
打开app/Http/Kernel.php
,增加一行
1234 |
protected $routeMiddleware = [ ...... //前面的中间件 ‘cors‘=> \Medz\Cors\Laravel\Middleware\ShouldGroup::class,]; |
5.1.5. 修改路由
打开routes/api.php
,在路由组中增加使用中间件
123456 |
Route::namespace(‘Api‘)->prefix(‘v1‘)->middleware(‘cors‘)->group(function () { Route::get(‘/users‘,‘[email protected]‘)->name(‘users.index‘); Route::get(‘/users/{user}‘,‘[email protected]‘)->name(‘users.show‘); Route::post(‘/users‘,‘[email protected]‘)->name(‘users.store‘); Route::post(‘/login‘,‘[email protected]‘)->name(‘users.login‘);}); |
5.2. 统一Response响应处理
接口主流返回json
格式,其中包含http状态码
,status请求状态
,data请求资源结果
等等。需要我们有一个API接口全局都能有统一的格式和对应的数据处理。参考于这里。
5.2.1. 封装返回的统一消息
在 app/Api/Helpers
目录(不存在目录自己新建)下新建 ApiResponse.php
填入如下内容
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132 |
<?phpnamespace 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
专门的基类
.
填入以下内容
12345678910111213 |
<?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
12345678910 |
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
中
123456789101112131415161718192021222324252627282930313233343536 |
<?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. 测试
- 返回用户列表
请求http://你的域名/api/v1/users
- 返回单一用户
请求http://你的域名/api/v1/users/1
- 登陆正确
请求http://你的域名/api/v1/login
- 登陆错误
请求http://你的域名/api/v1/login
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
文件
123456789101112131415161718192021222324252627282930313233343536 |
<?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)); |
返回用户列表(资源列表)
123 |
return UserResource::collection($users);//这里不能用$this->success(UserResource::collection($users))//否则不能返回分页标签信息 |
5.3.4. 修改用户控制器
12345678910 |
//返回用户列表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. 测试
返回单一用户(单一的资源)
返回用户列表(资源列表)
5.4. Enum枚举
我们常常会使用数字来代表状态,比如用户表,我们使用 -1
代表已删除 0
代表正常 1
代表冻结。
5.4.1. 两个问题
- 当我们判断一个用户,如果是删除或者冻结状态就不让其登陆了。判断代码这样写
123456
//有可能状态有很多,所以这边就直接用 或 来判断不取反了。if($user->status==-1||$user->status==1){ // 不允许用户登录逻辑 return}//用户正常登录逻辑
上面逻辑和编写没有什么问题。因为是现在看,可以很明白的知道-1
代表已删除,1
代表冻结。但是如果一个月后再来看这行代码,早已经忘记了 -1
跟 1
具体表示的含义。
- 参考上面
UserResource.php
编写时,判断status
具体状态函数,我们是使用switch
语句。这样太不美观,而且地方用多了还容易冗余,每次编写都需要去查看每个数字代表的具体意思。
5.4.2. 解决思路
- 第一个问题:为什么一段时间后再看就不知道
-1
跟1
具体表示的含义?
这是因为单纯的数字没有解释说明的作用,变量以及函数这些具有解释说明的作用,可以让我们立刻知道具体含义。
- 第二个问题:如何给一个数字就能直接知道它代表的含义?
提供一个函数,返回这个数字代表的具体含义。
而这些,都可以使用Enum枚举
可以解决。
5.4.3. 注意
PHP
和Laravel
框架本身是不支持Enum枚举
的,不过我们可以模拟枚举的功能
5.4.4. 创建枚举
在 app/Models
下新建目录 Enum
,并在目录Enum
下新建 UserEnum.php
文件,填写以下内容
1234567891011121314151617181920212223 |
<?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.表示具体含义
123456 |
//有可能状态有很多,所以这边就直接用 或 来判断不取反了。if($user->status==UserEnum::INVALID||$user->status==UserEnum::FREEZE){ // 不允许用户登录逻辑 return}//用户正常登录逻辑 |
2.修改UserResource.php
12345678910 |
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
文件中修改
12345 |
//返回单一用户信息public function show(User $user){ 3/0; return $this->success(new UserResource($user));} |
故意报个错,请求看看结果
我们再把设置成ajax
的header
头去掉
报错非常详细,并且把我们隐私设置都暴露出来了,这是由于我们.env
的APP_DEBUG
是true
状态。我们不希望这些信息被其他访问者看到。我们改为false
,再请求看看结果。
嗯。很好,不仅别人看不到了,连我们自己都看不到了
5.5.2. 需求
- 所有的异常信息都以统一
json
格式输出 - 因为我们是开发者,并且
.env
文件默认是不加入git
上传线上的,我们希望可以当APP_DEBUG
为true
(本地)的时候可以继续显示详细的错误信息,false
(线上)的时候就显示简要json
信息,比如500。
5.5.3. 创建自定义异常处理
在 app/Api/Helpers
目录下新建 ExceptionReport.php
文件,填入以下内容
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110 |
<?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
文件
123456789101112131415161718192021222324252627282930 |
<?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. 测试
继续打开设置AJAX
的header
头
1.关闭APP_DEBUG
,请求刚刚故意错误的接口。
2.开启APP_DEBUG
,请求刚刚故意错误的接口。
3.请求一个不存在的路由,查看返回结果。
其他的异常显示,自行测试啦~
5.6. jwt-auth
在传统web中,我们一般是使用session
来判定一个用户的登陆状态。而在API
开发中,我们使用的是token
。jwt-token
是Laravel
开发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文件,添加下面代码
123456 |
‘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文件,修改为下面代码
1234567891011 |
‘guards‘ => [ ‘web‘ => [ ‘driver‘ => ‘session‘, ‘provider‘ => ‘users‘, ], ‘api‘ => [ ‘driver‘ => ‘jwt‘, ‘provider‘ => ‘users‘, ],], |
这样,我们就能让api的用户认证变成使用jwt
。
5.更改 Model
如果需要使用 jwt-auth
作为用户认证,我们需要对我们的 User
模型进行一点小小的改变,实现一个接口,变更后的 User
模型如下
12345678910111213141516171819202122 |
<?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
文件配置详解
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203 |
<?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
方法用来获取当前用户的信息。
123456789101112131415161718 |
//用户登录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
12 |
//当前用户信息Route::get(‘/users/info‘,‘[email protected]‘)->name(‘users.info‘); |
3.接着我们打开postman
,请求http://你的域名/api/v1/login
.可以看到接口返回的token
.
1234567 |
{ "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.我们在Postman
的Header
头部分再加一个key
为Authorization
,value
为登陆成功后返回的token
值,然后再次进行请求,可以看到成功返回当前登陆用户的信息。
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
文件,填写以下内容
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253 |
<?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 的 BaseMiddlewareclass 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
文件,添加如下一行
1234 |
protected $routeMiddleware = [ ...... ‘api.refresh‘=>\App\Http\Middleware\Api\RefreshTokenMiddleware::class,]; |
5.7.4. 路由器修改
接着我们将路由进行修改,添加上我们写好的中间件。routes/api.php
1234567891011121314151617181920 |
<?php use Illuminate\Http\Request; Route::namespace(‘Api‘)->prefix(‘v1‘)->middleware(‘cors‘)->group(function () { //用户注册 Route::post(‘/users‘,‘[email protected]‘)->name(‘users.store‘); //用户登录 Route::post(‘/login‘,‘[email protected]‘)->name(‘users.login‘); Route::middleware(‘api.refresh‘)->group(function () { //当前用户信息 Route::get(‘/users/info‘,‘[email protected]‘)->name(‘users.info‘); //用户列表 Route::get(‘/users‘,‘[email protected]‘)->name(‘users.index‘); //用户信息 Route::get(‘/users/{user}‘,‘[email protected]‘)->name(‘users.show‘); //用户退出 Route::get(‘/logout‘,‘[email protected]‘)->name(‘users.logout‘); });}); |
5.7.5. 测试
1.此时我们再次不携带token,使用Postman
直接访问http://你的域名/api/v1/users/info
,返回如下错误
12345 |
{ "status": "error", "code": 422, "message": "未登录或登录状态失效"} |
2.那随便输入token又会是怎么样呢?我们也来尝试一下
12345 |
{ "status": "error", "code": 400, "message": "token不正确"} |
3.现在,我们再做一个如果token
过期了,但是刷新限制没有过期的情况,看看会有什么结果。我们先将config/jwt.php
里的ttl
从60
改成1
。意味着重新生成的token将会1分钟后过期。
然后我们重新登录获取到token
,替换/api/v1/users/info
原有的token,进行访问,可以正常返回用户的信息。
等过了一分钟,我们再进行访问,发现依旧可以返回用户信息,但是我们在返回的Headers
的Authorization
可以看到新的token
。
此时如果我们再次访问,则报出异常
12345 |
{ "status": "error", "code": 422, "message": "未登录或登录状态失效"} |
我们替换上新的token
,再次访问,访问正常通过。
4.现在,我们接着继续做token
和刷新时间都过期的情况,会发生什么。我们再将config/jwt.php
里的refresh_ttl
从20160
改成2
。
重新按照3步骤执行一次,当刚过一分钟时,返回结果与3相同,都是正常返回信息并且在Headers
携带了新的token。
当2分钟过后,报如下错误信息。
12345 |
{ "status": "error", "code": 422, "message": "未登录或登录状态失效"} |
5.为了后面的方便,我们将修改的ttl
和refresh_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
文件,修改如下内容
123456789101112131415161718192021222324252627282930 |
‘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
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253 |
<?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 的 BaseMiddlewareclass 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 文件,添加如下一行
1234 |
protected $routeMiddleware = [ ...... ‘admin.refresh‘=>\App\Http\Middleware\Api\RefreshAdminTokenMiddleware::class,]; |
5.8.6. 路由文件
routes/api.php
123456789101112131415161718192021222324252627282930313233343536 |
<?php use Illuminate\Http\Request; Route::namespace(‘Api‘)->prefix(‘v1‘)->middleware(‘cors‘)->group(function () { //用户注册 Route::post(‘/users‘, ‘[email protected]‘)->name(‘users.store‘); //用户登录 Route::post(‘/login‘, ‘[email protected]‘)->name(‘users.login‘); Route::middleware(‘api.refresh‘)->group(function () { //当前用户信息 Route::get(‘/users/info‘, ‘[email protected]‘)->name(‘users.info‘); //用户列表 Route::get(‘/users‘, ‘[email protected]‘)->name(‘users.index‘); //用户信息 Route::get(‘/users/{user}‘, ‘[email protected]‘)->name(‘users.show‘); //用户退出 Route::get(‘/logout‘, ‘[email protected]‘)->name(‘users.logout‘); }); //管理员注册 Route::post(‘/admins‘, ‘[email protected]‘)->name(‘admins.store‘); //管理员登录 Route::post(‘/admin/login‘, ‘[email protected]‘)->name(‘admins.login‘); Route::middleware(‘admin.refresh‘)->group(function () { //当前管理员信息 Route::get(‘/admins/info‘, ‘[email protected]‘)->name(‘admins.info‘); //管理员列表 Route::get(‘/admins‘, ‘[email protected]‘)->name(‘admins.index‘); //管理员信息 Route::get(‘/admins/{user}‘, ‘[email protected]‘)->name(‘admins.show‘); //管理员退出 Route::get(‘/admins/logout‘, ‘[email protected]‘)->name(‘admins.logout‘); }); }); |
5.8.7. 控制器文件
app/Http/Controllers/Api/AdminController.php
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647 |
<?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
的请求用户信息接口,看看会不会串号。结果返回
1234567891011 |
{ "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
的请求用户信息接口,看看会不会串号。结果返回
12345678910111213 |
{{ "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
文件,填入以下内容
12345678910111213141516171819202122 |
<?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
文件,添加如下一行
1234 |
protected $routeMiddleware = [ ...... ‘admin.guard‘=>\App\Http\Middleware\Api\AdminGuardMiddleware::class,]; |
4.修改路由
接着我们将路由进行修改,添加上我们写好的中间件。routes/api.php
12345678910111213141516 |
Route::middleware(‘admin.guard‘)->group(function () { //管理员注册 Route::post(‘/admins‘, ‘[email protected]‘)->name(‘admins.store‘); //管理员登录 Route::post(‘/admin/login‘, ‘[email protected]‘)->name(‘admins.login‘); Route::middleware(‘admin.refresh‘)->group(function () { //当前管理员信息 Route::get(‘/admins/info‘, ‘[email protected]‘)->name(‘admins.info‘); //管理员列表 Route::get(‘/admins‘, ‘[email protected]‘)->name(‘admins.index‘); //管理员信息 Route::get(‘/admins/{user}‘, ‘[email protected]‘)->name(‘admins.show‘); //管理员退出 Route::get(‘/admins/logout‘, ‘[email protected]‘)->name(‘admins.logout‘); }); }); |
5.修改控制器app/Http/Controllers/Api/AdminController.php
12345678910111213141516171819 |
//返回当前登录用户信息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. 函数
我们先来看几个我们在中间件中用的函数
12345678 |
$this->checkForToken($request)//这个函数只会检测是否携带token以及token是否能被当前密钥所解析 $this->auth->parseToken()->authenticate()//将使用token进行登录,如果token过期,则抛出 TokenExpiredException 异常 $this->auth->refresh(); //刷新当前token |
然后我们再来看一个有趣的函数
1234 |
Auth::check();//可以根据当前的`guard`来判断这个token是否属于这个 guard ,不是则抛出 TokenInvalidException 异常//但是,当token过期时,无论是不是属于这个 guard ,它也是都抛出 TokenInvalidException 异常。这导致我们无法正常判断出到底是属于哪种问题//所以,想要用check()来判断,是不可能的。 |
接着,我们继续看一个有意思的函数
123 |
Auth::payload();//可以输出当前token的载荷信息(也就是token解析后的内容)//但是,如果你这个token已经过期了,那这个函数将会报错 |
5.9.2. 原理
我们通过Auth::payload()
可以看到未过期token的载荷信息
123456789 |
{ "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
中加入我们定义的字段。
1234567891011 |
//用户登录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
也能读取出里面的信息
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172 |
<?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 的 BaseMiddlewareclass 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
。
12345678910111213141516171819202122 |
//用户登录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
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576 |
<?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 的 BaseMiddlewareclass 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
。正常访问。
当我们再次请求登陆/api/v1/admin/login
,然后继续用原token
访问/api/v1/admins/info
,提示错误。
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_CONNECTION
从sync
改成redis
1 |
QUEUE_CONNECTION=redis |
5.11.4. 仪表盘权限验证
仪表盘不能通过接口访问。所以我们做验证的时候,可以通过指定的IP
才能正常通过进入仪表盘。IP
可以写在.env
文件里,当IP发生变化时进行修改。
在 .env
最后加上一行
123 |
HORIZON_IP=想通过访问的IP地址比如HORIZON_IP=127.0.0.1 |
修改改app/Providers/AuthServiceProvider.php
文件 里的 boot
方法
12345678910111213 |
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
文件 ,填写以下内容
12345678910111213141516171819202122232425262728293031323334353637383940 |
<?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. 使用任务类
将控制器与中间件里的
12 |
$user->last_token = $token;$user->save(); |
统一替换为
1 |
SaveLastTokenJob::dispatch($user,$token); |
5.11.7. 运行Horizon
1 |
php artisan horizon |
此时,进程处于阻塞状态。
打开浏览器输入http://你的域名/horizon
,可以看到Horizon
仪表盘。
5.11.8. Supervisor守护进程
我们可以使用Supervisor来守护我们的horizon阻塞进程。具体方法可以看我之前写的文章:安装和使用守护进程–Supervisor
5.11.9. 测试
确认horizon
已经正常启动。然后我们访问/api/v1/admin/login
这个登陆接口。打开数据库可以发现,last_token
与返回结果的token
相同。我们也可以再打开仪表盘,看任务完成情况
5.11.10. 注意
如果修改了job
类的源码,需要将horizon
重新启动,否则代码还是未改动前的。(应该是horzion
是将所有任务类常驻内存的原因)
6. 成品
到此,所有修改已经全部完成,如果还有新的更改也会实时更新。同时,本文中的所有修改都已经在正式项目中运行过了。
如果你已经看完了整篇文章,知道了修改的原因,但是不想受累自己修改一遍。我已经将修改后的上传到全球最大的同性交友网站了,可以直接点击这里直接搬走。或者复制下方的链接打开。
项目地址:
https://github.com/guaosi/Laravel_api_init
原文地址:https://www.cnblogs.com/onew/p/12085183.html