工程经验 - 服务间认证方案

一、背景

我们的产品是一个基于浏览器的 web 应用,提供综合用户、数据管理和虚拟编码环境的 AI 训练平台。用户可以方便地访问数据,直接使用 jupyter notebook 进行 AI 训练,并通过提交审批流程获取训练模型。因此,该产品涉及一系列与数据和 AI 训练相关的业务。

所有功能由三个团队负责维护和开发:

  • AI 平台组:负责 jupyter 环境二次开发、k8s 调度平台对接、镜像管理、代码 SDK 集成等业务功能。
  • 基础组件组:负责各种数据组件的研发,提供基于 NLP、Spark、SDG 等技术的数据水印、数据脱敏、数据抽样等无状态请求接口的组件。
  • 后端组:负责数据存储、数据权限管控、资源管理、用户管理、用户认证、数据源管理等业务功能。

今天我们就拿 AI 平台组在调用后端组服务作为实例,聊聊服务间调用的认证设计。

二、服务间认证的必要性

我们的服务部署在两个集群,以下以其中一个集群的服务间认证为例:

  • notebook-controller:AI 平台组处理前端请求的业务服务
  • api-controller:后端组处理前端请求,同时也是 notebook-controller 的业务服务

AI 平台组通过扩展插件机制对 jupyter 进行二次开发和维护,以提供面向用户的 jupyter 环境。这些插件可能会将用户在浏览器触发的请求发送到 notebook-controller 进行处理,而处理过程中可能需要调用 api-controller 进行进一步处理。

在这种情况下,notebook-controller 和 api-controller 都面临一个认证问题:如何确定当前请求的用户身份?

只有解决了认证问题,才能进行权限校验并正确更新用户相关的数据。因此,从前端到 notebook-controller,再到 api-controller,我们需要一个统一的认证方案。

三、初版方案 - 手动实现 oauth2

初版方案,api-controller 充当了 IDC(用户身份验证中心)和 oauth2 授权服务器两个角色,提供了 oauth2 的简陋实现:获取授权码接口 & 根据授权换取 access_token 接口。

v1-oauth2-flow

四、夭折方案 - 标准实现 oauth2

初版方案存在以下问题:

  • 自定义 oauth2 实现,细节不符合标准
  • 未实现 oauth2 的授权步骤,如果要实现,将带来一定成本
  • 自行维护 oauth2 认证框架,重复造轮子

鉴于这些问题以及产品未来可能需要开放用户认证体系给其他产品接入,需要调研和重构现有认证实现。

理想的认证平台实现应该包括:

  • 应用管理页面,支持新建、删除应用,颁发应用的 client_id 和 client_secret 等
  • 外部暴露的 oauth2 认证端点,包括登录页面、RefreshToken 接口、GetToken 接口、Get UserInfo 接口等

一期可先实现简陋版本,能管理 notebook-controller 并使原有流程顺利进行即可。让 oauth2 的组件能力转移到外部实现,内部业务可以利用到 oauth2 组件的能力,进行自身的管理业务。

这个设计方案是我入职后不久做的第一个较大的重构方案。然而,尽管方案已经设计好,但由于其他紧急需求的出现,最终未能排期,进行落地改造。

该方案是在 2020 年末左右完成的。当时,业界流行的 oauth2 授权服务是 Ory Hydra,这是一个开箱即用的 Go 实现的服务。因此,我选择了 Ory Hydra 作为认证平台的授权服务器;而 oauth2 客户端则选择使用 Spring Security(后来发现应该使用 Hydra 提供的 SDK,而非自行实现)。

总体效果:api-controller 变成了胶水层,对外非自身系统的认证转移到了 Ory Hydra。如果有第三方应用要对接产品的用户认证,将按照类似 notebook-controller 的角色进行对接。

v2-oauth2-flow

五、改进方案 - 简化实现 jwt

由于时间和需求排期的原因,我们没有在实际中应用标准的 OAuth2 实现。然而,在产品中期,由于一系列前端需求,Jupyter 自带特性已经无法满足,AI 平台组和前端组重新设计了代码 IDE 前端和 IDE 文件系统。

在这里,我们要重点关注 IDE 文件系统功能。回顾一下,notebook-controller 在手动实现 OAuth2 方案中,获得的 access_token 是一个没有意义的 uuid,它并不包含用户信息,但满足初期的需求。因为任何涉及用户(文件)数据更新的接口,notebook-controller 都会携带 access_token 到 header 中,以调用 api-controller 相关接口来完成更新。而现在,AI 平台组需要在其 IDE 文件系统服务上独立完成用户文件的数据更新和维护,不再需要转发到无后端的情况。这意味着 notebook-controller 需要了解用户的信息。

如果我们根据已有的内容进行改造,那么自然而然可以想到 notebook-controller 去调用 api-controller 来获取用户信息。但是从应用归属角度来看,notebook-controller 属于产品的一部分,可以看作是二方应用,本身并不需要 OAuth2 接入管理,只需要一个用户信息的串。因此,我们去掉整个 OAuth2 的认证实现,转而使用 JWT。任何发送到 notebook-controller 的请求都是从用户端发起的,用户端拥有认证 cookie,因此可以直接请求 api-controller 提供的 GET JWT 接口,从而获取当前认证的用户信息。如果 api-controller 发起的到 notebook-controller 的调用,就先内部根据当前用户换取一个 JWT 放到请求头,再去调用。

从协议角度来看, JWT 包含 header、payload、signature,这里实现的认证其实只有 payload。正常使用肯定是有安全问题的,payload 所有人都可以伪造,但此处被一个 trick 暂时规避了。notebook-controller 换取 JWT 的接口是在一个入口网关写 LUA 换取的,用户 Chrome 无法获取到 JWT 的信息,也不知道换取 JWT 的接口,所以对用户来说,无法伪造。

v3-jwt-flow

六、理想方案

设计方案取决于实际业务场景,我认为基于我们现有场景,理想方案是各服务对接完整 JWT 方案。

对于当前的情况,没有三方对接的场景,去除 OAuth2 设计是一个正确的决定,这样做可以降低部署和交互的复杂性。然而,现有的 JWT 方案存在一些问题,因此需要进行改进:

1、频繁请求 JWT 问题:openresty 每一个打到 notebook-controller 的接口都会换取一次 JWT

解决方案:完善 header、payload、signature 设计。这样客户端发送请求时就可以验证 JWT 是否过期,可防止频繁换取 JWT 问题。

2、计算资源问题:openresty 换取 JWT 利用的是服务端资源,这部分资源可以转义到客户端 Chrome

解决方案:将 openresty 获取 jwt 的逻辑放到 Chrome 前端做,由 Chrome LocalStorage 做 JWT 存储。

3、完整性安全问题:原 JWT 不完整,缺少服务端验签的步骤,是否存在篡改的隐患

解决方案:完善 header、payload、signature 设计。服务端收到请求按照协议做验签。

总结

本文聊了下工作中我们产品的服务间认证设计,涉及到 oauth2、jwt ,其实 oauth2 和 jwt 也可以互相结合,例如 oauth2 办法的 access_token 就是各 jwt。两者都具备认证机制,但是如果工程中没有认证-授权、三方接入等场景,直接使用 jwt 更合适