【微思探索】【实战】账号系统有了cookie,为什么还需要token

大家在使用很多网站的过程中,实际上都只需要cookie来管理用户session就够了,然而实际上,笔者认为如果在cookie的基础上,再加入用户的token,两者共同来管理用户的session,那就更加完美了。

以我新上线的网站微思探索为例,我们使用了cookie的同时,也增加了token作为用户的另外一重身份认证机制。

众所周知,cookie存在CSRF窃取session的问题,当然你可以通过在请求http header中加入任意头来防止CSRF攻击,可以将cookie设置为httponly来防止javascript读取。但是笔者的感受是,如果有token的存在会更加完美。

首先我说一下为什么需要token的原因?

  1. token使用了签名机制,一次性nonce,以及时间戳判断,更加安全
  2. token本身自带一些用户信息,后端接口加个@CheckToken标注就能拿到用户信息
  3. token有一定的时效性,目前我们设置为24小时,24小时后需要自动刷新token,保证了更高的安全性
  4. token更适合于移动端APP开发,也就是说,后端接口同时满足了web端和移动端的需求
  5. 针对登陆后接口的限流,可以直接在@CheckToken所在的AOP切面中针对用户id进行限流

话不多说,先上token的结构:

uid:用户id,一般是用户身份的主键id

tid:token id, token的标识,一般是一串唯一字符串,我们采用UUID,由后端生成

nonce:一次性nonce,每个请求都会带上唯一的字符串,由前端生成

ts:时间戳,当前服务器时间,由后端返回

hash:签名,base64(HmacSHA256("tid=xxx&&uid=xxx&&ts=xxx&&nonce=xxx"))

当然,可以按照自己的业务情况在token里加入更多字段,比如用户名等等,这样会方便后端接口获取需要的信息。

token一般保存在浏览器的local storage里面,这样浏览器关闭并不会消失。但是如果存在session storage里面,它跟session cookie一样是会在浏览器关闭时随即消失的。有些人可能会疑问放在localstorage里面的安全性问题,毕竟它不像httponly的cookie一样,它是可以被javascript读取的。但是只要网站能做到防XSS攻击,目前前后端框架都能很好地支持防止脚本注入,只要开发过程中留意一些,不会出什么问题。

一旦防止了XSS攻击,token的安全性是很高的。token在前后端传输过程中,tid对应的tokenKey秘钥是不会一起传输的,而且签名用的是HmacSha256,目前作暴力破解是很难的。到此你可能会有疑问,前端作hmac签名的tokenKey从哪里来的?我们是在用户登录的那一刻返回的tokenKey,以后所有的请求都不会再带上这个tokenKey。此刻你可能又会担心如果一旦登录返回那一刻的响应被人拦截,你的tokenKey被窃取,你的session就被劫持了,问题就严重了。放心,我们有措施完全杜绝这种情况,笔者会在下一篇文章“【微思探索】www.wisexplore.com为什么不用https,但是已足够安全”中进行详细阐述。

token中的nonce是前端通过算法生成的尽可能唯一的字符串:

export function nonce (length = 64) {
  var nonce,
    hash,
    ret = '';
  for (var i = 0; i < 2; i++) {
    nonce = Math.random() * Math.pow(2, 53);

    hash = sjcl.hash.sha256.hash(
      nonce.toString() + (new Date()).valueOf()
    )

    ret += sjcl.codec.base64.fromBits(hash);
  }

  return ret.substr(0, length);
}

所谓尽可能唯一,只是说它重复的概率很小。一次性nonce可以杜绝用户的请求被拦截,然后被重放的攻击。重放攻击有几种情况:

  1. 拦截请求后,马上重放到服务器
  2. 拦截请求后,过一段时间再重放到服务器

一次性nonce可以防止第一种情况,但是防止不了第二种情况,这样说的原因是除非后端永久保存nonce的值,所有的nonce在后端都会有记录存在,从而拒绝该非法请求,然而这样做的成本很高,也没有必要。我们往往都会在缓存中将nonce缓存一段时间,比如缓存半小时,在半个小时内我们可以防止重放攻击,但是一旦是第二种情况,半小时后再发起重放攻击,我们是无法防止的。这时候就是时间戳ts发挥作用的时候了。

服务器端在登录时返回的是服务器端的时间戳,跟客户端的时间有可能是不一致的,甚至可能存在不小的偏差,那如何保持客户端传过来的ts跟服务器时间保持同步呢?客户端在登录后收到服务端返回的时间戳serverTime,同时会在localstorage中保存客户端当前时间clientTime,在下一次请求发起时,token中的

ts = serverTime + (currentTime - clientTime)    其中的currentTime是请求发起时客户端的时间

理论上服务器时间和ts的值只会存在很小的偏差,服务器一旦发现传过来的ts跟服务器时间差的比较大,就可以判断为重放攻击,直接拒绝该请求。

综上所述,token的引入可以防止以下攻击:

1.重放攻击

2.伪造攻击,无法伪造是因为攻击者无法获取tokenKey

3.篡改攻击,攻击者无法篡改任何请求参数,还是因为无法获取tokenKey,一旦篡改任何参数,请求的签名hash值就是非法的。

至此,对于用户登录后的任何后端接口的访问,都可以用token保护起来:

 @CheckToken
 @PostMapping(value="/test")
 public Resource test(TokenContext tokenContext){
     return null;
 }

 其中@CheckToken对应于一个AOP切面, 以下是切面中的主要逻辑:

public void checkToken(TokenContext tokenContext) throws Exception {
        if (!needCheck) {
            return;
        }
        logger.info("TokenContext uid:{},tid:{},nonce:{},ts:{},hash:{}",
                tokenContext.getUid(), tokenContext.getTid(), tokenContext.getNonce(),
                tokenContext.getTs(), tokenContext.getHash());
        Assert.notNull(tokenContext.getUid(), "uid null");
        Assert.notNull(tokenContext.getTid(), "tid null");
        Assert.notNull(tokenContext.getNonce(), "nonce null");
        Assert.notNull(tokenContext.getTs(), "ts null");
        Assert.notNull(tokenContext.getHash(), "hash null");

        //针对用户进行限流
        Integer visitTimes = shortTermCache.get(tokenContext.getUid(), Integer.class);
        Boolean isLockedHalfHour = lockCache.get(tokenContext.getUid(), Boolean.class);
        Integer totalVisitTimes = visitCache.get(tokenContext.getUid(), Integer.class);
        if (visitTimes == null) {
            visitCache.put(tokenContext.getUid(), 1);
            if(isLockedHalfHour == null){
                shortTermCache.put(tokenContext.getUid(), 1);
            } else {
                //继续锁定半小时
                lockCache.put(tokenContext.getUid(), true);
                throw new IdentityValidationException(applicationContext, IdentityExceptionCode.MALICIOUS_REQUEST);
            }
        } else if (totalVisitTimes >= visitPermitTimes || isLockedHalfHour != null) {
            //3分钟内发起超过N次请求,肯定是接口被刷,接口锁定半小时
            lockCache.put(tokenContext.getUid(), true);
            throw new IdentityValidationException(applicationContext, IdentityExceptionCode.MALICIOUS_REQUEST);
        } else {
            visitCache.put(tokenContext.getUid(), totalVisitTimes + 1);
        }

        String wise = executionContext.isMobile() ? request.getHeader(CommonConstants.MOBILE_COOKIE_HEADER)
                : (executionContext.isPlugin() ? request.getHeader(CommonConstants.PLUGIN_COOKIE_HEADER)
                : CookieUtils.getCookie(request.getHeader("Cookie"), CommonConstants.COOKIE_NAME));

        TokenEntity tokenEntity;
        //从缓存获取token
        Object cachedToken = tokenCache.get(tokenContext.getTid());
        if (cachedToken != null) {
            tokenEntity = (TokenEntity) ((Cache.ValueWrapper) cachedToken).get();
        } else {
            tokenEntity = tokenMapper.findTokenByTokenId(tokenContext.getTid());
            if (tokenEntity == null) {
                logger.error("token not exists");
                throw new UnAuthorizedException();
            }
            if ((tokenEntity.getExpireTime() - Long.valueOf(tokenContext.getTs())) > (HALF_AN_HOUR + 300000)) {
                //token还有至少半小时的有效期
                tokenCache.put(tokenContext.getTid(), tokenEntity);
            }
        }

        if (tokenEntity == null
                || !tokenContext.getUid().equals(tokenEntity.getOwnerId().toString())) {
            logger.error("token null or token ownerId not match");
            throw new UnAuthorizedException();
        }

        //在没有cookie的情况下,safari插件只校验token
        if (StringUtils.isEmpty(wise) && !executionContext.isSafariPlugin()) {
            //直接置token失效
            logger.info("no cookie, set token {} expired", tokenEntity.getTokenId());
            tokenService.deleteToken(tokenEntity.getTokenId());
            tokenCache.evict(tokenEntity.getTokenId());
            throw new IdentityValidationException(applicationContext, IdentityExceptionCode.TOKEN_EXPIRED_NEED_RELOGIN);
        }
        if (Long.valueOf(tokenContext.getTs()) > tokenEntity.getExpireTime()) {
            logger.error("token expired");
            //判断cookie是否还在有效期内
            if (StringUtils.hasText(wise)) {
                CookieToken cookieToken = cookieTokenService.findByCookie(tokenEntity.getOwnerId(), wise);
                if (cookieToken == null //一年的cookie再有不足半小时就过期了
                        || (cookieToken.getExpireTime().getTime() - dbUtilMapper.now().getTime()) < HALF_AN_HOUR) {
                    throw new IdentityValidationException(applicationContext, IdentityExceptionCode.TOKEN_EXPIRED_NEED_RELOGIN);
                } else {
                    if (CommonConstants.TOKEN_REFRESH_URL.equals(request.getRequestURI())) {
                        //token refresh接口,校验token生效的情况下放行
                        checkTokenValid(tokenContext, tokenEntity);
                        return;
                    }
                    throw new IdentityValidationException(applicationContext, IdentityExceptionCode.TOKEN_EXPIRED_REFRESH_TOKEN);
                }
            } else if (executionContext.isSafariPlugin()) {
                //safari插件请求,token过期,就让其发起refresh token
                if (CommonConstants.TOKEN_REFRESH_URL.equals(request.getRequestURI())) {
                    logger.info("safari plugin token refresh request");
                    checkTokenValid(tokenContext, tokenEntity);
                    return;
                }
                throw new IdentityValidationException(applicationContext, IdentityExceptionCode.TOKEN_EXPIRED_REFRESH_TOKEN);
            }

            throw new IdentityValidationException(applicationContext, IdentityExceptionCode.TOKEN_EXPIRED_NEED_RELOGIN);
        }

        checkTokenValid(tokenContext, tokenEntity);
    }

    private void checkTokenValid(TokenContext tokenContext, TokenEntity tokenEntity) throws UnsupportedEncodingException {
        //检测nonce是否有效
        Object value = nonceCache.get(tokenContext.getNonce());
        if (value == null) {
            //nonce或许使用过,但是已经过期
            Date date = dbUtilMapper.now();
            Long requestTime = Long.valueOf(tokenContext.getTs());
            if ((date.getTime() - requestTime) > nonceValidTime * 1000) {
                //过期后重放的请求
                logger.error("Repeated nonce after expired,now_date:{},Ts:{}", date.getTime(), requestTime);
                throw new UnAuthorizedException();
            }
            nonceCache.put(tokenContext.getNonce(), tokenContext.getNonce());
        } else {
            //nonce重复
            logger.error("Repeated nonce,{}", tokenContext.getNonce());
            throw new UnAuthorizedException();
        }

        String rawString = "tid=" + tokenContext.getTid() + "&&uid=" + tokenContext.getUid() + + "&&ts=" + tokenContext.getTs() + "&&nonce=" + tokenContext.getNonce() ;
        String token_key = tokenEntity.getTokenKey();
        String created_signature = Base64Utils.encodeToString(HmacSHA256.encodeHmacSHA256(rawString, token_key.getBytes("UTF-8")));
        if (!created_signature.equals(tokenContext.getHash())) {
            logger.error("hash not valid");
            throw new UnAuthorizedException();
        }
    }

这样对于后端接口的保护来说是很方便的,而且可以方便地对用户请求进行限流。

有没有感觉到有一种安全感?笔者建议在使用cookie的同时,也同时使用token。欢迎跟我交流!

文章已经收录在此 :

【微思探索】创业团队诚邀前端小伙伴加入我们团队,一起参与后续功能的开发。让我们一同创造用户满意的产品。我们的产品很有潜力哦。

  • 0
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值