Spring Cloud中国社区博客

spring boot / cloud 认证鉴权设计与思考

前言

本篇接着这篇博客来继续讨论微服务间接口调用的认证和鉴权的思考和设计

在上一篇文章中主要是偏实现方面,具体的实现思想没有过多讨论,本篇文章则是主要讨论一下设计的思路.

我们都知道,在微服务的架构设计中,一个大的系统会被按照不同的领域拆分成一个个小的微服务,而这些微服务之间不可避免的会有业务数据的交互,

那么我们会用一些远程服务调用的方式来连接各个微服务( 比如:RPC,RestFul,等 ).

不过,就像前面说的一样,期初,应用不大,随便弄弄无所谓,但是等应用规模起来以后,你会发现,有成百上千的服务在运行,这些服务相互依赖,仿佛一团乱麻.

更可怕的事情是如果没有有效的权限控制,我们很有可能都不清楚是谁调用了你的服务……

所以说,我认为,在微服务架构设计中,内部服务调用的权限控制是非常必要的(至少我参与的项目都有这种需求),它应该满足如下几个主要的功能:

  • 防止越权行为

  • 管理服务的依赖关系

  • 规范服务调用行为

  • 能够在运行时修改权限配置

下面我们来看看具体的分析:

场景分析

防止越权行为

在系统中添加权限相关的控制,主要是为了增加系统的安全性,总结下来主要是为了防止如下的两种越权行为:

  • 横向越权 (指的是攻击者尝试访问与他拥有相同权限的用户的资源)

  • 纵向越权 (指的是一个低级别攻击者尝试访问高级别用户的资源)

所以说通常,在系统中的权限校验也按照以上划分会分为两个步骤:

  • 校验访问者身份

  • 校验是否拥有被访问资源的权限

校验访问者身份

先说校验访问者身份,这个主要目的就是确定A的确是A,不是其他的阿猫阿狗.

要做到这一点并不难,并且有很多安全框架都能支持,比如apache shiro,spring security,jwt,等等.

这些框架主要思路还是使用token签名的方式,也就是说,

要么调用方和服务方约定一个私钥,然后调用方自行通过算法生成token,

或者服务方提供一个获取token的接口(OAuth2),调用方主动调用接口获取token,

最后调用方在调用服务的时候,都把这个token给带上,便于服务方认证身份.

那么那种方式更好呢? 个人经验我会按场景做如下架构原则定义:

  • 如果服务是给系统用的,则采用私钥的方式

  • 如果服务是给用户(人)使用的,则采用获取token的方式(通过用户名和密码来获取token)

那么,还有一种情况,如果这个接口既是给人使用的,也是给第三方系统使用的,怎么办呢?

这个其实也不复杂,不过不会在今天这篇文章中讨论,这里只提一点,通过入口区分,也就是网关,大家可先自行脑洞.

那么,回过头来看,我们今天讨论的场景显然是属于是给系统使用的服务,所以在我设计的RMS组件中,是采用的私钥的方式.

采用这种身份认证方式需要注意如下几点:

  • 私钥的安全性

  • token的过期策略

  • token的计算算法

在RMS组件中,我并没有引入第三方的依赖,因为我希望,这个身份认证是轻量级的,灵活的,这些第三方认证框架大而全,很优秀,但我们只会用到其中的一小块,会造成一些没必要的依赖.

从实现方面,首先,所有的私钥,都会配置到远端的配置中心里面,本地不做任何存储,由专门的人员管理和维护,系统只有在运行的时候,才能获取到私钥.

同时依赖于spring cloud config server的特性,可以在运行时更换私钥,更加灵活,也保证了的私钥的安全,

如下的sign(token)的算法,通过应用名称和私钥,要有当前时间(精确到小时),拼接起来然后进行md5,得到最终的sign,因为加入了时间的这个因子,所以计算出来的sign是每小时过期的

算法方面大家可以随意设计,但是切记,不要过度设计,满足需求即可

1
2
3
4
5
6
7
public static String sign(String rmsApplicationName, String secret) {
final String split = "_";
StringBuilder sb = new StringBuilder();
sb.append(rmsApplicationName).append(split).append(secret).append(split)
.append(new SimpleDateFormat(DATA_FORMAT).format(new Date()));
return DigestUtils.md5Hex(sb.toString());
}

校验是否拥有被访问资源的权限

然后我们再聊校验是否拥有被访问资源的权限,这个点说简单也简单,说复杂也非常复杂.

在前面一步的校验中,已经确定了身份,现在是要确定,A是否有访问B的/user服务的权限.

其他的不说,我这边只提两点:

  • uri匹配

  • 性能

在没有RestFul风格的url的时候,一切其实都还蛮美好的,因为,url就是唯一值,是整个系统的最小颗粒度的权限点.

大家以前可能是这样做的,有张表,记录这系统的url,以及其他的角色,岗位等的关联,然后,如何校验呢,非常简单,

直接的sql语句select count(1) form xxx where url=’/aaa/getUserByName’就行了,能查到值就代表有权限.

在稍微进阶一点的,会考虑性能问题,会将某个用户的一些权限缓存起来,然后在内存中进行判断.

但是,当RestFul风格的url到来的时候,这一切变得不那么美好了,先看如下几个url的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
GET /users -- 查询用户列表
GET /user/{id} -- 查询用户详情
POST /user -- 新增用户
PUT /user --更新用户
DELETE /user/{id} --删除用户
GET /user/{id}/scores -- 查询某个用户的所有成绩
GET /user/{id}/score/{sid} -- 查询某个用户的某门课程的成绩

我们可以看到,按照原有的方式已经不那么使用了,url的定义从原来的平面化的,变成了立体化的,

按照原来的方式,那么就变成了,如果拥有查询用户详情接口权限的系统,同时也就拥有了更新用户和删除用户的权限,这是非常严重的越权行为.这显然不是我们期望看到的.

那么如何优化呢? 首先,我们的权限判断中应该加入httpmethod的判断,这样,就能很简单的避免以上的情况.

但是更严重的问题来了,url不再是固定不变的了,而是动态的,怎么办呢?先拍脑子想想,处理方案可能有如下几种:

  • 正则匹配

  • 将所有url解析成树形结构,将动态部分用星号表示,然后进行最短匹配

以上两种方案我都试过,不过方案都过于复杂,甚至存在性能问题,因为以上两种方式都不可不免会进行循环匹配.

我们当然不想因为一个url校验,而引入一个性能问题的风险,那么如何解决这个问题呢?

其实我们回过头来想想,spring mvc为什么就能准确的定位到每个url对应的handler呢?

其实还是那句话,最复杂的部分,spring已经帮我们完成了,在spring 上下文初始化的时候,容器就会记录所有的mapping,如下:

1
2
3
4
Mapped "{[/health || /health.json],methods=[GET],produces=[application/json || application/json]}" onto HealthMvcEndpoint.invoke(HttpServletRequest,Principal)
Mapped "{[/env/{name:.*}],methods=[GET],produces=[application/json || application/json]}" onto EnvironmentMvcEndpoint.value(String)
Mapped "{[/env || /env.json],methods=[GET],produces=[application/json || application/json]}" onto EndpointMvcAdapter.invoke()
Mapped "{[/features || /features.json],methods=[GET],produces=[application/json || application/json]}" onto EndpointMvcAdapter.invoke()

以上日志大家随便启动一个spring boot应用都能看到,其实这类输出就是我们controller定义的requestMapping

1
@RequestMapping(value = "/user/{id}/score/{sid}", method = RequestMethod.GET)

而我们平时获取url的方式是这样的:

1
String url = request.getRequestURI();

这样获取到的url 大概是,也就是我们实际请求的url:

1
/user/1/score/5

那么如何改变呢?以上这个url我们还是不知道如何匹配? 换个思路想想,能进入拦击器,则表示spring将这个地址已经匹配到了对应的handler,我们只需找到这个url对应的那个handler就行了.

在request的作用域中(Attribute),存放这很多spring的信息,debug一下,打个断点,看看都有啥?,最终我定位到了bestMatchingPattern这个属性,大致含义就是最佳匹配模式,也里面的值是requesMapping里的value

这不正是我们想要的结果吗?spring已经帮我们做了最佳的替换,如下代码:

1
2
String url = request.getAttribute(HandlerMapping.BEST_MATCHING_PATTERN_ATTRIBUTE).toString();
//url的值为 : /user/{id}/score/{sid}

那么后续我们就可以调整我们的设计了,如果你的权限配置是配置在数据库中的那么,最简单的鉴权sql语句就是:

1
select count(1) form xxx where url='/user/{id}/score/{sid}' method='GET'

这样做的好处就是可以做到最精确的匹配,颗粒度达到最细,并且毫无性能损耗,你无需做任何的循环匹配.详细代码可以在项目的RmsAuthHandlerInterceptor类中找到

其他

在上面还提到了 管理服务的依赖关系 , 规范服务调用行为 , 能够在运行时修改权限配置 这几点 ,

在上一篇讲解RMS代码的时候也都提及到了,会通过远程配置文件的方式,来进行管理,如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
# application
org.itkk.rms.properties.application.udf-service-a-demo.serviceId=UDF-SERVICE-A-DEMO
org.itkk.rms.properties.application.udf-service-a-demo.secret=ASD5S2SDF6ASD2S2SD32S
org.itkk.rms.properties.application.udf-service-a-demo.purview=ID_2,SCHEDULER_JOB_4,SCH_CLIENT_CALLBACK_1
org.itkk.rms.properties.application.udf-service-a-demo.all=false
org.itkk.rms.properties.application.udf-service-a-demo.disabled=false
org.itkk.rms.properties.application.udf-service-a-demo.description=测试服务A
# service
org.itkk.rms.properties.service.ID_1.owner=udf-general-server-demo
org.itkk.rms.properties.service.ID_1.uri=/service/id
org.itkk.rms.properties.service.ID_1.method=GET
org.itkk.rms.properties.service.ID_1.isHttps=false
org.itkk.rms.properties.service.ID_1.description=获得分布式ID

会分为application和service两类,application主要描述身份认证和权限,service主要描述服务详情 . 通过这种结构来管理服务间的依赖关系

然后在项目中,大家可以看Rms这个类,里面抽象出了一个公共的方法,用于规范调用行为,最终调用的方式如下:

1
2
3
ResponseEntity<RestResponse<FileInfo>> fileInfo = rms.call("FILE_4", fileParam, null,
new ParameterizedTypeReference<RestResponse<FileInfo>>() {
}, null);

在系统中,开发人员都无需关心任何接口的定义,只需通过接口编号就可以进行调用.

最后,所有的RMS相关的配置都会放在配置中心,同一管理.

结束

今天代码层面的东西讲的比较少,主要是跟大家介绍一下设计的思路,还是一个原则,使代码更健壮,更灵活,更合理,同时,也切记不要重复造轮子,也不要过度玩技术.

在下一篇文章中,我会介绍一下分布式任务调度的思考和设计,敬请期待.

代码仓库 (博客配套代码)


想获得最快更新,请关注公众号

想获得最快更新,请关注公众号