背景
Snapper权限系统是一套完善的权限解决方案,是在复杂的业务需求中不断完善,直到现在形成一套完善的解决方案,开始总结下系统如何从0开始逐步到1完善的过程,第一步先从功能设计开始。
功能设想
良好的系统设计,总是从一个有效的简单系统发展而来,千万不要从零开始设计一个复杂的系统。-摘自GitHub 的高级工程师肖恩·戈德克
我们先从最简单的开始头脑风暴,想一想一套完善的权限系统大概都有哪些功能,哪些功能是重要的?那些是次要的?token登录、用户设计、角色设计、RBAC、组织设计、资源权限、数据权限....等等等等
大概罗列了一下形成下面的图:
通过花型图总结有以下几个核心功能:
- 登录 包括用户名密码校验 、登录后其他功能请求的鉴权和认证,这些功能需要用户及其关联结构的支持
- 分配权限 权限涉及到资源权限 、数据权限
- 架构 当前比较流行的两种架构 单体和微服务,为了设计简洁先以单体版为准
- 安全 包括必要的限流及黑白名单设计
暂时先从以上三个核心功能开始,逐步完善,最终覆盖整个花型图的完整功能
功能定义
功能定义在系统中抽象为按钮,在权限系统中定义为资源,我们把请求URL+请求方法作为访问资源的唯一路径,有以下约定:
- POST /api/user 是用户新增
- PUT /api/user 是用户更新
- DELETE /api/user 是用户删除
- GET /api/user/{} 是用户明细 其中{}中是用户的ID,ID为全数字
功能流程
核心功能总结起来就是:登录、鉴权、认证、执行业务、SQL拦截改写,以及通用的框架层面处理,包括异常处理、统一参数返回处理,以请求功能为例,流程大概如下所示:

- 鉴权 判断当前的登录是否有效
- 认证 判断当前的用户是否有资源的权限
- 封装用户 帮助我们在业务中能够更好的获取当前登录的用户相关信息
- SQL拦截 通过数据权限的配置拦截业务的SQL并进行改写以满足数据权限的要求
- 异常处理 处理系统的错误,包括权限相关的错误、业务相关错误以及不可预知的错误
- 数据返回处理 通过处理返回统一格式的数据,接收端就可以进行标准格式数据的操作
登录功能
登录是每个系统必备的功能,对于权限系统尤为重要,大概思考下登录的流程包括:用户名查询是否存在、密码匹配 等于成功后生成TOKEN信息、是否支持同一账号同时登录、是否支持验证码登录等等,大概流程如下:

其中缓存用户信息尤为重要,是用于后续操作的数据基础。
流程中:密码加密、密码匹配功能等诸多功能我们可以借助安全框架来完成,包括Spring Security和Shiro等,当前系统我们采用Spring Security。
登录成功后会生成TOKEN信息,后续用户的请求都以TOKEN为准,根据不同的需求为TOKEN生成不同时长的有效期,比如针对手机端有效期长一点,对于客户端有效期与工作时间大概匹配,登录中有一个"终端"的概念,也是后续用户设计中需要注意的地方,此部分将在后续用户设计中深入讨论
登录中有一些细节需要注意:
- TOKEN的安全性及携带信息 为保证TOKEN的安全性, 采用公钥和私钥的方式进行处理, 公钥参与生成TOKEN, 私钥参与校验TOKEN。TOKEN中会封装一些用户信息,比如用户名信息,便于识别用户,这对后续的鉴权很重要。
- 同一账号同时登录问题,前面我们引入"终端"概念,可以保证同一账号在不同的"终端"中同时登录,此功能在Snapper系统中为默认约定,如果同一账号在同一终端要求可以同时登录时, 需要借助缓存中间进行进一步处理,该需求可以在用户功能进行设置,要求某个用户满足账号同时登录的需求,此部分在用户设计中深入讨论
- 多开问题,如果设置了"业务"终端只允许同一个账号登录,在浏览器窗口只能打开一个窗口,这对业务的操作很不利,有时需要多窗口进行对照操作,此时可以在前端添加SESSION复制功能,即复制地址,打开新浏览器窗口进行粘贴会将SESSION信息进行复制
- 验证码问题 Snapper演示系统(账号密码ximen/123456)默认登录时没有验证码的,但系统集成了验证码的功能,提供了验证码验证的抽象方法,如果需要验证码只需实现该抽象方法即可
鉴权
登录获取TOKEN信息后,后续的功能请求都通过TOKEN来进行身份校验,也就是鉴权。流程如下:

鉴权的核心是获取TOKEN信息后根据私钥验证TOKEN的有效性,一些细节需要注意:
- 获取用户缓存信息 是在登录时缓存的信息,Snapper系统中主要缓存了,当前用户的角色信息 、组织信息 、公司信息 、职位信息 等,这些信息会封装到用户上下文中,在业务逻辑中通过上下文就能获取这些信息。
- 流程中的添加头信息 ,会将缓存的用户信息添加到请求头,方便后续的过滤器使用, 这在认证功能中尤为重要。
认证
鉴权完成后就要认证资源的有效性(URL+请求方法),资源的认证主要是基于角色(RBAC)的模式,采用松散匹配模式:即资源的角色列表中只要包含当前用户所拥有的角色,认为当前用户是拥有该资源权限的,具体流程如下:

认证的核心是找当前用户的角色以及角色与资源的匹配。为提高匹配效率,资源拥有的角色在项目启动时进行缓存预热。
以上流程有一些细节需要注意:
- 头部信息 是在鉴权中进行封装,所以有一个过滤器的执行顺序问题,认证过滤器要在鉴权过滤器之后
- 查找资源 目的是从资源缓存预热中查找对应的资源,对于GET请求需要特别处理,例如当前GET请求的URL为/api/user/1234567(暂时以此为例),要求ID全是数字这样就区分GET /api/user/listAll类似的接口,根据前面功能定义中的约定GET /api/user/{} 把全是数字的替换成{}以获取正确的资源信息
- 无权限匹配 有些资源是无权限的,在缓存预热时可以将无权限的资源对应角色设置为固定的常量标记,在认证时根据资源所需的角色来判断是否无权限。
异常处理
在以上的功能中都包含异常处理 和数据返回处理,这是保证框架稳定性的基础,异常做如下处理:
- 定义通用的异常接口 ,包括编号 和内容 ,后续所有的异常枚举都实现此接口,按照不同的业务类型分配不同的编号段,例如系统级的异常从0-199999(仅举例,具体以业务为准),不同的业务根据需要也划分不同的段,这样根据对应的号段就能判断出对应的异常种类。
- 定义通用的异常类,所有异常抛出的类都以此为准,异常类包含异常接口的构造函数,例如
throw new ServerRuntimeException(ExceptionEnum.NO_PRIVILEGE_EXCEPTION)
- 通用的异常处理 所有的异常最终都转化为通用的异常类。
详情请参考框架依赖 snapper-dependence
java
public interface ExceptionEnumable {
int getExceptionCode();
String getExceptionMessage();
String name();
default ExceptionEnumable loadByLang(String v) {
try {
String language = RequestContext.reqLanguage();
ExceptionEnumable e = null;
if (Checker.beEmpty(language) || Strings.ZH_CN.equalsIgnoreCase(language)) {
e = this;
} else {
Class clz = Class.forName(this.getClass().getName() + Strings.UNDERSCORE + language);;
e = (ExceptionEnumable) EnumUtils.getEnum(clz, v);
}
if (Checker.beNull(e)) {
return this;
}
return e;
} catch (Throwable e) {
throw new RuntimeException(e);
}
}
}
java
public enum ExceptionEnum implements ExceptionEnumable {
NO_PRIVILEGE_EXCEPTION(403, "用户没有访问资源的权限!"),
USER_BE_UNAUTHERIZED(401, "用户权限资源认证失败,没有权限!"),
CAPTCHA_NOT_CORRECT(995, "验证码错误, 请填写正确验证码!"),
OBJECT_BE_LOCK_NOT_OPERATE(996, "该信息已被锁定,无法进行此操作!"),
USER_NAME_NOT_EXIST(999, "用户名不存在!")
...
;
private int code;
private String message;
@Override
public int getExceptionCode() {
return this.code;
}
@Override
public String getExceptionMessage() {
return this.message;
}
ExceptionEnum(final int code, final String message){
this.code = code;
this.message = message;
}
}
java
public class ServerRuntimeException extends RuntimeException {
protected int code;
protected String codeMessage;
public ServerRuntimeException(ExceptionEnumable exceptionEnum) {
super(MessageFormat.format(exceptionEnum.loadByLang(exceptionEnum.name()).getExceptionMessage(), new Object[] {"","","",""}));
codeMessage = super.getMessage();
this.code = exceptionEnum.getExceptionCode();
}
public ServerRuntimeException(ExceptionEnumable exceptionEnum, Object... arguments) {
super(MessageFormat.format(exceptionEnum.loadByLang(exceptionEnum.name()).getExceptionMessage(),
Checker.beEmpty(arguments) ? new Object[] { "", "", "", "" } : arguments));
codeMessage = super.getMessage();
this.code = exceptionEnum.getExceptionCode();
}
public ServerRuntimeException(int code, String message) {
super(message);
this.code = code;
this.codeMessage = message;
}
}
数据返回处理
统一的数据返回是将所有的数据都汇总成固定的格式进行返回,包括异常的数据,具体做如下处理:
- 某些情况下需要返回原始数据,针对此定义一个 @NotWrap 注解,用于控制器方法上,拥有此注解不做统一封装处理,同样可以针对包路径进行处理,只在
snapper.wrap.package
定义的包目录下的才会进行处理 - 私有方法不做处理
- 返回数据格式 统一为 {code:200, message:"OK", data:[]}格式的数据,data中为对应的业务返回数据
详情请参考:ReturnHandlerAdvice
总结
通过以上的功能设计,完成了权限系统设计最基础的功能设计,后续会逐渐完善其他模块,包括 用户设计 、角色设计 、职位设计 、组织设计 ,还有核心的数据权限设计 , 下一章节将开始用户相关的设计