• 微服务下token设计方案


    背景介绍

            项目初期(项目为微服务)为了快速开发使用了jwt生成token的无状态开发(未进行存储)并为生成的token指定一个过期时间为第二天的04:30,这样只要拿着今天生成的token就都可以用,这样不仅不利于项目自身安全,并且也无法实现以下功能。

            需求一:是否支持并发登录

            需求二:超时无操作过期设置

            需求三:记录在线人数

    为解决上述问题,微服务下需要管理有状态的token,并进行数据格式管理,实现上述需求。

    1.token数据存储位置

    token数据存储位置,首选当然是redis

    2.微服务下的token一致性问题

             一般情况下,生成并存储token的步骤都放在登录环节,一开始我也这么想的,后面在设计过程中,发现一个问题,在一套微服务集群下,有些微服务使用着不同的redis,这对于管理一致性的token有点困难。后面的解决办法是,在拦截部件(其实就是所有微服务都有的一个拦截器token验证有效性的拦截器)上进行token管理,并记录下所有token(为判断是否是第一次生成的token)。

    3.token数据格式与存储格式

    一、考虑不同设备的登录

    由于现在存储token的位置是在拦截器上,是没有办法知道过来的请求是从哪个设备发来的,所以需要用到jwt的Claim,为了后期扩展,将Claim对象用一个map存储。代码如下

    1. /**
    2. * 生成签名
    3. *
    4. * @param userId 用户Id
    5. * @param secret 用户密码
    6. * @return 加密的token
    7. */
    8. public static String sign(Long userId, String userName, String secret, Map map) {
    9. try {
    10. Algorithm algorithm = Algorithm.HMAC256(secret);
    11. JWTCreator.Builder builder = JWT.create()
    12. .withClaim(CLAIM_USER_ID, userId)
    13. .withClaim(CLAIM_USER_NAME, userName)
    14. .withClaim(CLAIM_LOGIN_TIME, System.currentTimeMillis());
    15. for (Map.Entry entry : map.entrySet()) {
    16. builder.withClaim(entry.getKey(), entry.getValue());
    17. }
    18. return builder.withExpiresAt(getExpiresTime())
    19. .sign(algorithm);
    20. } catch (UnsupportedEncodingException e) {
    21. return null;
    22. }
    23. }

    这样token内就存在我们需要的信息,并且可以动态的获取,这边我们存在了一个设备信息,放在map中,后期如果想加其他的,就可以通过map来扩展。 

    二、考虑是否支持并发登录

    这部分比较简单,即在不允许并发登录的情况下,建立一个映射对象,用户id->token集合的数据格式,当不允许并发登录开启时,获取id对应的token集合,并主动将对应的token状态变成顶下线,以下是部分代码,提供思路。

    1. if (!configurationFile.isConcurrent()) {
    2. String deviceEnums = getMapKey(tokenValue, JwtClaimKey.DEVICEENUMS);
    3. // --- 如果不允许并发登录,则将这个账号的历史登录标记为:被顶下线
    4. replaced(userId, deviceEnums);
    5. // ------ 开启session记录-获取 User-Session , 续期
    6. TokenSession session = getSessionByUserId(userId, true);
    7. // 在 User-Session 上记录token签名
    8. session.addTokenSign(tokenValue, deviceEnums);
    9. getSaasTokenDao().updateSession(session);
    10. }
    11. /**
    12. * 顶人下线,根据账号id 和 设备类型
    13. *

      当对方再次访问系统时,会抛出NotLoginException异常,场景值=-4

    14. *
    15. * @param userId 账号id
    16. * @param deviceType 设备类型 (填null代表顶替所有设备类型)
    17. */
    18. private void replaced(Long userId, String deviceType) {
    19. TokenSession session = getSessionByUserId(userId, false);
    20. if (session != null) {
    21. for (TokenSign tokenSign : session.tokenSignListCopyByDevice(deviceType)) {
    22. // 清理: token签名、token最后活跃时间
    23. String tokenValue = tokenSign.getValue();
    24. if (session.removeTokenSign(tokenValue)) {
    25. getSaasTokenDao().updateSession(session);
    26. }
    27. // 将此 token 标记为已被顶替
    28. updateTokenToOffline(tokenValue, Authorize.BE_REPLACED);
    29. }
    30. }
    31. }

    三、海量登录下的数据格式构造

    由于现在存储token的位置是在拦截器上,所以是没办法对token进行redis的ttl(设置过期时间,自动消失),所以需要有一个统一的管理键。使用redis的hash格式

     将过期的token放到 键为tokenOffline中进行统一管理。上面有说过,我们生成的token设定的过期时间是第二天的04:30,这个是不变的,所以我们需要管理的数据只是一天内的即可。这就是为什么我们的键值后面还上时间的原因。

    四、超时无操作过期设置

    判断出该token为第一次登录的前提下,进行 ( token - > 设定的过期时间除2 )    的映射生成。

    并且设置ttl为设定的过期时间。

    我们来说一说为什么设定( token - > 设定的过期时间除2+当前时间戳 )这一个映射,(设定的过期时间除2+当前时间戳 = faultToleranceTime)是一个容错时间,每次访问时,获取这个映射,并与当前时间戳(current)进行判断,如果faultToleranceTime<=current则进行,更新( token - > 设定的过期时间除2+当前时间戳 )往后推。并且重新更新这个键值对的ttl为设定的过期时间。

    五、记录在线人数

    判断出该token为第一次登录的前提下,将新的token放到 键为tokenOnline中进行统一管理。

    上面有说过,我们生成的token设定的过期时间是第二天的04:30,这个是不变的,所以我们需要管理的数据只是一天内的即可。这就是为什么我们的键值后面还上时间的原因。这个键值对,放着所有在线的集合。

    问题:这样数据设计会出现一个问题,也就是token过期了,但是并没有放到tokenOffline的情况,这个时候就可以用上tokenOnline键值的value,与当前时间判断如果超过了时间,即认为已经离线即可。

    4.考虑频繁访问redis问题

    token验证存在问题有一个页面存在好几个请求,一起发过来,这样redis就获取几次,为了降低redis的获取频率,我们可以使用项目的内存,并且可以自动过期那种。我们这里使用的是谷歌guava

    设置了30秒过期,这样在30秒内访问的请求都不会去连接redis。

    5.考虑使用单独的redis连接

    由于有可能存在数据量上大,并且访问量大的问题,所以作为扩展,我们还专门弄了为token管理的redis连接,这样也能在一定程度上分压。

    1. import org.slf4j.Logger;
    2. import org.slf4j.LoggerFactory;
    3. import org.springframework.boot.autoconfigure.data.redis.RedisProperties;
    4. import org.springframework.boot.context.properties.EnableConfigurationProperties;
    5. import org.springframework.context.annotation.Configuration;
    6. import org.springframework.data.redis.connection.RedisStandaloneConfiguration;
    7. import org.springframework.data.redis.connection.jedis.JedisConnectionFactory;
    8. import org.springframework.data.redis.core.RedisTemplate;
    9. import org.springframework.data.redis.serializer.StringRedisSerializer;
    10. import org.springframework.util.StringUtils;
    11. /**
    12. * @author kaixin
    13. * @version 1.0
    14. * @date 2022/7/27
    15. */
    16. @Configuration
    17. @EnableConfigurationProperties({TokenRedisProperties.class, RedisProperties.class})
    18. public class TokenRedisTemplateManager {
    19. private static final Logger LOGGER = LoggerFactory.getLogger(TokenRedisTemplateManager.class);
    20. private final TokenRedisProperties tokenRedisProperties;
    21. private final RedisProperties redisProperties;
    22. private RedisTemplate tokenRedisTemplate;
    23. public TokenRedisTemplateManager(TokenRedisProperties tokenRedisProperties, RedisProperties redisProperties) {
    24. this.tokenRedisProperties = tokenRedisProperties;
    25. this.redisProperties = redisProperties;
    26. buildRedisTemplate();
    27. }
    28. private void buildRedisTemplate() {
    29. //单机模式
    30. RedisStandaloneConfiguration rsc = new RedisStandaloneConfiguration();
    31. if (!StringUtils.isEmpty(tokenRedisProperties.getHost())) {
    32. rsc.setPort(tokenRedisProperties.getPort());
    33. rsc.setPassword(tokenRedisProperties.getPassword());
    34. rsc.setHostName(tokenRedisProperties.getHost());
    35. rsc.setDatabase(tokenRedisProperties.getDatabase());
    36. LOGGER.info("==============token管理-启动token自带redis配置=============");
    37. } else {
    38. rsc.setPort(redisProperties.getPort());
    39. rsc.setPassword(redisProperties.getPassword());
    40. rsc.setHostName(redisProperties.getHost());
    41. rsc.setDatabase(redisProperties.getDatabase());
    42. LOGGER.info("==============token管理-启动spring默认redis配置=============");
    43. }
    44. RedisTemplate template = new RedisTemplate<>();
    45. //单机模式
    46. JedisConnectionFactory fac = new JedisConnectionFactory(rsc);
    47. fac.afterPropertiesSet();
    48. template.setDefaultSerializer(new StringRedisSerializer());
    49. template.setConnectionFactory(fac);
    50. template.afterPropertiesSet();
    51. tokenRedisTemplate = template;
    52. }
    53. public RedisTemplate get() {
    54. return tokenRedisTemplate;
    55. }
    56. }

    该方式把RedisTemplate作为类的内部实现,并不会影响到全局的redis使用(这个方法其实我找了好久)

    使用的时候,按照以下代码即可。

    1. @Resource
    2. private TokenRedisTemplateManager redisTemplateConcetion;
    3. /**
    4. * 获得在线token集合
    5. *
    6. * @return
    7. */
    8. public boolean checkOnlineByToken(String token) {
    9. return redisTemplateConcetion.get().opsForHash().hasKey(splicingLineTokenValue(ONLINE), token);
    10. }

    作为自己的一个分享并记录,有什么意见或者建议可以留言跟我说。谢谢大家!!!


     博主新推出的gitee免费开源项目(商城+APP+小程序+H5),有兴趣的小伙伴可以了解一下。

    生鲜商城kxmall-小程序 + App + 公众号H5: kxmall-生鲜商城+APP+小程序+H5。同时支持微信小程序、H5、安卓App、苹果App。支持集群部署,单机部署。可用于B2C商城,O2O外卖,社区超市,生鲜【带配套骑手端配送系统】。kxmall使用uniapp编码。使用Java开发,SpringBoot 2.1.x框架,MyBatis-plus持久层框架、Redis作为缓存、MySql作为数据库。前端vuejs作为开发语言。https://gitee.com/zhengkaixing/kxmall

  • 相关阅读:
    规范系列之代码提交日志
    Unity之Hololens如何实现3D物体交互
    hot100-最大正方形
    2024年高新技术企业认定标准
    喂饭级AI神器!免代码一键绘制图表,文本数据秒变惊艳视觉盛宴!
    不甘于被强势厂商捆绑,中国移动未来或自研5G基站
    Contest2850 - 【在线编程平台】2022年计算机类数据结构作业12.20221201-1206
    类和函数的泛化、偏特化和全特化
    python-0009-django对数据的增删改
    自定义mvc增删改查
  • 原文地址:https://blog.csdn.net/qq_38377190/article/details/126154154