原文:https://www.sohu.com/a/201641619_467759
目录
1 令牌和 STS
1.1 STS 对客户端的认证
1.2 公钥发布
1.3 密钥更换和缓存
2 JSON Web Token
2.1 请求的授权
3 再论微服务
作者介绍
自 Martin Fowler 提出微服务架构的概念后,这个名词就一直比较流行,总是成为众多技术论坛和公众号的讨论热点。很多互联网和软件公司都在将原有的整体架构进行拆分,朝着微服务架构的方向进行迭代,而新的项目也几乎无一例外的成为了实践微服务架构的场所。
对于大多数有经验的工程师来说,将传统的异步函数调用直接改成 REST API 或者某种 RPC 并不是一件很困难的事,要面临的问题包括序列化,调用延时和版本等。
但服务接口之间的安全和身份认证(Authentication)问题往往比较棘手,而且也是比较敏感的部分。这里所指的身份认证,既包括用户的身份,更强调程序和服务的身份,也就是微服务调用之间的基本接口信任关系。
一些项目在进行微服务架构设计的时候,采用私有网络或者 IP 网段的隔离来保护被拆分的服务,或者配置类似 Kerberos 的认证系统。这样的做法比较简便,在进行原有整体架构的迁移时可以尽快上线。
然而从宏观角度来看,这样的安全模式有一定的局限性,尤其对底层网络架构的依赖会带来不便,容易造成错误配置,在某些应用场景下甚至不符合安全规范。如果服务需要暴露给外部网路和互联网,则尤其要求相对完善成熟的解决方案。
通常意义上认为微服务是 SOA 架构的一个子集,而关于微服务和 SOA 的区别也有很多的讨论。随着云计算成为主流,容器技术的普及和无服务架构的日趋流行,服务的粒度和和边界定义变得比较灵活,因而要求上层服务与底层计算及网络架构有相对低的耦合度。另外企业混合云的兴起也要求各种分布在多个云里的服务能协同工作,对于上面的安全模式也是一大挑战。
本文在这里暂时模糊一下微服务的边界和粒度问题,和大家讨论一种具有普遍性的解决方案。这种认证模型可以被引入到新搭建的微服务架构中,也能应用于正在重组的整体架构,或者作为服务集群的联邦化方案,并且不容易受未来总体架构变迁的影响。
最直接简单的身份认证方式是基于用户名和密码,包括 App ID 和 App Secret。理论上讲,微服务之间可以在调用时候传递密码,让接收方来验证。
但众所周知保存和传递 secret 都是有很大安全负担的,而且在绝大多数的通信中也完全没有必要去传递密码,因此比较成熟的解决方案是基于数字签名的的令牌(token)。签名和 RSA 加密是常用认证技术的基础,是各位读者日常工作都要面对的问题,在这里不去过多叙述其原理。
Security Token Service (STS),是一种发行和验证 token 的网络服务,扮演着客户端和被访问资源之间的中介者的角色。STS 被双方共同信任,和数字证书的 CA 类似,其已知网址被认为是可信任的信息发布端。STS 可能实现 REST 协议,也可能支持浏览器跳转协议。下面的网址是一些现实的例子:
下面的序列图是一个基于 STS 认证的理论模型:
其大致步骤为:
在具体实现的时候,根据现实情况和要求,该流程可能会有各种变化或者调整,例如:
STS 很大程度上降低了客户端和被访问资源之间的耦合性,在现实生活中可以把 STS 比作是金融交易中的第三方担保人,在软件设计中可以把 STS 当作像消息队列一样的组建来类比。
STS 是独立于系统业务逻辑之外的中间件,从架构设计的角度讲,和其他组件相比具有较强的稳定性和长期性。
前面提到,当微服务之间的调用网格(mesh)比较复杂的时候,让各个服务去做点对点的安全认证是工作量很大的任务,从开发和运维的角度都不经济且不安全,因此 STS 角色的引入可以简化请求者和被调用服务的设计和实现。
在有用户参与的微服务调用中,单点登录(SSO)是用户体验的最基本要求。作为 token 的中心发布者和仲裁者,STS 可以简化 SSO 的实现。各个服务只要对 STS 负责,从而实现 token 在服务间的传递和交换。
当客户端 app 调用 STS 服务获取 token 时候,首先要解决的第一个问题是客户端自身的认证问题。通常有以下几种解决方案:
以上的不同方案都要求一个 Client App 的注册过程,并且可能需要生成或者交换某种形式的对称或者非对称密码。在技术团队的开发运维中,这个注册的操作需要有特定权限的工程或者管理人员完成,对于有较高合规性要求的产品或者系统,则更要经过严格的流程管控。
有意思的是,认证和授权问题总是一个鸡生蛋蛋生鸡的链条,用人去授权程序,然后再用程序来验证人。
在一些实现方案中,在注册 app 的时候同时也要指定该 app 的权限,资源范围,以及其他的相关参数。总的来讲就是描述当前 app 的使用场景,从安全的角度来考虑的话,这种描述越细致越好。
例如在有多租户(multi-tenancy)的环境中,app 可能被局限于当前所属的公司组织,也有可能作为全局通用的客户端。
当 STS 决定给 Client App 颁发 token 时候,将使用自己的私钥生成 token 消息主体的数字签名,并最终组装成完整的 token 返回给 app。接下来 app 把 token 随请求发送给被访问的资源服务,由该服务来验证当前 token 确实由其信任的某个 STS 颁发。
前面提到,校验 token 最直接的办法是把 token 发到已知的 STS 服务端的验证 endpoint,但这样做的代价很大,影响整个流程的性能。按照加密原理,校验签名的真实性只需要从可信任的来源获得之前 STS 使用的私钥所对应的公钥。
获取公钥的一种做法是由运维人员事先设置好,可以是某个本地文件,或者从分布缓存中读取。然而无论这个过程是手动的还是自动的,如果程序本身不能在需要的时候去初始源获取公钥,那么总是有一定的运维负担,并且在变更签名密钥的时候可能会因为公钥不匹配而导致无法完成验证。
由于公钥数据本身不是 secret,所以业界常用的做法是在某一个已知(well-known)的网络地址发布公钥数据来供需要校验 token 的程序来下载,并支持匿名访问。下面是几个 STS 的公钥 endpoint 的例子:
从网址可以看出这些 URL 是由 Google、Microsoft 和 Salesforce 所提供的,在 HTTPS 的前提下是可以被信任的。这里再次强调 well-known 的重要性,也就是说 token 的校验程序本身独立而静态地知道所信任的 STS 的公钥地址,而不是从 token 动态获得,否则就失去了信任的逻辑基础。
当然程序可以信任多个 STS,而根据当前 token 的信息来匹配使用哪一个 STS 对应的公钥。在实际实现中,可以借助相对应的 SDK 来简化工作。
另外以上的这些 URL 实际上是各个公共 STS 实现的 OpenID Connect 协议的一部分(基于 OAuth 2.0),也可以在对应的 Discovery Document 中获得,比如 Google 的是 https://accounts.google.com/.well-known/openid-configuration。
上面的公钥地址例子是面向公共网络和第三方服务设计的,所以任何可以连接到互联网的程序都可以通过这些 URL 获取公钥。在微服务架构的场景下,可以灵活实现这样的公钥发布端。
在保证 well-known 的前提下,这种 endpoint 可以是内部地址,某个云托管虚拟机或者 NLB 的 DNS 名称,甚至可以是私有 VPC 网络的 IP 或者 VIP 地址。
众所周知,处于安全考虑,需要对密钥数据进行定期更换。如果你在浏览器里面打开上面的几个公钥地址,会发现每个地址都包含多个 key。需要发布多个 key 的其中一个原因就是更换的过程是需要时间的,尤其在考虑分布式系统和缓存的情况下。
对于 STS 来说,跟换密钥的典型流程如下:
概括来看,这种模型在某个时刻可能总是存在一个当前密钥(current key)和之前密钥(previous key)的配对。实际情况下一般只有在开始引入新的密钥的时候才会将之前的公钥彻底移除,也就是说当 current 成为 previous 的时候,next 挤掉了 previous 的 previous。
对于需要下载和使用公钥的 Resource Server 来说,处于性能考虑基本上需要缓存公钥。推荐的做法是默认使用当前缓存的公钥来认证,直到当前 token 和所缓存的公钥都不匹配,这个时候要考虑重新下载公钥。
这里说的不匹配并不一定要执行真正的签名验证算法,也可以根据快速比对公钥的 key ID 与 token 中的 key ID 来实现。
另外值得注意的是,如果当前的服务被暴露于公网中,有被攻击的风险的话,在下载公钥前应该检查一下自己缓存数据是否已经足够新,而不应该盲目开始下载以免被用来做 DDoS。
JSON Web Token,简称 JWT,是目前应用得最广泛的 token 格式,其具体的规格定义在 RFC 7519 文档中。 JWT 由一个 JSON 头部,一个 JSON 消息主体和一个数字签名连接在一起组成,在实际传输时候分别用 base64 对各部分编码。
推荐的 JWT 工具是 https://jwt.io,上面有在线解码和签名验证工具,并且收集了各种编程语言的 JWT 库。
JWT 的头部包含该 token 签名使用的密钥类型和 key ID,方便于校验代码来选择公钥。JWT 的主体部分则包含多条断言(claim),用来描述请求的客户端,用户信息,请求对象和目的,授权信息等。接收到 JWT 的服务在验证签名后根据这些 claim 的值来执行相应的业务逻辑。
从用户信息的角度来看,常见的 JWT 以下有几种类型:
User Token 是由 STS 颁发的包含用户信息的 token,适用于大多数包含用户身份的请求,尤其以面向公共网络的用户界面和 API 网关为典型用例。以《权利的游戏》里的龙母为例,代表她身份的 user token 可能是这样一种形式:
{
"appid": "92d0312b-26b9-4887-a338-7b00fb3c5eab",
"iss": "https://authority.westerossevenkingdoms.com",
"aud": "https://houseoftargaryen.com/ ",
"iat": "1433978353",
"exp": "1433981953",
"username": " dtargaryen",
"name": "Daenerys Targaryen",
"scp": "soldiers.attack dragons.burn ravens.send",
"roles": "mother_of_dragons queen_of_meereen breaker_of_chains …",
}
这些 claim 的说明如下:
App Token 用于非用户场景,所包含的 claim 是上面 User Token 的一个子集,没有用户相关信息。一些后台程序,系统工作流,数据聚合整理的调用都不是由用户请求事件驱动的,适合使用 App Token。
当把整体架构的函数调用或 RPC 调用改造成微服务调用的时候,可以用 App Token 作为基本的桥梁来实现服务之间的验证。但要注意 App Token 可能容易造成权限范围过大的问题,一旦泄露的话会影响多个用户的数据。
App Asserted User Token 介于 User Token 和 App Token 之间。在权限允许的情况下,Client App 从 STS 请求一个 App Token,然后将其作为一个 claim 来自己生成一个 User Token 包裹在外面,形成一个 App Asserted User Token。
事实上这种 token 没有自己的数字签名,由 Client App 自己填写用户信息。Resource Server 在做验证时候取出里面内嵌的 App Token 来验证数字签名和 app 权限,并在验证通过后信任和使用用户信息。因为 App Token 有较高的重用率,因此也容易被缓存。
App Asserted User Token 可以重复使用同一个未过期的 App Token 来和不同的用户身份配对,减少调用 STS 的次数,从而避免对系统性能的影响。这种方法和将用户信息放在 token 之外的做法没有本质区别,其前提是 Resource Server 对特定 Client App 的信任,但从设计的角度可能会更整洁。
前面的章节提到,STS 在收到 token 请求时,会根据一系列条件决定要颁发的 token 里所包含的权限集。这里可能有用户或者管理员的因素,而所请求的权限应该是 Client App 注册权限的一个子集,原则上只应该请求当前操作所需要的最小权限。
第二阶段的授权校验发生在 Resource Server 接收端,根据 token 中的各个 claim 的值来决定最后的操作是否被允许,或者决定的操作的具体行为。下面是一些决定授权的因素:
基于 HTTP 的 RESTful API 是目前最常用 API 形式,无论是 CRUD 模型还是 DDD 模型,当涉及到授权问题的时候,一个重要的设计原则就是其 URL 必须能够描述被访问资源的 scope。
例如下面的 URL 可以很好地和权限 groups.documents.delete 相吻合:
DELETE https://OurAwesomeChat.com/api/v1/groups/11EYKTN682A79T/documents/B002Y27P3M/
而下面的例子仅分析 URL 的话就不知所云:
DELETE https://OurAwesomeChat.com/api/v1/?objectId=11EYKTN682A79T_B002Y27P3M
另外读取 HTTP 请求(POST 或 PUT)的正文(body)是有性能代价的,除了解压和反序列化之外,有的时候由于使用 Transfer-Encoding 还有额外网络 IO。
被调用的服务在读取请求正文之前,应该能根据动词(verb)和 URL 路径,对照当前接收到的 token 来作出授权决定,从而避免读取正文。有的系统设计在做反向代理和分发时候会引入授权的因素,而让代理或者 API 网关去分析(parse)请求正文从设计上和性能上都是很不理想的。
一些读者可能会觉得我混淆了 SOA 和微服务架构,认为文中讨论的基于 STS 的模型太复杂或者代价太高,更适合于服务之间的联邦化协作而并非可控管的微服务。我们最后再来专门讨论一下这个问题。
微服务的其中一个重要理念是系统低耦合,各个服务能够独立开发部署。在一个大中型规模的产品中,如果要让其中一个小团队能够很快上线一个微服务,就需要有一个中心化的身份验证 broker。
新的微服务只需要和 STS 登记注册,就可以开始调用一些现有的服务,而不需要去和每个被调用者做显式对接。STS 的引入可以降低整个体统的耦合度,可以被看做是服务发现的一部分,让快速注册称为可能,也避免了重新发明轮子。对于有一定规模的项目,STS 的建设是一件一劳永逸的工作,可以带来长期的回报。
作为使用云计算平台的项目和团队,新的微服务可能是在独立环境中开发部署的,甚至可能是从 hackathon 项目进化来的。新的微服务对于已有服务来说很可能是位置透明的,也就是说调用请求是从互联网的某个角落来的,那么唯一要关心的问题就是请求里是不是带着一个由 STS 颁布的合法 token,而 token 是怎么来的却不是被调用者应该关心的问题。
被调用的微服务只需保证自己的计算能力可以在客户端增长的情况下弹性增长,而不需要根据新的调用者来改变自己的安全策略。云计算平台容器和无服务架构的流行让微服务的搭建变得更加动态,连传统的后台程序都可以改造成事件驱动模型,而 STS 简化了这些动态微服务的进入和退出。
API 网关是很多微服务架构中的重要组件。当整个系统有统一的认证协议时,就很容易使用 API 网关来做部分甚至全部的认证工作。对于接受外部用户请求的微服务系统,API 网关和 STS 协同工作可以分担一些内部微服务的工作量,尤其是核心数据服务。在一些设计中,SSL 卸载可以和 STS 协同工作以达到优化内部微服务调用性能的目的。
需要指出的是,常见的 OAuth 2.0 协议也是基于 STS 的,但 OAuth 2.0 所解决问题的侧重点不一样。OAuth 2.0 本质上是一个授权协议,它强调用户在授权过程中的角色,要求用户与浏览器的参与,而且其中某些模式甚至完全淡化 app 自身的身份和权限问题。
在 OAuth 2.0 中,STS 被称为 Authorization Server,通常由独立于 Client App 和 Resource Server 之外的第三方来实现。而微服务对整体架构的拆分往往聚焦于改进自身架构,因此 STS 的引入是为了解决系统耦合问题,其目的并非为了改变用户的登录和授权方式。
理论上讲,一个系统无论是选择整体架构还是微服务架构,对用户来说都应该是透明的。根据微服务的具体需要,STS 甚至可以是私有的。值得一提的是,OAuth 2.0 中的 Client Credential Flow 严格意义上不是 OAuth 2.0 所要解决的授权问题,但这个模式却可能是最适用于微服务架构的模型。
个人认为基于 STS 的身份认证方案可以满足多种认证要求,无论是微服务系统内部,外部服务之间,还是混合云的应用场景,都有 STS 的用武之地。当然,脱离业务需求的架构设计都是空谈,也没有什么解决方案是万金油。本文讨论的一些概念和思想,比如 JWT,数字签名和令牌缓存,可以本着因地制宜的原则去采纳。
微服务架构提高了很多项目和团队的敏捷性和创新能力,并且同云计算的理念一致。我在最近几年的工作中也在做各种形式的微服务实践,觉得受益颇多,很赞同这种设计思想,也感叹当年 AWS 的前瞻性。
有时候刚进入行业的同学处于好奇问我用工作用什么编程语言,我会开玩笑说我用的语言叫做 UJJ,中文读作“优加加”,其实是 URI、JSON 和 JWT。
虞雷 Jason Yu,微软 Office 365 Core 部门首席软件工程师,在生态系统项目组主要负责跨产品协作的架构设计和项目协调。