learn-tech/专栏/周志明的架构课/40_如何实现零信任网络下安全的服务访问?.md
2024-10-16 06:37:41 +08:00

23 KiB
Raw Blame History

                        因收到Google相关通知网站将会择期关闭。相关通知内容
                        
                        
                        40 _ 如何实现零信任网络下安全的服务访问?
                        你好,我是周志明。

在上节课“零信任网络安全”当中我们探讨了与微服务运作特点相适应的零信任安全模型。今天这节课我们会从实践和编码的角度出发一起来了解在前微服务时代以Spring Cloud为例和云原生时代以Kubernetes with Istio为例零信任网络分别是如何实现安全传输、认证和授权的。

这里我要说明的是,由于这节课是面向实践的,必然会涉及到具体代码,为了便于讲解,在课程中我只贴出了少量的核心代码片段,所以我建议你在开始学习这节课之前,先去浏览一下这两个样例工程的代码,以便获得更好的学习效果。

建立信任

首先我们要知道,零信任网络里不存在默认的信任关系,一切服务调用、资源访问成功与否,都需要以调用者与提供者间已建立的信任关系为前提。

之前我们在第23讲也讨论过真实世界里能够达成信任的基本途径不外乎基于共同私密信息的信任和基于权威公证人的信任两种而在网络世界里因为客户端和服务端之间一般没有什么共同私密信息所以真正能采用的就只能是基于权威公证人的信任它有个标准的名字公开密钥基础设施Public Key InfrastructurePKI

这里你可以先记住一个要点PKI是构建传输安全层Transport Layer SecurityTLS的必要基础。

在任何网络设施都不可信任的假设前提下无论是DNS服务器、代理服务器、负载均衡器还是路由器传输路径上的每一个节点都有可能监听或者篡改通讯双方传输的信息。那么要保证通讯过程不受到中间人攻击的威胁唯一具备可行性的方案是启用TLS对传输通道本身进行加密让发送者发出的内容只有接受者可以解密。

建立TLS传输说起来好像并不复杂只要在部署服务器时预置好CA根证书以后用该CA为部署的服务签发TLS证书就行了。

但落到实际操作上,这个事情就属于典型的“必须集中在基础设施中自动进行的安全策略实施点”,毕竟面对数量庞大且能够自动扩缩的服务节点,依赖运维人员手工去部署和轮换根证书,肯定是很难持续做好的。

而除了随服务节点动态扩缩而来的运维压力外微服务中TLS认证的频次也很明显地高于传统的应用。比起公众互联网中主流单向的TLS认证在零信任网络中往往要启用双向TLS认证Mutual TLS Authentication常简写为mTLS也就是不仅要确认服务端的身份还需要确认调用者的身份。

单向TLS认证只需要服务端提供证书客户端通过服务端证书验证服务器的身份但服务器并不验证客户端的身份。单向TLS用于公开的服务即任何客户端都被允许连接到服务进行访问它保护的重点是客户端免遭冒牌服务器的欺骗。 双向TLS认证客户端、服务端双方都要提供证书双方各自通过对方提供的证书来验证对方的身份。双向TLS用于私密的服务即服务只允许特定身份的客户端访问它除了保护客户端不连接到冒牌服务器外也保护服务端不遭到非法用户的越权访问。

另外对于前面提到的围绕TLS而展开的密钥生成、证书分发、签名请求Certificate Signing RequestCSR、更新轮换等等这其实是一套操作起来非常繁琐的流程稍有疏忽就会产生安全漏洞。所以尽管它在理论上可行但实践中如果没有自动化的基础设施的支持仅靠应用程序和运维人员的努力是很难成功实施零信任安全模型的。

那么接下来我们就结合Fenixs Bookstore的代码聚焦于“认证”和“授权”这两个最基本的安全需求来看看它们在微服务架构下有或者没有基础设施支持的时候各自都是如何实现的。

我们先来看看认证。

认证

根据认证的目标对象我们可以把认证分为两种类型一种是以机器作为认证对象即访问服务的流量来源是另外一个服务这被叫做服务认证Peer Authentication直译过来是“节点认证”另一种是以人类作为认证对象即访问服务的流量来自于最终用户这被叫做请求认证Request Authentication

当然,无论是哪一种认证,无论有没有基础设施的支持,它们都要有可行的方案来确定服务调用者的身份,只有建立起信任关系才能调用服务。

好,下面我们来了解下服务认证的相关实现机制。

服务认证

Istio版本的Fenixs Bookstore采用了双向TLS认证作为服务调用双方的身份认证手段。得益于Istio提供的基础设施的支持我们不需要Google Front End、Application Layer Transport Security这些安全组件也不需要部署PKI和CA甚至无需改动任何代码就可以启用mTLS认证。

不过Istio毕竟是新生事物如果你要在自己的生产系统中准备启用mTLS还是要先想一下是否整个服务集群的全部节点都受Istio管理如果每一个服务提供者、调用者都会受到Istio的管理那mTLS就是最理想的认证方案。你只需要参考以下简单的PeerAuthentication CRD配置就可以对某个Kubernetes名称空间范围内的所有流量启用mTLS

apiVersion: security.istio.io/v1beta1 kind: PeerAuthentication metadata: name: authentication-mtls namespace: bookstore-servicemesh spec: mtls: mode: STRICT

不过如果你的分布式系统还没有达到完全云原生的程度其中还存在部分不受Istio管理即未注入Sidecar的服务端或者客户端这是很常见的你也可以将mTLS传输声明为“宽容模式”Permissive Mode

宽容模式的含义是受Istio管理的服务会允许同时接受纯文本和mTLS两种流量。纯文本流量只用来和那些不受Istio管理的节点进行交互你需要自行想办法解决纯文本流量的认证问题而对于服务网格内部的流量就可以使用mTLS认证。

这里你要知道的是宽容模式为普通微服务向服务网格迁移提供了良好的灵活性让运维人员能够逐个给服务进行mTLS升级。甚至在原本没有启用mTLS的服务中启用mTLS时可以不中断现存已经建立的纯文本传输连接完全不会被最终用户感知到。

这样一旦所有服务都完成迁移就可以把整个系统设置为严格TLS模式即前面代码中的mode: STRICT。

在Spring Cloud版本的Fenixs Bookstore里因为没有基础设施的支持一切认证工作就不得不在应用层面去实现。我选择的方案是借用OAtuh 2.0协议的客户端模式来进行认证的,其大体思路有如下两步。

第一步每一个要调用服务的客户端都与认证服务器约定好一组只有自己知道的密钥Client Secret这个约定过程应该是由运维人员在线下自行完成通过参数传给服务而不是由开发人员在源码或配置文件中直接设定。我在演示工程的代码注释中也专门强调了这点以免你被示例代码中包含密钥的做法所误导。

这个密钥其实就是客户端的身份证明客户端在调用服务时会先使用该密钥向认证服务器申请到JWT令牌然后通过令牌证明自己的身份最后访问服务。

你可以看看下面给出的代码示例它定义了五个客户端其中四个是集群内部的微服务均使用客户端模式并且注明了授权范围是SERVICE授权范围在下面介绍授权中会用到示例中的第一个是前端代码的微服务它使用密码模式授权范围是BROWSER。

/**

  • 客户端列表 */ private static final List clients = Arrays.asList( new Client("bookstore_frontend", "bookstore_secret", new String[]{GrantType.PASSWORD, GrantType.REFRESH_TOKEN}, new String[]{Scope.BROWSER}), // 微服务一共有Security微服务、Account微服务、Warehouse微服务、Payment微服务四个客户端 // 如果正式使用这部分信息应该做成可以配置的以便快速增加微服务的类型。clientSecret也不应该出现在源码中应由外部配置传入 new Client("account", "account_secret", new String[]{GrantType.CLIENT_CREDENTIALS}, new String[]{Scope.SERVICE}), new Client("warehouse", "warehouse_secret", new String[]{GrantType.CLIENT_CREDENTIALS}, new String[]{Scope.SERVICE}), new Client("payment", "payment_secret", new String[]{GrantType.CLIENT_CREDENTIALS}, new String[]{Scope.SERVICE}), new Client("security", "security_secret", new String[]{GrantType.CLIENT_CREDENTIALS}, new String[]{Scope.SERVICE}) );

第二步每一个对外提供服务的服务端都扮演着OAuth 2.0中的资源服务器的角色它们都声明为要求提供客户端模式的凭证如以下代码所示。客户端要调用受保护的服务就必须先出示能证明调用者身份的JWT令牌否则就会遭到拒绝。这个操作本质上是授权的过程但它在授权过程中其实已经实现了服务的身份认证。

public ClientCredentialsResourceDetails clientCredentialsResourceDetails() { return new ClientCredentialsResourceDetails(); }

而且由于每一个微服务都同时具有服务端和客户端两种身份它们既消费其他服务也提供服务供别人消费所以在每个微服务中都应该要包含以上这些代码放在公共infrastructure工程里

另外Spring Security提供的过滤器会自动拦截请求驱动认证、授权检查的执行以及申请和验证JWT令牌等操作无论是在开发期对程序员还是在运行期对用户都能做到相对透明。

不过尽管如此,这样的做法仍然是一种应用层面的、不加密传输的解决方案。为什么呢?

前面我提到在零信任网络中面对可能的中间人攻击TLS是唯一可行的办法。其实我的言下之意是即使应用层的认证能在一定程度上保护服务不被身份不明的客户端越权调用但是如果内容在传输途中被监听、篡改或者被攻击者拿到了JWT令牌之后冒认调用者的身份去调用其他服务应用层的认证就无法防御了。

所以简而言之,这种方案并不适用于零信任安全模型,只有在默认内网节点间具备信任关系的边界安全模型上,才能良好工作。

好,我们再来说说请求认证。

请求认证

对于来自最终用户的请求认证Istio版本的Fenixs Bookstore仍然能做到单纯依靠基础设施解决问题整个认证过程不需要应用程序参与JWT令牌还是在应用中生成的因为Fenixs Bookstore并没有使用独立的用户认证服务器只有应用本身才拥有用户信息

当来自最终用户的请求进入服务网格时Istio会自动根据配置中的JWKSJSON Web Key Set来验证令牌的合法性如果令牌没有被篡改过且在有效期内就信任Payload中的用户身份并从令牌的Iss字段中获得Principal。关于Iss、Principals等概念我在安全架构这个小章节中都介绍过了你可以去回顾复习一下第23到30讲。而JWKS倒是之前从没有提到过它代表了一个密钥仓库。

我们知道在分布式系统中JWT要采用非对称的签名算法RSA SHA256、ECDSA SHA256等默认的HMAC SHA256属于对称加密认证服务器使用私钥对Payload进行签名资源服务器使用公钥对签名进行验证。

而常与JWT配合使用的JWKJSON Web Key就是一种存储密钥的纯文本格式在功能上它和JKSJava Key Storage、P12Predecessor of PKCS#12、PEMPrivacy Enhanced Mail这些常见的密钥格式并没有什么本质上的差别。

所以顾名思义JWKS就是一组JWK的集合。支持JWKS的系统能通过JWT令牌Header中的KIDKey ID自动匹配出应该使用哪个JWK来验证签名。

以下是Istio版本的Fenixs Bookstore中的用户认证配置。其中jwks字段配的就是JWKS全文实际生产中并不推荐这样做应该使用jwkUri来配置一个JWKS地址以方便密钥轮换根据这里配置的密钥信息Istio就能够验证请求中附带的JWT是否合法。

apiVersion: security.istio.io/v1beta1 kind: RequestAuthentication metadata: name: authentication-jwt-token namespace: bookstore-servicemesh spec: jwtRules: - issuer: "[email protected]" # Envoy默认只认“Bearer”作为JWT前缀之前其他地方用的都是小写这里专门兼容一下 fromHeaders: - name: Authorization prefix: "bearer " # 在rsa-key目录下放了用来生成这个JWKS的证书最初是用java keytool生成的jks格式一般转jwks都是用pkcs12或者pem格式为方便使用也一起附带了 jwks: | { "keys": [ { "e": "AQAB", "kid": "bookstore-jwt-kid", "kty": "RSA", "n": "i-htQPOTvNMccJjOkCAzd3YlqBElURzkaeRLDoJYskyU59JdGO-p_q4JEH0DZOM2BbonGI4lIHFkiZLO4IBBZ5j2P7U6QYURt6-AyjS6RGw9v_wFdIRlyBI9D3EO7u8rCA4RktBLPavfEc5BwYX2Vb9wX6N63tV48cP1CoGU0GtIq9HTqbEQs5KVmme5n4XOuzxQ6B2AGaPBJgdq_K0ZWDkXiqPz6921X3oiNYPCQ22bvFxb4yFX8ZfbxeYc-1rN7PaUsK009qOx-qRenHpWgPVfagMbNYkm0TOHNOWXqukxE-soCDI_Nc--1khWCmQ9E2B82ap7IXsVBAnBIaV9WQ" } ] } forwardOriginalToken: true

而Spring Cloud版本的Fenixs Bookstore就要稍微麻烦一些它依然是采用JWT令牌作为用户身份凭证的载体认证过程依然在Spring Security的过滤器里中自动完成。不过因为这节课我们讨论的重点不在Spring Security的过滤器工作原理所以它的详细过程就不展开了只简单说说其主要路径过滤器→令牌服务→令牌实现。

既然如此Spring Security已经做好了认证所需的绝大部分工作那么真正要开发者去编写的代码就是令牌的具体实现即代码中名为“RSA256PublicJWTAccessToken”的实现类。

它的作用是加载Resource目录下的公钥证书public.cert实在是怕“抄作业不改名字”的行为我再一次强调不要将密码、密钥、证书这类敏感信息打包到程序中示例代码只是为了演示实际生产应该由运维人员管理密钥验证请求中的JWT令牌是否合法。

@Named public class RSA256PublicJWTAccessToken extends JWTAccessToken { RSA256PublicJWTAccessToken(UserDetailsService userDetailsService) throws IOException { super(userDetailsService); Resource resource = new ClassPathResource("public.cert"); String publicKey = new String(FileCopyUtils.copyToByteArray(resource.getInputStream())); setVerifierKey(publicKey); } }

如果JWT令牌合法Spring Security的过滤器就会放行调用请求并从令牌中提取出Principals放到自己的安全上下文中即“SecurityContextHolder.getContext()”)。

在开发实际项目的时候你可以根据需要自行决定Principals的具体形式比如既可以像Istio中那样直接从令牌中取出来以字符串的形式原样存放节省一些数据库或者缓存的查询开销也可以统一做些额外的转换处理以方便后续业务使用比如将Principals转换为系统中的用户对象。

Fenixs Bookstore的转换操作是在JWT令牌的父类JWTAccessToken中完成的。所以可见尽管由应用自己来做请求验证会有一定的代码量和侵入性但同时自由度确实也会更高一些。

这里为了方便不同版本实现之间的对比在Istio版本中我保留了Spring Security自动从令牌转换Principals为用户对象的逻辑因此就必须在YAML中包含forwardOriginalToken: true的配置告诉Istio验证完JWT令牌后不要丢弃掉请求中的Authorization Header而是要原样转发给后面的服务处理。

授权

那么经过认证之后合法的调用者就有了可信任的身份此时就不再需要区分调用者到底是机器服务还是人类最终用户只需要根据其身份角色来进行权限访问控制就行即我们常说的RBAC。

不过为了更便于理解Fenixs Bookstore提供的示例代码仍然沿用此前的思路分别针对来自“服务”和“用户”的流量来控制权限和访问范围。

举个具体例子。如果我们准备把一部分微服务看作是私有服务,限制它只接受来自集群内部其他服务的请求,把另外一部分微服务看作是公共服务,允许它可以接受来自集群外部的最终用户发出的请求;又或者,我们想要控制一部分服务只允许移动应用调用,另外一部分服务只允许浏览器调用。

那么一种可行的方案就是为不同的调用场景设立角色进行授权控制另一种常用的方案是做BFF网关

我们还是以Istio和Spring Cloud版本的Fenixs Bookstore为例。

在Istio版本的Fenixs Bookstore中通过以下文稿这里给出的配置就限制了来自bookstore-servicemesh名空间的内部流量只允许访问accounts、products、pay和settlements四个端点的GET、POST、PUT、PATCH方法而对于来自istio-system名空间Istio Ingress Gateway所在的名空间的外部流量就不作限制直接放行。

apiVersion: security.istio.io/v1beta1 kind: AuthorizationPolicy metadata: name: authorization-peer namespace: bookstore-servicemesh spec: action: ALLOW rules: - from: - source: namespaces: ["bookstore-servicemesh"] to: - operation: paths: - /restful/accounts/* - /restful/products* - /restful/pay/* - /restful/settlements* methods: ["GET","POST","PUT","PATCH"] - from: - source: namespaces: ["istio-system"]

但针对外部的请求不来自bookstore-servicemesh名空间的流量又进行了另外一层控制如果请求中没有包含有效的登录信息就限制不允许访问accounts、pay和settlements三个端点如以下配置所示

apiVersion: security.istio.io/v1beta1 kind: AuthorizationPolicy metadata: name: authorization-request namespace: bookstore-servicemesh spec: action: DENY rules: - from: - source: notRequestPrincipals: [""] notNamespaces: ["bookstore-servicemesh"] to: - operation: paths: - /restful/accounts/ - /restful/pay/* - /restful/settlements*

由此可见Istio已经提供了比较完善的目标匹配工具比如前面配置中用到的源from、目标to以及没有用到的条件匹配when还有其他像是通配符、IP、端口、名空间、JWT字段等等。

当然了要说灵活和功能强大它肯定还是不可能跟在应用中由代码实现的授权相媲美但对绝大多数场景来说已经够用了。在便捷性、安全性、无侵入、统一管理等方面Istio这种在基础设施上实现授权的方案显然要更具优势。

而在Spring Cloud版本的Fenixs Bookstore中授权控制自然还是使用Spring Security、通过应用程序代码来实现的。

常见的Spring Security授权方法有两种。

第一种是使用它的ExpressionUrlAuthorizationConfigurer也就是类似下面编码所示的写法来进行集中配置。这个写法跟前面在Istio的AuthorizationPolicy CRD中的写法在体验上是比较相似的也是几乎所有Spring Security资料中都会介绍的最主流的方式比较适合对批量端点进行控制不过在Fenixs Bookstore的示例代码中并没有采用没有什么特别理由就是我的个人习惯而已

http.authorizeRequests() .antMatchers("/restful/accounts/").hasScope(Scope.BROWSER) .antMatchers("/restful/pay/").hasScope(Scope.SERVICE)

第二种写法就是下面的示例代码中采用的方法了。它是通过Spring的全局方法级安全Global Method Security以及JSR 250的@RolesAllowed注解来做授权控制。

这种写法对代码的侵入性更强,需要以注解的形式分散写到每个服务甚至是每个方法中,但好处是能以更方便的形式做出更加精细的控制效果。比如,要控制服务中某个方法,只允许来自服务或者来自浏览器的调用,那直接在该方法上标注@PreAuthorize注解即可而且它还支持SpEL表达式来做条件。

表达式中用到的SERVICE、BROWSER代表的是授权范围就是在声明客户端列表时传入的具体你可以参考这节课开头声明客户端列表的代码清单。

/**

  • 根据用户名称获取用户详情 */ @GET @Path("/{username}") @Cacheable(key = "#username") @PreAuthorize("#oauth2.hasAnyScope('SERVICE','BROWSER')") public Account getUser(@PathParam("username") String username) { return service.findAccountByUsername(username); }

/**

  • 创建新的用户 */ @POST @CacheEvict(key = "#user.username") @PreAuthorize("#oauth2.hasAnyScope('BROWSER')") public Response createUser(@Valid @UniqueAccount Account user) { return CommonResponse.op(() -> service.createAccount(user)); }

小结

这节课里,我们尝试以程序代码和基础设施两种方式,去实现功能类似的认证与授权,通过这两者的对比,探讨了在微服务架构下,应该如何把业界的安全技术标准引入并实际落地,实现零信任网络下安全的服务访问。

由此我们也得出了一个基本的结论在以应用代码为主去实现安全需求的微服务系统中是很难真正落地零信任安全的这不仅仅是由于安全需求所带来的庞大开发、管理如密钥轮换和建设如PKI、CA的工作量更是因为这种方式很难符合上节课所提到的零信任安全中“集中、共享的安全策略实施点”“自动化、标准化的变更管理”等基本特征。

但另一方面我们也必须看到现在以代码去解决微服务非功能性需求的方案是很主流的像Spring Cloud这些方案在未来的很长一段时间里都会是信息系统重点考虑的微服务框架。因此去学习、了解如何通过代码尽最大可能地去保证服务之间的安全通讯仍然非常有必要。

一课一思

有人说在未来,零信任安全模型很可能会取代边界安全模型,成为微服务间通讯的标准安全观念,你认为这个判断是否会实现呢?或者你是否觉得这只是存在于理论上的美好期望?

欢迎在留言区分享你的答案。如果你觉得有收获,欢迎你把今天的内容分享给更多的朋友。

好,感谢你的阅读,我们下一讲再见。