使用 Jwt-Auth 实现 API 用户认证以及无痛刷新访问令牌

filex


最近在做一个公司的项目,前端使用 Vue.js,后端使用 Laravel 构建 Api 服务,用户认证的包本来是想用 Laravel Passport 的,但是感觉有点麻烦,于是使用了 jwt-auth


安装

jwt-auth 最新版本是 1.0.0 rc.1 版本,已经支持了 Laravel 5.5。如果你使用的是 Laravel 5.5 版本,可以使用如下命令安装。根据评论区 @tradzero 兄弟的建议,如果你是 Laravel 5.5 以下版本,也推荐使用最新版本,RC.1 前的版本都存在多用户token认证的安全问题。

$ composer require tymon/jwt-auth 1.0.0-rc.1


配置


添加服务提供商

将下面这行添加至 config/app.php 文件 providers 数组中:

app.php

'providers' => [

    ...

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


发布配置文件

在你的 shell 中运行如下命令发布 jwt-auth 的配置文件:

shell

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

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


生成密钥

jwt-auth 已经预先定义好了一个 Artisan 命令方便你生成 Secret,你只需要在你的 shell 中运行如下命令即可:

shell

$ php artisan jwt:secret

此命令会在你的 .env 文件中新增一行 JWT_SECRET=secret


配置 Auth guard

config/auth.php 文件中,你需要将 guards/driver 更新为 jwt

auth.php

'defaults' => [
    'guard' => 'api',
    'passwords' => 'users',
],

...

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

只有在使用 Laravel 5.2 及以上版本的情况下才能使用。


更改 Model

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

User.php

<?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.
     *
     * @return mixed
     */
    public function getJWTIdentifier()
    {
        return $this->getKey();
    }

    /**
     * Return a key value array, containing any custom claims to be added to the JWT.
     *
     * @return array
     */
    public function getJWTCustomClaims()
    {
        return [];
    }
}


配置项详解

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,

    ],

];


自定义认证中间件

先来说明一下我想要达成的效果,我希望用户提供账号密码前来登录。如果登录成功,那么我会给前端颁发一个 access _token ,设置在 header 中以请求需要用户认证的路由。

同时我希望如果用户的令牌如果过期了,可以暂时通过此次请求,并在此次请求中刷新该用户的 access _token,最后在响应头中将新的 access _token 返回给前端,这样子可以无痛的刷新 access _token ,用户可以获得一个很良好的体验,所以开始动手写代码。

执行如下命令以新建一个中间件:

php artisan make:middleware RefreshToken

中间件代码如下:

RefreshToken.php

<?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);
    }
}


设置 Axios 拦截器

我选用的 HTTP 请求套件是 axios。为了达到无痛刷新 token 的效果,我们需要对 axios 定义一个拦截器,用以接收我们刷新的 Token,代码如下:

app.js

import Vue from 'vue'
import router from './router'
import store from './store'
import iView from 'iview'
import 'iview/dist/styles/iview.css'

Vue.use(iView)

new Vue({
    el: '#app',
    router,
    store,
    created() {
        // 自定义的 axios 响应拦截器
        this.$axios.interceptors.response.use((response) => {
            // 判断一下响应中是否有 token,如果有就直接使用此 token 替换掉本地的 token。你可以根据你的业务需求自己编写更新 token 的逻辑
            var token = response.headers.authorization
            if (token) {
                // 如果 header 中存在 token,那么触发 refreshToken 方法,替换本地的 token
                this.$store.dispatch('refreshToken', token)
            }
            return response
        }, (error) => {
            switch (error.response.status) {

                // 如果响应中的 http code 为 401,那么则此用户可能 token 失效了之类的,我会触发 logout 方法,清除本地的数据并将用户重定向至登录页面
                case 401:
                    return this.$store.dispatch('logout')
                    break
                // 如果响应中的 http code 为 400,那么就弹出一条错误提示给用户
                case 400:
                    return this.$Message.error(error.response.data.error)
                    break
            }
            return Promise.reject(error)
        })
    }
})

Vuex 内的代码如下:

store/index.js

import Vue from 'vue'
import Vuex from 'vuex'
import axios from 'axios'

Vue.use(Vuex)

export default new Vuex.Store({
    state: {
        name: null,
        avatar: null,
        mobile: null,
        token: null,
        remark: null,
        auth: false,
    },
    mutations: {
        // 用户登录成功,存储 token 并设置 header 头
        logined(state, token) {
            state.auth = true
            state.token = token
            localStorage.token = token
        },
        // 用户刷新 token 成功,使用新的 token 替换掉本地的token
        refreshToken(state, token) {
            state.token = token
            localStorage.token = token
            axios.defaults.headers.common['Authorization'] = state.token
        },
        // 登录成功后拉取用户的信息存储到本地
        profile(state, data) {
            state.name = data.name
            state.mobile = data.mobile
            state.avatar = data.avatar
            state.remark = data.remark
        },
        // 用户登出,清除本地数据
        logout(state){
            state.name = null
            state.mobile = null
            state.avatar = null
            state.remark = null
            state.auth = false
            state.token = null

            localStorage.removeItem('token')
        }
    },
    actions: {
         // 登录成功后保存用户信息
        logined({dispatch,commit}, token) {
            return new Promise(function (resolve, reject) {
                commit('logined', token)
                axios.defaults.headers.common['Authorization'] = token
                dispatch('profile').then(() => {
                    resolve()
                }).catch(() => {
                    reject()
                })
            })
        },
        // 登录成功后使用 token 拉取用户的信息
        profile({commit}) {
            return new Promise(function (resolve, reject) {
                axios.get('profile', {}).then(respond => {
                    if (respond.status == 200) {
                        commit('profile', respond.data)
                        resolve()
                    } else {
                        reject()
                    }
                })
            })
        },
        // 用户登出,清除本地数据并重定向至登录页面
        logout({commit}) {
            return new Promise(function (resolve, reject) {
                commit('logout')
                axios.post('auth/logout', {}).then(respond => {
                    Vue.$router.push({name:'login'})
                })
            })
        },
        // 将刷新的 token 保存至本地
        refreshToken({commit},token) {
            return new Promise(function (resolve, reject) {
                commit('refreshToken', 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);
    }
}

更新完此方法后,我们上面自定义的中间件里抛出的异常和我们下面参数验证错误抛出的异常都会被转为指定的格式抛出。


使用

现在,我们可以在我们的 routes/api.php 路由文件中新增几条路由来测试一下了:

api.php

Route::prefix('auth')->group(function($router) {
    $router->post('login', 'AuthController@login');
    $router->post('logout', 'AuthController@logout');

});

Route::middleware('refresh.token')->group(function($router) {
    $router->get('profile','UserController@profile');
});

在你的 shell 中运行如下命令以新增一个控制器:

$ php artisan make:controller AuthController

打开此控制器,写入如下内容

<?php

namespace App\Http\Controllers;

use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use App\Transformers\UserTransformer;

class AuthController extends Controller
{

    /**
     * Get a JWT token via given credentials.
     *
     * @param  \Illuminate\Http\Request $request
     *
     * @return \Illuminate\Http\JsonResponse
     */
    public function login(Request $request)
    {
        // 验证规则,由于业务需求,这里我更改了一下登录的用户名,使用手机号码登录
        $rules = [
            'mobile'   => [
                'required',
                'exists:users',
            ],
            'password' => 'required|string|min:6|max:20',
         ];

        // 验证参数,如果验证失败,则会抛出 ValidationException 的异常
        $params = $this->validate($request, $rules);

       // 使用 Auth 登录用户,如果登录成功,则返回 201 的 code 和 token,如果登录失败则返回
        return ($token = Auth::guard('api')->attempt($params))
            ? response(['token' => 'bearer ' . $token], 201)
            : response(['error' => '账号或密码错误'], 400);
    }

    /**
     * 处理用户登出逻辑
     *
     * @return \Illuminate\Http\JsonResponse
     */
    public function logout()
    {
        Auth::guard('api')->logout();

        return response(['message' => '退出成功']);
    }
}

然后我们进入 tinker

$ php artisan tinker

执行以下命令来创建一个测试用户,我这里的用户名是用的是手机号码,你可以自行替换为邮箱。别忘了设置命名空间哟:

>>> namespace App\Models;
>>> User::create(['name' => 'Test','mobile' => 17623239881,'password' => bcrypt('123456')]);

正确执行结果如下图:

file

然后打开 Postman 来进行 api 测试

正确的请求结果如下图:

可以看到我们已经成功的拿到了 token,接下来我们就去验证一下刷新 token 吧

如图可以看到我们已经拿到了新的 token,接下来的事情便会交由我们前面设置的 axios 拦截器处理,它会将本地的 token 替换为此 token。


版本科普

感觉蛮多人对版本没什么概念,所以在这里科普下常见的版本。

  • α(Alpha)版

    ​ 这个版本表示该 Package 仅仅是一个初步完成品,通常只在开发者内部交流,也有很少一部分发布给专业测试人员。一般而言,该版本软件的 Bug 较多,普通用户最好不要安装。

  • β(Beta)版

    该版本相对于 α(Alpha)版已有了很大的改进,修复了严重的错误,但还是存在着一些缺陷,需要经过大规模的发布测试来进一步消除。通过一些专业爱好者的测试,将结果反馈给开发者,开发者们再进行有针对性的修改。该版本也不适合一般用户安装。

  • RC/ Preview版

    RC 即 Release Candidate 的缩写,作为一个固定术语,意味着最终版本准备就绪。一般来说 RC 版本已经完成全部功能并清除大部分的 BUG。一般到了这个阶段 Package 的作者只会修复 Bug,不会对软件做任何大的更改。

  • 普通发行版本

    一般在经历了上面三个版本后,作者会推出此版本。此版本修复了绝大部分的 Bug,并且会维护一定的时间。(时间根据作者的意愿而决定,例如 Laravel 的一般发行版本会提供为期一年的维护支持。)

  • LTS(Long Term Support) 版

    该版本是一个特殊的版本,和普通版本旨在支持比正常时间更长的时间。(例如 Laravel 的 LTS 版本会提供为期三年的 维护支持。)


结语

jwt-auth 确实是一个很棒的用户认证 Package,配置简单,使用方便。

文章结束,感谢阅读。

本作品采用《CC 协议》,转载必须注明作者和本文链接
本帖由 Summer 于 6年前 加精
《L04 微信小程序从零到发布》
从小程序个人账户申请开始,带你一步步进行开发一个微信小程序,直到提交微信控制台上线发布。
《G01 Go 实战入门》
从零开始带你一步步开发一个 Go 博客项目,让你在最短的时间内学会使用 Go 进行编码。项目结构很大程度上参考了 Laravel。
讨论数量: 148

@Littlesqx

  1. jwt-auth 配置项里的 blacklist_grace_period 就是为了解决此问题,如果你配置了此项,在高并发的情况下,服务器同时接收到了数个来自前端的请求,而这时又恰好第一个请求过期了,刷新了 Token ,那么 jwt-auth 在一段时间内是允许余下请求通过验证的,具体的时间取决于你配置的时间。
  2. 的确,绕的这一大圈就是为了延长 Token 的使用时间,但是我文章中的方法和你说的方法不同的是,在一段时间内你使用的都是一个 Token,而我这边却是在不停的更换 Token,而且每个 Token 都是唯一的,从某种程度上来说,它提升了很大的安全性。安全性这个话题很虚无缥缈,可却是网站服务正常运行很重要的一个保障。
  3. refresh_ttl 就是专门为了此要求设计的,只要 refresh 时间到期,那么服务端会强制性的拒绝颁发 Token 并要求重新登录,确保了你真的是这个账号的主人(知道账号密码)。

总结概括一下就是一句话:这样做更加具有安全性

6年前 评论

@史沟飞 如果被盗用了,是会有一个 refresh 时间的,在 refresh 时间结束后会强制要求重新获取新的 token,而不能继续刷新了,并不是说可以一直拿着这个 token 去刷新。每个 token 都有两个时间概念,一个是 access time,一个是 refresh time,access time 可以理解为这个 token 的有效时间,refresh time 则是共享的刷新时间。

举个例子,例如我使用账号与密码拿到了一个 A token,这个 A token 的 access time 是 1 小时,refresh time 是 7 天。在一个小时后,我的 A token 的 access time 到期了,我可以拿这个 A token 去换取一个新的 token,这个新的 token 的access time 是重新计算的,但是它的 refresh time 是累计的。也就是不管你换取多少次,token 始终会在 refresh time 到期的时间点,也就是 7 天后失效,无法继续刷新。

5年前 评论
wgkeep1 4年前

@Ken 牺牲了午睡时间赶出来的 :smile:

6年前 评论
hkzj0571

爸爸牛批 :cry:

6年前 评论

无论如何还是建议使用新版本, 旧版本 RC1之前都有多用户token认证的安全问题(User, Admin同时使用JWT驱动验证, User1id为1获取到token, 然后用该token访问Admin的任意路由,JWT-Auth都会通过认证, 因为JWT-Auth使用的是id做为校验方式,而没考虑驱动的模型)

6年前 评论

@tradzero 受教了,已修改文章 :+1:

6年前 评论

@Seaony 不敢不敢 还是多谢你的分享

6年前 评论
Destiny

老铁高质量文章

6年前 评论
LOST

@tradzero 之前也有用不同的模型进行认证,确实也发现了这个问题。可以为不同的模型配置不同的 jwt-secret,这样使用 user 模型生成的 token 就无法通过 admin 模型的认证了。

6年前 评论

我不是很理解这样的做法有什么好处。。。第一,这样做有一个问题,如果token过期了,接下来如果前端有两个或以上的请求发出(这些请求的token都是过期的),那最后其实刷新多次了。第二,为了保证token长时间可用,这大可以直接修改token有效期,绕了一个圈,不就是为了这个吗?(反正中间件自动帮忙延长时间)。第三,我更推荐token过期后前端主动刷新或者过期后直接销毁token,需要重新登录。毕竟刷新token的需求很大程度不是给WEB 应用 API设计的。

6年前 评论

@tanjibo :joy: 既然学到了不如给个赞,哈哈哈哈

6年前 评论

一个问题,BaseMiddleware里的authenticate方法,token验证成功后其实每次都会执行JWTGuard的onceUsingId方法,就是去请求userprovider的retrieveById方法。这样每个请求验证都会去查数据库了,不是跟jwt的状态无关的优点冲突了吗?jwt中间件只验证token好了啊,因为不是每次请求都会去取auth::user(),浪费数据库操作

5年前 评论

@ebenmo 有空我再过一遍,看看能不能优化一下~

5年前 评论

@Seaony新建项目没有UserControll@profile控制器和profile方法, Route::middleware('refresh.token') 这个refresh.token中间件从哪添加的?

5年前 评论
levi 4年前

@php_yt 可以通过login 方法得到token

3年前 评论
php_yt 3年前

很好啊,这次总算可以比较系统的学习到新知识了。

6年前 评论

多谢分享。我现在也在用jwt ,使用过程中发现请求多个api接口的时候,如果token过期去刷新的话,因为多个请求,会导致多次刷新token,产生token不能用的问题。这个有什么解决办法吗?

6年前 评论

@fordongu 试着设置一下配置项中的 blacklist_grace_period 看看?这个配置项的作用就是为了防止并发高的时候刷新掉了
token 导致其他的请求失效,你可以根据你的业务逻辑设置合适的时长。

6年前 评论

@Seaony 好的 非常感谢

6年前 评论

@fordongu 不用客气,很帮到你我很高兴 :beer:

6年前 评论
Jourdon

如果只是做API认证,确实还是JWT方便。。passport有点大了

6年前 评论

@Destiny 谢谢,老铁稳! :stuck_out_tongue:

6年前 评论

只能说简直完美

file

6年前 评论

@Seaony 实验下来出了点了问题 过期后 直接不让刷新了 抛出的是UnauthorizedHttpException 过期设置为1分钟 刷新时间默认 ....

6年前 评论

@Qi 你使用的中间件是这个中间件嘛?要在 kernel 中注册这个中间件,然后再使用它

6年前 评论

@Seaony 注册了 结果用错了 。。。哈哈哈 用的dingo+jwt

6年前 评论

@Seaony 1. 请问余下不通过验证的请求响应你让前端怎么做?从你的代码上看,并没有解决这个问题。本质上讲,这不是并发的问题,是你这个所谓无痛刷新的设计缺陷。2. 这根本没有提高安全性,过期的token只要没有销毁,一样可以访问(因为在可刷新期限内,所谓的无痛刷新会帮忙更新)。 3. 我们的讨论不在一个点上。

6年前 评论

@Littlesqx 你真的有看我文章嘛....里面写的很清楚了。

第一点,如果你配置了 blacklist_grace_period 项,那么 jwt-auth 会在此配置的时间里让余下已经过期的 token 通过验证,相当于变相的使 token 的过期时间延长了,以满足并发下的请求。并不存在你说的余下不通过的请求响应。

第二点,针对一个 token 只会抛出一次已经过期的异常,也就是说只要不主动刷新,每一个 token 都且仅有只有一次被动刷新的机会。只要一个携带 token 的请求过期了,而且没有刷新,其他携带此 token 的请求都不会有被动刷新的机会,只会抛出认证未通过的异常。而且 jwt-auth 里的 token 并不存在销毁这一概念,只有过期时间,只要这个 token 过期且过期的时候没有刷新那么它就无法通过认证了,我实际测试也证明了这一点,如下图:

fileQQ20171227-193405@2x.png

我也觉得我们的讨论不在一个点上

6年前 评论

@Qi 哈哈,写代码还是要细心一点好,我写代码就是太粗心了,老是错在一些小细节上。

6年前 评论

@Seaony blacklist_grace_period 这个配置确实我理解错了,抱歉,有空我自己会测试验证一下。关于第二点,我说了我认为并没有更安全的理由,我希望你从(你认为)更安全的角度解释下。你所说的 只要一个携带 token 的请求过期了,而且没有刷新,其他携带此 token 的请求都不会有被动刷新的机会,只会抛出认证未通过的异常不是正和第一点自相矛盾吗。。。token被加入黑名单不就是销毁的概念吗。最后,我说的讨论不在一个点上,请仔细看我上条回复的,我说针对回复你的第三点。

6年前 评论

@Littlesqx 意思就是那个意思,可能我表述有错误,blacklist_grace_period 配置和 token 刷新是两个概念,因为 blacklist_grace_period 的存在,即使前面的请求已经刷新了 token,后面携带此 token 的请求在 blacklist_grace_period 配置的时间内还是会通过认证。你也可以去看一下 jwt-auth 的源码,其间的流程确实有点复杂,所以这里就不细讲了,有空我会单独写一篇文章来分析一下它的验证机制,不过可以肯定的是你说的安全问题是不存在的。

6年前 评论
Toiu

虽然我个人更倾向于passport, 但还是点赞.. 因为我是菜鸟:smile: , 而且我们坐标一致

6年前 评论

@LOST 还有这种操作吗 是重写了什么方法吧 jwt-secret不是在config统一配置的么

6年前 评论
LOST

@tradzero 写个中间件,用 config 函数覆盖 jwt 中原有的配置。

6年前 评论

666,其实前端中使用axios 拦截器就可以做到无痛刷新token。看你了的解决办法,又学习了一种姿势

6年前 评论

很棒的文章,感謝分享。
由於 JWT 沒有銷毀概念,此作法若被有心人士取得 token,一樣也可以無痛 refresh token:sweat_smile:

6年前 评论

老铁看过你好多文章了, 这篇JWT给了并发请求的一些思路, 赞
另外有试过laravoole吗?性能无痛提升10倍, 就是跨域问题不知道怎么解决

6年前 评论

@泽 有空去看看,这个没玩过,平时用 Swoole 都是直接用的

6年前 评论

为啥说passport比较麻烦馁(手动问号(●'◡'●))

6年前 评论

@LOST jwt 好像只支持一个密码,请问你的实现方式是什么?

6年前 评论

哦哦 看到了

6年前 评论

@Seaony 有空的话恳请发布个swoole话题呀:+1:

6年前 评论

@泽 嗯呐,有空会搞的,但是目前实在是忙得喘不过气来,哈哈

6年前 评论

AuthControlleruse App\Transformers\UserTransformer, 引入后在 loginlogout 方法中貌似没用到, 也可能是我孤陋寡闻了 :)

6年前 评论

请教一下大神,看到你在项目里面用的vue单页,在考虑seo上的问题是怎么处理的啊?

6年前 评论

@mayne 先前写的时候用了的,后来写文章的时候去掉了,怕看文章的人理解出错,也懒得再去介绍 Transformer 的作用

6年前 评论

@dptms emmm,老实说,这个项目我并没有考虑 SEO,因为是后台相关的项目。如果你希望在前台用 SPA 的形式,可以去看一下 Vue 文章里服务端渲染的章节,我对这方面没有什么经验 =。=

6年前 评论

非常好!!谢谢大神,学习了!

6年前 评论
xcaptain

学习了,请教一下jwt多站点登录有什么好办法吗?我现在是把JWT_SECRET在多个站点中共享的,但是感觉这样管理起来不是很方便。

6年前 评论

@xcaptain 多个网站用一套账号?如果是这样的话要搞个主从应用然后做 OAuth 2.0 会比较好一点吧?

6年前 评论
幽弥狂

假如我们的api要提供学员登录,后台登陆。。
用 Oauth 这样就不行了吧。。。只能用 dingo 的 V1、V2、V3这种版本的吧

6年前 评论
xcaptain

@Seaony 我们公司现在内部的系统就是做了个中心的授权服务,每个应用都接入这个服务进行登陆和登陆校验,但是感觉这种方法不是很好,要走http请求去校验登陆状态。

6年前 评论

如果有些接口在登陆时需要token,不登陆时不需要token;
php里面怎么解决?

6年前 评论

这样是不是refresh_token就没啥用了,前端发出一个请求若返回401直接跳转登录,不需要再用refresh_token刷新token然后再次发送请求了对吧?

6年前 评论

探讨以下情况:如果token被盗用并且在到期后成功刷新了,那么盗用者就等于永久拥有了这个token的使用权了。而我作为原token的所有者会被告知token失效只能重新授权,而我也没有任何途径使盗用者的token失效。

6年前 评论
kinyou

有一种临界点不知道楼主是否考虑过!
例如: 当一个token快要过期的那一刹那, 前端多个请求并发上来, 这样就会到第一个请求成功, 后面的多个请求都会是403或者401

5年前 评论
晏南风 2年前

本地环境 Laravel 5.6 的可以 执行 composer require tymon/jwt-auth 1.0.0-rc.2

5年前 评论

在你的segmentfault上看了这篇文章,然后就采用了,,我也是看laravel的passport好麻烦的样子,,

感谢

5年前 评论

请问,是不是不用vuex,就用localstore就能实现了。。。

5年前 评论

@史沟飞
你的回答是正解, token 过期了就得拿 refresh_token 去颁发新 token 看来楼主还是不懂什么是 oauth2.0

5年前 评论

@BIBIBABIBO 是你没有理解什么才是 oauth2.0,如果你理解了,就会知道我这篇文章根本就不是在讲 oauth2.0。

5年前 评论

@kinyou 请看 blacklist_grace_period 配置项

5年前 评论

提问前请先仔细阅读文章,多找一点资料看一下。
很多问题真是蠢萌得让我不想回答 :unamused:

5年前 评论

@Seaony 我说是TOKEN被盗用,并且被盗用者在你系统自动刷新了TOKEN。原TOKEN持有人就无法作废这个TOKEN了。
用你的例子:你拿到一个A token,access time 是 1 小时。但保管不善被人盗取了,而你并不知情。在一小时后盗用者使用这个A token访问接口并且系统『自动』refresh为B token。那么盗用者就掌握了这个B token的所有权了,而你用A token去refresh系统会提示你已经blacklist了,你只能眼睁睁看着人家盗用你的身份啦~

5年前 评论

@Seaony 哦,好的看明白了,refresh time是累计的。但被盗用的token还是可以在7天内可以被人任意使用了,而原token所有人却不能作废他。

5年前 评论

@Seaony 如果 token 在 7 天之后就无法刷新的话。比如在 app 中,不可能要求用户 7 天就重新登录一次吧?当然这个 7 天可以改长,但是改长了确实又和 @史沟飞 所说的,安全风险也增加了。

5年前 评论

@Seaony 你好 我按照你的示例来写的

file

当token过期后 token刷新后 就报错,
我怀疑这句错误了。
Auth::guard('api')->onceUsingId($this->auth->manager()->getPayloadFactory()->buildClaimsCollection()->toPlainArray()['sub']);

错误是:The token has been blacklisted

希望得到你的帮助

5年前 评论
寻梦 2年前

@Zach 请详细描述一下你的操作流程,有具体定位到是哪一行抛出的错误吗?

5年前 评论

file

file
以上是刷新的那次请求,能看到header里返回了新的token,但是响应里却为空,我是完全照搬你的中间件代码的,哪里有问题吗?@Seaony

Route::middleware('refresh.token')->get('/auth/me', function (Request $request) {
    return response()->json(auth()->user());
});
5年前 评论

@lukedever headers 和 body 是不同的,你说为空的是 body,你需要从 headers 里取

5年前 评论

@Seaony 对啊,我的接口是返回auth()->user(),所以body应该有登录用户的信息,但是没有

5年前 评论

了解了,log 一下 auth()->user(),看看是不是没有 login 进去 @lukedever

5年前 评论

@Seaony 是没登录进去,第二次就是刷新的那次的log,header获取新token是正常的,用新token又正常了

file

5年前 评论

@lukedever 试试 Auth::guard('your guard')->user()

5年前 评论

@Seaony 现在可以了,感觉没动代码就好了,辛苦大佬了

5年前 评论

@Seaony @Zach 用户退出登录的时候,然后拿着老的 token 去调用接口就会报 The token has been blacklisted,在中间件 $this->auth->parseToken()->authenticate() 里面报的错

5年前 评论

讲解的非常细致,真的比好多文章细致很多,而且是一步一步去实现的,这里非常感谢作者。

5年前 评论

@江渚之上 logout api,最好不要抛错,能删则删,不能删也给前端返回2xx,这样前端收到回调,删除本地token退出。会不会更好呢。

5年前 评论

有跨域需求的时候,通过 laravel-cors 在配置文件中 exposedHeaders 选项把 Authorization 暴露,不然好像会拿不到这个token哦

5年前 评论
yema

你好。根据这种方法,代码会执行俩次数据库查询用户的代码。根据我的检查,好像JWT会默认执行一次,然后这个中间件中会执行一次。请问怎么排除第一次的sql查询

5年前 评论

请问 token 过期时间设定了一周,但是在使用过程中总是提示token过期,请问这是什么原因造成的?

4年前 评论

这个其他分组能用吗,例如admins 我这边用会出现未登录

4年前 评论

@coral Bearer后面接一个空格

4年前 评论

@kinyou @Seaony 有一种临界点不知道楼主是否考虑过!
例如:当一个 token 快要过期的那一刹那,前端多个请求并发上来,这样就会到第一个请求成功,后面的多个请求都会是 403 或者 401 我目前就遇到这个问题了? 最后怎么解决的那???

4年前 评论
晏南风 2年前

@zach_zx 我用其他分组的时候,使用这个中间件,直接跳到401显示未登陆,是否要更改其他东西

4年前 评论

@yema 这个问题 你解决了吗?

4年前 评论
yema

@Zach 没有,已经放弃了。2次我也接受了。毕竟后台没多少人用。

4年前 评论

@zach_zx

'blacklist_grace_period' => env('JWT_BLACKLIST_GRACE_PERIOD', 0),

但是我这没有起作用

4年前 评论

jwt-auth 生成的 token 太长了,可以自定义这个 token 的长度吗?

4年前 评论

@Seaony 新建项目没有 UserControll@profile 控制器和 profile 方法, Route::middleware ('refresh.token') 这个 refresh.token 中间件从哪添加的?
file

4年前 评论

@levi 中间件的代码已经 po 出来了,直接在 kernel 中注册中间件即可。UserControll@profile 只是指定一个可以请求的方法而已,随便新建一个控住器 return [] 也可以达到同样的效果,中间件才是 refresh 的核心逻辑

4年前 评论
levi 4年前
levi 4年前
levi 4年前
Zach 4年前

怎么安装不了

Your requirements could not be resolved to an installable set of packages.

  Problem 1
    - Installation request for tymon/jwt-auth ^0.5.12 -> satisfiable by tymon/jwt-auth[0.5.12].
    - Conclusion: remove nesbot/carbon 2.21.2
    - Conclusion: don't install nesbot/carbon 2.21.2
    - tymon/jwt-auth 0.5.12 requires nesbot/carbon ~1.0 -> satisfiable by nesbot/carbon[1.0.0, 1.0.1, 1.1.0, 1.10.0, 1.11.0, 1.12.0, 1.13.0, 1.14.0, 1.15.0, 1.16.0, 1.17.0, 1.18.0, 1.19.0, 1.2.0, 1.20.0, 1.21.0, 1.22.0, 1.22.1, 1.23.0, 1.24.0, 1.24.1, 1.24.2, 1.25.0, 1.25.1, 1.25.3, 1.26.0, 1.26.1, 1.26.2, 1.26.3, 1.26.4, 1.26.5, 1.26.6, 1.27.0, 1.28.0, 1.29.0, 1.29.1, 1.29.2, 1.3.0, 1.30.0, 1.31.0, 1.31.1, 1.32.0, 1.33.0, 1.34.0, 1.34.1, 1.34.2, 1.34.3, 1.34.4, 1.35.0, 1.35.1, 1.36.0, 1.36.1, 1.36.2, 1.37.0, 1.37.1, 1.38.0, 1.38.1, 1.38.2, 1.38.3, 1.38.4, 1.39.0, 1.4.0, 1.5.0, 1.6.0, 1.7.0, 1.8.0, 1.9.0].
    - Can only install one of: nesbot/carbon[1.26.3, 2.21.2].
    - Can only install one of: nesbot/carbon[1.26.4, 2.21.2].
    - Can only install one of: nesbot/carbon[1.26.5, 2.21.2].
    - Can only install one of: nesbot/carbon[1.26.6, 2.21.2].
    - Can only install one of: nesbot/carbon[1.27.0, 2.21.2].
    - Can only install one of: nesbot/carbon[1.28.0, 2.21.2].
    - Can only install one of: nesbot/carbon[1.29.0, 2.21.2].
    - Can only install one of: nesbot/carbon[1.29.1, 2.21.2].
    - Can only install one of: nesbot/carbon[1.29.2, 2.21.2].
    - Can only install one of: nesbot/carbon[1.30.0, 2.21.2].
    - Can only install one of: nesbot/carbon[1.31.0, 2.21.2].
    - Can only install one of: nesbot/carbon[1.31.1, 2.21.2].
    - Can only install one of: nesbot/carbon[1.32.0, 2.21.2].
    - Can only install one of: nesbot/carbon[1.33.0, 2.21.2].
    - Can only install one of: nesbot/carbon[1.34.0, 2.21.2].
    - Can only install one of: nesbot/carbon[1.34.1, 2.21.2].
    - Can only install one of: nesbot/carbon[1.34.2, 2.21.2].
    - Can only install one of: nesbot/carbon[1.34.3, 2.21.2].
    - Can only install one of: nesbot/carbon[1.34.4, 2.21.2].
    - Can only install one of: nesbot/carbon[1.35.0, 2.21.2].
    - Can only install one of: nesbot/carbon[1.35.1, 2.21.2].
    - Can only install one of: nesbot/carbon[1.36.0, 2.21.2].
    - Can only install one of: nesbot/carbon[1.36.1, 2.21.2].
    - Can only install one of: nesbot/carbon[1.36.2, 2.21.2].
    - Can only install one of: nesbot/carbon[1.37.0, 2.21.2].
    - Can only install one of: nesbot/carbon[1.37.1, 2.21.2].
    - Can only install one of: nesbot/carbon[1.38.0, 2.21.2].
    - Can only install one of: nesbot/carbon[1.38.1, 2.21.2].
    - Can only install one of: nesbot/carbon[1.38.2, 2.21.2].
    - Can only install one of: nesbot/carbon[1.38.3, 2.21.2].
    - Can only install one of: nesbot/carbon[1.38.4, 2.21.2].
    - Can only install one of: nesbot/carbon[1.39.0, 2.21.2].
    - Can only install one of: nesbot/carbon[1.0.0, 2.21.2].
    - Can only install one of: nesbot/carbon[1.0.1, 2.21.2].
    - Can only install one of: nesbot/carbon[1.1.0, 2.21.2].
    - Can only install one of: nesbot/carbon[1.10.0, 2.21.2].
    - Can only install one of: nesbot/carbon[1.11.0, 2.21.2].
    - Can only install one of: nesbot/carbon[1.12.0, 2.21.2].
    - Can only install one of: nesbot/carbon[1.13.0, 2.21.2].
    - Can only install one of: nesbot/carbon[1.14.0, 2.21.2].
    - Can only install one of: nesbot/carbon[1.15.0, 2.21.2].
    - Can only install one of: nesbot/carbon[1.16.0, 2.21.2].
    - Can only install one of: nesbot/carbon[1.17.0, 2.21.2].
    - Can only install one of: nesbot/carbon[1.18.0, 2.21.2].
    - Can only install one of: nesbot/carbon[1.19.0, 2.21.2].
    - Can only install one of: nesbot/carbon[1.2.0, 2.21.2].
    - Can only install one of: nesbot/carbon[1.20.0, 2.21.2].
    - Can only install one of: nesbot/carbon[1.21.0, 2.21.2].
    - Can only install one of: nesbot/carbon[1.22.0, 2.21.2].
    - Can only install one of: nesbot/carbon[1.22.1, 2.21.2].
    - Can only install one of: nesbot/carbon[1.23.0, 2.21.2].
    - Can only install one of: nesbot/carbon[1.24.0, 2.21.2].
    - Can only install one of: nesbot/carbon[1.24.1, 2.21.2].
    - Can only install one of: nesbot/carbon[1.24.2, 2.21.2].
    - Can only install one of: nesbot/carbon[1.25.0, 2.21.2].
    - Can only install one of: nesbot/carbon[1.25.1, 2.21.2].
    - Can only install one of: nesbot/carbon[1.25.3, 2.21.2].
    - Can only install one of: nesbot/carbon[1.26.0, 2.21.2].
    - Can only install one of: nesbot/carbon[1.26.1, 2.21.2].
    - Can only install one of: nesbot/carbon[1.26.2, 2.21.2].
    - Can only install one of: nesbot/carbon[1.3.0, 2.21.2].
    - Can only install one of: nesbot/carbon[1.4.0, 2.21.2].
    - Can only install one of: nesbot/carbon[1.5.0, 2.21.2].
    - Can only install one of: nesbot/carbon[1.6.0, 2.21.2].
    - Can only install one of: nesbot/carbon[1.7.0, 2.21.2].
    - Can only install one of: nesbot/carbon[1.8.0, 2.21.2].
    - Can only install one of: nesbot/carbon[1.9.0, 2.21.2].
    - Installation request for nesbot/carbon (locked at 2.21.2) -> satisfiable by nesbot/carbon[2.21.2].

Installation failed, reverting ./composer.json to its original content.
4年前 评论
myhui0926 4年前
myhui0926 4年前

下面列出来代码我不理解。这行代码返回结果是boolean,如何保证此次请求成功?直接next(),并且把新的token 返回,应该就可以通过了吧?

 // 使用一次性登录以保证此次请求的成功
                Auth::guard('api')->onceUsingId($this->auth->manager()->getPayloadFactory()->buildClaimsCollection()->toPlainArray()['sub']);
4年前 评论

刷新token时报错是怎么回事啊
Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException: Token not provided in file /web/csgo/vendor/tymon/jwt-auth/src/Http/Middleware/BaseMiddleware.php on line 52

4年前 评论
kingThegirl (作者) 4年前
uax

发现有个问题,在刷新 token 的时候,是无法获取到当前用户的。

控制器中,auth()->user()可以获取到用户,

但是如果碰巧遇到需要刷新token,就无法获取到当前用户,因为传入的token已经失效。

4年前 评论
xrh527 3年前
海狗王 3年前
sudden3 2年前
西巴以及 2年前
LeO荣 2年前

@blade

 Route::middleware ('refresh.token') 这个 refresh.token 中间件如何添加的?参见如下:
app/Http/Kernel.php文件中的protected $routeMiddleware配置中添加:

'refresh.token' => 'App\Http\Middleware\RefreshToken',

当用户退出当前会话时,再拿之前的token去访问,返回的异常是TokenBlacklistedException,所以RefreshToken.php文件中,也要捕获下TokenBlacklistedException,代码如下:

//上面省略....
try {
            // 检测用户的登录状态,如果正常则通过
            if ($this->auth->parseToken()->authenticate()) {
                return $next($request);
            }
            throw new UnauthorizedHttpException('jwt-auth', '未登录');
        }catch(TokenBlacklistedException $exception){
            //当用户退出之后,token就会被拉黑,就会返回TokenBlacklistedException
            throw new UnauthorizedHttpException('jwt-auth', '未登录');
        }catch (TokenExpiredException $exception) {
        //下面省略...

参考来自文中的第一个评价https://segmentfault.com/a/119000001260624...

后面我又进行了一些调整,新添加了一个数据库用于保存token,登录的时候以及刷新时将token保存到数据库中,退出登录的时候可以将数据库中的tokens加入黑名单。登录的时候,如果将库中的有效tokens取出加入黑名单,即可实现单点登录的功能。注意,最好在.env的配置文件中加入

JWT_BLACKLIST_GRACE_PERIOD=10

用于解决当access_token过期时并发刷新获得新的token时之前的access_token报异常的问题。

4年前 评论
levi 4年前

@uax 请问这个问题解决了吗?该如何解决呢?

4年前 评论
wangxi
$this->auth->manager()->getPayloadFactory()->buildClaimsCollection()->toPlainArray()['sub']

请问这段代码我该怎么理解呢

4年前 评论

作者你好,,这个thinkphp 能用不?

4年前 评论

请问一下无关token的问题, 如果是小程序并没有账号密码,该怎么使用jwt-auth的auth呢? 这困扰我很长时间了

3年前 评论
wZzz_98 2年前

最后一次返回了新的token但是策略授权通不过

3年前 评论

我在执行php artisan tinker后 显示如下错误 :

file

3年前 评论

\Auth::guard('api')->onceUsingId($this->auth->manager()->getPayloadFactory()->buildClaimsCollection()->toPlainArray()['sub']); 这句话好像没起作用,过期后成功刷新了新token但是一直报错The token has been blacklisted,我预期的效果是这一次暂时给他通过,但是他连这一次都不通过,直接报The token has been blacklisted,有朋友知道是什么原因吗

3年前 评论
leeyi 3年前
siiiyaa (作者) 3年前
leeyi 3年前

我现在在 App\Models\User 里面添加了下面的方法,(给user_id 做了一个hashids:encode 之后,导致 authenticate 方法校验不通过 401 错误),如何解决该问题?

    public function getJWTCustomClaims()
    {
        return [
            'sub' => $this->getRouteKey(),
        ];
    }
3年前 评论
leeyi (作者) 3年前

大神,为啥我的前端没办法获取返回的刷新token,直接被Handler捕获了返回错误

file

3年前 评论
leeyi 3年前
leeyi 3年前

大佬,我想请问一下我的token过期的时候报错500函数不存在,如下图片

file

应该是我的Auth引入的错误,我像你一样use Auth是不行的,请问是我还漏了是吗步骤吗? 我使用的laravel版本是5.6

2年前 评论

我去年做的项目,基于laravel5.*+JWT的前后端分离的项目,客户想要无感刷新。
但是我按照文档,修改代码测试了几次感觉有些疑惑。
在jwt.php配置文件中,设置了
'ttl' => env('JWT_TTL', 60),
'refresh_ttl' => env('JWT_REFRESH_TTL', 20160),
'blacklist_grace_period' => env('JWT_BLACKLIST_GRACE_PERIOD', 10),
那么是不是可以理解为:
我成功登录生成了一个$token,有效期一小时,生命周期两周

blacklist_grace_period黑名单宽限期设置了10,只要生成token,就算1小时后token过期,但是还有十分钟的宽限期,在这10分钟之内,我还能拿old_token去调用刷新方法,生成新的token?
已经解决了单点登录的问题,这个无感刷新有官网文档吗?我只找到了这个jwt-auth.readthedocs.io/en/develop...

2年前 评论
levi 2年前

讨论应以学习和精进为目的。请勿发布不友善或者负能量的内容,与人为善,比聪明更重要!