1. 起因
随着前后端完全分离,PHP
也基本告别了 view
模板嵌套开发,转而专门写资源接口。Laravel
是 PHP 框架中最优雅的框架,国内也越来越多人告别 ThinkPHP
选择了 Laravel
。Laravel
框架本身对 API
有支持,但是感觉再工作中还是需要再做一些处理。Lumen
用起来不顺手,有些包不能很好地支持。所以,将 Laravel
框架进行一些配置处理,让其在开发 API
时更得心应手。
当然,你也可以点击这里 , 直接跳到成果~
2. 准备工作
2.1. 环境
PHP > 7.1
MySQL > 5.5
Redis > 2.8
2.2. 工具
postman
composer
2.3. 使用 postman
为了模拟 AJAX 请求,请将 header头
设置 X-Requested-With
为 XMLHttpRequest
2.4. 安装 Laravel
Laravel
只要 >=5.5
皆可,这里采用文章编写时最新的 5.7
版本
composer create-project laravel/laravel Laravel --prefer-dist "5.7.*"
2.5. 创建数据库
CREATE TABLE `users` (
`id` INT UNSIGNED NOT NULL PRIMARY KEY auto_increment COMMENT ‘主键ID‘,
`name` VARCHAR ( 12 ) NOT NULL COMMENT ‘用户名称‘,
`password` VARCHAR ( 80 ) NOT NULL COMMENT ‘密码‘,
`last_token` text COMMENT ‘登陆时的token‘,
`status` TINYINT NOT NULL DEFAULT 0 COMMENT ‘用户状态 -1代表已删除 0代表正常 1代表冻结‘,
`created_at` TIMESTAMP NULL DEFAULT NULL COMMENT ‘创建时间‘,
`updated_at` TIMESTAMP NULL DEFAULT NULL COMMENT ‘修改时间‘
) ENGINE = INNODB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_general_ci;
3. 初始化数据
3.1. Model 移动
在项目的 app
目录下可以看到,有一个 User.php
的模型文件。因为 Laravel
默认把模型文件放在 app
目录下,如果数据表多的话,这里模型文件就会很多,不便于管理,所以我们先要将模型文件移动到其他文件夹内。
1) 在 app
目录下新建 Models
文件夹,然后将 User.php
文件移动进来。
2) 修改 User.php
的内容
<?php
namespace App\Models; //这里从App改成了App\Models
use Illuminate\Notifications\Notifiable;
use Illuminate\Foundation\Auth\User as Authenticatable;
class User extends Authenticatable
{
use Notifiable;
protected $table = ‘users‘;
//去掉我创建的数据表没有的字段
protected $fillable = [
‘name‘, ‘password‘
];
//去掉我创建的数据表没有的字段
protected $hidden = [
‘password‘
];
//将密码进行加密
public function setPasswordAttribute($value)
{
$this->attributes[‘password‘] = bcrypt($value);
}
}
3) 因为有关于 User 的命名空间发生了改变,所以我们全局搜索 App\User
, 将其替换为 App\Models\User
. 我一共搜索到 4 个文件
app/Http/Controllers/Auth 目录下的 RegisterController.php
config 目录下的 services.php
config 目录下的 auth.php
database/factories 目录下的 UserFactory.php
3.2. 控制器
因为是专门做 API 的,所以我们要把是 API 的控制器都放到 app\Http\Controllers\Api
目录下。
使用命令行创建控制器
php artisan make:controller Api/UserController
编写 app/Http/Controllers/Api
目录下的 UserController.php
文件
<?php
namespace App\Http\Controllers\Api;
use Illuminate\Http\Request;
use App\Http\Controllers\Controller;
class UserController extends Controller
{
//
public function index(){
return ‘guaosi‘;
}
}
这里写了 index 函数,用来下面建立路由后的测试,查看是否可以正常访问。
3.3. 路由
在 routes
目录下的 api.php
是专门用来写 Api 接口的路由,所以我们打开它,填写以下内容,做一个测试.
<?php
use Illuminate\Http\Request;
Route::namespace(‘Api‘)->prefix(‘v1‘)->group(function () {
Route::get(‘/users‘,‘[email protected]‘)->name(‘users.index‘);
});
因为我们 Api 控制器的命名空间是
App\Http\Controllers\Api
, 而Laravel
默认只会在命名空间App\Http\Controllers
下查找控制器,所以需要我们给出namespace
。同时,添加一个
prefix
是为了版本号,方便后期接口升级区分。
打开 postman
, 用 get
方式请求你的域名/api/v1/users
, 最后返回结果是
guaosi
则成功
3.4. 创建验证器
在创建用户之前,我们先创建验证器,来让我们服务器接收到的数据更安全。当然,我们也要把关于 Api 验证的放在一个专门的文件夹内。
先创建一个 Request
的基类
php artisan make:request Api/FormRequest
因为验证器默认的权限验证是 false
,导致返回都是 403
的权限不通过错误。这里我们没有用到权限认证,为了方便处理,我们默认将权限都是通过的状态。所以,每个文件都需要我们将 false
改成 true
。
public function authorize()
{
//false代表权限验证不通过,返回403错误
//true代表权限认证通过
return true;
}
所以我们修改 app/Http/Requests/Api
目录下的 FormRequest.php
文件
<?php
namespace App\Http\Requests\Api;
use Illuminate\Foundation\Http\FormRequest as BaseFormRequest;
class FormRequest extends BaseFormRequest
{
public function authorize()
{
//false代表权限验证不通过,返回403错误
//true代表权限认证通过
return true;
}
}
这样这个命名空间下的验证器都会默认通过权限验证。当然,如果你需要权限验证,可以通过直接覆盖方法。
接着我们开始创建关于 UserController
的专属验证器
php artisan make:request Api/UserRequest
编辑 app/Http/Requests/Api
目录下的 UserRequest.php
文件
<?php
namespace App\Http\Requests\Api;
class UserRequest extends FormRequest
{
public function rules()
{
switch ($this->method()) {
case ‘GET‘:
{
return [
‘id‘ => [‘required,exists:shop_user,id‘]
];
}
case ‘POST‘:
{
return [
‘name‘ => [‘required‘, ‘max:12‘, ‘unique:users,name‘],
‘password‘ => [‘required‘, ‘max:16‘, ‘min:6‘]
];
}
case ‘PUT‘:
case ‘PATCH‘:
case ‘DELETE‘:
default:
{
return [
];
}
}
}
public function messages()
{
return [
‘id.required‘=>‘用户ID必须填写‘,
‘id.exists‘=>‘用户不存在‘,
‘name.unique‘ => ‘用户名已经存在‘,
‘name.required‘ => ‘用户名不能为空‘,
‘name.max‘ => ‘用户名最大长度为12个字符‘,
‘password.required‘ => ‘密码不能为空‘,
‘password.max‘ => ‘密码长度不能超过16个字符‘,
‘password.min‘ => ‘密码长度不能小于6个字符‘
];
}
}
3.5. 创建用户
现在我们来编写创建用户接口,制作一些虚拟数据。(就不使用 seeder 来填充了)
打开 UserController.php
//用户注册
public function store(UserRequest $request){
User::create($request->all());
return ‘用户注册成功。。。‘;
}
//用户登录
public function login(Request $request){
$res=Auth::guard(‘web‘)->attempt([‘name‘=>$request->name,‘password‘=>$request->password]);
if($res){
return ‘用户登录成功...‘;
}
return ‘用户登录失败‘;
}
然后我们创建路由,编辑 api.php
Route::post(‘/users‘,‘[email protected]‘)->name(‘users.store‘);
Route::post(‘/login‘,‘[email protected]‘)->name(‘users.login‘);
打开 postman
, 用 post
方式请求你的域名/api/v1/users
, 在 form-data
记得填写要创建的用户名和密码。
最后返回结果是
用户创建成功。。。
则成功。
如果返回
{
"message": "The given data was invalid.",
"errors": {
"name": [
"用户名不能为空"
],
"password": [
"密码不能为空"
]
}
}
则证明验证失败。
然后验证是否可以正常登录。因为我们认证的字段是 name
跟 password
, 而 Laravel
默认认证的是 email
跟 password
。所以我们还要打开 app/Http/Controllers/auth
目录下的 LoginController.php
, 加入如下代码
public function username()
{
return ‘name‘;
}
打开 postman
, 用 post
方式请求你的域名/api/v1/login
最后返回结果是
用户登录成功...
则成功
3.6. 创建 10 个用户
为了测试使用,请自行通过接口创建 10 个用户。
3.7. 编写相关资源接口
给出整体控制器信息 UserController.php
<?php
namespace App\Http\Controllers\Api;
use App\Http\Requests\Api\UserRequest;
use App\Models\User;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
class UserController extends Controller
{
//返回用户列表
public function index(){
//3个用户为一页
$users = User::paginate(3);
return $users;
}
//返回单一用户信息
public function show(User $user){
return $user;
}
//用户注册
public function store(UserRequest $request){
User::create($request->all());
return ‘用户注册成功。。。‘;
}
//用户登录
public function login(Request $request){
$res=Auth::guard(‘web‘)->attempt([‘name‘=>$request->name,‘password‘=>$request->password]);
if($res){
return ‘用户登录成功...‘;
}
return ‘用户登录失败‘;
}
}
3.8. 编写路由
给出整体路由信息 api.php
<?php
use Illuminate\Http\Request;
Route::namespace(‘Api‘)->prefix(‘v1‘)->group(function () {
Route::get(‘/users‘,‘[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
composer require medz/cors
5.1.2. 发布配置文件
php artisan vendor:publish --provider="Medz\Cors\Laravel\Providers\LaravelServiceProvider" --force
5.1.3. 修改配置文件
打开 config/cors.php
, 在 expose-headers
添加值 Authorization
return [
......
‘expose-headers‘ => [‘Authorization‘],
......
];
这样跨域请求时,才能返回
header
头为Authorization
的内容,否则在刷新用户token
时不会返回刷新后的token
5.1.4. 增加中间件别名
打开 app/Http/Kernel.php
, 增加一行
protected $routeMiddleware = [
...... //前面的中间件
‘cors‘=> \Medz\Cors\Laravel\Middleware\ShouldGroup::class,
];
5.1.5. 修改路由
打开 routes/api.php
, 在路由组中增加使用中间件
Route::namespace(‘Api‘)->prefix(‘v1‘)->middleware(‘cors‘)->group(function () {
Route::get(‘/users‘,‘[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
填入如下内容
<?php
namespace App\Api\Helpers;
use Symfony\Component\HttpFoundation\Response as FoundationResponse;
use Response;
trait ApiResponse
{
/**
* @var int
*/
protected $statusCode = FoundationResponse::HTTP_OK;
/**
* @return mixed
*/
public function getStatusCode()
{
return $this->statusCode;
}
/**
* @param $statusCode
* @return $this
*/
public function setStatusCode($statusCode,$httpCode=null)
{
$httpCode = $httpCode ?? $statusCode;
$this->statusCode = $statusCode;
return $this;
}
/**
* @param $data
* @param array $header
* @return mixed
*/
public function respond($data, $header = [])
{
return Response::json($data,$this->getStatusCode(),$header);
}
/**
* @param $status
* @param array $data
* @param null $code
* @return mixed
*/
public function status($status, array $data, $code = null){
if ($code){
$this->setStatusCode($code);
}
$status = [
‘status‘ => $status,
‘code‘ => $this->statusCode
];
$data = array_merge($status,$data);
return $this->respond($data);
}
/**
* @param $message
* @param int $code
* @param string $status
* @return mixed
*/
/*
* 格式
* data:
* code:422
* message:xxx
* status:‘error‘
*/
public function failed($message, $code = FoundationResponse::HTTP_BAD_REQUEST,$status = ‘error‘){
return $this->setStatusCode($code)->message($message,$status);
}
/**
* @param $message
* @param string $status
* @return mixed
*/
public function message($message, $status = "success"){
return $this->status($status,[
‘message‘ => $message
]);
}
/**
* @param string $message
* @return mixed
*/
public function internalError($message = "Internal Error!"){
return $this->failed($message,FoundationResponse::HTTP_INTERNAL_SERVER_ERROR);
}
/**
* @param string $message
* @return mixed
*/
public function created($message = "created")
{
return $this->setStatusCode(FoundationResponse::HTTP_CREATED)
->message($message);
}
/**
* @param $data
* @param string $status
* @return mixed
*/
public function success($data, $status = "success"){
return $this->status($status,compact(‘data‘));
}
/**
* @param string $message
* @return mixed
*/
public function notFond($message = ‘Not Fond!‘)
{
return $this->failed($message,Foundationresponse::HTTP_NOT_FOUND);
}
}
5.2.2. 新建 Api 控制器基类
在 app/Http/Controller/Api
目录下新建一个 Controller.php
作为 Api
专门的基类
.
填入以下内容
<?php
namespace App\Http\Controllers\Api;
use App\Api\Helpers\ApiResponse;
use App\Http\Controllers\Controller as BaseController;
class Controller extends BaseController
{
use ApiResponse;
// 其他通用的Api帮助函数
}
5.2.3. 继承 Api 控制器基类
让 Api 的控制器继承这个基类即可。
打开 UserController.php
文件,去掉命名空间 use App\Http\Controllers\Controller
namespace App\Http\Controllers\Api;
use App\Http\Requests\Api\UserRequest;
use App\Models\User;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
class UserController extends Controller
{
......
}
5.2.4. 如何使用
得益于前面统一消息的封装,使用起来非常容易。
1. 返回正确信息
return $this->success(‘用户登录成功...‘);
2. 返回正确资源信息
return $this->success($user);
3. 返回自定义 http 状态码的正确信息
return $this->setStatusCode(201)->success(‘用户登录成功...‘);
4. 返回错误信息
return $this->failed(‘用户注册失败‘);
5. 返回自定义 http 状态码的错误信息
return $this->failed(‘用户登录失败‘,401);
6. 返回自定义 http 状态码的错误信息,同时也想返回自己内部定义的错误码
return $this->failed(‘用户登录失败‘,401,10001);
默认 success 返回的状态码是 200,failed 返回的状态码是 400
5.2.5. 修改用户控制器
我们将统一消息封装运用到 UserController
中
<?php
namespace App\Http\Controllers\Api;
use App\Http\Requests\Api\UserRequest;
use App\Models\User;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
class UserController extends Controller
{
//返回用户列表
public function index(){
//3个用户为一页
$users = User::paginate(3);
return $this->success($users);
}
//返回单一用户信息
public function show(User $user){
return $this->success($user);
}
//用户注册
public function store(UserRequest $request){
User::create($request->all());
return $this->setStatusCode(201)->success(‘用户注册成功‘);
}
//用户登录
public function login(Request $request){
$res=Auth::guard(‘web‘)->attempt([‘name‘=>$request->name,‘password‘=>$request->password]);
if($res){
return $this->setStatusCode(201)->success(‘用户登录成功...‘);
}
return $this->failed(‘用户登录失败‘,401);
}
}
5.2.6. 测试
- 返回用户列表
请求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. 创建单一用户资源和列表用户资源
php artisan make:resource Api/UserResource
修改 app/Http/Resources/Api
目录下的 UserResource.php
文件
<?php
namespace App\Http\Resources\Api;
use Illuminate\Http\Resources\Json\JsonResource;
class UserResource extends JsonResource
{
/**
* Transform the resource into an array.
*
* @param \Illuminate\Http\Request $request
* @return array
*/
public function toArray($request)
{
switch ($this->status){
case -1:
$this->status = ‘已删除‘;
break;
case 0:
$this->status = ‘正常‘;
break;
case 1:
$this->status = ‘冻结‘;
break;
}
return [
‘id‘=>$this->id,
‘name‘ => $this->name,
‘status‘ => $this->status,
‘created_at‘=>(string)$this->created_at,
‘updated_at‘=>(string)$this->updated_at
];
}
}
5.3.3. 如何使用
返回单一用户 (单一的资源)
return $this->success(new UserResource($user));
返回用户列表 (资源列表)
return UserResource::collection($users);
//这里不能用$this->success(UserResource::collection($users))
//否则不能返回分页标签信息
5.3.4. 修改用户控制器
//返回用户列表
public function index(){
//3个用户为一页
$users = User::paginate(3);
return UserResource::collection($users);
}
//返回单一用户信息
public function show(User $user){
return $this->success(new UserResource($user));
}
5.3.5. 测试
返回单一用户 (单一的资源)
返回用户列表 (资源列表)
5.4. Enum 枚举
我们常常会使用数字来代表状态,比如用户表,我们使用 -1
代表已删除 0
代表正常 1
代表冻结。
5.4.1. 两个问题
- 当我们判断一个用户,如果是删除或者冻结状态就不让其登陆了。判断代码这样写
//有可能状态有很多,所以这边就直接用 或 来判断不取反了。 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
文件,填写以下内容
<?php
namespace App\Models\Enum;
class UserEnum
{
// 状态类别
const INVALID = -1; //已删除
const NORMAL = 0; //正常
const FREEZE = 1; //冻结
public static function getStatusName($status){
switch ($status){
case self::INVALID:
return ‘已删除‘;
case self::NORMAL:
return ‘正常‘;
case self::FREEZE:
return ‘冻结‘;
default:
return ‘正常‘;
}
}
}
5.4.5. 使用
1. 表示具体含义
//有可能状态有很多,所以这边就直接用 或 来判断不取反了。
if($user->status==UserEnum::INVALID||$user->status==UserEnum::FREEZE){
// 不允许用户登录逻辑
return
}
//用户正常登录逻辑
2. 修改 UserResource.php
public function toArray($request)
{
return [
‘id‘=>$this->id,
‘name‘ => $this->name,
‘status‘ => UserEnum::getStatusName($this->status),
‘created_at‘=>(string)$this->created_at,
‘updated_at‘=>(string)$this->updated_at
];
}
再请求单一用户和用户列表接口,返回结果和之前一样。
5.5. 异常自定义处理
5.5.1. 再发现一个问题
我们在 UserController.php
文件中修改
//返回单一用户信息
public function show(User $user){
3/0;
return $this->success(new UserResource($user));
}
故意报个错,请求看看结果
我们再把设置成 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
文件,填入以下内容
<?php
namespace App\Api\Helpers;
use Exception;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Auth\AuthenticationException;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Http\Request;
use Illuminate\Validation\ValidationException;
use Symfony\Component\HttpKernel\Exception\MethodNotAllowedHttpException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException;
use Tymon\JWTAuth\Exceptions\TokenInvalidException;
class ExceptionReport
{
use ApiResponse;
/**
* @var Exception
*/
public $exception;
/**
* @var Request
*/
public $request;
/**
* @var
*/
protected $report;
/**
* ExceptionReport constructor.
* @param Request $request
* @param Exception $exception
*/
function __construct(Request $request, Exception $exception)
{
$this->request = $request;
$this->exception = $exception;
}
/**
* @var array
*/
//当抛出这些异常时,可以使用我们定义的错误信息与HTTP状态码
//可以把常见异常放在这里
public $doReport = [
AuthenticationException::class => [‘未授权‘,401],
ModelNotFoundException::class => [‘该模型未找到‘,404],
AuthorizationException::class => [‘没有此权限‘,403],
ValidationException::class => [],
UnauthorizedHttpException::class=>[‘未登录或登录状态失效‘,422],
TokenInvalidException::class=>[‘token不正确‘,400],
NotFoundHttpException::class=>[‘没有找到该页面‘,404],
MethodNotAllowedHttpException::class=>[‘访问方式不正确‘,405],
QueryException::class=>[‘参数错误‘,401],
];
public function register($className,callable $callback){
$this->doReport[$className] = $callback;
}
/**
* @return bool
*/
public function shouldReturn(){
//只有请求包含是json或者ajax请求时才有效
// if (! ($this->request->wantsJson() || $this->request->ajax())){
//
// return false;
// }
foreach (array_keys($this->doReport) as $report){
if ($this->exception instanceof $report){
$this->report = $report;
return true;
}
}
return false;
}
/**
* @param Exception $e
* @return static
*/
public static function make(Exception $e){
return new static(\request(),$e);
}
/**
* @return mixed
*/
public function report(){
if ($this->exception instanceof ValidationException){
$error = array_first($this->exception->errors());
return $this->failed(array_first($error),$this->exception->status);
}
$message = $this->doReport[$this->report];
return $this->failed($message[0],$message[1]);
}
public function prodReport(){
return $this->failed(‘服务器错误‘,‘500‘);
}
}
5.5.4. 捕捉异常
修改 app/Exceptions
目录下的 Handler.php
文件
<?php
namespace App\Exceptions;
use App\Api\Helpers\ExceptionReport;
use Exception;
use Illuminate\Foundation\Exceptions\Handler as ExceptionHandler;
class Handler extends ExceptionHandler
{
public function render($request, Exception $exception)
{
//ajax请求我们才捕捉异常
if ($request->ajax()){
// 将方法拦截到自己的ExceptionReport
$reporter = ExceptionReport::make($exception);
if ($reporter->shouldReturn()){
return $reporter->report();
}
if(env(‘APP_DEBUG‘)){
//开发环境,则显示详细错误信息
return parent::render($request, $exception);
}else{
//线上环境,未知错误,则显示500
return $reporter->prodReport();
}
}
return parent::render($request, $exception);
}
}
5.5.5. 测试
继续打开设置 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. 安装
composer require tymon/jwt-auth 1.0.0-rc.3
如果是 Laravel5.5
版本,则安装 rc.1
。如果是 Laravel5.6
版本,则安装 rc.2
5.6.2. 配置
配置参考来自使用 Jwt-Auth 实现 API 用户认证以及无痛刷新访问令牌
1. 添加服务提供商
打开 config
目录下的 app.php 文件,添加下面代码
‘providers‘ => [
...
Tymon\JWTAuth\Providers\LaravelServiceProvider::class,
]
2. 发布配置文件
php artisan vendor:publish --provider="Tymon\JWTAuth\Providers\LaravelServiceProvider"
此命令会在 config
目录下生成一个 jwt.php
配置文件,你可以在此进行自定义配置。
3. 生成密钥
php artisan jwt:secret
此命令会在你的 .env
文件中新增一行 JWT_SECRET=secret
。以此来作为加密时使用的秘钥。
4. 配置 Auth guard
打开 config
目录下的 auth.php 文件,修改为下面代码
‘guards‘ => [
‘web‘ => [
‘driver‘ => ‘session‘,
‘provider‘ => ‘users‘,
],
‘api‘ => [
‘driver‘ => ‘jwt‘,
‘provider‘ => ‘users‘,
],
],
这样,我们就能让 api 的用户认证变成使用 jwt
。
5. 更改 Model
如果需要使用 jwt-auth
作为用户认证,我们需要对我们的 User
模型进行一点小小的改变,实现一个接口,变更后的 User
模型如下
<?php
namespace App\Models;
use Illuminate\Notifications\Notifiable;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Tymon\JWTAuth\Contracts\JWTSubject;
class User extends Authenticatable implements JWTSubject
{
use Notifiable;
public function getJWTIdentifier()
{
return $this->getKey();
}
public function getJWTCustomClaims()
{
return [];
}
......
6. 配置项详解config
目录下的 jwt.php
文件配置详解
<?php
return [
/*
|--------------------------------------------------------------------------
| JWT Authentication Secret
|--------------------------------------------------------------------------
|
| 用于加密生成 token 的 secret
|
*/
‘secret‘ => env(‘JWT_SECRET‘),
/*
|--------------------------------------------------------------------------
| JWT Authentication Keys
|--------------------------------------------------------------------------
|
| 如果你在 .env 文件中定义了 JWT_SECRET 的随机字符串
| 那么 jwt 将会使用 对称算法 来生成 token
| 如果你没有定有,那么jwt 将会使用如下配置的公钥和私钥来生成 token
|
*/
‘keys‘ => [
/*
|--------------------------------------------------------------------------
| Public Key
|--------------------------------------------------------------------------
|
| 公钥
|
*/
‘public‘ => env(‘JWT_PUBLIC_KEY‘),
/*
|--------------------------------------------------------------------------
| Private Key
|--------------------------------------------------------------------------
|
| 私钥
|
*/
‘private‘ => env(‘JWT_PRIVATE_KEY‘),
/*
|--------------------------------------------------------------------------
| Passphrase
|--------------------------------------------------------------------------
|
| 私钥的密码。 如果没有设置,可以为 null。
|
*/
‘passphrase‘ => env(‘JWT_PASSPHRASE‘),
],
/*
|--------------------------------------------------------------------------
| JWT time to live
|--------------------------------------------------------------------------
|
| 指定 access_token 有效的时间长度(以分钟为单位),默认为1小时,您也可以将其设置为空,以产生永不过期的标记
|
*/
‘ttl‘ => env(‘JWT_TTL‘, 60),
/*
|--------------------------------------------------------------------------
| Refresh time to live
|--------------------------------------------------------------------------
|
| 指定 access_token 可刷新的时间长度(以分钟为单位)。默认的时间为 2 周。
| 大概意思就是如果用户有一个 access_token,那么他可以带着他的 access_token
| 过来领取新的 access_token,直到 2 周的时间后,他便无法继续刷新了,需要重新登录。
|
*/
‘refresh_ttl‘ => env(‘JWT_REFRESH_TTL‘, 20160),
/*
|--------------------------------------------------------------------------
| JWT hashing algorithm
|--------------------------------------------------------------------------
|
| 指定将用于对令牌进行签名的散列算法。
|
*/
‘algo‘ => env(‘JWT_ALGO‘, ‘HS256‘),
/*
|--------------------------------------------------------------------------
| Required Claims
|--------------------------------------------------------------------------
|
| 指定必须存在于任何令牌中的声明。
|
|
*/
‘required_claims‘ => [
‘iss‘,
‘iat‘,
‘exp‘,
‘nbf‘,
‘sub‘,
‘jti‘,
],
/*
|--------------------------------------------------------------------------
| Persistent Claims
|--------------------------------------------------------------------------
|
| 指定在刷新令牌时要保留的声明密钥。
|
*/
‘persistent_claims‘ => [
// ‘foo‘,
// ‘bar‘,
],
/*
|--------------------------------------------------------------------------
| Blacklist Enabled
|--------------------------------------------------------------------------
|
| 为了使令牌无效,您必须启用黑名单。
| 如果您不想或不需要此功能,请将其设置为 false。
|
*/
‘blacklist_enabled‘ => env(‘JWT_BLACKLIST_ENABLED‘, true),
/*
| -------------------------------------------------------------------------
| Blacklist Grace Period
| -------------------------------------------------------------------------
|
| 当多个并发请求使用相同的JWT进行时,
| 由于 access_token 的刷新 ,其中一些可能会失败
| 以秒为单位设置请求时间以防止并发的请求失败。
|
*/
‘blacklist_grace_period‘ => env(‘JWT_BLACKLIST_GRACE_PERIOD‘, 0),
/*
|--------------------------------------------------------------------------
| Providers
|--------------------------------------------------------------------------
|
| 指定整个包中使用的各种提供程序。
|
*/
‘providers‘ => [
/*
|--------------------------------------------------------------------------
| JWT Provider
|--------------------------------------------------------------------------
|
| 指定用于创建和解码令牌的提供程序。
|
*/
‘jwt‘ => Tymon\JWTAuth\Providers\JWT\Namshi::class,
/*
|--------------------------------------------------------------------------
| Authentication Provider
|--------------------------------------------------------------------------
|
| 指定用于对用户进行身份验证的提供程序。
|
*/
‘auth‘ => Tymon\JWTAuth\Providers\Auth\Illuminate::class,
/*
|--------------------------------------------------------------------------
| Storage Provider
|--------------------------------------------------------------------------
|
| 指定用于在黑名单中存储标记的提供程序。
|
*/
‘storage‘ => Tymon\JWTAuth\Providers\Storage\Illuminate::class,
],
];
5.6.3. 测试
1. 我们在 UserController
控制器中将 login
方法进行修改以及新增一个 logout
方法用来退出登录还有 info
方法用来获取当前用户的信息。
//用户登录
public function login(Request $request){
$token=Auth::guard(‘api‘)->attempt([‘name‘=>$request->name,‘password‘=>$request->password]);
if($token) {
return $this->setStatusCode(201)->success([‘token‘ => ‘bearer ‘ . $token]);
}
return $this->failed(‘账号或密码错误‘,400);
}
//用户退出
public function logout(){
Auth::guard(‘api‘)->logout();
return $this->success(‘退出成功...‘);
}
//返回当前登录用户信息
public function info(){
$user = Auth::guard(‘api‘)->user();
return $this->success(new UserResource($user));
}
2. 添加一下路由routes/api.php
//当前用户信息
Route::get(‘/users/info‘,‘[email protected]‘)->name(‘users.info‘);
3. 接着我们打开 postman
, 请求 http://你的域名/api/v1/login
. 可以看到接口返回的 token
.
{
"status": "success",
"code": 201,
"data": {
"token": "bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJodHRwOlwvXC90ZXN0LmNvbVwvYXBpXC92MVwvbG9naW4iLCJpYXQiOjE1NTEzMzUyNzgsImV4cCI6MTU1MTMzODg3OCwibmJmIjoxNTUxMzM1Mjc4LCJqdGkiOiJrUzZSWHRoQVBkczR6ck4wIiwic3ViIjoxLCJwcnYiOiIyM2JkNWM4OTQ5ZjYwMGFkYjM5ZTcwMWM0MDA4NzJkYjdhNTk3NmY3In0.FLk-JPFBDTWcItPRN8SVGaLI0j2zgiWLLs_MNKxCafQ"
}
}
4. 此时,我们打开 Postman
直接访问 http://你的域名/api/v1/users/info
, 你会看到报了如下错误.
Trying to get property ‘id‘ of non-object
这是我们没有携带 token 导致的。报错不友好我们将在下面自动刷新用户认证
解决。
5. 我们在 Postman
的 Header
头部分再加一个 key
为 Authorization
,value
为登陆成功后返回的 token
值,然后再次进行请求,可以看到成功返回当前登陆用户的信息。
5.7. 自动刷新用户认证
5.7.1. 需求
现在我想用户登录后,为了保证安全性,每个小时该用户的 token 都会自动刷新为全新的,用旧的 token 请求不会通过。我们知道,用户如果 token 不对,就会退到当前界面重新登录来获得新的 token,我同时希望虽然刷新了 token,但是能否不要重新登录,就算重新登录也是一周甚至一个月之后呢?给用户一种无感知的体验。
看着感觉很神奇,我们一起手摸手来实现。
5.7.2. 自定义认证中间件
php artisan make:middleware Api/RefreshTokenMiddleware
打开 app/Http/Middleware/Api
目录下的 RefreshTokenMiddleware.php
文件,填写以下内容
<?php
namespace App\Http\Middleware\Api;
use Auth;
use Closure;
use Tymon\JWTAuth\Exceptions\JWTException;
use Tymon\JWTAuth\Facades\JWTAuth;
use Tymon\JWTAuth\Http\Middleware\BaseMiddleware;
use Tymon\JWTAuth\Exceptions\TokenExpiredException;
use Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException;
// 注意,我们要继承的是 jwt 的 BaseMiddleware
class RefreshTokenMiddleware extends BaseMiddleware
{
/**
* Handle an incoming request.
*
* @param \Illuminate\Http\Request $request
* @param \Closure $next
*
* @throws \Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException
*
* @return mixed
*/
public function handle($request, Closure $next)
{
// 检查此次请求中是否带有 token,如果没有则抛出异常。
$this->checkForToken($request);
// 使用 try 包裹,以捕捉 token 过期所抛出的 TokenExpiredException 异常
try {
// 检测用户的登录状态,如果正常则通过
if ($this->auth->parseToken()->authenticate()) {
return $next($request);
}
throw new UnauthorizedHttpException(‘jwt-auth‘, ‘未登录‘);
} catch (TokenExpiredException $exception) {
// 此处捕获到了 token 过期所抛出的 TokenExpiredException 异常,我们在这里需要做的是刷新该用户的 token 并将它添加到响应头中
try {
// 刷新用户的 token
$token = $this->auth->refresh();
// 使用一次性登录以保证此次请求的成功
Auth::guard(‘api‘)->onceUsingId($this->auth->manager()->getPayloadFactory()->buildClaimsCollection()->toPlainArray()[‘sub‘]);
} catch (JWTException $exception) {
// 如果捕获到此异常,即代表 refresh 也过期了,用户无法刷新令牌,需要重新登录。
throw new UnauthorizedHttpException(‘jwt-auth‘, $exception->getMessage());
}
}
// 在响应头中返回新的 token
return $this->setAuthenticationHeader($next($request), $token);
}
}
5.7.3. 增加中间件别名
打开 app/Http
目录下的 Kernel.php
文件,添加如下一行
protected $routeMiddleware = [
......
‘api.refresh‘=>\App\Http\Middleware\Api\RefreshTokenMiddleware::class,
];
5.7.4. 路由器修改
接着我们将路由进行修改,添加上我们写好的中间件。routes/api.php
<?php
use Illuminate\Http\Request;
Route::namespace(‘Api‘)->prefix(‘v1‘)->middleware(‘cors‘)->group(function () {
//用户注册
Route::post(‘/users‘,‘[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
, 返回如下错误
{
"status": "error",
"code": 422,
"message": "未登录或登录状态失效"
}
2. 那随便输入 token 又会是怎么样呢?我们也来尝试一下
{
"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
。
此时如果我们再次访问,则报出异常
{
"status": "error",
"code": 422,
"message": "未登录或登录状态失效"
}
我们替换上新的 token
,再次访问,访问正常通过。
4. 现在,我们接着继续做 token
和刷新时间都过期的情况,会发生什么。我们再将 config/jwt.php
里的 refresh_ttl
从 20160
改成 2
。
重新按照 3 步骤执行一次,当刚过一分钟时,返回结果与 3 相同,都是正常返回信息并且在 Headers
携带了新的 token。
当 2 分钟过后,报如下错误信息。
{
"status": "error",
"code": 422,
"message": "未登录或登录状态失效"
}
5. 为了后面的方便,我们将修改的 ttl
和 refresh_ttl
的时间复原。
5.7.6. 前端逻辑
上面可以看出,当 token 过期或者无效以及乱写,返回的 HTTP状态码
都是 422
。这是因为这个异常被我们上面自定义异常捕捉了
UnauthorizedHttpException::class=>[‘未登录或登录状态失效‘,422],
所以,可以跟前端小伙伴商量一个状态码,专门表示接收到这个状态码就要退回重新登录了。当 Header
头携带 Authorization
时,就要及时自动替换新的 token,不需要回到重新登录界面。这样用户就能完全无感知啦~
5.8. 多角色认证
如果我们的系统不仅仅只有一种角色身份,还有其他的角色身份需要认证呢?目前我们的角色认证是认证 Users
表的,如果我们再加入一个 Admins
表,也要角色认证要如何操作?
5.8.1. Admin 用户表
我们将数据库的 Users
表复制一份,将其命名为 Admins
表,并且将其中的一个用户名进行修改,以示区别。
5.8.2. 框架文件
我们分别将 User.php
模型文件,UserEnum.php
枚举文件,UserResource.php
资源文件,UserRequest.php
验证器文件 UserController.php
控制器文件各复制一份,更改为 Admin
的,并将其中内容也改为 Admin
相关。因为就是复制粘贴,把 user
改成 admin
, 由于篇幅问题具体修改过程我就不放代码了。具体的可以看下面的成品
5.8.3. 用户认证文件
打开 config/auth.php
文件,修改如下内容
‘guards‘ => [
‘web‘ => [
‘driver‘ => ‘session‘,
‘provider‘ => ‘users‘,
],
‘api‘ => [
‘driver‘ => ‘jwt‘,
‘provider‘ => ‘users‘,
],
‘admin‘ => [
‘driver‘ => ‘jwt‘,
‘provider‘ => ‘admins‘,
],
],
‘providers‘ => [
‘users‘ => [
‘driver‘ => ‘eloquent‘,
‘model‘ => App\Models\User::class,
],
‘admins‘ => [
‘driver‘ => ‘eloquent‘,
‘model‘ => App\Models\Admin::class,
],
// ‘users‘ => [
// ‘driver‘ => ‘database‘,
// ‘table‘ => ‘users‘,
// ],
],
此时,guard 守护就多了一个 admin
,当 Auth::guard(‘admin‘)
时,就会自动查找 Admin
模型文件,这样就能跟上面的 User
模型认证分开了。
5.8.4. 刷新用户认证中间件
我们需要再复制一个刷新用户认证的中间件,专门为 admin
认证以及刷新 token
.app/Http/Middleware/Api/RefreshAdminTokenMiddleware.php
<?php
namespace App\Http\Middleware\Api;
use Auth;
use Closure;
use Tymon\JWTAuth\Exceptions\JWTException;
use Tymon\JWTAuth\Facades\JWTAuth;
use Tymon\JWTAuth\Http\Middleware\BaseMiddleware;
use Tymon\JWTAuth\Exceptions\TokenExpiredException;
use Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException;
// 注意,我们要继承的是 jwt 的 BaseMiddleware
class RefreshAdminTokenMiddleware extends BaseMiddleware
{
/**
* Handle an incoming request.
*
* @param \Illuminate\Http\Request $request
* @param \Closure $next
*
* @throws \Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException
*
* @return mixed
*/
public function handle($request, Closure $next)
{
// 检查此次请求中是否带有 token,如果没有则抛出异常。
$this->checkForToken($request);
// 使用 try 包裹,以捕捉 token 过期所抛出的 TokenExpiredException 异常
try {
// 检测用户的登录状态,如果正常则通过
if ($this->auth->parseToken()->authenticate()) {
return $next($request);
}
throw new UnauthorizedHttpException(‘jwt-auth‘, ‘未登录‘);
} catch (TokenExpiredException $exception) {
// 此处捕获到了 token 过期所抛出的 TokenExpiredException 异常,我们在这里需要做的是刷新该用户的 token 并将它添加到响应头中
try {
// 刷新用户的 token
$token = $this->auth->refresh();
// 使用一次性登录以保证此次请求的成功
Auth::guard(‘admin‘)->onceUsingId($this->auth->manager()->getPayloadFactory()->buildClaimsCollection()->toPlainArray()[‘sub‘]);
} catch (JWTException $exception) {
// 如果捕获到此异常,即代表 refresh 也过期了,用户无法刷新令牌,需要重新登录。
throw new UnauthorizedHttpException(‘jwt-auth‘, $exception->getMessage());
}
}
// 在响应头中返回新的 token
return $this->setAuthenticationHeader($next($request), $token);
}
}
5.8.5. 增加中间件别名
打开 app/Http 目录下的 Kernel.php 文件,添加如下一行
protected $routeMiddleware = [
......
‘admin.refresh‘=>\App\Http\Middleware\Api\RefreshAdminTokenMiddleware::class,
];
5.8.6. 路由文件
routes/api.php
<?php
use Illuminate\Http\Request;
Route::namespace(‘Api‘)->prefix(‘v1‘)->middleware(‘cors‘)->group(function () {
//用户注册
Route::post(‘/users‘, ‘[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
<?php
namespace App\Http\Controllers\Api;
use App\Http\Requests\Api\UserRequest;
use App\Http\Resources\Api\AdminResource;
use App\Models\Admin;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
class AdminController extends Controller
{
//返回用户列表
public function index(){
//3个用户为一页
$admins = Admin::paginate(3);
return AdminResource::collection($admins);
}
//返回单一用户信息
public function show(Admin $admin){
return $this->success(new AdminResource($admin));
}
//返回当前登录用户信息
public function info(){
Auth::guard(‘admin‘)->user();
return $this->success(new AdminResource($admins));
}
//用户注册
public function store(UserRequest $request){
Admin::create($request->all());
return $this->setStatusCode(201)->success(‘用户注册成功‘);
}
//用户登录
public function login(Request $request){
$token=Auth::guard(‘admin‘)->attempt([‘name‘=>$request->name,‘password‘=>$request->password]);
if($token) {
return $this->setStatusCode(201)->success([‘token‘ => ‘bearer ‘ . $token]);
}
return $this->failed(‘账号或密码错误‘,400);
}
//用户退出
public function logout(){
Auth::guard(‘admin‘)->logout();
return $this->success(‘退出成功...‘);
}
}
5.8.8. 测试
我们将 admin
这边登陆返回的 token 放在 admin
的请求用户信息接口,看看会不会串号。结果返回
{
"status": "success",
"code": 200,
"data": {
"id": 1,
"name": "guaosi123",
"status": "正常",
"created_at": "2019-02-26 08:12:31",
"updated_at": "2019-02-26 08:12:31"
}
}
我们再将 token 放在 user
的请求用户信息接口,看看会不会串号。结果返回
{
{
"status": "success",
"code": 200,
"data": {
"id": 1,
"name": "guaosi123",
"status": "正常",
"created_at": "2019-02-26 08:12:31",
"updated_at": "2019-03-01 01:48:12"
}
}
}
看来 jwt-auth
真的串号了,这个问题我们下面再开一个标题进行解决。
5.8.9. 自动区分 guard
1. 当我们编写登陆,退出,获取当前用户信息的时候,都需要
Auth::guard(‘admin‘)
通过制定 guard
的具体守护是哪一个。因为框架默认的 guard
默认守护的是 web
。
所以,我希望可以让 guard
自动化,如果我请求的是 users
的,我就守护 api
。如果我请求的是 admins
的,我就守护 admin
。
接下来,就以 admins
的为例,users
的保持不动
2. 新建中间件
php artisan make:middleware Api/AdminGuardMiddleware
打开 app/Http/Middleware/Api/AdminGuardMiddleware.php
文件,填入以下内容
<?php
namespace App\Http\Middleware\Api;
use Closure;
class AdminGuardMiddleware
{
/**
* Handle an incoming request.
*
* @param \Illuminate\Http\Request $request
* @param \Closure $next
*
* @throws \Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException
*
* @return mixed
*/
public function handle($request, Closure $next)
{
config([‘auth.defaults.guard‘=>‘admin‘]);
return $next($request);
}
}
3. 添加中间件别名
打开 app/Http
目录下的 Kernel.php
文件,添加如下一行
protected $routeMiddleware = [
......
‘admin.guard‘=>\App\Http\Middleware\Api\AdminGuardMiddleware::class,
];
4. 修改路由
接着我们将路由进行修改,添加上我们写好的中间件。routes/api.php
Route::middleware(‘admin.guard‘)->group(function () {
//管理员注册
Route::post(‘/admins‘, ‘[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
//返回当前登录用户信息
public function info(){
$admins = Auth::user();
return $this->success(newAdminResource($admins));
}
//用户登录
public function login(Request $request){
$token=Auth::attempt([‘name‘=>$request->name,‘password‘=>$request->password]);
if($token) {
return $this->setStatusCode(201)->success([‘token‘ => ‘bearer ‘ . $token]);
}
return $this->failed(‘账号或密码错误‘,400);
}
//用户退出
public function logout(){
Auth::logout();
return $this->success(‘退出成功...‘);
}
6. 测试结果
将 admin
登陆后的 token 再次携带访问 /api/v1/admins/info
, 依旧可以正常输出当前用户信息。
user 的自动区分请自己填写,这里就不再啰嗦一遍了。
5.9. 修复角色认证串号问题
首先,我们需要知道一个问题,jwt-auth
颁发的 token
里面是不包含模型驱动
的。也就是说,通过这个令牌,我们不知道它到底是属于 api
还是属于 admin
的。
折腾了一晚上,百度了很多资料,想找找有没有解决办法。结果找到的都是没什么作用的,或者是让自动刷新失效了。最后自己追源码,找到了这种比较完美的方式。
5.9.1. 函数
我们先来看几个我们在中间件中用的函数
$this->checkForToken($request)
//这个函数只会检测是否携带token以及token是否能被当前密钥所解析
$this->auth->parseToken()->authenticate()
//将使用token进行登录,如果token过期,则抛出 TokenExpiredException 异常
$this->auth->refresh();
//刷新当前token
然后我们再来看一个有趣的函数
Auth::check();
//可以根据当前的`guard`来判断这个token是否属于这个 guard ,不是则抛出 TokenInvalidException 异常
//但是,当token过期时,无论是不是属于这个 guard ,它也是都抛出 TokenInvalidException 异常。这导致我们无法正常判断出到底是属于哪种问题
//所以,想要用check()来判断,是不可能的。
接着,我们继续看一个有意思的函数
Auth::payload();
//可以输出当前token的载荷信息(也就是token解析后的内容)
//但是,如果你这个token已经过期了,那这个函数将会报错
5.9.2. 原理
我们通过 Auth::payload()
可以看到未过期 token 的载荷信息
{
"sub": "1",
"iss": "http://test.com/api/v1/admin/login",
"iat": 1551407332,
"exp": 1551407392,
"nbf": 1551407332,
"jti": "f9zwcMHaXBr5kQYp",
"prv": "df883db97bd05ef8ff85082d686c45e832e593a9"
}
我们其实是可以拿到这些荷载信息的。同时,我们也可以加入自己的信息,这样在中间件时候进行解析,拿到我们的负载,就可以进行判断是否是属于当前 guard
的 token 了。
5.9.3. 实现
修改 app\Http\Controllers\Api\AdminController.php
中的 login
方法,在 token
中加入我们定义的字段。
//用户登录
public function login(Request $request)
{
//获取当前守护的名称
$present_guard =Auth::getDefaultDriver();
$token = Auth::claims([‘guard‘=>$present_guard])->attempt([‘name‘ => $request->name, ‘password‘ => $request->password]);
if ($token) {
return $this->setStatusCode(201)->success([‘token‘ => ‘bearer ‘ . $token]);
}
return $this->failed(‘账号或密码错误‘, 400);
}
再修改中间件 app/Http/Middleware/Api/RefreshAdminTokenMiddleware.php
,让其就算过期 token
也能读取出里面的信息
<?php
namespace App\Http\Middleware\Api;
use Auth;
use Closure;
use Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException;
use Tymon\JWTAuth\Exceptions\JWTException;
use Tymon\JWTAuth\Exceptions\TokenExpiredException;
use Tymon\JWTAuth\Exceptions\TokenInvalidException;
use Tymon\JWTAuth\Http\Middleware\BaseMiddleware;
// 注意,我们要继承的是 jwt 的 BaseMiddleware
class RefreshAdminTokenMiddleware extends BaseMiddleware
{
/**
* Handle an incoming request.
*
* @param \Illuminate\Http\Request $request
* @param \Closure $next
*
* @throws \Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException
*
* @return mixed
* @throws TokenInvalidException
*/
public function handle($request, Closure $next)
{
// 检查此次请求中是否带有 token,如果没有则抛出异常。
$this->checkForToken($request);
//1. 格式通过,验证是否是专属于这个的token
//获取当前守护的名称
$present_guard = Auth::getDefaultDriver();
//获取当前token
$token=Auth::getToken();
//即使过期了,也能获取到token里的 载荷 信息。
$payload = Auth::manager()->getJWTProvider()->decode($token->get());
//如果不包含guard字段或者guard所对应的值与当前的guard守护值不相同
//证明是不属于当前guard守护的token
if(empty($payload[‘guard‘])||$payload[‘guard‘]!=$present_guard){
throw new TokenInvalidException();
}
//使用 try 包裹,以捕捉 token 过期所抛出的 TokenExpiredException 异常
//2. 此时进入的都是属于当前guard守护的token
try {
// 检测用户的登录状态,如果正常则通过
if ($this->auth->parseToken()->authenticate()) {
return $next($request);
}
throw new UnauthorizedHttpException(‘jwt-auth‘, ‘未登录‘);
} catch (TokenExpiredException $exception) {
// 3. 此处捕获到了 token 过期所抛出的 TokenExpiredException 异常,我们在这里需要做的是刷新该用户的 token 并将它添加到响应头中
try {
// 刷新用户的 token
$token = $this->auth->refresh();
// 使用一次性登录以保证此次请求的成功
Auth::onceUsingId($this->auth->manager()->getPayloadFactory()->buildClaimsCollection()->toPlainArray()[‘sub‘]);
} catch (JWTException $exception) {
// 如果捕获到此异常,即代表 refresh 也过期了,用户无法刷新令牌,需要重新登录。
throw new UnauthorizedHttpException(‘jwt-auth‘, $exception->getMessage());
}
}
// 在响应头中返回新的 token
return $this->setAuthenticationHeader($next($request), $token);
}
}
这个中间件是通用的,可以直接替换 User 的刷新用户认证中间件噢
5.9.4. 测试
此时再次进行测试是否串号,最后结果可以成功阻止之前的串号问题,暂未发现其他 BUG。
user 的修复串号问题请自己修改,这里就不再啰嗦一遍了。
5.10. 单一设备登陆
5.10.1. 提出需求
同一时间只允许登录唯一一台设备。例如设备 A 中用户如果已经登录,那么使用设备 B 登录同一账户,设备 A 就无法继续使用了。
5.10.2. 原理
我们在登陆,token
过期自动更换的时候,都会产生一个新的 token
。
我们将 token
都存到表中的 last_token
字段。在登陆接口,获取到 last_token
里的值,将其加入黑名单。
这样,只要我们无论在哪里登陆,之前的 token
一定会被拉黑失效,必须重新登陆,我们的目的也就达到了。
5.10.3. 实现
修改 app\Http\Controllers\Api\AdminController.php
中的 login
方法,在登陆的时候,拉黑上一个 token
。
//用户登录
public function login(Request $request)
{
//获取当前守护的名称
$present_guard =Auth::getDefaultDriver();
$token = Auth::claims([‘guard‘=>$present_guard])->attempt([‘name‘ => $request->name, ‘password‘ => $request->password]);
if ($token) {
//如果登陆,先检查原先是否有存token,有的话先失效,然后再存入最新的token
$user = Auth::user();
if ($user->last_token) {
try{
Auth::setToken($user->last_token)->invalidate();
}catch (TokenExpiredException $e){
//因为让一个过期的token再失效,会抛出异常,所以我们捕捉异常,不需要做任何处理
}
}
$user->last_token = $token;
$user->save();
return $this->setStatusCode(201)->success([‘token‘ => ‘bearer ‘ . $token]);
}
return $this->failed(‘账号或密码错误‘, 400);
}
再修改中间件 app/Http/Middleware/Api/RefreshAdminTokenMiddleware.php
,更新的 token
加到 last_token
<?php
namespace App\Http\Middleware\Api;
use Auth;
use Closure;
use Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException;
use Tymon\JWTAuth\Exceptions\JWTException;
use Tymon\JWTAuth\Exceptions\TokenExpiredException;
use Tymon\JWTAuth\Exceptions\TokenInvalidException;
use Tymon\JWTAuth\Http\Middleware\BaseMiddleware;
// 注意,我们要继承的是 jwt 的 BaseMiddleware
class RefreshAdminTokenMiddleware extends BaseMiddleware
{
/**
* Handle an incoming request.
*
* @param \Illuminate\Http\Request $request
* @param \Closure $next
*
* @throws \Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException
*
* @return mixed
* @throws TokenInvalidException
*/
public function handle($request, Closure $next)
{
// 检查此次请求中是否带有 token,如果没有则抛出异常。
$this->checkForToken($request);
//1. 格式通过,验证是否是专属于这个的token
//获取当前守护的名称
$present_guard = Auth::getDefaultDriver();
//获取当前token
$token=Auth::getToken();
//即使过期了,也能获取到token里的 载荷 信息。
$payload = Auth::manager()->getJWTProvider()->decode($token->get());
//如果不包含guard字段或者guard所对应的值与当前的guard守护值不相同
//证明是不属于当前guard守护的token
if(empty($payload[‘guard‘])||$payload[‘guard‘]!=$present_guard){
throw new TokenInvalidException();
}
//使用 try 包裹,以捕捉 token 过期所抛出的 TokenExpiredException 异常
//2. 此时进入的都是属于当前guard守护的token
try {
// 检测用户的登录状态,如果正常则通过
if ($this->auth->parseToken()->authenticate()) {
return $next($request);
}
throw new UnauthorizedHttpException(‘jwt-auth‘, ‘未登录‘);
} catch (TokenExpiredException $exception) {
// 3. 此处捕获到了 token 过期所抛出的 TokenExpiredException 异常,我们在这里需要做的是刷新该用户的 token 并将它添加到响应头中
try {
// 刷新用户的 token
$token = $this->auth->refresh();
// 使用一次性登录以保证此次请求的成功
Auth::onceUsingId($this->auth->manager()->getPayloadFactory()->buildClaimsCollection()->toPlainArray()[‘sub‘]);
//刷新了token,将token存入数据库
$user = Auth::user();
$user->last_token = $token;
$user->save();
} catch (JWTException $exception) {
// 如果捕获到此异常,即代表 refresh 也过期了,用户无法刷新令牌,需要重新登录。
throw new UnauthorizedHttpException(‘jwt-auth‘, $exception->getMessage());
}
}
// 在响应头中返回新的 token
return $this->setAuthenticationHeader($next($request), $token);
}
}
5.10.4. 测试
我们先登陆一次 /api/v1/admin/login
,将获取到 token
携带访问 /api/v1/admins/info
。正常访问。
当我们再次请求登陆 /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
的详细介绍可以查看手册。
composer require laravel/horizon
5.11.2. 发布配置文件
php artisan vendor:publish --provider="Laravel\Horizon\HorizonServiceProvider"
5.11.3. 修改队列驱动
打开 .env
文件,将 QUEUE_CONNECTION
从 sync
改成 redis
QUEUE_CONNECTION=redis
5.11.4. 仪表盘权限验证
仪表盘不能通过接口访问。所以我们做验证的时候,可以通过指定的 IP
才能正常通过进入仪表盘。IP
可以写在.env
文件里,当 IP 发生变化时进行修改。
在 .env
最后加上一行
HORIZON_IP=想通过访问的IP地址
比如
HORIZON_IP=127.0.0.1
修改改 app/Providers/AuthServiceProvider.php
文件 里的 boot
方法
public function boot()
{
$this->registerPolicies();
Horizon::auth(function($request){
if(env(‘APP_ENV‘,‘local‘) ==‘local‘{
return true;
}else{
$get_ip=$request->getClientIp();
$can_ip=en(‘HORIZON_IP‘‘127.0.0.1‘);
return $get_ip == $can_ip;
}
});
}
5.11.5. 编写任务类
创建一个专门负责保存 last_token
的任务类
php artisan make:job Api/SaveLastTokenJob
打开 app/Jobs/Api/SaveLastTokenJob.php
文件 ,填写以下内容
<?php
namespace App\Jobs\Api;
use Illuminate\Bus\Queueable;
use Illuminate\Queue\SerializesModels;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
class SaveLastTokenJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
protected $model;
protected $token;
/**
* Create a new job instance.
*
* @return void
*/
public function __construct($model,$token)
{
//
$this->model=$model;
$this->token=$token;
}
/**
* Execute the job.
*
* @return void
*/
public function handle()
{
//
$this->model->last_token = $this->token;
$this->model->save();
}
}
5.11.6. 使用任务类
将控制器与中间件里的
$user->last_token = $token;
$user->save();
统一替换为
SaveLastTokenJob::dispatch($user,$token);
5.11.7. 运行 Horizon
php artisan horizon
此时,进程处于阻塞状态。
打开浏览器输入 http://你的域名/horizon
, 可以看到 Horizon
仪表盘。
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.guaosi.com/2019/02/26/laravel-api-initialization-preparation/
https://learnku.com/articles/25947#f80eda
原文地址:https://www.cnblogs.com/imike/p/10849808.html