V2EX = way to explore
V2EX 是一个关于分享和探索的地方
现在注册
已注册用户请  登录
DavidNineRoc
V2EX  ›  PHP

API 开发中可选择传递 token 接口遇到的一个坑

  •  
  •   DavidNineRoc · 2018-06-24 12:22:14 +08:00 · 5445 次点击
    这是一个创建于 2351 天前的主题,其中的信息可能已经有所发展或是发生改变。
    1. 在做 API 开发时,不可避免会涉及到登录验证,我使用的是jwt-auth
    2. 在登录中会经常遇到一个token过期的问题,在config/jwt.php默认设置中,这个过期时间是一个小时,不过为了安全也可以设置更小一点,我设置了为五分钟。
    3. 五分钟过期,如果就让用户去登录,这种体验会让用户直接抛弃你的网站,所以这就会使用到刷新token这个功能
    4. 正常情况下是写一个刷新token的接口,当过期的时候前端把过期的token带上请求这个接口换取新的token
    5. 不过为了方便前端也可以使用后端刷新返回,直至不可刷新,我用的就是这个方法:使用 Jwt-Auth 实现 API 用户认证以及无痛刷新访问令牌
    6. 而坑就是这样来的,
    • 在必须需要登录验证的接口设置刷新token
    <?php
    
    namespace App\Http\Middleware;
    
    use App\Services\StatusServe;
    use Closure;
    use Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException;
    use Tymon\JWTAuth\Exceptions\JWTException;
    use Tymon\JWTAuth\Exceptions\TokenExpiredException;
    use Tymon\JWTAuth\Http\Middleware\BaseMiddleware;
    
    class CheckUserLoginAndRefreshToken extends BaseMiddleware
    {
        /**
         * 检查用户登录,用户正常登录,如果 token 过期
         * 刷新 token 从响应头返回
         *
         * @param         $request
         * @param Closure $next
         * @return \Illuminate\Http\JsonResponse|\Illuminate\Http\Response
         * @throws JWTException
         */
        public function handle($request, Closure $next)
        {
            /****************************************
             * 检查 token 是否存在
             ****************************************/
            $this->checkForToken($request);
    
            try {
                /****************************************
                 * 尝试通过 tokne 登录,如果正常,就获取到用户
                 * 无法正确的登录,抛出 token 异常
                 ****************************************/
                if ($this->auth->parseToken()->authenticate()) {
                    return $next($request);
                }
                throw new UnauthorizedHttpException('jwt-auth', 'User not found');
    
            } catch (TokenExpiredException $e) {
                try {
                    /****************************************
                     * token 过期的异常,尝试刷新 token
                     * 使用 id 一次性登录以保证此次请求的成功
                     ****************************************/
                    $token = $this->auth->refresh();
                    $id = $this->auth
                        ->manager()
                        ->getPayloadFactory()
                        ->buildClaimsCollection()
                        ->toPlainArray()['sub'];
    
                    auth()->onceUsingId($id);
                } catch (JWTException $e) {
                    /****************************************
                     * 如果捕获到此异常,即代表 refresh 也过期了,
                     * 用户无法刷新令牌,需要重新登录。
                     ****************************************/
                    throw new UnauthorizedHttpException('jwt-auth', $e->getMessage(), null, StatusServe::HTTP_PAYMENT_REQUIRED);
                }
            }
    
            // 在响应头中返回新的 token
            return $this->setAuthenticationHeader($next($request), $token);
        }
    }
    
    • 而有些页面,比如文章列表页面,这个接口登录与不登录皆可访问,不过登录的时候可以在页面上显示是否点赞了这篇文章。所以这个接口直接使用的是jwt-auth默认的option中间件
    <?php
    
    /*
     * This file is part of jwt-auth.
     *
     * (c) Sean Tymon <[email protected]>
     *
     * For the full copyright and license information, please view the LICENSE
     * file that was distributed with this source code.
     */
    
    namespace Tymon\JWTAuth\Http\Middleware;
    
    use Closure;
    use Exception;
    
    class Check extends BaseMiddleware
    {
        /**
         * Handle an incoming request.
         *
         * @param  \Illuminate\Http\Request  $request
         * @param  \Closure  $next
         *
         * @return mixed
         */
        public function handle($request, Closure $next)
        {
            if ($this->auth->parser()->setRequest($request)->hasToken()) {
                try {
                    $this->auth->parseToken()->authenticate();
                } catch (Exception $e) {
    
    
                }
    
            }
    
            return $next($request);
        }
    }
    
    1. 一开始也没有发现问题,直到测试的时候,发现文章列表页面点赞过的文章,过了一段时间再刷新的时候发现不显示已点赞,但是进入个人中心的已点赞文章可以看到。
    2. 刚开始测试没找出原因,直接暴力调试代码,发现没获取到登录用户,一想不对呀,已经传token为何获取不到。经过发现,去到个人中心,再回到新闻列表页就可以正常显示,过了一段时间又不显示了。
    3. 经过这一轮之后,大概明白,在新闻列表页时,token已经过期,但是当时图方便用的jwt-auth默认的中间件,不会刷新token,所以这个接口获取不到登录的用户。当进入个人中心,发现当前token已经过期,后台刷新token返回,这时候再回到文章列表页就可以得到正常的数据,一段时间后,token又失效了,所以有无法看到点赞过的文章
    4. 解决方法,自己写一个option中间件,当存在token的时候,也需要做token刷新处理。
    <?php
    
    namespace App\Http\Middleware;
    
    use Closure;
    use Exception;
    
    class Check extends BaseMiddleware
    {
    
        public function handle($request, Closure $next)
        {
            if ($this->auth->parser()->setRequest($request)->hasToken()) {
                try {
                    $this->auth->parseToken()->authenticate();
                } catch (TokenExpiredException $e) {
    				// 此处做刷新 token 处理
    				// 具体代码可以参考必须需要登录验证的接口
    				// 在响应头中返回新的 token
                    return $this->setAuthenticationHeader($next($request), $token);
    			} catch (Exception $e) {
    			
                }
    
            }
    
            return $next($request);
        }
    }
    

    问题解决。 最后说一个并发会出现的问题:

    # 当前 token_1 过期,先发起 a 请求,之后马上发起 b 请求
    # a 请求到服务器,服务器判断过期,刷新 token_1
    # 之后返回 token_2 给 a 请求响应
    # 这时候迟一点的 b 请求用的还是 token_1 
    # 服务器已经将此 token_1 加入黑名单,所以 b 请求无效
           token_1         刷新返回 token_2
    a 请求 --------> server -------> 成功
           token_1         过期的 token_1,应该使用 token_2
    b 请求 --------> server ------> 失败
    

    jwt-auth已经想到这种情况,我们只需要设置一个黑名单宽限时间即可 我设置为5秒,就是当token_1过期了,你还能继续使用token_1操作5秒时间

    原文地址

    15 条回复    2018-06-26 09:29:06 +08:00
    sujin190
        1
    sujin190  
       2018-06-24 14:39:10 +08:00 via Android
    现在都会有 https 支持,那么根本不需要这么短吧,用户每次重新打开 app 或者重新进入网站刷新一遍差不多也就可以了,而且在大量用户使用过程中你也不能保证 5 秒客户端就一定能收到吧,如果用户没收到又发起刷新了呢,那你这不是搞的很复杂了而且还不稳定了
    m939594960
        2
    m939594960  
       2018-06-24 14:46:21 +08:00
    不要使用 JWT 做网站的授权,不要使用 JWT 做网站的授权,不要使用 JWT 做网站的授权。 重要的事说三遍。 各种坑爹的问题。
    sdrzlyz
        3
    sdrzlyz  
       2018-06-24 15:30:51 +08:00 via Android
    @m939594960 千万别写代码 千万别写代码 千万别写代码,各种问题!!!
    m939594960
        4
    m939594960  
       2018-06-24 15:57:56 +08:00   ❤️ 1
    @sdrzlyz
    其实用过 JWT 的人应该都大概知道这东西做网站的授权很蛋疼。我列举几个例子。
    1.很难做一个账号同时只能一个位置登录 ,如果允许多个端登录,又会有安全问题,例如获得一个账号之后 疯狂拿 token,之后即使你改密码这个 token 也一直能用,还没办法改密码之后拉黑前面的 token
    2.用户忘记密码 /修改密码之后无法拉黑前面的 token,前面有 token 的人还可以继续用
    3.楼主所说的并发问题

    当时使用的时候还是遇到了特别多的麻烦,但是具体已经记不清了。 后来查了一下感觉 jwt 这东西也确实不适合做网站的授权 。
    lemayi
        5
    lemayi  
       2018-06-24 16:03:15 +08:00
    @m939594960 既然觉得 JWT 不适合做网站授权,那么应该怎么做呢?
    session ?
    m939594960
        6
    m939594960  
       2018-06-24 17:16:12 +08:00
    @lemayi 随便了,反正我觉得 JWT 不适合做网站的授权。
    如果要是接口形式 可以用 token。类似这种
    用户登录后在数据库中生成一个 refersh_token,然后生成一个 token 放在缓存中
    客户端定时通过 refersh_token 换取 新的 token。

    其他端登录 /修改账号密码之后重新生成 refersh_token,这时候其他账号在换取新 token 的时候就会掉线。
    pynix
        7
    pynix  
       2018-06-24 17:23:01 +08:00
    还是用服务端 token 吧,,,自包含的 token 也很坑。。
    GTim
        8
    GTim  
       2018-06-24 17:26:08 +08:00
    我回复一下,啊,有点像刷 V2EX 了,刚刚回复了好多帖子

    楼主,我只想说一个,那就是:JWT 是 JWT,如何保存是如何保存,如何传输是如何传输,如何失效是如何失效,如何标识一个用户是如何标识一个用户

    你还可以拆开更多维度,但一定要这么拆分,你才能很好的使用 JWT,把它们拆的更细,更独立,你就会发现,JWT 只是标识一个用户的载体
    iyangyuan
        9
    iyangyuan  
       2018-06-24 17:26:32 +08:00 via iPhone   ❤️ 1
    安利一下自己开发的安全框架,没错就是好用: https://github.com/iyangyuan/security
    sdrzlyz
        10
    sdrzlyz  
       2018-06-25 06:39:50 +08:00 via Android
    @m939594960 怕是用错了吧,说下我司相关实践。
    0.要结合 Redis.
    1.jwt 里面本身是要签 session 来做单点登录之类的。通过 session 跟 scope 来判断单点。
    2.修改密码的时候,将相关 session 拉黑一段时间。
    3.有一个 can refresh 的时间点。这个时间点跟最大 expire 时间点差值看需求。请求过来后如果可刷新,就刷新后随 header 返回,前端做替换。

    4.为啥不直接用 session ?也不是不可以,只 session 的话,Redis 挂掉,就彻底瘫了。。。这块看场景。
    fuxkcsdn
        11
    fuxkcsdn  
       2018-06-25 12:19:27 +08:00 via iPhone
    @sdrzlyz redis 挂了 session 跟着挂了的话,那你前面 2 点里提到的和 session 相关的也一起挂了。
    那问题来了,session 挂掉期间,你如何做单点登录? session 挂掉期间,你如何拉黑修改密码后的 token ?
    DavidNineRoc
        12
    DavidNineRoc  
    OP
       2018-06-25 15:11:02 +08:00
    @sujin190 API 开发中你无法得知用户什么时候是进入网站,对于后台而言用户的每一次都是从数据库查询重新登录。这个并发是指同一个用户同一时间发起多个请求,这时候 token 已经过期,如果不做并发处理,第一个请求刷新了 token,后面的那几个请求都是无效的。

    @m939594960 你说授权可能需要 passport oauth,jwt 拿来做 API 的登录验证,我觉得完全 OK,还有你说的这几个问题都已经有解决方案了:
    1. 同一个账号如果多次登录,这个很容易解决,只要用户表加一个字段标记当前 token 就行,如果不是,直接告诉已经在其他地方登录
    2. 现在 jwt-auth 都可以主动拉黑 token, 所以你这个问题很容易解决,只要在修改密码之后拉黑当前登录的 token 即可
    3. 并发问题已经解决的,我设置了 5 s,代表当前所有请求的 token 就算过期,还是可以再使用 5s,
    其实你说的这些安全问题,都已经把 token 弄丢了,还谈什么安全呢,就算传统 web,你把登录的 cookie 弄丢了,别人也能伪造身份(所以 token 过期时间可以设置短一点)
    @pynix 不听懂,token 不都是服务端生成吗 >_<
    @GTim 既然能标识身份,所以做登录验证完全没问题,我没太听懂你要表达什么。
    m939594960
        13
    m939594960  
       2018-06-25 16:18:18 +08:00
    @DavidNineRoc
    我觉的 JWT 的优势就是可以不依赖数据库 验证并且获取用户,如果要是还得用上 redis,数据库这类的才能实现安全的授权,那我觉得完全没必要用 jwt,因为加密解密完全无意义的浪费时间,还不如登录时候生成个 session_id 进行存储
    1.是能解决,但是如果还得弄个字段记录当前 token,然后每次去数据库获取当前 token,那么 JWT 的作用是什么,还不如弄个传统的 token
    2.当然可以主动拉黑 token,但是不能根据 user_id 拉黑 token,所以用户多次登录呢?用户找回密码,不登录呢?
    3.丢了是不安全,这时候用户就要改密码或者做些补救的操作,但是补救了之后,之前的 token 如果还能进行刷新,延长有效时间,那么就很有问题。
    DavidNineRoc
        14
    DavidNineRoc  
    OP
       2018-06-25 21:20:31 +08:00
    @m939594960 你说的只是 jwt 的原理,该存储什么。而这个扩展包叫做 jwt-auth 这个名字表达了什么,我觉得不用多说了。
    jwt 的确只存在需要的信息,不过这个扩展为了方便,通过内部存储的信息去数据库匹配正确的用户。
    传统的 token 字段没有过期的概念。
    拉黑为什么要针对用户呢,而是针对 token,比如你修改了密码,难道要拉黑用户?正确的做法应该是拉黑当前的 token,这样就能强制用户重新登录。
    丢了 token,所以设置过期时间为 5 分钟是可以接收的。
    m939594960
        15
    m939594960  
       2018-06-26 09:29:06 +08:00
    @DavidNineRoc
    传统的 token,也可以做过期,而且很轻松。
    拉黑当然要针对用户,这个用户修改密码之后,所有端都要掉线啊,而且用户用其他电脑重新登录后,以前的 token 也应该做掉线处理,如果不掉线,那我拿到 token 就可以为所欲为无限刷新获取新 token,无限使用。


    我觉得 JWT 应该只适合一些简单的地方,例如内部接口的授权,例如只能在指定时间才能打开的 url
    关于   ·   帮助文档   ·   博客   ·   API   ·   FAQ   ·   实用小工具   ·   1084 人在线   最高记录 6679   ·     Select Language
    创意工作者们的社区
    World is powered by solitude
    VERSION: 3.9.8.5 · 24ms · UTC 23:36 · PVG 07:36 · LAX 15:36 · JFK 18:36
    Developed with CodeLauncher
    ♥ Do have faith in what you're doing.