ruoyi-cloud认证-token改造为双token

ruoyi-cloud认证-token改造为双token

前言

Token在计算机身份认证中是令牌(临时)的意思,在词法分析中是标记的意思。一般作为邀请、登录系统使用。

什么是双token

双token一般是指:access_token和refresh_token。

access_token是一种JWT(json web token),有效时间通常较短,用户在获取资源的时候需要携带access_token,当access_token过期后,如果是活跃用户,就需要使用refresh_token获取一个新的access_token,这样就避免了用户使用正high被踢出去,重新登录,那估计摔手机都有可能。但是对于登录上去,长时间不操作的用户呢,一般会设置超时时间,比如:设置5分钟超时时间,那连续5分钟没有任何操作就会被认为是超时,就会被请下去,需要重新登录,获取新的token。

ruoyi-cloud的token机制简述

  1. 在登录成功后,创建token。
    首先创建UUID,作为userKey,以该key为主键存储用户信息到redis,设置过期时间。
    其次,使用userKey及用户部分信息生成JWT token。而该JWT token即为对客户端暴漏的用户token。
  2. 在业务调用期间,由Gateway的com.ruoyi.gateway.filter.AuthFilter对token的合法性进行校验。上面说到,JWT token中包含userKey信息,则解析JWT token后,即可通过userKey从redis中获取到用户信息。当redis中该信息不存在,则意味着用户token失效。
  3. 那么ruoyi-cloud是怎么对token延期的呢?在com.ruoyi.common.security.interceptor.HeaderInterceptor中可以看到,在每次请求中都调用方法com.ruoyi.common.security.auth.AuthUtil.verifyLoginUserExpire(loginUser),该方法最终执行逻辑如下:
public void verifyToken(LoginUser loginUser)
    {
        long expireTime = loginUser.getExpireTime();
        long currentTime = System.currentTimeMillis();
        if (expireTime - currentTime <= MILLIS_MINUTE_TEN)
        {
            refreshToken(loginUser);
        }
    }

即当过期时间减去当前时间小于某一固定时间时,则刷新token。实际是做了token延期处理。

以上,即为ruoyi-cloud的token机制。

存在的问题

  1. 一个token可无限延期下去,过期时间越长,则安全性越低;
  2. token延期的机制,取决于用户请求的时间点、token过期时长、及MILLIS_MINUTE_TEN的取值。比如:token有效时长30分钟,MILLIS_MINUTE_TEN的取值为15分钟,那么在登录后的前15分钟,不会刷新token,即不会延长token。这样就形成了,我在第14分钟还在操作,本身人为要到第44分钟token才会过期,结果在第30分钟token就过期了。(😊有点绕)

如何改造

主要流程见下图:
双token认证机制

主要处理逻辑

  1. 用户登录验证通过,则分别生成accessToken、refreshToken,并将它们对应的过期时间一并返回给客户端;
  2. 客户端存储信息,在每次业务交互时,验证本地的token过期时间;
  3. 如果accessToken未过期,则携带accessToken调用业务接口;
  4. 如果accessToken已过期,refreshToken未过期,则携带refreshToken调用/auth/refresh接口,获取新的accessToken;
  5. 如果accessToken已过期,refreshToken已过期,则跳转要求重新登录。

实现关键代码

登录token创建
public Map<String, Object> createAllToken(LoginUser loginUser)
    {
        String token = IdUtils.fastUUID();
        String refToken = IdUtils.fastUUID();
        Long userId = loginUser.getSysUser().getUserId();
        Long deptId = loginUser.getSysUser().getDeptId();
        String userName = loginUser.getSysUser().getUserName();
        long currentTimeMillis = System.currentTimeMillis();
        long expires_time = currentTimeMillis+ expireTime * MILLIS_MINUTE;
        long refresh_expires_time = currentTimeMillis + refreshExpireTime * MILLIS_MINUTE;

        loginUser.setUserid(userId);
        loginUser.setUsername(userName);
        loginUser.setDeptId(deptId);
        loginUser.setIpaddr(IpUtils.getIpAddr(ServletUtils.getRequest()));
        loginUser.setLoginTime(System.currentTimeMillis());
        loginUser.setToken(token);
        loginUser.setRefToken(refToken);
        loginUser.setExpireTime(expires_time);
        loginUser.setRefExpireTime(refresh_expires_time);

        cacheAllToken(loginUser);
        // Jwt存储信息
        Map<String, Object> claimsMap = new HashMap<>();
        claimsMap.put(SecurityConstants.USER_KEY, token);
        claimsMap.put(SecurityConstants.DETAILS_USER_ID, userId);
        claimsMap.put(SecurityConstants.DETAILS_DEPT_ID, deptId);
        claimsMap.put(SecurityConstants.DETAILS_USERNAME, userName);
        claimsMap.put(SecurityConstants.TOKEN_TYPE, SecurityConstants.TOKEN_TYPE_ACCESS);
        String accToken = JwtUtils.createToken(claimsMap);
        // Jwt ref token
        claimsMap.put(SecurityConstants.USER_KEY, refToken);
        claimsMap.put(SecurityConstants.TOKEN_TYPE, SecurityConstants.TOKEN_TYPE_REFRESH);
        String refreshToken = JwtUtils.createToken(claimsMap);
        // 接口返回信息
        Map<String, Object> rspMap = new HashMap<String, Object>();
        rspMap.put("access_token", accToken);
        rspMap.put("expires_in", expireTime);
        rspMap.put("expires_time", expires_time);
        rspMap.put("refresh_token", refreshToken);
        rspMap.put("refresh_expires_in", refreshExpireTime);
        rspMap.put("refresh_expires_time", refresh_expires_time);

        return rspMap;
    }

可以看到,创建两个token,并返回。且两个token的信息中,仅userKey、过期时间不一致,其余信息都一致。两个token都作为userKey,存储用户信息到redis。refreshToken的过期时间长与accessToken的过期时间。

token刷新

根据refreshToken获取到用户信息

String userkey = JwtUtils.getUserKey(refreshToken);
return redisService.getCacheObject(getRefTokenKey(userkey));

从refreshToken中解析出userKey,然后取出登录用户信息。

创建accessToken,并建立新的accessToken与refreshToken的映射关系

public Map<String, Object> createAccessToken(LoginUser loginUser)
    {
        // 在创建新的accessToken前,判断之前的token是否存在,如果存在则删除
        String oldToken = loginUser.getToken();
        delAccessToken(oldToken);
        String token = IdUtils.fastUUID();
        long currentTimeMillis = System.currentTimeMillis();
        long expires_time = currentTimeMillis+ expireTime * MILLIS_MINUTE;

        loginUser.setIpaddr(IpUtils.getIpAddr(ServletUtils.getRequest()));
        loginUser.setLoginTime(System.currentTimeMillis());
        loginUser.setToken(token);
        loginUser.setExpireTime(expires_time);

        cacheAccessToken(loginUser);
        // Jwt存储信息
        Map<String, Object> claimsMap = new HashMap<>();
        claimsMap.put(SecurityConstants.USER_KEY, token);
        claimsMap.put(SecurityConstants.DETAILS_USER_ID, loginUser.getUserid());
        claimsMap.put(SecurityConstants.DETAILS_DEPT_ID, loginUser.getDeptId());
        claimsMap.put(SecurityConstants.DETAILS_USERNAME, loginUser.getUsername());
        claimsMap.put(SecurityConstants.TOKEN_TYPE, SecurityConstants.TOKEN_TYPE_ACCESS);
        String accToken = JwtUtils.createToken(claimsMap);
        // 接口返回信息
        Map<String, Object> rspMap = new HashMap<String, Object>();
        rspMap.put("access_token", accToken);
        rspMap.put("expires_in", expireTime);
        rspMap.put("expires_time", expires_time);
        return rspMap;
    }

token的刷新,实际我这里仅创建了新的accessToken,这里就要求将refreshToken的超时时间设置的足够长。这个需要根据业务实际需要综合考虑。实际也可以通过延长refreshToken的过期时间解决。

token的验证
    private static final String REFRESH_PATH = "/auth/refresh";
	public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
        ServerHttpRequest request = exchange.getRequest();
        ServerHttpRequest.Builder mutate = request.mutate();

        String url = request.getURI().getPath();
        // 跳过不需要验证的路径
        if (StringUtils.matches(url, ignoreWhite.getWhites())) {
            return chain.filter(exchange);
        }
        String token = getToken(request);
        if (StringUtils.isEmpty(token)) {
            return unauthorizedResponse(exchange, "令牌不能为空");
        }
        Claims claims = JwtUtils.parseToken(token);
        if (claims == null) {
            return unauthorizedResponse(exchange, "令牌已过期或验证不正确!");
        }
        String userkey = JwtUtils.getUserKey(claims);
        String tokenType = JwtUtils.getTokenType(claims);
        boolean isLogin;
        if(SecurityConstants.TOKEN_TYPE_ACCESS.equalsIgnoreCase(tokenType)){
            isLogin = redisService.hasKey(getTokenKey(userkey));
        }else{
            // 如果时refreshToken,则url只能是刷新接口。
            if(!REFRESH_PATH.equalsIgnoreCase(url)){
                return unauthorizedResponse(exchange, "令牌验证失败");
            }
            isLogin = redisService.hasKey(getRefTokenKey(userkey));
        }
        if (!isLogin) {
            return accUnauthorizedResponse(exchange, "登录状态已过期");
        }
        String userid = JwtUtils.getUserId(claims);
        String username = JwtUtils.getUserName(claims);
        String deptId = JwtUtils.getDeptId(claims);
        if (StringUtils.isEmpty(userid) || StringUtils.isEmpty(username)) {
            return unauthorizedResponse(exchange, "令牌验证失败");
        }

        // 设置用户信息到请求
        addHeader(mutate, SecurityConstants.USER_KEY, userkey);
        addHeader(mutate, SecurityConstants.DETAILS_USER_ID, userid);
        addHeader(mutate, SecurityConstants.DETAILS_DEPT_ID, deptId);
        addHeader(mutate, SecurityConstants.DETAILS_USERNAME, username);
        // 内部请求来源参数清除
        removeHeader(mutate, SecurityConstants.FROM_SOURCE);
        return chain.filter(exchange.mutate().request(mutate.build()).build());
    }

这里为了严格控制,实际对accessToken、refreshToken增加了tokenType字段。限定refreshToken只能在访问/auth/refresh接口时访问。

以上步骤即完成了双token的改造。

最后

至于为什么改造为双token,以及双token有哪些好处?我在这里就不一一阐述了。
大家有兴趣可以看下这篇文章:http://www.mobiletrain.org/about/BBS/77900.html

评论 3
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值