安全功能扩展
[一]概述
Dante Cloud 使用 Spring Authorization Server
和 Spring Security
实现整个系统的认证和授权相关功能和支持。但是,组件大多仅提供基础性支持,距离实际应用的安全性需求还有较大的距离。
所以,Dante Cloud 在基础组件应用的基础之上,扩展和实现了较多安全性的支撑功能,以进一步提升系统的安全能力。
[二]数字信封
[1]什么是数字信封?
数字信封的功能类似于普通信封。普通信封在法律的约束下保证只有收信人才能阅读信的内容;数字信封则采用密码技术保证了只有规定的接收人才能阅读信息的内容。
数字信封中采用了单钥加密体制和公钥密码体制。信息发送者首先利用随机产生的【对称密码】加密信息(因为非对称加密技术的速度比较慢),再利用接收方的【公钥】加密对称密码,被公钥加密后的对称密钥被称之为数字信封。在传递信息时,信息接收方要解密信息时,必须先用自己的私钥解密数字信封,得到对称密码,才能利用对称密码解密所得到的信息。
数字信封既发挥了对称加密算法速度快、安全性好的优点,又发挥了非对称加密算法密钥管理方便的优点。
数字信封工作原理,如下图所示
[2]在Dante Cloud中的应用
数字信封技术,在Dante Cloud中主要被应用于前后端数据加密传输场景。
为什么使用数字信封技术
前后端数据加密需求,是现今应用系统中必不可少的安全措施。不同系统所采用的实现方式千差万别,常见的实现方案安全性都不是很高。
之所以存在这样的问题是因为:
- 前后端数据加密必须要使用对称性加密算法,否则能加密就不能解密。
- 使用对称性加密算法,就需要在前端存储秘钥,前端是非常不安全的,放在前端的秘钥很容易暴露。
- 可以采用“实时”生成对称算法秘钥的方式,比如,根据 Session 的不同每次由后台单独生成再发送给前台,但是这种方式容易在传输过程中被截获。
为了解决上述问题,Dante Cloud 采用了数字信封技术,实现“一人一码”的安全机制:
- 首先,基于数字信封原理,采用非对称加密算法加密,保证数据在传输过程中的安全。这个过程首先需要前后端分别生成非对称加密的秘钥对,然后创建一个“握手”的过程,交换非对称加密秘钥的公钥
- 其次,采用的是“实时”生成对称算法秘钥的方式,为不同的 Session(不同的用户)在后台生成不同的对称算法秘钥。利用上一步非对称加密算法对该“对称算法秘钥”进行加密传输,保证传输过程中的数据安全。
- 前端在接收到后端发送过来的“对称算法秘钥”后,进行解密然后临时存储在前端,如果 Session 失效那么该秘钥也随之失效。再次使用需要重复前面的过程,由后端重新生成。
- 因为前端在接收到“对称算法秘钥”后会进行解密存储,为了提升安全性 Dante Cloud 用前端的对称加密算法,对解密后的、后端发过来的对称算法秘钥进行了加密,提升一定的安全性。
- 因为,整个过程都需要依赖一个重要的内容就是 Session。利用 Session 才能唯一区分用户,利用 Session 的“状态”性,才能保证申请的秘钥在用户的登录期间有效。
所以,不要再说微服务架构是无状态的,Session 没有用啦。即使,不考虑前后端数据加密传输,
Spring Authorization Server
中很多认证模式也要依赖于 Session,否则运行不了,特别是如果认证服务是多实例的情况。
提示
Dante Cloud 花了大量的时间和精力研究解决微服务架构的 Session 一致性问题。为了保证 Session 一致性实现的健壮性,还专门增加了一个自研的“Session”机制做兜底。想要了解实现详情,可以阅读【微服务Session共享】
Dante Cloud 中的实现
通过前面的介绍,我们大体了解了整套数字信封的机制,那么下面就来看看 Dante Cloud 是怎么实现的。
- 新增一套兜底的 Session 机制:
微服务的 Session 共享通过 Spring Session + Redis 实现,但是前端使用的组件会非常不同(比如,Vue 中会使用 Axios),Session 的生成和管理就会有很大差异。可能就会出现前端的 Session 无法于后端的统一,或者前端会频繁创建新的 Session 等问题。
所以,Dante Cloud 在请求中增加了一个额外的请求头 X-Herodotus-Session-id
。
- 统一 Session
在 Dante Cloud Vue 前端中,每次打开首页都会首先发送一个 /open/identity/session
请求。具体代码如下:
@Operation(summary = "获取后台加密公钥", description = "根据未登录时的身份标识,在后台创建RSA/SM2公钥和私钥。身份标识为前端的唯一标识,如果为空,则在后台创建一个",
requestBody = @io.swagger.v3.oas.annotations.parameters.RequestBody(content = @Content(mediaType = "application/json")),
responses = {@ApiResponse(description = "自定义Session", content = @Content(mediaType = "application/json"))})
@Parameters({
@Parameter(name = "sessionCreate", required = true, description = "Session创建请求参数", schema = @Schema(implementation = SessionCreate.class)),
})
@PostMapping("/open/identity/session")
public Result<Session> create(@Validated @RequestBody SessionCreate sessionCreate, HttpServletRequest request) {
String sessionId = sessionCreate.getSessionId();
if (StringUtils.isEmpty(sessionId)) {
HttpSession session = request.getSession();
sessionId = session.getId();
}
SecretKey secretKey = interfaceSecurityService.createSecretKey(sessionCreate.getClientId(), sessionCreate.getClientSecret(), sessionId);
if (ObjectUtils.isNotEmpty(secretKey)) {
Session session = new Session();
session.setSessionId(secretKey.getIdentity());
session.setPublicKey(secretKey.getPublicKey());
session.setState(secretKey.getState());
return Result.content(session);
}
return Result.failure();
}
这个请求的目的就是在创建自定义的 Session 系统,如果在这个请求中可以拿到 Session ID,那么就使用这个 Session ID 来创建自定义 Session 系统的 Session ID;如果这个请求中没有 Session ID,那么就由后端重新创建一个。最后,将这个 Session ID 作为响应返回给前端,作为 X-Herodotus-Session-id
请求头的值,在每个请求中携带上这个头。
通过这种方式,既创建了自定义的 Session 系统,然后配合 Spring Session 的 Session 共享,又将其与 Java Web系统、微服务系统以及 Vue 的 Session 系统进行了统一。详情,可以阅读【微服务Session共享】
- 实现前后端“握手”
拿到后端确认的 Session ID 之后,前端还会发送一个 /open/identity/exchange
请求。具体代码如下:
@Operation(summary = "获取AES秘钥", description = "用后台publicKey,加密前台publicKey,到后台换取AES秘钥",
requestBody = @io.swagger.v3.oas.annotations.parameters.RequestBody(content = @Content(mediaType = "application/json")),
responses = {@ApiResponse(description = "加密后的AES", content = @Content(mediaType = "application/json"))})
@Parameters({
@Parameter(name = "sessionExchange", required = true, description = "秘钥交换", schema = @Schema(implementation = SessionExchange.class)),
})
@PostMapping("/open/identity/exchange")
public Result<String> exchange(@Validated @RequestBody SessionExchange sessionExchange) {
String encryptedAesKey = interfaceSecurityService.exchange(sessionExchange.getSessionId(), sessionExchange.getPublicKey());
if (StringUtils.isNotEmpty(encryptedAesKey)) {
return Result.content(encryptedAesKey);
}
return Result.failure();
}
这个请求的目的,就是与后端实现“握手”,拿到与当前 Session 对应的对称加密算法秘钥,同时利用非对称加密算法实现这个秘钥可以安全的传输到前端。
- 前端秘钥加解密
前端拿到后端传过来的对称加密算法的秘钥后,首先要利用之前生成的非对称加密算法的私钥对其解密,这样才能在实际应用中对业务数据进行加解密。
但是,这个解密后的非对称加密算法的私钥,是需要存储在前端的,这样并不安全(实际上也没有太好的安全措施,除非是像银行系统一样,把这个私钥存储在U盾中)。
为了尽可能的提升一定的安全性,Dante Cloud 的 Vue 前端,利用前端的对称加密组件,对这个后端传过来的、并且是已经解了密的秘钥,进行了一次简单的加密。
在前端工程配置文件中,VITE_SECRET_KEY
的值就是用于对后端传过来的对称加密秘钥进行加密的。
补充说明
可能一些朋友看到这里,会觉得:“你说的那个高大上,还什么数字信封,还不是要在前端存储一个秘钥,整这么麻烦没有必要”。这里我觉得有必要进行一定的说明
强调
安全问题是一个庞大的体系,不同类型的问题、不同场景的问题,需要采用不同的方式来解决。没有绝对的、100%的加密或安全手段,更不会说有一种方案可以解决所有的安全问题。这就好比:
- 各种类型的验证码并不会对系统安全性有多大提升,其作用主要是要解决使用者身份的判断,防止非用户操作带来的暴力登录。
- 前后端数据加密传输,本质也不是提升数据安全性的,主要是用来防止数据在传输过程中被抓包。
上面这种设计并不是说绝对的好,但是可以把一些已知的问题解决是:
- 这种设计根据不同用户动态生成不同的秘钥,比把秘钥在前端写死,所有人都用一个安全性有一定提升
- 秘钥在传输过程中即使被抓包,想要破解也很难。
- 虽然,最终也是把秘钥存放于前端,但是也进行了加密,提升了一定的安全性。
- 增加了时效性,Session 过期就需要重新申请,也在一定程度上提升了安全性。
而且前端也不是说绝对不能存放任何关键信息的,就好比用了 OAuth2,那么 OAuth2 也是需要存储在前端的。
当然,这种情况是有一个假设前提的,在使用 OAuth2 时为什么敢把 Token 存在前端,除了技术上的保障以外(合理的过期时间),这里面还有一个很大的前提,就是对于使用者有个的信任。这个信任就是:用户自己申请的 Token,他不会把它泄露给别人,也不会让别人使用的他的电脑,不会让别人直接用他的浏览器访问他已经登录过的系统。
提示
如果,你觉得我说的不对,那就自己想想你自己会不会把自己的用户名和密码告诉别。把自己的用户名和密码告诉别人,你是怎么保证自己账户安全的。用户自己把自己的账号和密码告诉了其他人,回来说系统够安全,你会不会接受呢?
[3]加密算法
Dante Cloud 在早期版本中,实现数字信封是混合使用 RSA 和 AES 加密算法实现。后期,为了更符合国内使用需求,数字信封算法改为混合使用国密 SM2 和 SM4 实现。原有 RSA 和 AES 算法实现仍旧保留,后端系统可以通过修改配置进行切换。参数配置,参见Crypto
注
如果后端通过配置修改了加密算法,那么前端也需要同步修改。目前,前端需要手动进行加密算法的修改。
[三]前后端数据加密传输
注
前后端数据加密传输的实现,完全依赖于前面数字信封以及 Session 共享内容。
[1]@Crypto
Dante Cloud 中要使用前后端数据加密,需要在需要开启数据加解密的接口上标记 @Crypto
注解,如下代码所示:
@Operation(summary = "修改密码", description = "修改用户使用密码,默认使用加密请求传输",
requestBody = @io.swagger.v3.oas.annotations.parameters.RequestBody(content = @Content(mediaType = "application/x-www-form-urlencoded")),
responses = {@ApiResponse(description = "修改密码后的用户信息", content = @Content(mediaType = "application/json"))})
@Parameters({
@Parameter(name = "userId", required = true, description = "userId"),
@Parameter(name = "password", required = true, description = "角色对象组成的数组")
})
@Crypto(responseEncrypt = false)
@PutMapping("/change-password")
public Result<SysUser> changePassword(@RequestParam(name = "userId") String userId, @RequestParam(name = "password") String password) {
SysUser sysUser = sysUserService.changePassword(userId, password);
return result(sysUser);
}
@Crypto
注解有两个参数:requestDecrypt
和 responseEncrypt
, 默认值均为 true
,即默认开发
requestDecrypt
:对接口接收的参数进行解密responseEncrypt
:对接口响应的数据进行加密
[2]支持的场景
Spring Boot 用于支持 REST 注解非常非常多,要实现前后端数据传输,本质就是针对 Spring Boot 的各种 REST 注解进行处理。想要全面的了解 Spring Boot 中的 REST,可以阅读【接口传参方式详解】
目前,Dante Cloud 前后端数据加密,主要支持以下几个场景:
- 未指定名称的
@RequestParam
注解,同时参数类型为Map的接口,支持解密 - 使用了
@Crypto
注解,且requestDecrypt
参数为 true的接口,支持解密 - 使用了
@RequestParam
注解的接口,支持解密 - 使用了
@RequestBody
注解的接口,支持解密 - 请求路径是 '/oauth/token',支持解密
- 使用了
@Crypto
注解,且responseEncrypt
参数为 true的接口,即支持@ResponseBody
注解的接口,支持加密
[3]注意事项
- 考虑到数据加解密是一项比较损耗性能的操作,所以 Dante Cloud 不提供全局的加解密设置。数据加解密只能针对具体的接口具体设置。
- 数据加解密,需要前后端互相配合。某个接口要数据加解密,前端调用这个接口的代码需要进行加解密的处理。仅在后端使用
@Crypto
注解是无法实现数据加解密传输的。 - 数据加解密,依赖于整个 Dante Cloud 中的 Session 体系(包括,自定义 Session 和 Session 共享)。如果请求中没有 Session 或者不包含
X-Herodotus-Session-id
请求头,数据加解密会自动失效
提示
在 POSTMAN 等工具中进行接口调试时,如果设置了数据加解密,调试会非常麻烦,要保证 Session 的生成和准备。前面第三点所说,为我们日常开发提供了便捷。在 POSTMAN 中调试时,数据加解密会自动失效,就不再需要手动调用多个接口模拟 Session 环境。
[四]Scope与接口权限绑定
在使用 OAuth2 时,经常让人比较疑惑的知识点就是 Scope
。OAuth2 默认的权限就是 Scope
,而不是我们熟悉的接口(REST API)。
在使用 OAuth2 时,我们可以通过设计实现,修改 OAuth2 所使用的权限体系,比如和 RBAC 权限体系结合,让其不再局限于使用 Scope
。但是由于 RBAC 权限体系是面对“用户”的,所以与 OAuth2 结合以后,只能对授权码模式(Authorization Code Grant
)、密码模式(Resource Owner Password Credentials Grant
)以及其它自定义的、涉及需要“用户”参与的授权模式起效。
想要深入了解,可以阅读【OAuth2中Scope与Role】。
对于像客户端凭证模式(Client Credentials Grant
)这种,完全没有“用户”参与的授权模式,权限体系还必须使用 Scope
。这就导致:
- 如果我们开发涉及 REST 接口的、使用客户端凭证模式的应用,使用
Scope
是无法保护接口安全的。 - 在
Spring Authorization Server
的标准实现中,Scope
权限的验证也仅仅是字符串的比较,达不到安全需求。
因此,Dante Cloud 专门对 Spring Authorization Server
进行了扩展,支持将 Scope
权限与接口进行绑定。配置好 Scope
权限之后,就可以直接对请求的接口进行权限校验。
提示
这是 Dante Cloud 中比较具有特色的功能,目前还少有开源微服务系统支持。
[五]登录次数限制
Dante Cloud 对 Spring Authorization Server
和 Spring Security
进行了扩展,增加了登录失败次数限制。
默认失败次数为 5 次,时间间隔为 2 小时,失败 5 次 后将会对该账户进行锁定。如果开启自动解锁,规定时间到达之后,会自动解除锁定状态。如果没有开启,则需要管理员手动解锁。详细配置,参见OAuth2
注
符合国家三级等保要求检查项:
- 应提供登录失败处理功能,可采取结束会话、限制非法登录次数和自动退出等措施
- 应提供登录失败处理功能,可采取结束会话、限制非法登录次数和自动退出等措施
[六]用户终端限制(仅 Opaque Token 模式有效)
Dante Cloud 对 Spring Authorization Server
和 Spring Security
进行了扩展,增加了对同一终端,允许同时登录的最大数量限制。
可以设置同一用户账户,同一时间允许等于的终端数量(例如:浏览器)。假设,设置为 1
,那么用户在第二个浏览器进行登录时,将登录失败。详细配置,参见OAuth2
注
符合国家三级等保要求检查项:
- 应能够对系统的最大并发会话连接数进行限制
[七]用户账户踢出(仅 Opaque Token 模式有效)
Dante Cloud 对 Spring Authorization Server
和 Spring Security
进行了扩展,增加了对同一账户的用户提出的支持。
可以设置同一用户账户,同一时间是否允许多处登录。如果开启该功能,用户在其它终端登录,将会之前已经登录的账号踢出。详细配置,参见OAuth2
注
符合国家三级等保要求检查项:
- 应能够对单个帐户的多重并发会话进行限制
[八]用户登录登出记录
Dante Cloud 对 Spring Authorization Server
进行了扩展,对用户登录登出等关键操作进行系统记录。
注
符合国家三级等保要求检查项:
- 应提供覆盖到每个用户的安全审计功能,对应用系统重要安全事件进行审计
[九]SQL注入防护
Dante Cloud 在整个微服务系统层面做了统一的 SQL 注入防护。并且提供SQL注入防护工具类 SqlInjectionUtils
,方便使用和开发扩展功能。
提示
已通过第三方安全测试机构测试
[十]XSS攻击防护
Dante Cloud 在整个微服务系统层面做了统一的 XSS 攻击防护。并且提供 XSS 攻击防护工具类 XssUtils
,方便使用和开发扩展功能。
提示
已通过第三方安全测试机构测试
[十一]防刷保护
注
防刷保护依赖于前面所述的 Session 共享内容。不依赖 Session 无法确认是否为同一个用户。
对接口的调用提供防刷保护,在一定时间周期内,如果频繁访问某个接口将会被限制访问。Dante Cloud 提供注解 @AccessLimited
,来实现接口的防刷保护。示例代码如下:
@AccessLimited
@Operation(summary = "获取全部前端元素接口", description = "获取全部前端元素接口",
responses = {@ApiResponse(description = "元素列表", content = @Content(mediaType = "application/json", schema = @Schema(implementation = Result.class)))})
@GetMapping("/list")
public Result<List<SysElement>> findAll() {
List<SysElement> sysElements = sysElementService.findAll();
return result(sysElements);
}
详细配置,参见Secure
[十二]幂等
注
防护保护依赖于前面所述的 Session 共享内容。不依赖 Session 无法确认是否为同一个用户。
对接口的调用提供幂等性保护,防止出现重复提交。Dante Cloud 提供注解 @Idempotent
,来实现接口的防刷保护。示例代码如下:
@Idempotent
@Operation(summary = "根据dialogueId删除私信整个对话", description = "根据实体dialogueId删除私信整个对话,包括相关联的关联数据",
requestBody = @io.swagger.v3.oas.annotations.parameters.RequestBody(content = @Content(mediaType = "application/json")),
responses = {@ApiResponse(description = "操作消息", content = @Content(mediaType = "application/json"))})
@Parameters({
@Parameter(name = "id", required = true, in = ParameterIn.PATH, description = "DialogueId 关联私信联系人和私信详情的ID")
})
@DeleteMapping("/dialogue/{id}")
public Result<String> deleteDialogueById(@PathVariable String id) {
dialogueDetailService.deleteDialogueById(id);
return Result.success("删除成功");
}
详细配置,参见Secure