前言

本章主要介绍用户权限的模型设计和代码实现逻辑。在开发过程中,如遇到与权限相关的问题并需要进一步调试时,请先阅读本章。如需了解用户权限的具体配置操作,可参考开发文档 用户权限管理章节

权限设计

在大多数后台管理系统中,权限模块的设计很多都是基于经典的 RBAC 权限设计模型。然而在生产环境中,如果只是单纯的照搬该模型,是很难满足实际项目需求的。比如,简单的 RBAC 只能覆盖后台接口 (权限) 的权限验证,对于前后端分离的架构,是无法控制前端页面组件的显示隐藏,或是禁用可用的。

引入权限字

为了更好的支持前后端分离的架构,我们引入了权限字,用来唯一标识前端的页面组件,比如按钮组件、卡片或 Tab 容器组件等。下面先简单对比一下典型 RBAC 和引入权限字后权限模型的差别。

在用户登录时,后台登录接口会根据当前用户 ID,获取该用户实际可用的权限字列表和权限列表,分别返回给前端和存入后台 Redis 缓存。权限字列表在返回前端后,前端应用会将其保存至浏览器的 SessionStorage 中,每次访问表单页面时,都会逐一比对页面组件的标识符,是否存在于当前 SessionStorage 缓存的已授权权限字列表之中,如不存在,按钮组件将被禁用,而卡片或 Tab 容器组件会被直接隐藏。

权限表结构

在深入了解实现机制和代码逻辑之前,如果没有很好的理解权限表模型的设计,那么后续的学习一定会是事倍功半的。在下图中,橙色边框为权限数据表,绿色边框的是权限数据表之间的多对多关联表,关联表只是包含了两个权限数据表的主键 ID。

用户表

用户通过所在的角色,可以获取到登录后菜单。

  • 与角色之间是多对多的关系。
  • 同一个用户可以设定多个角色。
  • 同一个角色当然可以包含多个用户了。
  • 通过中间表 zz_sys_user_role 关联。zz_sys_user --> zz_sys_user_role  --> zz_sys_role。

角色表

角色向下关联的就是菜单,用户可以通过加入角色,从而访问该角色所关联的菜单。

  • 与菜单之间是多对多关系。
  • 同一个角色可以包含多个菜单。
  • 同一个菜单当然可以所属不同的角色了。
  • 通过中间表 zz_sys_role_menu 关联。zz_sys_role --> zz_sys_role_menu --> zz_sys_menu。

菜单表

菜单的类型包括表单和按钮。每个菜单都会对应一个或多个权限字,每个权限字在前端都会有一个操作按钮或表单片段 (如表单 Tab) 与之对应。那么结果是,我们通过给用户添加角色,角色中包含了菜单,菜单再通过所关联的权限字,控制前端组件的可见性,从而实现了用户的权限不同,前端可见组件也不同的效果。

权限字表

权限字通常对应两种类型的前端组件,下面我们分别讲解。

  • 操作按钮 (Button)。如列表页面中,每个列表项的末尾,都带有「编辑」和「删除」按钮,见下图。对于「删除」按钮,点击后通常会触发一次 URL 调用,该 URL 被我们定义为「权限」。对于「编辑」按钮,点击后可能会打开一个弹框,而该操作不会触发任何 URL 调用,因此该权限字没有对应的「权限」。
  • 表单片段 (Fragment)。为了便于理解,我们可以简单的将表单片段想象成表单上的 Tab 页,如果表单没有 Tab,则可以理解为该表单仅包含一个片段 (Fragment)。如下图页面中包含的多个 Tab 标签页,每个标签页的数据加载显示,都可能会调用一到多个后台接口权限。
  • 总结一下,权限字可以包含 0 到多个权限。

权限表

权限字和权限之间是多对多的关系,每个权限对应一个 URL 地址 (Controller 接口)。由此,我们就可以关联出用户可访问的 URL 地址列表了。

权限操作

看到这里,如果您仍然不熟悉橙单中用户权限的配置操作,可参考开发文档 用户权限管理章节

登录授权

前面详细介绍了权限模型及其数据表之间的关联关系,为了帮助大家更好的理解,本小节将从使用视角,进一步分析权限数据在企业级业务系统中的实际应用。用户的登录逻辑可分为两个部分,鉴权 (用户身份的验证) 和授权 (获取当前用户的权限数据)。下面我们只介绍与本节相关的登录用户授权部分。

用户权限查询

在下面的讲解中,我们主要以直观的登录代码为例,同时配以极为详细的代码注释,相信如果您已经了解了用户权限管理的页面操作,以及权限模型数据之间的关联关系,那么此时,代码将会是最好的文字。

  • 登录接口。
public ResponseResult<JSONObject> doLogin(
       @MyRequestBody String loginName,
       @MyRequestBody String password,
       @MyRequestBody String captchaVerification) throws Exception {
 
   // ... ... 省略登录参数和验证码验证的若干行代码。
 
   SysUser user = sysUserService.getSysUserByLoginName(loginName);
   password = URLDecoder.decode(password, StandardCharsets.UTF_8.name());
   password = RsaUtil.decrypt(password, ApplicationConstant.PRIVATE_KEY);
   if (user == null || !passwordEncoder.matches(password, user.getPassword())) {
       return ResponseResult.error(ErrorCodeEnum.INVALID_USERNAME_PASSWORD);
   }
   // 这里是重点,buildLoginData私有方法,负责构建当前用户的权限数据。
   JSONObject jsonData = this.buildLoginData(user);
   return ResponseResult.success(jsonData);
}
  • 查询当前用户的所有权限数据。
private JSONObject buildLoginData(SysUser user) {
   // ... ... 省略为当前用户会话构建Session数据的若干行代码。
   // 先获取当前用户的全部所属权限数据列表。
   List<SysUserRole> userRoleList = sysRoleService.getSysUserRoleListByUserId(user.getUserId());
   if (CollectionUtils.isNotEmpty(userRoleList)) {
       Set<Long> userRoleIdSet = userRoleList
               .stream().map(SysUserRole::getRoleId).collect(Collectors.toSet());
       tokenData.setRoleIds(StringUtils.join(userRoleIdSet, ","));
   }
   Collection<SysMenu> menuList;
   Collection<String> permCodeList;
   if (isAdmin) {
       // 管理员缺省情况下,返回所有菜单和权限字列表。
       menuList = sysMenuService.getAllMenuList();
       permCodeList = sysPermCodeService.getAllPermCodeList();
   } else {
       // 这里才是重点,获取当前用户的授权菜单列表和授权权限字列表。
       menuList = sysMenuService.getMenuListByUserId(user.getUserId());
       permCodeList = sysPermCodeService.getPermCodeListByUserId(user.getUserId());
   }
   // 这里的jsonData数据,是作为应答数据返回给前端应用的。
   // 由此可见,当前用户的菜单数据和权限字数据,都是前端应用进行权限约束时使用的数据。
   jsonData.put("menuList", menuList);
   jsonData.put("permCodeList", permCodeList);
   if (user.getUserType() != SysUserType.TYPE_ADMIN) {
       // 对于非管理员用户,我们需要查询当前用户的所有已授权的权限数据列表,并将结果缓存至Redis。
       // 从这里可以看到,对应于后台接口的权限数据,无需返回给前端应用,因此也没有存入jsonData对象中。
       sysPermService.putUserSysPermCache(sessionId, user.getUserId());
   }
   return jsonData;
}
  • 当前用户的菜单数据查询语句。
SELECT
   -- 为了减轻数据库的计算压力,没有使用DISTINCT去重,而是在Java代码中做了去重处理。
   m.*
FROM
   -- 用户与角色的多对多关联中间表。
   zz_sys_user_role ur,
   -- 角色和菜单的多对多关联中间表。
   zz_sys_role_menu rm,
   -- 菜单数据表。
   zz_sys_menu m
WHERE
   -- 过滤指定用户Id的权限数据。
   ur.user_id = #{userId}
   -- 下面通过一系列的内关联,最终通过 
   -- user -> role -> menu 的关联关系,
   -- 查询到当前用户的菜单列表。
   AND ur.role_id = rm.role_id
   AND rm.menu_id = m.menu_id
   -- 由于前端的左边栏,只是显示目录和表单类型的菜单,所以这里进行了菜单类型的过滤。
   AND m.menu_type >= 2 
ORDER BY m.show_order
  • 当前用户的权限字数据查询语句。
SELECT
   -- 为了减轻数据库的计算压力,没有使用DISTINCT去重,而是在Java代码中做了去重处理。
   pc.perm_code
FROM
   -- 用户与角色的多对多关联中间表。
   zz_sys_user_role ur,
   -- 角色和菜单的多对多关联中间表。
   zz_sys_role_menu rm,
   -- 菜单和权限字的多对多关联中间表。
   zz_sys_menu_perm_code mpc,
   -- 权限字数据表。
   zz_sys_perm_code pc
WHERE
   -- 过滤指定用户Id的权限数据。
   ur.user_id = #{userId}
   -- 下面通过一系列的内关联,最终通过 
   -- user -> role -> menu -> perm_code 的关联关系,
   -- 查询到当前用户的权限字列表。
   AND ur.role_id = rm.role_id
   AND rm.menu_id = mpc.menu_id
   AND mpc.perm_code_id = pc.perm_code_id
  • 当前用户的权限数据查询语句。
SELECT
   -- 为了减轻数据库的计算压力,没有使用DISTINCT去重,而是在Java代码中做了去重处理。
   p.url
FROM
   -- 用户与角色的多对多关联中间表。
   zz_sys_user_role ur,
   -- 角色和菜单的多对多关联中间表。
   zz_sys_role_menu rm,
   -- 菜单和权限字的多对多关联中间表。
   zz_sys_menu_perm_code mpc,
   -- 权限字和权限的多对多关联中间表。
   zz_sys_perm_code_perm pcp,
   -- 权限数据表。
   zz_sys_perm p
WHERE
   -- 过滤指定用户Id的权限数据。
   ur.user_id = #{userId}
   -- 下面通过一系列的内关联,最终通过 
   -- user -> role -> menu -> perm_code -> perm 的关联关系,
   -- 查询到当前用户的权限字列表。
   AND ur.role_id = rm.role_id
   AND rm.menu_id = mpc.menu_id
   AND mpc.perm_code_id = pcp.perm_code_id
   AND pcp.perm_id = p.perm_id

权限数据缓存

在上面给出的 LoginController 类的.buildLoginData 方法中,会调用如下方法 (putUserSysPermCache),查询当前用户的权限数据列表,并将结果缓存至Redis,这样在后续需要授权的接口调用中,单体服务的拦截器或微服务网关的前置过滤器,会统一完成接口调用的权限验证。由于在登录时,已经将当前用户会话的权限数据缓存到 Redis 中,后续的验证直接访问 Redis 即可,无需再进行数据库的查询操作,以提升系统运行时的整体效率。具体的验证逻辑,我们会在后面的小节详细介绍。

public Collection<String> putUserSysPermCache(String sessionId, Long userId) {
   // 从数据库查询当前用户的权限列表数据。具体的SQL语句,在上一小节已经给出。
   Collection<String> permList = this.getPermListByUserId(userId);
   // 查询当前系统的白名单接口地址列表。
   List<String> whitelist = sysPermWhitelistService.getWhitelistPermList();
   if (CollUtil.isEmpty(permList) && CollUtil.isEmpty(whitelist)) {
       return permList;
   }
   String sessionPermKey = RedisKeyUtil.makeSessionPermIdKey(sessionId);
   RSet<String> redisPermSet = redissonClient.getSet(sessionPermKey);
   // 合并同时去重当前用户已授权的后台接口地址集合与系统白名单接口地址集合。
   if (CollUtil.isNotEmpty(permList)) {
       redisPermSet.addAll(permList.stream().map(Object::toString).collect(Collectors.toSet()));
   }
   if (CollUtil.isNotEmpty(whitelist)) {
       redisPermSet.addAll(whitelist);
   }
   // 最后将合并与去重后的后台访问接口地址,存入Redis。
   // 这里之所以使用Redis的Set数据结构,主要是为了优化高频调用的用户后台接口鉴权操作。
   // Redis的Set数据结构,可以直接判断指定的url数据是否存在,而无需返回全部的url列表后,
   // 再在Java服务的本地验证,这样可以极大的降低Redis的IO开销和Java服务的内存开销。以提升运行时效率。
   redisPermSet.expire(applicationConfig.getSessionExpiredSeconds(), TimeUnit.SECONDS);
   return permList;
}

后台鉴权

用户登录成功后,会将该用户的已授权权限和白名单地址,一并存入当前会话的 Redis 缓存。后续的所有接口调用,均会做鉴权验证。

流程图

下图是用户登录和接口访问鉴权的流程图。对于微服务和单体服务而言,其鉴权逻辑本身是一致的。不同的是,在微服务工程中,接口鉴权是在网关前置过滤器 (AuthenticationPreFilter) 中统一完成的,每个业务微服务无需关心。而单体工程,则是由业务服务的拦截器 (AuthenticationInterceptor) 统一拦截并实现的用户接口访问鉴权。

白名单

如果您尚未了解橙单的白名单配置和处理机制,推荐您先阅读开发文档的 白名单章节

接口鉴权

单体工程的接口鉴权逻辑位于 AuthenticationInterceptor 拦截器中。而微服务工程的接口统一鉴权功能则位于网关服务的前置过滤器 AuthenticationPreFilter 中,每个业务服务则无需做任何处理。无论是单体还是微服务工程,统一鉴权的实现逻辑极为相近,具体步骤如下。

  • 对于已登录用户,会根据 HTTP 请求头中的身份验证数据,构建当前用户的 TokenData 对象,并存入当前请求的属性对象中,以便于 Controller 进行后续的业务处理。
  • 在登录方法中,我们已经将所有的已授权后台接口列表与白名单接口列表合并后存入 Redis 缓存,因此这里可以直接从 Redis 中读取即可。由此可以在高频的鉴权处理中,有效的避免了数据库的查询操作,从而提升系统的整体运行时效率和横向弹性扩充的能力。
  • 刚刚已经提到,接口鉴权处理是极为高频的,因此在橙单中,我们为了进一步提升运行时效率,在已有 Redis 二级缓存基础之上,加设了基于 Caffeine 的一级缓存,以此来降低 Redis 以及整个系统运行环境的网络开销。

JWT续租

JWT 的概念和原理不在本章的讨论范围之内,我们假设您已经有了很好的了解。JWT 中有个过期时间的字段,如果该值时间设置过长,就会存在安全隐患,反之,在用户进行正常的操作时,比如录入一个存在很多字段信息的表单数据,录完之后提交时,提示「当前用户会话已过期,请重新登录!」。为了解决此类问题,我们需要支持 JWT 的续租机制,具体处理步骤如下。

  • 为 JWT 的过期时间字段设置一个相对较短的合理值,比如 30 分钟。
  • 每间隔几分钟更换一次 Token,如 5 分钟。每次更换后的 Token 过期时间将向后顺延,即为从此刻起的 30 分钟之后过期。
  • 经过以上两步处理,我们可以理解为,只有当用户连续 30 分钟没有发起过任何后端请求,处于安全考虑,该用户会话被视为过期。
  • 最后介绍一下为啥要每间隔几分钟刷新一次 JWT Token,而不是每次请求后都要更换。一句话,还是因为性能问题,Token 的加密是相对比较耗时的,属于 CPU 计算密集型操作,试想如果每次请求都进行一次 JWT Token 的加密,这样就会给 CPU 增加很多不必要的计算负担。

代码解析

以下代码仅以单体工程的鉴权拦截器为例。

@Slf4j
public class AuthenticationInterceptor implements HandlerInterceptor {
   // ... ... 省略若干成员变量的声明,以及bean对象的获取。
   
   @Override
   public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler)
           throws Exception {
       String url = request.getRequestURI();
       String token = request.getHeader(appConfig.getTokenHeaderKey());
       boolean noLoginUrl = false;
       // 在橙单框架中,如果接口方法标记NoAuthInterface注解,被视为免登陆白名单接口。
       if (handler instanceof HandlerMethod) {
           HandlerMethod hm = (HandlerMethod) handler;
           if (hm.getBeanType().getAnnotation(NoAuthInterface.class) != null
                   || hm.getMethodAnnotation(NoAuthInterface.class) != null) {
               noLoginUrl = true;
               // 这里非常非常重要,如果当前用户请求头中没有Token数据,会被视为未登录用户。
               // 对于此类请求,未登录用户将会被直接交由Controller处理,Controller也会
               // 判断当前请求是否存在Token数据,不存在就会返回默认数据,存在则返回与当前
               // 会话用户相关的数据。
               if (StringUtils.isBlank(token)) {
                   // 这里返回true,表示拦截器的处理已经通过了,可以交由业务Controller进行业务处理了。
                   return true;
               }
           }
       }
       // 正常的解析HTTP请求中的Token数据,如果过期或者无效,则直接视为非法请求,要求重新登录并建立会话。
       Claims c = JwtUtil.parseToken(token, appConfig.getTokenSigningKey());
       if (JwtUtil.isNullOrExpired(c)) {
           response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
           this.outputResponseMessage(response,
                   ResponseResult.error(ErrorCodeEnum.UNAUTHORIZED_LOGIN, "用户会话已过期或尚未登录!"));
           return false;
       }
       // 处于安全考虑,JWT的Token中保存的只是SessionId这样毫无业务含义的数据,
       // 尽量不要在前端可见的Token中保存任何带有业务含义的数据,避免任何潜在的安全隐患。
       // 与此同时,尽量减少JWT的字节数,以此降低公网数据传输的网络开销。
       String sessionId = (String) c.get("sessionId");
       String sessionIdKey = RedisKeyUtil.makeSessionIdKey(sessionId);
       // 根据JWT Token中的SessionId,从Redis缓存中,获取当前用户会话数据。
       RBucket<String> sessionData = redissonClient.getBucket(sessionIdKey);
       TokenData tokenData = null;
       if (sessionData.isExists()) {
           tokenData = JSON.parseObject(sessionData.get(), TokenData.class);
       }
       if (tokenData == null) {
           response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
           this.outputResponseMessage(response,
                   ResponseResult.error(ErrorCodeEnum.UNAUTHORIZED_LOGIN, "用户会话已失效,请重新登录!"));
           return false;
       }
       // 这里将解析后的用户会话数据对象TokenData存入HTTP请求的属性对象中,以便于后续业务代码方便读取。
       TokenData.addToRequest(tokenData);
       // 对于noLoginUrl为true的免登录白名单接口请求,就不用做任何鉴权了。
       // 对于管理员用户也不用鉴权了。
       if (!noLoginUrl && Boolean.FALSE.equals(tokenData.getIsAdmin())) {
           String cacheKey = RedisKeyUtil.makeSessionPermIdKey(sessionId);
           @SuppressWarnings("unchecked")
           // 先从本地基于Caffeine的一级缓存中读取用户有权访问的接口数据列表 (含白名单接口列表)。
           Set<String> localPermSet = (Set<String>)
                   cacheManager.getCache(CacheConfig.CacheEnum.USER_PERMISSION_CACHE.name()).get(cacheKey);
           // 如果一级缓存中不存在,就从基于Redis的二级缓存中读取,并将Redis中的检索结果,
           // 同步存入到Caffeine,以便后面的鉴权操作,可以直接从一级缓存中读取。
           if (CollUtil.isEmpty(localPermSet)) {
               RSet<String> permSet = redissonClient.getSet(cacheKey);
               localPermSet = new HashSet<>(permSet);
               cacheManager.getCache(
                       CacheConfig.CacheEnum.USER_PERMISSION_CACHE.name()).put(cacheKey, localPermSet);
           }
           // IMPORTANT!!!终于到接口接口鉴权了。
           if (!localPermSet.contains(url)) {
               response.setStatus(HttpServletResponse.SC_FORBIDDEN);
               this.outputResponseMessage(response, ResponseResult.error(ErrorCodeEnum.NO_OPERATION_PERMISSION));
               return false;
           }
       }
       // 这里的逻辑就是判断当前Token的过期时间,是否已经超过刷新间隔了,比如前面说的5分钟。
       // 如果超过了,就会更换一个新的Token,更新后的令牌过期中间仍然为新的30分钟。
       // 刷新后的Token,会作为应答数据头返回给前端,前端代码会进行统一处理,更换本地之前
       // 存储的Token数据,并在下次的请求中,携带新的Token。
       if (JwtUtil.needToRefresh(c)) {
           String refreshedToken = 
                     JwtUtil.generateToken(c, appConfig.getExpiration(), appConfig.getTokenSigningKey());
           response.addHeader(appConfig.getRefreshedTokenHeaderKey(), refreshedToken);
       }
       return true;
   }
}

会话管理

事实上,这一小节和前面介绍的用户权限管理没有太多的直接关系,之所以写在这里,是因为权限管理、用户登录和接口统一鉴权,他们是密不可分的,而会话管理又与登录和统一鉴权的关系非常紧密。

排他登录

同一用户使用相同设备类型,不能同时登录。其结果是,后面的登录会使之前使用相同设备类型登录的会话失效。具体实现方式,见如下用户登录的接口代码。

public ResponseResult<JSONObject> doLogin(
       @MyRequestBody String loginName,
       @MyRequestBody String password,
       @MyRequestBody String captchaVerification) throws Exception {
 
   // ... ... 省略若干用户身份验证,验证码验证等相关代码。
 
   // 会先根据配置项判断,当前服务是否支持排他登录。
   if (BooleanUtil.isTrue(appConfig.getExcludeLogin())) {
       // Redis中缓存的Session数据的KEY编码格式是:"SESSIONID:loginName_deviceType_UUID"。
       // 其中loginName是当前用户的登录名,比如"123456@qq.com",deviceType是一个数字枚举值,末尾是计算的UUID。
       // 因此在下面的代码中,我们先根据当前用户的loginName和设备类型,计算出Session键的前缀部分。所有包含此前缀
       // 的Session键,均可以视为同一用户相同设备登录的会话。
       String patternKey = 
               RedisKeyUtil.getSessionIdPrefix(user.getLoginName(), MyCommonUtil.getDeviceType()) + "*";
       // 通过Redisson的方法,以通配符模式的形式,直接从Redis中删除全部具有该前缀的Session数据。
       // 删除后,之前的登录会话再次访问后台接口时,由于会话信息已经从Redis中被删除,因此只能重新登录了。
       redissonClient.getKeys().deleteByPatternAsync(patternKey);
   }    
   // 这里不再展开buildLoginData方法中的代码了,事实上前面的例子中已经给出了。
   // 该方法内会基于同样的规则,计算出当前用户会话的Session键,并存入Redis。
   JSONObject jsonData = this.buildLoginData(user);
   return ResponseResult.success(jsonData);
}

在线用户列表

列出当前系统的所有在线用户列表。如下图。

具体实现方式,请详见如下代码和关键性注释。示例代码取自于橙单生成后工程的LoginUserController.java 文件。

@PostMapping("/list")
public ResponseResult<MyPageData<LoginUserInfo>> list(
       @MyRequestBody String loginName, @MyRequestBody MyPageParam pageParam) {
   // 因为是从Redis中批量获取用户会话数据列表,因此这里只能手动分页了。
   int queryCount = pageParam.getPageNum() * pageParam.getPageSize();
   int skipCount = (pageParam.getPageNum() - 1) * pageParam.getPageSize();
   // Redis中缓存的Session数据的KEY编码格式是:"SESSIONID:loginName_deviceType_UUID"。
   // 这里获取代码SESSIONID前缀的通配符模式,SESSIONID:*
   String patternKey = RedisKeyUtil.getSessionIdPrefix() + "*";
   List<LoginUserInfo> loginUserInfoList = new LinkedList<>();
   // 通过Redisson方法,在Redis中搜索 SESSIONID:* 通配符模式的KEY数据。
   // 所有符合该规则的,都是Session会话的缓存数据。
   Iterable<String> keys = redissonClient.getKeys().getKeysByPattern(patternKey);
   for (String key : keys) {
       // 将Redis中返回的Session数据,转换为前端页面可以显示的Vo数据对象列表。
       loginUserInfoList.add(this.buildTokenDataByRedisKey(key));
   }
   // 基于登录时间倒排序。
   loginUserInfoList.sort((o1, o2) -> (int) (o2.getLoginTime().getTime() - o1.getLoginTime().getTime()));
   // 手动分页,避免一次性返回给前端的数据过多。
   int toIndex = Math.min(skipCount + pageParam.getPageSize(), loginUserInfoList.size());
   List<LoginUserInfo> resultList = loginUserInfoList.subList(skipCount, toIndex);
   // 将分页后的数据子集,返回给前端。
   return ResponseResult.success(new MyPageData<>(resultList, (long) loginUserInfoList.size()));
}

强制退出

有些业务场景是需要将当前用户的会话强制退出的,比如更新了当前用户的权限后,需要用户重新登录后才能生效,此时可以采用强制退出的方式,让用户之前的登录会话失效并重新登录。如下图。

具体实现方式,请详见如下代码和关键性注释。示例代码取自于橙单生成后工程的LoginUserController.java 文件。

@PostMapping("/delete")
public ResponseResult<Void> delete(@MyRequestBody String sessionId) {
   // 为了保证被剔除用户正在进行的操作不被干扰,这里只是删除sessionIdKey即可,这样可以使强制下线操作更加平滑。
   // 比如,如果删除操作权限或数据权限的redis session key,那么正在请求数据的操作就会报错。
   redissonClient.getBucket(RedisKeyUtil.makeSessionIdKey(sessionId)).delete();
   return ResponseResult.success();
}

结语

赠人玫瑰,手有余香,感谢您的支持和关注,选择橙单,效率乘三,收入翻番。