laravel 结合JWT开发API

4,817 阅读10分钟

    最近一直在忙公司小程序,还有微信的一些H5开发,当然作为后端的我一直都只是写写简单的逻辑。由于之前一直都是混编开发,心里确实是累啊。所以也想可以提升下组内的技术水平,同时也是为了更好的分工,于是就想往前后端分离这种模式进行探索。

    虽知道接触一门新的东西,是要付出巨大的努力的。为了可以让以后自己少走点弯路,在项目进度符合预期的同时,专门抽了时间针对API开发进行了碎片的学习。本篇文章借鉴了很多同行的事例代码,同时也结合了自己的一点分析。希望各位道友能在其中有所感悟。

    谈起API开发,我想第一个要解决的就是用户的状态问题吧,毕竟HTTP是属于无状态的协议,我们在构建API的时候不能像普通的应用与系统开发一样,直接把用户的数据存放session然后再进行登录的校验。为此有大神就想出了JWT类似的解决方案,通过token来对用户的授权以及状态进行一个感知。

    在介绍 JWT 之前, 我们首先介绍一下, 传统的服务器端使用 session 对多用户进行授权的方式. 当然在 session 之前还有 cookie 的方式来保存用户的授权信息在客户机(比如浏览器)上, 不过纯 cookie 的方式过于不安全, 我们就把 cookie 跟 session 一起说.

之所以需要授权机制, 是因为 http 的无状态性(stateless).

也就是说当一个用户发送一次请求, 请求中附带账户名和密码登录成功之后, 如果这个这个用户再次发送一次请求, 服务器是不能知道这个用户是已经登录过的, 这个时候服务器就还需要用户再次提供授权信息, 也就是用户名和密码.

如果客户端能更少的把自己的身份授权信息在网络上传输, 那么客户端就能更大程度上避免自己的身份信息被泄露.

而 session 和 token 都是为了解决此问题出现的.

session 原理概述

认证流程

  1. 当用户使用用户名和密码登录之后, 服务器就会生成一个 session 文件, session 文件中保存着对这个用户的授权信息, 这个文件可以储存在硬盘/内存/数据库中.
  2. 同时还要生成一个对应这个 session 文件的 sessionid, 通过 sessionid 就能够找到这个 session 文件.
  3. 然后将 sessionid 发送给客户端, 客户端就将 sessionid 保存起来, 保存的方式有很多种, 目前大多情况是通过 cookie 来保存 sessionid.
  4. 保存之后, 当客户机以后再向服务器发送请求的时候, 请求携带上 sessionid, 这样服务器收到 sessionid 之后, 自己就会在服务区上查找对应的 session 文件, 如果查找成功, 就会得到该用户的授权信息, 从而完成一次授权.

session 的出现解决了一部分的问题, 但随着时间的推移和互联网的发展, 一些缺陷也随之暴露出来, 比如但不仅限于以下几点

  • 随着用户量的增加, 每个用户都需要在服务器上创建一个 session 文件, 这对服务器造成了压力
  • 对于服务器压力的分流问题, 如果一个用户的 session 被存储在某台服务器上, 那么当这个用户访问服务器时, 用户就只能在这台服务器上完成授权, 其他的分流服务器无法进行对这种请求进行分流
  • 同样也是 session 存储的问题, 当我们在一台服务器上成功登录, 如果我们想要另外的一台别的域名的服务器也能让用户不登录就能完成授权, 这个时候就会有很多麻烦

为了解决此类问题, token 应运而生了.

Token 原理概述

认证流程

  1. 客户端发送认证信息(一般就是用户名/密码), 向服务器发送请求
  2. 服务器验证客户端的认证信息, 验证成功之后, 服务器向客户端返回一个 加密的token(一般情况下就是一个字符串)
  3. 客户端存储(cookie, session, app 中都可以存储)这个 token, 在之后每次向服务器发送请求时, 都携带上这个 token
  4. 服务器验证这个 token 的合法性, 只要验证通过, 服务器就认为该请求是一个合法的请求

JWT 概述

token 只是一种思路, 一种解决用户授权问题的思考方式, 基于这种思路, 针对不同的场景可以有很多种的实现. 而在众多的实现中, JWT(JSON Web Token) 的实现最为流行.

JWT 这个标准提供了一系列如何创建具体 token 的方法, 这些缘故方法和规范可以让我们创建 token 的过程变得更加合理和效率.

比如, 传统的做法中, 服务器会保存生成的token, 当客户端发送来token时, 与服务器的进行比对, 但是 jwt 的不需要在服务器保存任何 token, 而是使用一套加密/解密算法 和 一个密钥 来对用户发来的token进行解密, 解密成功后就可以得到这个用户的信息.

这样的做法同时也增加了多服务器时的扩展性, 在传统的 token 验证中, 一旦用户发来 token, 那么必须要先找到存储这个 token 的服务器是哪台服务器, 然后由那一台服务器进行验证用户身份. 而 jwt 的存在, 只要每一台服务器都知道解密密钥, 那么每一台服务器都可以拥有验证用户身份的能力.

这样一来, 服务器就不再保存任何用户授权的信息了, 也就解决了 session 曾出现的问题.


简单介绍完了 JWT 之后, 接下来我们就简单看一下在实际场景中 JWT 的应用.


Laravel

1. 使用 composer 安装

# 建议使用1.0以上版本
composer require tymon/jwt-auth 1.*@rc

2. 进行一些配置

这里指的注意的是,有些文档会说要添加 Tymon\JWTAuth\Providers\LaravelServiceProvider::class,这只在 Laravel 5.4 及以下版本是必要的,更新的 Laravel 版本无需添加。

还有一些文档说要添加 Tymon\JWTAuth\Providers\JWTAuthServiceProvider 这是很久以前的 JWT 版本的(大概0.5.3 以前的版本)。

2.1 发布配置文件

# 这条命令会在 config 下增加一个 jwt.php 的配置文件
php artisan vendor:publish --provider="Tymon\JWTAuth\Providers\LaravelServiceProvider"

2.2 生成加密密钥

# 这条命令会在 .env 文件下生成一个加密密钥,如:JWT_SECRET=foobar
php artisan jwt:secret

2.3 更新你的模型

如果你使用默认的 User 表来生成 token,你需要在该模型下增加一段代码

<?php

namespace App;

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

class User extends Authenticatable implements JWTSubject    # 这里别忘了加
{
    use Notifiable;

    // Rest omitted for brevity

    /**
     * Get the identifier that will be stored in the subject claim of the JWT.
     * 获取将存储在JWT的中的标识符token。
     * @return mixed
     */
    public function getJWTIdentifier()
    {
        return $this->getKey();
    }

    /**
     * Return a key value array, containing any custom claims to be added to the JWT.
     * 返回一个键值数组,其中包含要添加到JWT中的任何自定义声明
     * @return array
     */
    public function getJWTCustomClaims()
    {
        return [];
    }
}

上面两个方法,其实就是实现了JWTSubject借口必须实现的方法。这个也是按照官方文档进行配置即可。

2.4 注册两个 Facade

这两个 Facade 并不是必须的,但是使用它们会给你的代码编写带来一点便利。

config/app.php

'aliases' => [
        ...
        // 添加以下两行
        'JWTAuth' => 'Tymon\JWTAuth\Facades\JWTAuth',
        'JWTFactory' => 'Tymon\JWTAuth\Facades\JWTFactory',
],

如果你不使用这两个 Facade,你可以使用辅助函数 auth()

auth() 是一个辅助函数,返回一个guard,暂时可以看成 Auth Facade。

关于Auth Facade。建议大家参考这篇文章,我在学习JWT的时候也是看了好几遍才看懂的。

Laravel 辅助函数 auth 与 JWT 扩展详解


2.5 修改 auth.php

config/auth.php

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

    'api' => [
        'driver' => 'jwt',      // 原来是 token 改成jwt
        'provider' => 'users',
    ],
],

2.6 注册一些路由

注意:在 Laravel 下,route/api.php 中的路由默认都有前缀 api

Route::group([

    'prefix' => 'auth'

], function ($router) {

    Route::post('login', 'AuthController@login');
    Route::post('logout', 'AuthController@logout');
    Route::post('refresh', 'AuthController@refresh');
    Route::post('me', 'AuthController@me');

});

2.7 创建 token 控制器

php artisan make:controller AuthController

AuthController

值得注意的是 Laravel 这要用 auth('api')

<?php

namespace App\Http\Controllers;

use Illuminate\Support\Facades\Auth;
use App\Http\Controllers\Controller;

class AuthController extends Controller
{
    /**
     * Get a JWT via given credentials.
     * 该方法用于生成token
     * @return \Illuminate\Http\JsonResponse
     */
    public function login()
    {
        $credentials = request(['email', 'password']);

        if (! $token = auth('api')->attempt($credentials)) {
            return response()->json(['error' => 'Unauthorized'], 401);
        }

        return $this->respondWithToken($token);
    }

    /**
     * Get the authenticated User.
     * 该方法通过token获取对应的用户信息
     * @return \Illuminate\Http\JsonResponse
     */
    public function me()
    {
        return response()->json(auth('api')->user());
    }

    /**
     * Log the user out (Invalidate the token).
     *
     * @return \Illuminate\Http\JsonResponse
     */
    public function logout()
    {
        auth('api')->logout();

        return response()->json(['message' => 'Successfully logged out']);
    }

    /**
     * Refresh a token.
     * 刷新token,如果开启黑名单,以前的token便会失效。
     * 值得注意的是用上面的getToken再获取一次Token并不算做刷新,两次获得的Token是并行的,即两个都可用。
     * @return \Illuminate\Http\JsonResponse
     */
    public function refresh()
    {
        return $this->respondWithToken(auth('api')->refresh());
    }

    /**
     * Get the token array structure.
     *
     * @param  string $token
     * 该方法按照指定的格式返回输出信息
     * @return \Illuminate\Http\JsonResponse
     */
    protected function respondWithToken($token)
    {
        return response()->json([
            'access_token' => $token,
            'token_type' => 'bearer',
            'expires_in' => auth('api')->factory()->getTTL() * 60
        ]);
    }
}

JWT Token 详解

1. token 的获取、使用、删除和刷新

  • 以下用 postman 演示,
  • Laravel 环境下写在 api.php 中的路由默认有前缀 api

1.1 获取 token


1.2 使用 token

有两种使用方法:

  • 加到 url 中:?token=你的token
  • 加到 header 中,建议用这种,因为在 https 情况下更安全:Authorization:Bearer 你的token


1.3 删除 token


删除 token 后,token就会失效,无法再利用其获取数据。


1.4 刷新 token



到此我们就已经可以成功地把JWT的开发例子演示完毕了,当然下面我会继续的把一些常用的方法展示出来,另外也会附上JWT的一些数据结构的专门文章。

JWT实现原理

2. token 的创建

前面的 AuthController.php 中有两行展现了这一种 token 的创建方法,即用用户所给的账号和密码进行尝试,密码正确则用对应的 User 信息返回一个 token

token 的创建方法不止这一种,接下来介绍 token 的三种创建方法:

  • 基于账密参数
  • 基于 users 模型返回的实例
  • 基于 users 模型中的用户主键 id

a) 基于账密参数

这就是刚刚说的哪一种,贴出具体代码。

// 使用辅助函数
$credentials = request(['email', 'password']); 
$token = auth('api')->attempt($credentials)

// 使用 Facade
$credentials = $request->only('email', 'password');
$token = JWTAuth::attempt($credentials);

b) 基于 users 模型返回的实例

// 使用辅助函数
$user = User::first();
$token = auth('api')->login($user);

// 使用 Facade
$user = User::first();
$token = JWTAuth::fromUser($credentials);

c) 基于 users 模型中的主键 id

// 使用辅助函数
$token = auth('api')->tokenById(1);

2.1 token 的解析

a) 解析 token 到对象

只有 Facade 需要这样。

// 把请求发送过来的直接解析到对象
JWTAuth::parseToken();

b) 获取 token 中的 user 信息

// 辅助函数
$user = auth()->user();

// Facade
$user = JWTAuth::parseToken()->authenticate();

c) 获取 token

如果 token 被设置则会返回,否则会尝试使用方法从请求中解析 token ,如果token未被设置或不能解析最终返回false。

// 辅助函数
$token = auth()->getToken();

// Facade
$token = JWTAuth::parseToken()->getToken();

d) 如果是前端

直接 base64 解码 token 的前两段即可以知道所需的信息。


3. 载荷的设置和获取

a) 载荷设置

载荷信息会在 token 解码时得到,同时越大的数组会生成越长的 token ,所以不建议放太多的数据。同时因为载荷是用 Base64Url 编码,所以相当于明文,因此绝对不能放密码等敏感信息。

$customClaims = ['foo' => 'bar', 'baz' => 'bob'];

// 辅助函数
$token = auth()->claims($customClaims)->attempt($credentials);

// Facade - 1
$token = JWTAuth::claims($customClaims)->attempt($credentials);

b) 载荷解析

从请求中把载荷解析出来。可以去看扩展源代码,里面还有很多的方法。

// 辅助函数
$exp = auth()->payload()->get('exp');
$json = auth()->payload()->toJson();
$array = auth()->payload()->jsonSerialize();
$sub = $array['sub'];

// Facade - 1
$payload = JWTAuth::parseToken()->getPayload();
$payload->get('sub'); // = 123
$payload['jti']; // = 'asfe4fq434asdf'
$payload('exp') // = 123456
$payload->toArray(); // = ['sub' => 123, 'exp' => 123456, 'jti' => 'asfe4fq434asdf'] etc

// Facade - 2
$exp = JWTAuth::parseToken()->getClaim('exp');

4. token 的三个时间

一个 token 一般来说有三个时间属性,其配置都在 config/jwt.php 内。

有效时间

有效时间指的的是你获得 token 后,在多少时间内可以凭这个 token 去获取内容,逾时无效。

// 单位:分钟
'ttl' => env('JWT_TTL', 60)

刷新时间

刷新时间指的是在这个时间内可以凭旧 token 换取一个新 token。例如 token 有效时间为 60 分钟,刷新时间为 20160 分钟,在 60 分钟内可以通过这个 token 获取新 token,但是超过 60 分钟是不可以的,然后你可以一直循环获取,直到总时间超过 20160 分钟,不能再获取。

// 单位:分钟
'refresh_ttl' => env('JWT_REFRESH_TTL', 20160)

宽限时间

宽限时间是为了解决并发请求的问题,假如宽限时间为 0s ,那么在新旧 token 交接的时候,并发请求就会出错,所以需要设定一个宽限时间,在宽限时间内,旧 token 仍然能够正常使用

// 宽限时间需要开启黑名单(默认是开启的),黑名单保证过期token不可再用,最好打开
'blacklist_enabled' => env('JWT_BLACKLIST_ENABLED', true)

// 设定宽限时间,单位:秒
'blacklist_grace_period' => env('JWT_BLACKLIST_GRACE_PERIOD', 60)

5.token 的刷新问题?

a) token 为什么要刷新吗?

首先 Basic Auth 是一种最简单的认证方法,但是由于每次请求都带用户名和密码,频繁的传输肯定不安全,所以才有 cookiessession 的运用。如果 token 不刷新,那么 token 就相当于上面的用户名+密码,只要获取到了,就可以一直盗用,因此 token 设置有效期并能够进行刷新是必要的。

b) token 有效期多久合适,刷新频率多久合适?

有效期越长,风险性越高,有效性越短,刷新频率越高,刷新就会存在刷新开销,所以这需要综合考虑。我个人一般考虑的范围是:15min ~ 120min。

c) 有没有必要每次都刷新 token ?

上面考虑的 15min ~ 120min,会存在一个问题,就是重放攻击风险,防御这个风险,在 JWT 可用的方案是每次请求后都刷新一次 token ,但这样又会存在一个新的问题:并发请求。一次并发请求是用的一个 token ,第一个完成的请求会导致后面的请求全部失败。可用的解决方案是设置宽限时间,即一个 token 刷新后,旧 token 仍然短暂的可用。可惜这样并不能完美的解决重放攻击,只是增大了不法者攻击的成本。这个问题在 JWT 中并没有很好的解决。

下面是可用的中间件,第一二个功能一样,但是第二个不会抛出错误,第三四个功能一样,没什么区别。

tymon\jwt-auth\src\Providers\AbstractServiceProvider.php

protected $middlewareAliases = [
    'jwt.auth' => Authenticate::class,
    'jwt.check' => Check::class,
    'jwt.refresh' => RefreshToken::class,
    'jwt.renew' => AuthenticateAndRenew::class,
];

5.1 token 的刷新总结

因为无法完全解决重放攻击,所以在因重放攻击会导致巨大安全问题和损失的地方,建议使用其他安全认证措施。而日常 Api 使用建议如下设置:

有效时间:15min ~ 120min
刷新时间:7天 ~ 30天
宽限时间:60s


其他常用方法附录

1. JWT 的 两个 Facade

1.1 JWTAuth

JWTAuth::parseToken()->方法() 一般都可以换成 auth()->方法()

token 生成

attempt

根据 user 账密新建一个 token。

$credentials = $request->only('email', 'password');
$token = JWTAuth::attempt($credentials);

fromUser or fromSubject

根据 user 对象生成一个 token。后者是前者别名

$user = User::find(1);
$token = JWTAuth::fromUser($user);

token 控制

refresh

更新 token。

$newToken = JWTAuth::parseToken()->refresh();

invalidate

让一个 token 无效。

JWTAuth::parseToken()->invalidate();

check

检验 token 的有效性。

if(JWTAuth::parseToken()->check()) {
    dd("token是有效的");
}

token 解析

authenticate or toUser or user

这三个效果是一样的,toUserauthenticate 的别名,而 user 比前两者少一个 user id 的校验,但并没有什么影响。

$user = JWTAuth::parseToken()->toUser();

parseToken

从 request 中解析 token 到对象中,以便进行下一步操作。

JWTAuth::parseToken();

getToken

从 request 中获取token。

$token = JWTAuth::getToken();  // 这个不用 parseToken ,因为方法内部会自动执行一次

载荷控制

customClaims or claims

设置载荷的 customClaims 部分。后者是前者的别名。

$customClaims = ['sid' => $sid, 'code' => $code];
$credentials = $request->only('email', 'password');
$token = JWTAuth::customClaims($customClaims)->attempt($credentials);

getCustomClaims

获取载荷的 customClaims 部分,返回一个数组。

$customClaims = JWTAuth::parseToken()->getCustomClaims()

getPayload or payload

获取所有载荷,三个都是一样的,最后一个一般用来检验 token 的有效性

$payload = JWTAuth::parseToken()->payload();

// then you can access the claims directly e.g.
$payload->get('sub'); // = 123
$payload['jti']; // = 'asfe4fq434asdf'
$payload('exp') // = 123456
$payload->toArray(); // = ['sub' => 123, 'exp' => 123456, 'jti' => 'asfe4fq434asdf'] etc

getClaim

获取载荷中指定的一个元素

$sub = JWTAuth::parseToken()->getClaim('sub');

1.2 JWTGuard

这个 Facade 主要进行载荷的管理,返回一个载荷对象,然后可以通过 JWTAuth 来对其生成一个 token

// 载荷的高度自定义
$payload = JWTFactory::sub(123)->aud('foo')->foo(['bar' => 'baz'])->make();
$token = JWTAuth::encode($payload);

$customClaims = ['foo' => 'bar', 'baz' => 'bob'];
$payload = JWTFactory::make($customClaims);
$token = JWTAuth::encode($payload);

1.3 其他一些用法

这里用 auth 的写法,因为 Laravel 有多个 guard,默认 guard 也不是 api ,所以需要写成 auth('api') 否则,auth() 即可。

设置载荷

$token = auth('api')->claims(['foo' => 'bar'])->attempt($credentials);

显示设置 token

$user = auth('api')->setToken('eyJhb...')->user();

显示设置请求

$user = auth('api')->setRequest($request)->user();

重写有效时间

$token = auth('api')->setTTL(7200)->attempt($credentials);

验证账密是否正确

$boolean = auth('api')->validate($credentials);


最后如果你想统一设置一下规则建议你还是使用中间件吧,这样你就可以通过你的中间件去保护你需要验证的路由与方法。文章贴出的是其他大神写的中间件,怎么说好呢?各位道友自己好好去摸索摸索吧!

中间件代码如下:

<?php

namespace App\Http\Middleware;

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

// 注意,我们要继承的是 jwt 的 BaseMiddleware
class RefreshToken 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);
    }
}

更新异常处理的 Handler

由于我们构建的是 api 服务,所以我们需要更新一下 app/Exceptions/Handler.php 中的 render

方法,自定义处理一些异常。

Handler.php

<?php

namespace App\Exceptions;

use Exception;
use Illuminate\Foundation\Exceptions\Handler as ExceptionHandler;
use Illuminate\Validation\ValidationException;
use Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException;

class Handler extends ExceptionHandler
{
    ...

    /**
     * Render an exception into an HTTP response.
     *
     * @param  \Illuminate\Http\Request $request
     * @param  \Exception $exception
     * @return \Illuminate\Http\Response
     */
    public function render($request, Exception $exception)
    {
        // 参数验证错误的异常,我们需要返回 400 的 http code 和一句错误信息
        if ($exception instanceof ValidationException) {
            return response(['error' => array_first(array_collapse($exception->errors()))], 400);
        }
        // 用户认证的异常,我们需要返回 401 的 http code 和错误信息
        if ($exception instanceof UnauthorizedHttpException) {
            return response($exception->getMessage(), 401);
        }

        return parent::render($request, $exception);
    }
}

至此,laravel的相关结合与学习暂告一段落!