快速接入 JWT 用户认证(多用户认证) —— tymon/jwt-auth

转: https://learnku.com/courses/laravel-package/jwt-user-authentication-tymonjwt-auth/2049

JWT 是 JSON Web Token 的缩写,它是一个规范,让用户和服务器之间传递安全可靠的信息。

在 Laravel 中安装 tymon/jwt-auth 这个扩展包就可以很方便的使用 JWT 了,在之前的 第三本教程 中,我们已经使用这个扩展包完成了接口的认证,不过我们是配合 Dingo/Api 来使用的,大家不一定都使用 Dingo/Api ,所以这节课我们以一个新项目为例完成相关的功能,完整的理解 JWT 以及这个扩展包,我们可以尝试完成以下功能:

  • 安装 tymon/jwt-auth 完成基础的用户认证功能;
  • 完成多用户认证,创建一个 admins 表,为两个用户分别创建并认证 Token。

创建新项目

创建一个 Laravel 5.5 的新项目,我们依然推荐大家使用 LTS 的版本:

$ cd ~/Code
$ composer create-project --prefer-dist laravel/laravel package "5.5.*"

file

退出 Homestead 修改一下配置:

Homestead.yaml

.
.
.
sites:
    - map: larabbs.test
      to: /home/vagrant/Code/larabbs/public
    - map: package.test
      to: /home/vagrant/Code/package/public
.
.
.
databases:
    - larabbs
    - package
.
.
.

加载一下配置,重启 Homestead:

> vagrant provision
> vagrant reload

修改一下 host

/etc/hosts

192.168.10.10 package.test

修改一下 env:

.
.
.
APP_URL=http://package.test
DB_DATABASE=package
.
.
.

创建一下基础的数据表。

$ php artisan migrate

file

这样 http://package.test 就可以正常访问了。

file

初始化一下代码:

$ git init
$ git add -A
$ git commit -m 'laravel package init'
$ git checkout -b jwt

这样我们有了一个 Laravel 5.5 的基础项目,以后的课程也可以在这个项目中演示,同时创建一个 jwt 分支,用于演示 JWT 的功能。

安装

安装一下扩展包,因为 1.0.0 版本还没有正式发布,所以需要指定一下版本。

$ composer require tymon/jwt-auth:1.0.0-rc.2

file

不发布配置也是可以使用的,可以直接通过 env 变量修改,为了方便之后的讲解,我们发布出出来。

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

执行一下 jwt:secret,这个命令会在 env 中增加一个 JWT_SECRET,同我们的 APP_KEY 这个 secret是十分重要的,用于给 Token 签名,更换这个 secret 会导致之前生成的所有 Token 无效,所以不要随意的更换这个 secret

$ php artisan jwt:secret

file

快速接入

创建 Token

创建 Token 之前,我们先快速创建两个用户,当然更好的方式是写一个 seed,打开 tinker:

factory(App\User::class, 2)->create();

file

修改一下 User 模型,需要实现扩展包提供的接口 Tymon\JWTAuth\Contracts\JWTSubject,接口要求我们实现两个方法:

  • getJWTIdentifier —— 返回模型的 id,一般直接使用 $this->getKey() 返回模型主键。
  • getJWTCustomClaims —— 返回数组,存放自定义的数据用于放在 Token 中,可以先返回空数组。

app/User.php

.
.
.
use Tymon\JWTAuth\Contracts\JWTSubject;
.
.
.
class User extends Authenticatable implements JWTSubject
.
.
.
    public function getJWTIdentifier()
    {
        return $this->getKey();
    }

    public function getJWTCustomClaims()
    {
        return [];
    }
.
.
.

这样就可以创建 Token 了,测试一下,打开 Tinker:

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

找到 ID 为 1 的用户,使用 JWTAuth::fromUser 为这个用户创建一个 JWT。

file

可以看到这个很长的字符串就是一个 JWT 了,看一下它的结构,使用 base64_decode 可以解码这个字符串,或者我们直接去 https://jwt.io/ 解码看的更加清楚:

file

JWT 由头部(header)、载荷(payload)与签名(signature)组成,一个 JWT 类似下面这样:

{
    "typ":"JWT",
    "alg":"HS256"
}
{
    "iss": "http://package.test",
    "iat": 1536052439,
    "exp": 1536056039,
    "nbf": 1536052439,
    "jti": "UIbnBVxa2K77MCMK",
    "sub": 1,
    "prv": "87e0af1ef9fd15812fdec97153a14e0b047546aa"
}
signature
  • 头部申明了加密算法;
  • 载荷中中记录了一些关键数据:
    • iss:—— 签发者,也就是 http://package.test ;
    • iat—— 签发时间;
    • exp—— 过期时间;
    • nbf —— 在这个时间之前,该 JWT 都是不可用的,一般同签发时间 iat
    • jti—— 唯一标识符,防止重放攻击。
    • sub—— 用户标识,这里是用户 ID
    • prv—— 扩展包自定义字段,模型名的哈希值,等于 sha1('App\User'),用于区别不同的模型,下面的课程会深入介绍。
  • 最后的 signature 是由服务器进行的签名,保证了 token 不被篡改。

JWT 最后是通过 Base64 编码的,也就是说,它可以被翻译回原来的样子来的。所以不要在 JWT 中存放一些敏感信息。

用户 id,过期时间等数据都保存在 Token 中了,所以并不需要将 Token 保存在服务器中,客户端请求的时候在 Header 中携带 Token,服务器获取 Token 后,进行 base64_decode 解码即可获取数据进行校验,由于已经有了签名,所以不用担心数据被篡改。

结合 Laravel Auth

一般我们希望通过 Laravel 的用户认证系统来完成相关的功能,而不是直接使用扩展包提供的门面 JWTAuth,修改一下相关配置:

config/auth.php

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

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

将 api 的 driver 由 token 改为 jwt,继续使用 tinker 测试一下,注意修改了代码要重启 tinker:

$credentials = ['email' => 'antoinette01@example.org', 'password' => 'secret'];
auth('api')->attempt($credentials);

注意替换上面的 email 为你数据库中的邮箱,我们定义了一个 $credentials,这个数组对应了请求提交过来的用户名以及密码,最后使用 attempt 来验证是否正确,验证成功会返回一个 JWT。

file

使用任意用户标识和用户密码,都可以作为验证参数。

完成接口

对于 API 来说一般需要以下几个接口:

  • login —— 用户登录,获取 JWT;
  • refresh—— 刷新 JWT;
  • logout —— 退出登录,注销 JWT;
  • user —— 获取当前 JWT 对应的用户。

当然你可能有自己的接口命名规范,我们这里只是讲解扩展包的使用,就直接使用扩展包文档中的命名了。这里可能会有疑惑的是 refresh 和 logout 两个接口,稍微解释一下:

  • 刷新 JWT
    任何一个永久有效的 token 都是相当危险的,通过任意方式泄露了 token 之后,用户的相关信息都有可能被利用。所以为了安全考虑,任何一种令牌的机制,都会有过期时间,过期时间一般也不会太长,可能几个小时或者几天。那么 token 过期以后,难道要用户重新登录吗?像 OAuth 2.0 有 refresh_token 可以用来刷新一个过期的 access_token,jwt-auth 同样也为我们提供了刷新的机制,只要在可刷新的时间范围内,即使 JWT 过期了,依然可以调用接口,换取一个新的 JWT。这对于客户端长期保持用户登录状态是十分重要的。

    我们需要了解两个时间:

    • jwt.ttl (JWT_TTL) —— 多长时间以后 JWT 就过期了 (单位分钟);
    • jwt.refresh_ttl (JWT_REFRESH_TTL) —— 多长时间以内, JWT 可以再次被刷新(单位分钟)。

    一般情况下 refresh_ttl 应该大于 ttl,也就是 JWT 过期以后,依然可以刷新一个新的 JWT。

  • 删除 JWT
    用户退出登录的时候,是需要将当前这个 JWT 注销的,但是 JWT 本身不用存储在服务端,因为本身已经包含了足够的信息以及签名,那如何来完成注销呢?其实是利用了黑名单,删除只是将 JWT 加入黑名单(Laravel 缓存)而已,加入黑名单的 JWT 都是无法继续使用的。

routes/api.php

Route::post('login', function(Request $request) {
    $credentials = $request->only('email', 'password');
    if (!$token = auth('api')->attempt($credentials)) {
        return response()->json(['error' => 'Unauthorized'], 401);
    }

    return response()->json(['token' => $token]);
});

Route::post('refresh', function() {
    return response()->json(['token' => auth('api')->refresh()]);
});

Route::post('logout', function() {
    auth('api')->logout();
    return response()->json(null, 204);
});

使用 PostMan 测试一下:

login 获取 JWT:

file

获取对应的用户,只需要将 JWT 放在 header 中,PostMan 可以填写在 Bearer Token 中:

file

刷新 JWT,注意刷新过之后,之前的 JWT 会被加入黑名单,也就不能继续使用了:

file

删除 JWT:

file

上面的代码应该很容易理解,你可以尝试一下优化一下,把方法写入 Controller,增加 Request 验证请求参数,返回合理的数据,等等,这节课主要为了快速介绍扩展包的使用,就不展开了。

多用户认证

创建 Admin

当我们的项目中需要为多个模型创建 Token,不同的 Token 可以使用不同的接口,这样的场景该如何处理呢?先来增加一个模型以及数据表。

$ php artisan make:model Admin -fm

-fm 参数是同时创建 migration 文件以及 factory 文件。

file

让 admins 与 users 拥有相同的字段:

database/migrations/< yourdate >createadminstable.php

<?php

use Illuminate\Support\Facades\Schema;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Database\Migrations\Migration;

class CreateAdminsTable extends Migration
{
    /**
     * Run the migrations.
     *
     * @return void
     */
    public function up()
    {
        Schema::create('admins', function (Blueprint $table) {
            $table->increments('id');
            $table->string('name');
            $table->string('email')->unique();
            $table->string('password');
            $table->rememberToken();
            $table->timestamps();
        });
    }

    /**
     * Reverse the migrations.
     *
     * @return void
     */
    public function down()
    {
        Schema::dropIfExists('admins');
    }
}

修改 Admin 模型:

app/Admin.php

<?php

namespace App;

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

class Admin extends Authenticatable implements JWTSubject
{
    use Notifiable;

    /**
     * The attributes that are mass assignable.
     *
     * @var array
     */
    protected $fillable = [
        'name', 'email', 'password',
    ];

    /**
     * The attributes that should be hidden for arrays.
     *
     * @var array
     */
    protected $hidden = [
        'password', 'remember_token',
    ];

    public function getJWTIdentifier()
    {
        return $this->getKey();
    }

    public function getJWTCustomClaims()
    {
        return [];
    }
}

database/factories/AdminFactory.php

<?php

use Faker\Generator as Faker;

$factory->define(App\Admin::class, function (Faker $faker) {
    return [
        'name' => $faker->name,
        'email' => $faker->unique()->safeEmail,
        'password' => '$2y$10$TKh8H1.PfQx37YgCzwiKb.KjNyWgaHb9cbcoQgdIVFlYg7B77UdFm', // secret
        'remember_token' => str_random(10),
    ];
});

执行 migrate:

$ php artisan migrate

快速创建两个用户:

factory(App\Admin::class, 2)->create();

file

创建 / 验证 Token

修改 auth 配置:

config/auth.php

.
.
.
    'guards' => [
        .
        .
        .
        'admin' => [
            'driver' => 'jwt',
            'provider' => 'admins',
        ],
    ],
    'providers' => [
        .
        .
        .
        'admins' => [
            'driver' => 'eloquent',
            'model' => App\Admin::class,
        ],
.
.
.

增加了一个 admin 的 guard,同时增加了对应的 provider。

测试一下为 admin 创建 JWT。

$admin = Admin::find(2);
auth('admin')->login($admin);

file

这次我们使用了 login 方法,与 fomUser 方法一样可以为某个用户创建一个 JWT,有兴趣的同学可以看看这两个方法的区别。

接下来我们可能就需要一份同 user 一样的接口登录以及获取信息的接口:

routes/api.php

.
.
.
Route::post('admin/login', function(Request $request) {
    $credentials = $request->only('email', 'password');
    if (!$token = auth('admin')->attempt($credentials)) {
        return response()->json(['error' => 'Unauthorized'], 401);
    }

    return response()->json(['token' => $token]);
});

Route::middleware('auth:admin')->get('/admin', function (Request $request) {
    return $request->user();
});
.
.
.

可以正确的创建出来 JWT:

file

也可以正确的获取到对应 admin 用户的信息。

file

容易被忽略的问题

我们先分别为 User 和 Admin 生成 JWT ,对比一下:

file

你可能会有个疑问,JWT 是通过 sub 这个字段说明模型 ID 的,也仅仅是通过这个字段去查询对应的用户,也就是说上面生成的 $userToken 和 $adminToken 基本相同,那么是不是可以通过 $adminToken 去访问得到 User 的用户信息呢?

我们来尝试一下:

file

你会发现扩展包已经考虑到了这个问题,还记得上面课程中我们介绍的 prv 字段,用于记录扩展包的模型,相当于 $userToken 记录了 sha1('App\User')$adminToken 记录了 sha1('App\Admin'),这样将不同模型的 JWT 进行隔离,不会出现问题。

需要注意的是,这个功能是在 1.0.0-rc.1 版本中才添加,对应的配置是 jwt.lock_subject 默认是 true。所以之前的版本确实会出现问题,我原来是通过模型中的 getJWTCustomClaims 方法,在 JWT 中存放一些额外的标识,然后自定义中间件来验证这个标识来解决这样的问题,不过将扩展包升级到最新之后就不用担心这个问题了,我们现在是 1.0.0-rc.2 版本。

并发问题

最后我们了解一个并发问题,JWT 在刷新了之后就会被加入黑名单,这样这个 JWT 就失效了。但是客户端有时候是并发请求的,也就是多个请求使用同一个 JWT 并发的请求各自的接口,但是如果某一个请求刷新了 JWT,那么其他所有的请求都会失败。

为了解决这个问题,扩展包提供了一个机制,可以配置多长时间内,JWT 被加入黑名单之后,依然可以使用,这个机制是用来防止并发问题,所以时间并不需要太长,具体的配置是 jwt.blacklist_grace_period ,可以在 env 中配置 JWT_BLACKLIST_GRACE_PERIOD。比如我们设置为 10,加入黑名单后 10 秒内依然可用。

创建一个可以正常使用的 JWT,

file

刷新这个 JWT,再次访问用户详情接口,依然可以获取到用户信息。但是等待 10 秒之后就会报错了。

file

代码版本控制

$ git add -A
$ git commit -m 'tymon/jwt-auth'

课程代码见这里 https://github.com/liyu001989/laravel-pack...

tags: laravel,jwt