前言
本章主要介绍用户权限的模型设计和代码实现逻辑。在开发过程中,如遇到与权限相关的问题并需要进一步调试时,请先阅读本章。如需了解用户权限的具体配置操作,可参考开发文档 用户权限管理章节。
权限设计
在大多数后台管理系统中,权限模块的设计很多都是基于经典的 RBAC 权限设计模型。然而在生产环境中,如果只是单纯的照搬该模型,是很难满足实际项目需求的。比如,简单的 RBAC 只能覆盖后台接口 (权限) 的权限验证,对于前后端分离的架构,是无法控制前端页面组件的显示隐藏,或是禁用可用的。
引入权限字
为了更好的支持前后端分离的架构,我们引入了菜单编码,用来唯一标识前端的页面组件,比如按钮组件、卡片或 Tab 容器组件等。

在用户登录时,后台登录接口会根据当前用户 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。
菜单表
菜单的类型包括表单和按钮。表单菜单不会关联任何权限数据,而按钮菜单则都会通过 extra_data 字段中的 menuCode 和 permCodeList 分别控制前端组件的可用性,以及后台接口的访问权限。
Sa-Token注解应用
橙单目前已集成国内主流的开源权限框架 Sa-Token,其中单体、微服务和多租户等工程类型均已支持。这里我们先介绍一下 Sa-Token 框架中常用的鉴权注解,以及在橙单中的使用方式。
SaIgnore
用于免登陆即可访问的白名单接口,如 doLogin 登录接口,具体代码如下。
@SaIgnore
@PostMapping("/doLogin")
public ResponseResult<JSONObject> doLogin(
@MyRequestBody String loginName,
@MyRequestBody String password) throws UnsupportedEncodingException {
// 这里忽略实现逻辑细节。
}
SaCheckLogin
用于已登录即可访问的白名单接口,即所有正常登录的用户均可访问的接口,在 Sa-Token 中,所有没有添加任何鉴权注解的接口均为该行为,因此橙单基础框架以及默认生成的代码中,对于此类接口均为定义该注解。最典型的即为 doLogout 登出接口,所有已登录用户无需授权均可正常调用,见如下代码。
// 这里没有定义任何注解,默认即为@SaCheckLogin
@PostMapping("/doLogout")
public ResponseResult<Void> doLogout() {
// 这里忽略实现逻辑细节。
}
SaCheckPermission
在橙单基础框架和默认生成的接口代码中,大量使用该注解用于用户操作的后台接口鉴权,见如下代码。
Sa-Token 的权限编码支持通配符规则,比如 sysUser.* 将等同于 sysUser.add / sysUser.update / sysUser.delete / sysUser.view 的合集,更多技术细节可参考 Sa-Token 的官网文档。
// 仅当用户的关联权限中包含 sysUser.add 权限时,该接口可访问。
@SaCheckPermission("sysUser.add")
@PostMapping("/add")
public ResponseResult<Long> add(
@MyRequestBody SysUserDto sysUserDto,
@MyRequestBody String deptPostIdListString,
@MyRequestBody String dataPermIdListString,
@MyRequestBody String roleIdListString) {
// 这里忽略实现逻辑细节。
}
如果同一接口被两个权限字控制,即用户包含任何一个权限均可访问指定接口,见如下代码。
// 注解中的两个权限字是 OR 的关系,当用户具有任何一个权限时即可访问该接口。
@SaCheckPermission(value = {"studentClass.add", "studentClass.update"}, mode = SaMode.OR)
@PostMapping("/listNotInClassCourse")
public ResponseResult<MyPageData<CourseVo>> listNotInClassCourse(
@MyRequestBody Long classId,
@MyRequestBody CourseDto courseDtoFilter,
@MyRequestBody MyOrderParam orderParam,
@MyRequestBody MyPageParam pageParam) {
// 这里忽略实现逻辑细节。
}
登录授权
前面详细介绍了权限模型及其数据表之间的关联关系,为了帮助大家更好的理解,本小节将从使用视角,进一步分析权限数据在企业级业务系统中的实际应用。用户的登录逻辑可分为两个部分,鉴权 (用户身份的验证) 和授权 (获取当前用户的权限数据)。下面我们只介绍与本节相关的登录用户授权部分。
用户权限查询
在下面的讲解中,我们主要以直观的登录代码为例,同时配以极为详细的代码注释,相信如果您已经了解了用户权限管理的页面操作,以及权限模型数据之间的关联关系,那么此时,代码将会是最好的文字。最后,强烈推荐仔细阅读代码中的文字注释。
- 登录接口。
@SaIgnore
@PostMapping("/doLogin")
public ResponseResult<JSONObject> doLogin(
@MyRequestBody String loginName,
@MyRequestBody String password) throws UnsupportedEncodingException {
// 验证当前请求登录的用户合法性。
ResponseResult<SysUser> verifyResult = this.verifyAndHandleLoginUser(loginName, password);
if (!verifyResult.isSuccess()) {
return ResponseResult.errorFrom(verifyResult);
}
// 构建登录接口需要返回给前端的数据对象,同时调用SaToken的login方法完成登录行为。
JSONObject jsonData = this.buildLoginDataAndLogin(verifyResult.getData());
return ResponseResult.success(jsonData);
}
- 查询当前用户的所有权限数据。
private JSONObject buildLoginDataAndLogin(SysUser user) {
// 构建用户会话的TokenData对象,同时调用SaToken的StpUtil.login方法完成登录行为。
TokenData tokenData = this.loginAndCreateToken(user);
TokenData.addToRequest(tokenData);
// 根据当前用户的信息创建返回给前端的JSON对象。
JSONObject jsonData = this.createResponseData(user);
// 根据用户的类型查询与当前用户关联的菜单数据列表。其中管理员可以查看所有菜单,
// 而普通用户则需要根据用户所分配的角色,以及角色关联的菜单进行多级的关联查询。
Collection<SysMenu> allMenuList;
boolean isAdmin = user.getUserType() == SysUserType.TYPE_ADMIN;
if (isAdmin) {
allMenuList = sysMenuService.getAllListByOrder(SHOW_ORDER_FIELD);
} else {
allMenuList = sysMenuService.getMenuListByUserId(user.getUserId());
}
// 这里先将存储权限数据的 extraData 字段解析为对象,以便于后续代码中直接使用。
allMenuList.stream().filter(m -> m.getExtraData() != null)
.forEach(m -> m.setExtraObject(JSON.parseObject(m.getExtraData(), SysMenuExtraData.class)));
// 这里的permCodeList是会缓存到后台的Redis中的,以供SaToken鉴权时使用。
// 本小节后面的代码会有介绍。
Collection<String> permCodeList = new LinkedList<>();
// 收集用户有权访问的菜单列表中,每个菜单所关联的后台接口权限字列表数据。
allMenuList.stream().filter(m -> m.getExtraObject() != null)
.forEach(m -> CollUtil.addAll(permCodeList, m.getExtraObject().getPermCodeList()));
// 这里的permSet主要用于存储 zz_sys_perm_whitelist 白名单表中后台接口,以及在线表单,在线表单工作流和
// 统计表单等动态页面的动态接口的URL,橙单的登录接口会分别推荐计算这些URL,并最后合并到permSet中缓存。
// permSet缓存数据的使用方式,本章后面会有具体的介绍,这里简单说一下,AuthenticationInterceptor拦截器会
// 先验证当前请求是否位于缓存的permSet中,如果存在就不再经过SaToken的鉴权了,否则会继续使用SaToken的
// 鉴权机制。
Set<String> permSet = new HashSet<>();
if (!isAdmin) {
// 所有登录用户都有白名单接口的访问权限。
// 重点解释一下,这里的白名单接口是可以动态添加的,和SaToken中的SaCheckLogin功能是等同的,都是属于
// 已登录用户的白名单的接口,两者最大的差别就是对于一些需要紧急添加的白名单接口,而又不想修改代码和重心
// 部署服务,可以通过在白名单表zz_sys_perm_whitelist中添加指定的url即可。
CollUtil.addAll(permSet, sysPermWhitelistService.getWhitelistPermList());
}
// 菜单编码集合,这个是需要返回给前端的组件权限控制列表,前端会根据登录接口中返回的menuCodeList中的数据
// 集合,缓存到浏览器的SessionStorage中,当打开表单页面时,都会进行比对,仅当这里返回的组件编码存在时,
// 前端组件才可使用。
List<String> menuCodeList = new LinkedList<>();
// 获取在线表单动态页面的前端权限编码和后台接口的集合,同时将这些动态渲染页面的菜单编码计算后一并返回给前端。
OnlinePermData onlinePermData = this.getOnlineMenuPermData(allMenuList);
CollUtil.addAll(menuCodeList, onlinePermData.permCodeSet);
// 获取在线表单工作流动态页面的前端权限编码和后台接口的集合。
OnlinePermData onlineFlowPermData = this.getFlowOnlineMenuPermData(allMenuList);
CollUtil.addAll(menuCodeList, onlineFlowPermData.permCodeSet);
if (!isAdmin) {
// 对于非管理员用户,用户访问任何接口都会进行精确的后台鉴权处理,因此需要将当前用户有权访问的后台接口
// 资源进行缓存,以便提高整体的运行时效率。
// 下面几行代码分别缓存在线表单、在线表单工作流和统计表单的动态url接口。
permSet.addAll(onlinePermData.permUrlSet);
permSet.addAll(onlineFlowPermData.permUrlSet);
Set<String> reportPermSet = this.getReportMenuPermData(allMenuList);
permSet.addAll(reportPermSet);
String sessionId = tokenData.getSessionId();
// 缓存用户的权限资源,这里缓存的是基于URL验证的权限资源,比如在线表单、工作流和数据表中的白名单资源。
this.putUserSysPermCache(sessionId, permSet);
// 缓存权限字字段,StpInterfaceImpl中会从缓存中读取,并交给satoken进行接口权限的验证。
this.putUserSysPermCodeCache(sessionId, permCodeList);
sysDataPermService.putDataPermCache(sessionId, user.getUserId(), user.getDeptId());
}
// 构建返回给前端的权限数据。
this.appendResponseMenuAndPermCodeData(jsonData, allMenuList, menuCodeList);
return jsonData;
}
- 登录接口返回给前端的数据。
private void appendResponseMenuAndPermCodeData(
JSONObject responseData, Collection<SysMenu> allMenuList, Collection<String> menuCodeList) {
allMenuList.stream().filter(m -> m.getExtraData() != null).forEach(m ->
m.setExtraObject(JSON.parseObject(m.getExtraData(), SysMenuExtraData.class)));
allMenuList.stream().filter(m -> m.getExtraObject() != null).forEach(m ->
CollUtil.addAll(menuCodeList, m.getExtraObject().getMenuCode()));
List<SysMenu> menuList = allMenuList.stream()
.filter(m -> m.getMenuType() <= SysMenuType.TYPE_MENU).collect(Collectors.toList());
// 返回给前端的菜单列表,用户在左边的菜单栏显示,因此不包含按钮类型的菜单。
responseData.put("menuList", menuList);
// 返回给前端用于前端组件可用性的比对。
responseData.put("permCodeList", menuCodeList);
}
- 当前非管理员用户的菜单数据查询语句。
SELECT
-- 为了减轻数据库的计算压力,没有使用DISTINCT去重,而是在Java代码中做了去重处理。
m.*
FROM
-- 角色和菜单的多对多关联中间表。
zz_sys_role_menu rm,
-- 菜单数据表。
zz_sys_menu m
WHERE
rm.role_id IN
<foreach collection="roleIds" item="item" separator="," open="(" close=")">
#{item}
</foreach>
AND rm.menu_id = m.menu_id
ORDER BY m.show_order
权限数据缓存
在上面给出的 LoginController 类.buildLoginDataAndLogin 方法中,会调用私有方法 putUserSysPermCodeCache 将后台鉴权所需的权限字列表缓存到 Redis,以供 Sa-Token 的鉴权接口实现类 StpInterfaceImpl 中的 getPermissionList 方法查询。
private void putUserSysPermCodeCache(String sessionId, Collection<String> permCodeSet) {
if (CollUtil.isEmpty(permCodeSet)) {
return;
}
String sessionPermCodeKey = RedisKeyUtil.makeSessionPermCodeKey(sessionId);
RSet<String> redisPermSet = redissonClient.getSet(sessionPermCodeKey);
redisPermSet.addAll(permCodeSet);
redisPermSet.expire(appConfig.getSessionExpiredSeconds(), TimeUnit.SECONDS);
}
后台鉴权
用户登录成功后,会将该用户的已授权动态权限和白名单地址,一并存入当前会话的 Redis 缓存。后续的所有接口调用均会做鉴权验证。无论是单体、微服务还是多租户工程,后台鉴权的逻辑均位于业务服务的 AuthenticationInterceptor 拦截器中。鉴权过程分为以下两步,更多逻辑细节可参考本小节剩余部分。
- 基于动态接口(在线表单、在线表单工作流和在线统计表单)和动态白名单接口(位于 zz_sys_perm_whitelist 表中的白名单 URL)先进行鉴权处理,如通过验证,则可直接调用后台接口逻辑。
- 如果上一步没有通过验证,则会继续执行基于 Sa-Token 权限框架的鉴权验证。如通过则可继续调用后台请求接口逻辑,否则直接返回应答状态码为 401 的错误。
流程图
下图是用户登录和接口访问鉴权的流程图。由于微服务工程也会将实际的鉴权逻辑交给每个业务微服务,因此对于微服务和单体服务而言,其鉴权逻辑本身是一致的,均位于业务服务的 AuthenticationInterceptor 拦截器中。

白名单
如果您尚未了解橙单的白名单配置和处理机制,推荐您先阅读开发文档的 白名单章节。
接口鉴权
所有类型工程的接口鉴权逻辑均位于 AuthenticationInterceptor 拦截器中,具体步骤如下。
- 先进行第三方接入的判断,如果是第三方接入就会走独立的鉴权方式。具体逻辑可以参考 开发文档的第三方接入章节。
- 动态接口鉴权,主要包括在线表单、在线表单工作流和在线统计表单等动态渲染表单的业务请求接口,以及位于 zz_sys_perm_whitelist 白名单表中地址,这些 URL集合会在登录时缓存到 Redis 中,以供拦截器进行接口鉴权的验证处理。
- 如果前面两步均没有通过验证,这里需要进行基于 Sa-Token 权限框架的鉴权验证。
代码解析
以下代码仅以单体工程的鉴权拦截器为例。这里强烈推荐仔细阅读代码中的文字注释。
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler)
throws Exception {
String url = request.getRequestURI();
String appCode = this.getAppCodeFromRequest(request);
if (StrUtil.isNotBlank(appCode)) {
return this.handleThirdPartyRequest(appCode, request, response);
}
ResponseResult<Void> result = saTokenUtil.handleAuthIntercept(request, handler);
if (!result.isSuccess()) {
ResponseResult.output(result.getHttpStatus(), result);
return false;
}
return true;
}
下面是拦截其中调用的基于 Sa-Token 框架的鉴权代码逻辑。
public ResponseResult<Void> handleAuthIntercept(HttpServletRequest request, Object handler) {
if (!(handler instanceof HandlerMethod)) {
return ResponseResult.success();
}
Method method = ((HandlerMethod) handler).getMethod();
String errorMessage;
//如果没有登录则直接交给satoken注解去验证。
if (!StpUtil.isLogin()) {
// 如果此 Method 或其所属 Class 标注了 @SaIgnore,则忽略掉鉴权
if (BooleanUtil.isTrue(SaStrategy.instance.isAnnotationPresent.apply(method, SaIgnore.class))) {
return ResponseResult.success();
}
errorMessage = "非免登录接口必须包含Token信息!";
return ResponseResult.error(HttpServletResponse.SC_UNAUTHORIZED, ErrorCodeEnum.UNAUTHORIZED_LOGIN, errorMessage);
}
//对于已经登录的用户一定存在session对象。
SaSession session = StpUtil.getTokenSession();
if (session == null) {
errorMessage = "用户会话已过期,请重新登录!";
return ResponseResult.error(HttpServletResponse.SC_UNAUTHORIZED, ErrorCodeEnum.UNAUTHORIZED_LOGIN, errorMessage);
}
TokenData tokenData = JSON.toJavaObject(
(JSONObject) session.get(TokenData.REQUEST_ATTRIBUTE_NAME), TokenData.class);
TokenData.addToRequest(tokenData);
//将最初前端请求使用的token数据赋值给TokenData对象,以便于再次调用其他API接口时直接使用。
tokenData.setToken(session.getToken());
//如果是管理员可以直接跳过验证了。
//基于橙单内部的权限规则优先验证,主要用于内部的白名单接口,以及在线表单和工作流那些动态接口的权限验证。
if (Boolean.TRUE.equals(tokenData.getIsAdmin())
|| this.hasPermission(tokenData.getSessionId(), request.getRequestURI())) {
return ResponseResult.success();
}
//对于应由白名单鉴权的接口,都会添加SaTokenDenyAuth注解,因此这里需要判断一下,
//对于此类接口无需SaToken验证了,而是直接返回未授权,因为基于url的鉴权在上面的hasPermission中完成了。
if (method.getAnnotation(SaTokenDenyAuth.class) != null) {
return ResponseResult.error(HttpServletResponse.SC_UNAUTHORIZED, ErrorCodeEnum.NO_OPERATION_PERMISSION);
}
try {
//执行基于stoken的注解鉴权。
SaStrategy.instance.checkMethodAnnotation.accept(method);
} catch (SaTokenException e) {
return ResponseResult.error(HttpServletResponse.SC_UNAUTHORIZED, ErrorCodeEnum.NO_OPERATION_PERMISSION);
}
return ResponseResult.success();
}
// 这里缓存的数据为动态接口鉴权,主要包括在线表单、在线表单工作流和在线统计表单等动态渲染表单的业务请求接口,
// 以及位于 zz_sys_perm_whitelist 白名单表中地址,这些 URL集合会在登录时缓存到 Redis 中
private boolean hasPermission(String sessionId, String url) {
// 为了提升效率,先检索Caffeine的一级缓存,如果不存在,再检索Redis的二级缓存,并将结果存入一级缓存。
Set<String> localPermSet;
String permKey = RedisKeyUtil.makeSessionPermIdKey(sessionId);
Cache cache = cacheManager.getCache(CacheConfig.CacheEnum.USER_PERMISSION_CACHE.name());
Assert.notNull(cache, "Cache USER_PERMISSION_CACHE can't be NULL.");
Cache.ValueWrapper wrapper = cache.get(permKey);
if (wrapper == null) {
RSet<String> permSet = redissonClient.getSet(permKey);
localPermSet = permSet.readAll();
cache.put(permKey, localPermSet);
} else {
localPermSet = (Set<String>) wrapper.get();
}
return CollUtil.contains(localPermSet, url);
}
会话管理
事实上,这一小节和前面介绍的用户权限管理没有太多的直接关系,之所以写在这里,是因为权限管理、用户登录和接口统一鉴权,他们是密不可分的,而会话管理又与登录和统一鉴权的关系非常紧密。
排他登录
同一用户使用相同设备类型,不能同时登录。其结果是后面的登录会使之前使用相同设备类型登录的会话失效。具体实现方式,见如下用户登录的接口代码。
// 该方法位于 LoginController 接口类中,doLogin方法会调用该私有方法,并实现排他登录功能。
private ResponseResult<SysUser> verifyAndHandleLoginUser(
String loginName, String password) throws UnsupportedEncodingException {
// ... ... 忽略其他不相干逻辑。
if (BooleanUtil.isTrue(appConfig.getExcludeLogin())) {
String deviceType = MyCommonUtil.getDeviceTypeWithString();
LoginUserInfo userInfo = BeanUtil.copyProperties(user, LoginUserInfo.class);
String loginId = SaTokenUtil.makeLoginId(userInfo);
// 这里主要是基于Sa-Token框架的工具方法实现排他登录。
StpUtil.kickout(loginId, deviceType);
}
return ResponseResult.success(user);
}
在线用户列表
列出当前系统的所有在线用户列表。如下图。

具体实现方式,请详见如下代码和关键性注释。示例代码取自于橙单生成后工程的 LoginUserController.java 文件。
由于 Sa-Token 框架自带的在线用户会话查询功能,存在性能问题,以及不能很好的基于 loginName 和 tenantId 进行过滤查询,因此我们自行维护了一套仅用于在线用户查询的 Session 列表缓存。
@SaCheckPermission("loginUser.view")
@PostMapping("/list")
public ResponseResult<MyPageData<LoginUserInfo>> list(
@MyRequestBody String loginName, @MyRequestBody MyPageParam pageParam) {
int skipCount = (pageParam.getPageNum() - 1) * pageParam.getPageSize();
String patternKey;
if (StrUtil.isBlank(loginName)) {
patternKey = RedisKeyUtil.getSessionIdPrefix() + "*";
} else {
patternKey = RedisKeyUtil.getSessionIdPrefix(loginName) + "*";
}
List<LoginUserInfo> loginUserInfoList = new LinkedList<>();
Iterable<String> keys = redissonClient.getKeys().getKeysByPattern(patternKey);
for (String key : keys) {
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()));
}
private LoginUserInfo buildTokenDataByRedisKey(String key) {
RBucket<String> sessionData = redissonClient.getBucket(key);
TokenData tokenData = JSON.parseObject(sessionData.get(), TokenData.class);
LoginUserInfo userInfo = BeanUtil.copyProperties(tokenData, LoginUserInfo.class);
userInfo.setSessionId(tokenData.getMySessionId());
return userInfo;
}
强制退出
有些业务场景是需要将当前用户的会话强制退出的,比如更新了当前用户的权限后,需要用户重新登录后才能生效,此时可以采用强制退出的方式,让用户之前的登录会话失效并重新登录。如下图。

具体实现方式,请详见如下代码和关键性注释。示例代码取自于橙单生成后工程的 LoginUserController.java 文件。
@SaCheckPermission("loginUser.delete")
@PostMapping("/delete")
public ResponseResult<Void> delete(@MyRequestBody String sessionId) {
RBucket<String> sessionData = redissonClient.getBucket(sessionId);
TokenData tokenData = JSON.parseObject(sessionData.get(), TokenData.class);
// 根据被强踢的用户会话Id,调用Sa-Token的工具方法将当前会话踢出。
StpUtil.kickoutByTokenValue(tokenData.getToken());
sessionData.delete();
return ResponseResult.success();
}
结语
赠人玫瑰,手有余香,感谢您的支持和关注,选择橙单,效率乘三,收入翻番。