服务安全

前置知识

本文涉及到SSL/TLS、PKI、CA、OAuth2、RBAC、JWT等概念,直接依赖于“安全架构”部分的认证授权凭证传输四节对安全基础知识的铺垫,这四篇文章中介绍过的内容,笔者在本篇中将不再重复,建议读者先行阅读。

在“安全架构”部分,我们了解过那些跟具体架构形式无关的、业界主流的安全概念和技术标准,在上一节“零信任网络”里,我们立足于微服务架构,探讨了与微服务运作特点相适应的安全模型。在本文中,我们将从实践和编码的角度出发,探讨在微服务架构下,如何将业界的安全技术标准引入并实际落地,实现零信任网络下安全的服务访问。

建立信任

零信任网络里不存在默认的信任关系,一切服务调用、资源访问成功与否,均需以调用者与提供者间已建立的信任关系为前提。此前我们曾讨论过,真实世界里,能够达成信任的基本途径不外乎基于共同私密信息的信任基于权威公证人的信任两种;网络世界里,因为客户端和服务端之间并没有什么共同私密信息,所以真正能采用的就只能是基于权威公证人的信任,它有个标准的名字:公开密钥基础设施(Public Key Infrastructure,PKI)。

PKI是构建传输安全层(Transport Layer Security,TLS)的必要基础。在任何网络设施都不可信任的假设前提下,无论是DNS、代理服务器、负载均衡器还是路由器,传输路径上的每一个节点都有可能监听或者篡改通讯双方传输的信息。要保证通讯过程不受到中间人攻击的威胁,启用TLS对传输通道本身进行加密,让发送者发出的内容只有接受者可以解密是唯一具备可行性的方案。建立TLS传输,说起来似乎不复杂,只要在部署服务器时预置好CA根证书,以后用该CA为部署的服务签发TLS证书便是。但落到实际操作上,这事情就属于典型的“必须集中在基础设施中自动进行的安全策略实施点”,面对数量庞大且能够自动扩缩的服务节点,依赖运维人员人工去部署和轮换根证书必定是难以为继的。除了服务节点扩缩与数量带来的复杂性外,微服务中TLS认证的频次也会显著高于传统的应用,比起公众互联网中主流单向的TLS认证,在零信任网络中,往往要启用双向的TLS认证(Mutual TLS,常简写为mTLS),即不仅要确认服务端的身份,还需要确认调用者的身份。

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

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

关于自动化基础设施在密钥、证书等方面该提供哪些支持、具体是如何运作的,我们还会在“不可变基础设施”中继续去探讨,这里先聚焦“认证”和“授权”两个最基本的安全需求,看它们在微服务架构下,有或者没有基础设施支持时,各是如何实现的。

认证

根据认证的目标对象可以把认证分为两种类型,一种是以机器作为认证对象,即访问服务的流量来源是另外一个服务,称为服务认证(Peer Authentication);另一种是以人类作为认证对象,即访问服务的流量来自于最终用户,称为请求认证(Request Authentication)。无论哪一种认证,无论是否没有基础设施的支持,均需要有可行的方案来确定服务调用者的身份,建立起信任关系才能调用服务,我们先从服务认证说起。

服务认证

Istio版本的Fenix's Bookstore采用了双向TLS认证作为服务调用双方的身份认证手段。得益于Istio提供的基础设施的支持,我们无需改动任何代码就可以启用mTLS认证。不过,如果你准备在自己的生产系统中启用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)的服务端或者客户端——这其实在大型系统中是颇为常见的,你也可以将传输声明为“宽容模式”(Permissive Mode)。宽容模式的含义是受Istio管理的服务会允许同时接受纯文本和mTLS两种流量,纯文本流量仅用于与那些不受Istio管理的节点进行交互,你需要自行想办法解决纯文本流量的认证问题;而对于服务网格内部的流量,就可以使用mTLS认证。宽容模式为普通微服务向服务网格迁移提供了极大的灵活性,运维人员能够逐个服务进行mTLS升级,原本没有启用mTLS的服务在启用mTLS时甚至允许不中断现存已建立的纯文本传输连接。一旦所有服务都完成迁移,便可将整个系统设置为严格TLS模式(即上面代码中的mode: STRICT)。

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

  • 每一个要调用服务的客户端都与认证服务器约定好一组只有自己知道的密钥(Client Secret),这个约定过程应该是由运维人员在线下自行完成,通过参数传给服务(而不是由开发人员在源码或配置文件中直接设定,笔者在演示工程的代码注释中也专门强调了这点,以免有读者被示例代码误导)。密钥就是客户端的身份证明,客户端调用服务时,会先使用该密钥向认证服务器申请到JWT令牌。如以下代码定义了五个客户端,其中四个是集群内部的微服务,均使用客户端模式,且注明了授权范围是“SERVICE”(后面介绍授权会用到)。
/**
 * 客户端列表
 */
private static final List<Client> 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})
);
  • 每一个对外提供服务的服务端都是OAuth2中的资源服务器,它们均声明为要求提供客户端模式的凭证,如以下代码所示。客户端要调用受保护的服务,就必须先出示能证明调用者身份的JWT令牌,否则就会遭到拒绝。这个操作本质上是授权,但是在授权过程中也已实现了身份认证。
public ClientCredentialsResourceDetails clientCredentialsResourceDetails() {
    return new ClientCredentialsResourceDetails();
}

由于每一个微服务都同时具有服务端和客户端两种身份,所以这些代码在每个微服务中都有包含(放在公共infrastructure工程里)。Spring Security提供的过滤器会自动拦截请求,驱动认证、授权检查的执行,申请和验证JWT令牌等操作无论在开发期对程序员,还是运行期对用户都能做到相对透明。尽管如此,以上仍然是一种应用层面的、不加密传输的解决方案。前文提到在零信任网络中,面对可能的中间人攻击,TLS是唯一可行的办法,言下之意是即使应用层的认证能一定程度上保护服务不被身份不明的客户端越权调用,但对传输过程中内容被监听、篡改、以及被攻击者在传输途中拿到JWT令牌后去再冒认调用者身份调用其他服务都是无法防御的。简而言之,这种方案不适用于零信任安全模型,只能在默认内网节点间具备信任关系的边界安全模型上才能良好工作。

用户认证

对于来自最终用户的请求认证,Istio版本的Fenix's Bookstore仍然能做到单纯依靠基础设施解决问题,整个认证过程无需应用程序参与(生成JWT令牌还是在应用中生成的,因为我们并没有使用独立的用户认证服务器,只有应用本身才拥有用户信息)。当来自最终用户的请求进入服务网格时,Istio会自动根据配置中的JWKS(JSON Web Key Set)来验证令牌的合法性,如果令牌没有被篡改过且在有效期内,就信任Payload中的用户身份,并从令牌的Iss字段中获得Principal。

关于Iss、Principals等概念,在安全架构中都有介绍过,这里不再重复。JWKS倒是之前没有提到过的概念,它代表了一个密钥仓库。我们知道分布式系统中,JWT应采用非对称的签名算法(RSA SHA256、ECDSA SHA256等,默认的HMAC SHA256属于对称加密),认证服务器使用私钥对Payload进行签名,资源服务器使用公钥对签名进行验证。常与JWT配合使用的JWK就是一种存储密钥的纯文本格式,本质上和JKS(Java Key Storage)、P12(Predecessor of PKCS#12)、PEM(Privacy Enhanced Mail)这些常见的密钥格式在功能上并没有什么差别。JKWS顾名思义就是一组JWK的集合,通过JWT令牌Header中的KID(Key ID)来匹配使用哪个JWK做签名验证。

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

apiVersion: security.istio.io/v1beta1
kind: RequestAuthentication
metadata:
  name: authentication-jwt-token
  namespace: bookstore-servicemesh
spec:
  jwtRules:
    - issuer: "icyfenix@gmail.com"
      # 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版本的Fenix's Bookstore就略微麻烦一些,它依然是采用JWT令牌作为用户身份凭证的载体,认证过程依然在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转换自动为系统中用户对象以方便后续业务使用。Fenix's Bookstore转换操作是在JWT令牌的父类JWTAccessToken中完成的。由此可见,尽管由应用自己来做请求验证会有一定的代码量和侵入性,但灵活性确实也要更高一些。

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

授权

经过认证之后,合法的调用者就有了可信任的身份,此时就已经不再需要区分调用者到底是机器(服务)还是人类(最终用户)了,只根据其身份角色来进行权限访问控制,即我们常说的RBAC。不过为了更便于理解,Fenix's Bookstore提供的示例代码仍然沿用此前的思路,针对来自“服务”和“用户”的流量来控制权限和访问范围。

举个具体例子,如果我们准备把一部分微服务视为私有服务,限制它只接受来自集群内部其他服务的请求,另外一部分微服务视为公共服务,允许它可接受来自集群外部的最终用户发出的请求;又或者我们想要控制一部分服务只允许移动应用调用,另外一部分服务只允许浏览器调用。那一种可行的方案就是为不同的调用场景设立角色,进行授权控制(另一种常用的方案是做BFF网关)。

在Istio版本的Fenix's Bookstore中,通过以下配置,限制了来自bookstore-servicemesh名空间的内部流量只允许访问accountsproductspaysettlements四个端点的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名空间的流量),又进行了另外一层控制,如果请求中没有包含有效的登录信息,就限制不允许访问accountspaysettlements三个端点,如以下配置所示:

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版本的Fenix's Bookstore中,授权控制自然还是使用Spring Security、通过应用程序代码来实现的。常见的Spring Security授权方法有两种,一种是使用它的ExpressionUrlAuthorizationConfigurer,即类似如下编码所示的写法来进行集中配置,这与上面在Istio的AuthorizationPolicy CRD中写法在体验上是非常相似的,这也是几乎所有Spring Security资料中都有介绍的最主流方式,适合对批量端点进行控制,不过在示例代码中并没有采用(没有什么特别原因,就是笔者的习惯而已)。

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

另一种写法,也是实例代码中采用的方法通过是Spring的全局方法级安全(Global Method Security)以及JSR 250的@RolesAllowed注解来做授权控制。这种写法对代码的侵入性更强,但也能以更方便的形式做出更加精细的控制效果。譬如要控制服务中某个方法只允许来自服务或者来自浏览器的调用,只许直接在该方法上标注@PreAuthorize注解即可,支持SpEL表达式。表达式中用到的SERVICEBROWSER代表授权范围,是在声明客户端列表时传入的(见开头声明客户端列表的代码清单)。

/**
 * 根据用户名称获取用户详情
 */
@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));
}
Kudos to Star
总字数: 5,343 字  最后更新: 8/11/2020, 8:22:53 PM