在构建以 gRPC 为核心的微服务架构的过程中,得益于 Envoy 对 gRPC 的“一等公民”支持,我们可以在过滤器中对 gRPC 服务进行转码,进而可以像调用 Web API 一样去调用一个 gRPC 服务。通常情况下, RPC 会作为微服务间内部通信的信使,例如,Dubbo、Thrift、gRPC、WCF 等等更多是应用在对内通信上。所以,一旦我们通过 Envoy 将这些 gRPC 服务暴露出来,其性质就会从对内通信变为对外通信。我们知道,对内和对外的接口,无论是安全性还是规范性,都有着相当大的区别。博主从前的公司,对内的 WCF 接口,长年处于一种"裸奔“的状态,属于没有授权、没有认证、没有文档的“三无产品”。那么,当一个 gRPC 服务通过 Envoy 暴露出来以后,我们如何保证接口的安全性呢?这就是今天这篇博客的主题,即 Envoy 作为网关如何提供身份认证功能,在这里,我们特指通过JWT,即 Json Web Token 来对接口调用方进行身份认证。
搭建 Keycloak
对于 JWT ,即 Json Web Token ,我想大家应该都非常熟悉了,它是目前最流行的跨域认证解决方案。考虑到,传统的 Session 机制,在面对集群环境时,扩展性方面表现不佳。在日益服务化、集群化的今天,这种无状态的、轻量级的认证方案,自然越来越受到人们的青睐。在 ASP.NET Core 中整合JWT非常简单,因为有各种第三方库可以帮助你生成令牌,你唯一需要做的就是配置授权/认证中间件,它可以帮你完成令牌校验这个环节的工作。除此以外,你还可以选择更重量级的 Identity Server 4,它提供了更加完整的身份认证解决方案。在今天这篇博客里,我们使用的 Keycloak,一个类似 Identity Server 4 的产品,它提供了一个更加友好的用户界面,可以更加方便的管理诸如客户端、用户、角色等等信息。其实,如果从头开始写不是不可以,可惜博主一时间无法实现 JWKS,所以,就请大家原谅在下拾人牙慧,关于 JWKS ,我们会在下一节进行揭晓。接触微服务以来,在做技术选型时,博主的一个关注点是,这个方案是否支持容器化。所以,在这一点上,显然是 Keycloak 略胜一筹,为了安装 Ketcloak ,我们准备了如下的服务编排文件:
1version: '3'
2services:
3 keycloak:
4 image: quay.io/keycloak/keycloak:14.0.0
5 depends_on:
6 - postgres
7 environment:
8 KEYCLOAK_USER: ${KEYCLOAK_USER}
9 KEYCLOAK_PASSWORD: ${KEYCLOAK_PASS}
10 DB_VENDOR: postgres
11 DB_ADDR: postgres
12 DB_DATABASE: ${POSTGRESQL_DB}
13 DB_USER: ${POSTGRESQL_USER}
14 DB_PASSWORD: ${POSTGRESQL_PASS}
15 ports:
16 - "7070:8080"
17 postgres:
18 image: postgres:13.2
19 restart: unless-stopped
20 environment:
21 POSTGRES_DB: ${POSTGRESQL_DB}
22 POSTGRES_USER: ${POSTGRESQL_USER}
23 POSTGRES_PASSWORD: ${POSTGRESQL_PASS}
其中,.env
文件放置了服务编排文件中使用到的环境变量:
1# KEYCLOAK
2KEYCLOAK_USER=admin
3KEYCLOAK_PASS=admin
4# POSTGRESQL
5POSTGRESQL_DB=keycloak
6POSTGRESQL_USER=keycloak
7POSTGRESQL_PASS=keycloak
此时,我们运行docker compose up
命令就可以得到一个 Keycloak 环境,它将作为我们整个微服务里的认证中心,负责对用户、角色、权限、客户端等进行管理。于此同时,接口消费方可以通过 Keycloak 获取令牌、JWKS,而 Envoy 正是利用 JWKS 来对令牌进行校验的。这个 JWKS 到底是何方神圣,我们暂且按下不表。在正式使用 Keycloak 前,我们需要做一点简单的配置工作,具体来说,就是指创建用户、角色和客户端,我们一起来看一下。
首先,是创建一个用户,这里以《天龙八部》中壮志未酬的“慕容龙城”为例:
《天龙八部》中提到,“慕容龙城”一心想光复大燕,可惜时不我与,正好遇上宋太祖建立宋朝,即使他创造出“斗转星移”的武功绝学,依然免不了郁郁而终的结局。慕容龙城算是第一代创业者,我们准备一个Developer
的角色:
在权限系统的设计中,角色总是需要和用户关联在一起。同样地,在 Keycloak 中,我们需要给“慕容龙城”分配一个Developer
的角色:
到了“慕容复”这一代,“慕容垂”假借死亡之名秘密活动,而活跃在台前的“慕容复”,实际上是作为慕容家族的“代理人”出现。在今天这篇文章中,Envoy 会充当认证服务的代理,因为我们希望 Envoy 可以对所有进站的 API 请求进行统一的认证。所以,这里,我们还需要创建一个客户端:envoy-client
,并为其分配客户端角色:
OK,我们都知道,OAuth 2.0 有这样四种认证方式:密码模式、客户端模式、简化模式、授权码模式。这四种认证方式如何在 Keycloak 中实现呢?目前,博主基本搞清楚了前面两种。我们在创建完客户端以后,可以通过设置访问类型来决定客户端使用哪种认证方式,目前已知,当访问类型的取值为public
时,表示密码模式。当访问类型的取值为confidential
时,表示客户端模式。这里,我们以客户端模式为例:
此时,我们就可以拿到一个重要的信息:client_secret
,如果大家使用过客户端模式,就会知道它是获取令牌的重要参数之一。好了,当我们有了这些信息以后,该怎么样去获取令牌呢?我们只需要用 POST 的方式,将grant_type
、client_id
、client_secret
、username
、password
、scope
传过去即可:
如果需要刷新令牌,则只需要再追加一个refresh_token
参数即可,它是我们第一次获取到的令牌:
可能大家会疑惑,博主是从哪里知道这些 API 的端点地址的呢?其实,和 Identity Server 4 类似, Keycloak 提供了一个用于服务发现的接口地址:/auth/realms/master/.well-known/openid-configuration
,通过这个接口地址,我们可以获得一份 API 列表:
可以注意到,图中有我们需要的换取令牌的接口,以及提供 JWKS 的接口:/auth/realms/master/protocol/openid-connect/certs"
,尤其第二点,它对于对我们进行下一个步骤意义重大,Envoy 能不能承担起微服务认证的重担,就看它的啦,至此, Keycloak 的搭建工作已经完成。
配置 Envoy
在上一节内容中,博主卖了一个关子,说要等到这一节再说 JWKS 是何方神圣?不过,博主以为,“饭要一口一口吃,步子迈太大,咔,容易扯着蛋”,我们还是先来说说 JWT ,因为只要你了解了它的结构,你才能了解如何去检验一个令牌。我们说,JWT,是 JSON Web Token 的简称,那这个 JSON 到底体现在哪里呢?而这要从 JWT 的结构开始说起。
这是一张来自 JWT 官网的截图,博主认为,这张图非常清晰地展示出了 JWT 的加密过程,我们熟悉的这个令牌,其实是由header
、payload
和signature
三个部分组成,其基本格式为:header.payload.signature
,细心的朋友会发现,图中生成的令牌中含有两个.
。其中,header
部分是一个 JSON 对象,表示类型(typ)及加密算法(alg),常见的加密算法主要有 HMAC、RSA、ECDSA 三个系列。payload
部分同样是一个 JSON 对象,主要用来存放实际需要传递的数据。目前,JWT 官方规定了以下7个备选字段:
- iss,即 issuer,表示:令牌签发人
- exp,即 expiration time,表示:令牌过期时间
- sub,即 subject,表示:令牌主题
- aud,即 audience,表示:令牌受众
- nbf,即 Not Before,表示:令牌生效时间
- iat,即 Issued At,表示:令牌签发时间
- jti,即 JWT ID,表示:令牌编号
需要注意的是,header
和payload
这两部分,默认是不加密的,这意味着任何人都可以读到这里的信息,所以,一个重要的原则是,不要在payload
中存放重要的、敏感的信息。无论是header
还是payload
,最终都需要通过 Base64URL 算法将其转化为普通的字符串,该算法和 Base64 算法类似,唯一的不同点在于它会对+
、/
和 =
这三个符号进行替换,因为这三个符号在网址中有着特殊的含义。
第三部分,signature
,即通常意义上的签名,主要是防止数据篡改。对于 HMAC 系列的加密算法,需要指定一个密钥,以 HMACSHA256 算法为例,其签名函数为:HMACSHA256(base64UrlEncode(header) + "." + base64UrlEncode(payload), secret)
。对于 RSA 和 ECDSA 这两个系列的加密算法,需要指定公钥和私钥,以 ECDSASHA512 算法为例,其签名函数为:ECDSASHA512(base64UrlEncode(header) + "." + base64UrlEncode(payload), PublicKey, PrivateKey)
。一旦计算出签名,就可以将这三部分合成一个令牌,而这就是 JWT 的产生原理,而如果我们对第一节中获得的令牌进行解密,我们就会得到下面的结果:
所以,JSON Web Token 中的 JSON,其实是指 header
和 payload
这两个 JSON 对象,并且我们可以注意到,Keycloak 中生成的令牌实际上携带了更多的信息,例如,客户端、IP 地址、realm_access
以及 resource_access
等等,所以。 JWT 其实是一个相对宽松的规范,在实现payload
这部分时,可以结合实际场景做更多的扩展,唯一的要求还是那句话,不要在payload
中存放重要的、敏感的信息。至此,我们讲清楚了 JWT 的底层原理。
OK,解释清楚了 JWT,我们再来说 JWKS,这位又是何方神圣呢?我们提到,JWT 至少需要一个密钥或者一对公/私钥来进行签名的校验,因为对于header
和payload
这两个部分而言,它的加密算法始终都是 Base64URL,所以,我们总是可以反推出原始的 JSON 字符串。接下来,我们只需要按签名函数计算签名即可,对于 HMAC 系列的加密算法,需要指定一个密钥;对于 RSA 和 ECDSA 这两个系列的加密算法,需要指定公钥和私钥。由此,我们就可以计算出一个签名,此时,我们只需要比较两个签名是否一致即可。
通过 JWKS 的 官网,我们可以了解到一件事情,那就是 JWKS 本质上是 Json Web Key Set 的简称,顾名思义,这是一组可以校验任意 JWT 的公钥,并且这些 JWT 必须是通过 RS256 算法进行签名的,RS256 则是我们上面这张图里的 RSA 非对唱加密算法,它需要一个公钥和一个私钥,通常强况下,私钥用来生成签名,公钥用来校验签名。这个 JWKS 呢?同样是一个 JSON 对象,它只有一个属性keys
,以 Keycloak 中获得的 JWKS 为例:
关于 JWKS 的规范,大家可以通过 RFC7515 来了解,作为一种通用的规范,Identity Server 4 和 Keycloak 都实现了这一规范,所以,就今天这篇博客而言,不管是哪一种方案,它都可以和 Envoy 配合得天衣无缝。为什么这样说呢?因为我们在 Envoy 中实现 JWT 认证,其核心还是 JWKS 这一套规范。博主没有选择从头开始实现这一切,就在于这个 JWKS 有特别多的细节。总之,我们只需要知道,通过 JWKS 可以对一个令牌进行验证,而 Envoy 刚好有这样一个过滤器,下面是 Envoy 中对应的配置项:
1http_filters:
2 - name: envoy.filters.http.jwt_authn
3 typed_config:
4 "@type": type.googleapis.com/envoy.extensions.filters.http.jwt_authn.v3.JwtAuthentication
5 providers:
6 jwt_provider:
7 issuer: "http://192.168.50.162:7070/auth/realms/master"
8 audiences:
9 - "account"
10 forward: true
11 remote_jwks:
12 http_uri:
13 uri: "http://192.168.50.162:7070/auth/realms/master/protocol/openid-connect/certs"
14 cluster: keycloak
15 timeout: 5s
16 rules:
17 - match:
18 prefix: "/api/w"
19 requires:
20 provider_name: jwt_provider
21 - match:
22 prefix: "/api/c"
23 requires:
24 provider_name: jwt_provider
25 - name: envoy.filters.http.router
可以注意到,我们这里配置了一个叫做envoy.filters.http.jwt_authn
的过滤器,并为这个过滤器指定了一个叫做jwt_provider
的认证提供者,其中的issuer
和audiences
,我们在讲解 JWT 结构的时候提到过,最为关键的是remote_jwks
,我们通过 Keycloak 的服务发现功能,可以获得这个地址,我们将其配置到 Envoy 中即可,Envoy 可以通过它来验证一个 JWT 的令牌,而下面的规则,表示哪些路由需要认证,这里我们假设需要对/api/w
和/api/c
这两个端点进行认证。所以,可以预见的是,我们可以为整个网关配置统一的认证流程,无论我们有多少个微服务。以往我们都是通过 ASP.NET Core 里的过滤器来实现应用级的认证服务,而此时此刻,我们有了容器级别的认证服务,基础设施从框架提升到了容器层面。除此以外,我们还需要为 Envoy 定义一个集群,这样读取远程 JWKS 的请求才会被正确地转发过去:
1clusters:
2 - name: keycloak
3 connect_timeout: 0.25s
4 type: STRICT_DNS
5 lb_policy: ROUND_ROBIN
6 load_assignment:
7 cluster_name: keycloak
8 endpoints:
9 - lb_endpoints:
10 - endpoint:
11 address:
12 socket_address:
13 address: 192.168.50.162
14 port_value: 7070
如此,整个认证服务相关的基础设施均已准备就绪,所谓“万事俱备,只欠东风”,我们还需要定义资源 API 供调用者消费,所以,接下来,我们来看看 API 如何编写。
编写 API
编写 API 非常简单,我们直接用 ASP.NET Core 创建两个项目即可,这里是两个服务:CityService
和 WeatherService
。
首先,是 CityService
:
1[ApiController]
2[Route("[controller]")]
3public class CityController : ControllerBase
4{
5 private static readonly string[] Cities = new[]
6 {
7 "中卫", "西安", "苏州", "安庆", "洛阳", "银川", "兰州"
8 };
9
10 private readonly ILogger<CityController> _logger;
11
12 public CityController(ILogger<CityController> logger)
13 {
14 _logger = logger;
15 }
16
17 [HttpGet]
18 public dynamic Get()
19 {
20 var rnd = new Random();
21 var city = Cities[rnd.Next(Cities.Length)];
22 return new { City = city, Now = DateTime.Now };
23 }
24}
接下来,是 WeatherService
:
1[ApiController]
2[Route("[controller]")]
3public class WeatherController : ControllerBase
4{
5 private static readonly string[] Summaries = new[]
6 {
7 "Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching"
8 };
9
10 private readonly ILogger<WeatherController> _logger;
11
12 public WeatherController(ILogger<WeatherController> logger)
13 {
14 _logger = logger;
15 }
16
17 [HttpGet]
18 public IEnumerable<WeatherForecast> Get()
19 {
20 var rng = new Random();
21 return Enumerable.Range(1, 5).Select(index => new WeatherForecast
22 {
23 Date = DateTime.Now.AddDays(index),
24 TemperatureC = rng.Next(-20, 55),
25 Summary = Summaries[rng.Next(Summaries.Length)]
26 })
27 .ToArray();
28 }
29}
关于这两个服务如何实现容器化、反向代理等等的细节,大家可以参考博主前面几篇文章,本文示例已托管到 Github,供大家做进一步的参考。
服务编排
这段时间最大的收获便是,学会了通过docker-compose
对服务进行编排,虽然目前还有点悬而未决的东西,可一旦接触了这种略显“高端”的技巧,便再不愿回到刀耕火种、敲命令行维护docker
环境的时代。等有时间了,博主会考虑写一点docker
或者docker-compose
使用技巧的文章,当然这些都是以后的事情啦!我们要活在当下啊,还是看看这个docker-compose.yaml
文件:
1version: '3'
2services:
3 envoy_gateway:
4 build: Envoy/
5 ports:
6 - "6060:9090"
7 - "6061:9091"
8 volumes:
9 - ./Envoy/envoy.yaml:/etc/envoy/envoy.yaml
10 city_service:
11 build: CityService/
12 ports:
13 - "8081:80"
14 environment:
15 ASPNETCORE_URLS: "http://+"
16 ASPNETCORE_ENVIRONMENT: "Development"
17 weather_service:
18 build: WeatherService/
19 ports:
20 - "8082:80"
21 environment:
22 ASPNETCORE_URLS: "http://+"
23 ASPNETCORE_ENVIRONMENT: "Development"
24 keycloak:
25 image: quay.io/keycloak/keycloak:14.0.0
26 depends_on:
27 - postgres
28 environment:
29 KEYCLOAK_USER: ${KEYCLOAK_USER}
30 KEYCLOAK_PASSWORD: ${KEYCLOAK_PASS}
31 DB_VENDOR: postgres
32 DB_ADDR: postgres
33 DB_DATABASE: ${POSTGRESQL_DB}
34 DB_USER: ${POSTGRESQL_USER}
35 DB_PASSWORD: ${POSTGRESQL_PASS}
36 ports:
37 - "7070:8080"
38 postgres:
39 image: postgres:13.2
40 restart: unless-stopped
41 environment:
42 POSTGRES_DB: ${POSTGRESQL_DB}
43 POSTGRES_USER: ${POSTGRESQL_USER}
44 POSTGRES_PASSWORD: ${POSTGRESQL_PASS}
等所有的服务都启动起来以后,我们来验证下这个网关,是不是真的像我们期待的那样。注意到,Envoy 对外暴露出来的端口是6060
,这里我们以CItyService
为例:
首先,是不带令牌直接访问接口,我们发现接口返回了401
状态码,并提示:Jwt is missing。
我们带上令牌会怎么样呢?可以注意到,接口成功地返回了数据,这表示我们的目的达到了,这些经由 Envoy 代理的 API 接口,今后都必须携带令牌进行访问:
因为 Keycloak 这个认证中心是独立于我们的应用单独存在的,所以,我们可以直接在 Keycloak 中设置令牌的过期时间、为用户分配角色、为不同的资源设置范围等等,而这一切都不需要应用程序或者 Envoy 做任何调整,开发者只需要认真地写好每一个后端服务即可,这是否就是传说中的基础设施即服务呢?
本文小结
本文主要分享了如何利用 Envoy 实现容器级别的 JWT 认证服务,在实现过程中,我们分别了解了 JWT 和 JWKS 这两个概念。其中,JWT 即JSON Web Token,是目前最为流行的跨域认证方案,一个 JWT 通常由 header
、payload
和 signature
三个部分组成,JWT 的 JSON 主要体现在header
和payload
这两个 JSON 对象上,通过 Base64Url 算法实现串化,而 signature
部分则是由header
和payload
按照签名函数进行生成,主要目的是防止数据篡改。JWKS 可以利用密钥或者公/私钥对令牌进行验证,利用这一原理,Envoy 中集成了 JWKS ,它表示一组可以校验任意 JWT 的公钥,同样是一个 JSON 对象。为了获得可用的 JWKS,我们可以通过 Identity Server 4 或者 Keycloak 中提供的地址来获得这一信息,方便起见,本文选择了更为便捷的 Keycloak。最终,我们实现了一个通用的、容器级别的认证网关,调用方在消费这些 API 资源时都必须带上从认证中心获得的令牌,进而达到保护 API 资源的目的,更好地保障系统和软件安全。