翻译: dameng
为Docker的镜像仓库实现OAuth认证服务
在Docker镜像仓库的V1版的使命即将结束之际,Quadra团队也正为向V2版的Docker镜像仓库迁移而忙碌着。与此同时,我们发现这恰好也是一个改善我们现有的镜像仓库的认证机制(现有的使用的是建立在HTTPS协议基础上的简单认证模式)的良机。在一番尝试之后,我们决定试着实现一套基于token的OAuth系统,因为它可以在为我们的镜像仓库提供更加复杂的访问控制机制的同时,还允许我们在不同的镜像仓库之间使用同一套认证机制。在这篇博文中,我会概览性的为大家介绍Docker镜像仓库所使用的OAuth工作流程,同时也会解释一些我们现有实现的细节。希望通过通读此文,你可以为镜像仓库搭建起自己的OAuth认证服务!
先来看看代码
你可以从这里看到我们的OAuth服务的实现样例。这是一个简单的flask程序,它实现了OAuth的工作流程并会产生一个V2版镜像仓库可以理解的token。这里将会告诉你如何搭建起这个项目,还会告诉你一些相关配置项的具体用法。
OAuth 的工作流程
Docker镜像仓库中的OAuth的认证流程有如下几步:
- 客户端发起向镜像仓库的链接
- 如果镜像仓库已经配置成了OAuth的认证模式,那么它将返回一个401的错误,并且其返回的消息体中会包含如何完成认证的信息。
- 紧接着客户端会按照上面返回消息体中的指引,向认证服务器发起请求。
- 然后认证服务器会返回一个象征着客户访问权限的token
- 客户端将带有客户访问权限标识的token放入请求头,向镜像仓库再一次发起请求
- 镜像仓库将尝试验证token,如果成功,则会返回客户端所请求的资源
让我们通过一个实际的例子来看看这样的流程。让我们从代码仓库中的示例项目开始,然后再向我们的本地仓库发起一个请求:
curl https://192.168.99.100:5000/v2/_catalog
* Trying 192.168.99.100...
* Connected to 192.168.99.100 (192.168.99.100) port 5000 (#0)
* TLS 1.2 connection using TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA
* Server certificate: localhost
> GET /v2/_catalog HTTP/1.1
> Host: 192.168.99.100:5000
> User-Agent: curl/7.43.0
> Accept: */*
>
< HTTP/1.1 401 Unauthorized
< Content-Type: application/json; charset=utf-8
< Docker-Distribution-Api-Version: registry/2.0
< Www-Authenticate: Bearer realm="http://192.168.99.100:8080/tokens",service="demo_registry",scope="registry:catalog:*"
< X-Content-Type-Options: nosniff
< Date: Sun, 31 Jan 2016 22:29:49 GMT
< Content-Length: 134
<
{"errors":[{"code":"UNAUTHORIZED","message":"authentication required","detail":[{"Type":"registry","Name":"catalog","Action":"*"}]}]}
和预期的一样,镜像仓库会返回一个401错误。然而更重要的是,在返回结果的头部中的Www-Authenticate字段指明了一个客户端应如何完成认证。让我们将其拆开来看:
- Realm字段: realm是用来告诉客户端OAuth服务器的地址的。在我们的例子中,它指向了http://192.168.99.100:8080/tokens
- Service字段:service则告诉OAuth服务器资源所托管的位置。在我们的例子中,它是demo_registry
- Scope字段:scope告诉OAuth服务器所需要权限的种类。在我们的例子中,我们请求的是超级用户访问catalog的权限。
现在我们应该已经具备了访问OAuth服务器所需的所有信息。为了和docker客户端的认证机制保持一致,让我们遵循HTTP的Basic认证方式,把我们的认证信息放在请求的头部。首先,使用base64将我们的认证信息进行编码:
$ echo -n username:password | base64
dXNlcm5hbWU6cGFzc3dvcmQ=
紧接着我们可以像下边这样,将结果放在http请求头部:
curl -H "Authorization: Basic dXNlcm5hbWU6cGFzc3dvcmQ=" http://192.168.99.100:8080/tokens?service=demo_registry&scope=registry:catalog:*
* Trying 192.168.99.100...
* Connected to 192.168.99.100 (192.168.99.100) port 8080 (#0)
> GET /tokens?service=demo_registry&scope=registry:catalog:* HTTP/1.1
> Host: 192.168.99.100:8080
> User-Agent: curl/7.43.0
> Accept: */*
> Authorization: Basic dXNlcm5hbWU6cGFzc3dvcmQ=
>
< HTTP/1.1 200 OK
< Server: gunicorn/19.4.5
< Date: Sun, 31 Jan 2016 22:58:54 GMT
< Connection: close
< Content-Type: application/json
< Content-Length: 719
<
{
"token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6IllJMkI6N01JUTpIT0I0OjdXN0I6Uk5NTDpaRUZVOkdLMkc6VkM3RTo3UUhHOkdVR1Y6T1FYVTozN0lUIn0.eyJzdWIiOiIiLCJpc3MiOiJkZW1vX29hdXRoX3NlcnZlciIsImFjY2VzcyI6W3sidHlwZSI6InJlZ2lzdHJ5IiwibmFtZSI6ImNhdGFsb2ciLCJhY3Rpb25zIjpbIioiXX1dLCJleHAiOjE0NTQyODQ3MzQsImlhdCI6MTQ1NDI4MTEzNCwibmJmIjoxNDU0MjgxMTM0LCJhdWQiOiJkZW1vX3JlZ2lzdHJ5In0.QYGsEkuFv5Mpg2_2oov3KylQcYZEhXJXGKB_ahDCmya4MUnyprRISFfk3Eovvc5OgGWUQx5-Gl7eSBidVI0z7K29wUV7ITL5prnbwg5pIjxJAYLkzBCmouiAyE24Uxy2vkVtDTicWsWT7H54Ou_v2umv7bQe6JB3t6vYsmb3taiDUI_RTWxfSOp7OK1n6UVFEEUHiV57wP3aWZ60A379a9ZP6sEHKhEi306OvXPyaz804KFH7sTqbSMYf9DP_Gy8Jh04Tw9zKmClk-byct8Hspelw1JytbsQonlKwV9OH30DTCjgaWyNiavTTdfpRmiDRRMRsROjw2JLL8ZMMTZEhQ"
}
和预想中的一样,OAuth服务会返回我们的token。通过这个token,现在我们可以向镜像仓库发起另一个请求了:
curl -H "Authorization: Bearer eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6IllJMkI6N01JUTpIT0I0OjdXN0I6Uk5NTDpaRUZVOkdLMkc6VkM3RTo3UUhHOkdVR1Y6T1FYVTozN0lUIn0.eyJzdWIiOiIiLCJpc3MiOiJkZW1vX29hdXRoX3NlcnZlciIsImFjY2VzcyI6W3sidHlwZSI6InJlZ2lzdHJ5IiwibmFtZSI6ImNhdGFsb2ciLCJhY3Rpb25zIjpbIioiXX1dLCJleHAiOjE0NTQyODQ3MzQsImlhdCI6MTQ1NDI4MTEzNCwibmJmIjoxNDU0MjgxMTM0LCJhdWQiOiJkZW1vX3JlZ2lzdHJ5In0.QYGsEkuFv5Mpg2_2oov3KylQcYZEhXJXGKB_ahDCmya4MUnyprRISFfk3Eovvc5OgGWUQx5-Gl7eSBidVI0z7K29wUV7ITL5prnbwg5pIjxJAYLkzBCmouiAyE24Uxy2vkVtDTicWsWT7H54Ou_v2umv7bQe6JB3t6vYsmb3taiDUI_RTWxfSOp7OK1n6UVFEEUHiV57wP3aWZ60A379a9ZP6sEHKhEi306OvXPyaz804KFH7sTqbSMYf9DP_Gy8Jh04Tw9zKmClk-byct8Hspelw1JytbsQonlKwV9OH30DTCjgaWyNiavTTdfpRmiDRRMRsROjw2JLL8ZMMTZEhQ" https://192.168.99.100:5000/v2/_catalog
* Trying 192.168.99.100...
* Connected to 192.168.99.100 (192.168.99.100) port 5000 (#0)
* TLS 1.2 connection using TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA
* Server certificate: localhost
> GET /v2/_catalog HTTP/1.1
> Host: 192.168.99.100:5000
> User-Agent: curl/7.43.0
> Accept: */*
> Authorization: Bearer
eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6IllJMkI6N01JUTpIT0I0OjdXN0I6Uk5NTDpaRUZVOkdLMkc6VkM3RTo3UUhHOkdVR1Y6T1FYVTozN0lUIn0.eyJzdWIiOiIiLCJpc3MiOiJkZW1vX29hdXRoX3NlcnZlciIsImFjY2VzcyI6W3sidHlwZSI6InJlZ2lzdHJ5IiwibmFtZSI6ImNhdGFsb2ciLCJhY3Rpb25zIjpbIioiXX1dLCJleHAiOjE0NTQyODQ3MzQsImlhdCI6MTQ1NDI4MTEzNCwibmJmIjoxNDU0MjgxMTM0LCJhdWQiOiJkZW1vX3JlZ2lzdHJ5In0.QYGsEkuFv5Mpg2_2oov3KylQcYZEhXJXGKB_ahDCmya4MUnyprRISFfk3Eovvc5OgGWUQx5-Gl7eSBidVI0z7K29wUV7ITL5prnbwg5pIjxJAYLkzBCmouiAyE24Uxy2vkVtDTicWsWT7H54Ou_v2umv7bQe6JB3t6vYsmb3taiDUI_RTWxfSOp7OK1n6UVFEEUHiV57wP3aWZ60A379a9ZP6sEHKhEi306OvXPyaz804KFH7sTqbSMYf9DP_Gy8Jh04Tw9zKmClk-byct8Hspelw1JytbsQonlKwV9OH30DTCjgaWyNiavTTdfpRmiDRRMRsROjw2JLL8ZMMTZEhQ
>
< HTTP/1.1 200 OK
< Content-Type: application/json; charset=utf-8
< Docker-Distribution-Api-Version: registry/2.0
< X-Content-Type-Options: nosniff
< Date: Sun, 31 Jan 2016 23:58:06 GMT
< Content-Length: 20
<
{"repositories":[]}
最后,镜像仓库在验证了我们token之后,会将期待的结果返回给我们。
OAuth的Token
现在我们已经对OAuth的工作流程有了初部的了解,我们还应该学习下OAuth的token以及OAuth的服务是如何生成它们的。
Docker的镜像仓库使用广为流行的名为JSON网络Token(JWT)作为其token的格式。一个JWT的token由三部分组成,头(Header),声明(Claim)和签名(Signature),它们之间由逗号分隔。一个典型的JWT的token类似如下:
eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6IllJMkI6N01JUTpIT0I0OjdXN0I6Uk5NTDpaRUZVOkdLMkc6VkM3RTo3UUhHOkdVR1Y6T1FYVTozN0lUIn0.eyJzdWIiOiIiLCJpc3MiOiJkZW1vX29hdXRoX3NlcnZlciIsImFjY2VzcyI6W3sidHlwZSI6InJlZ2lzdHJ5IiwibmFtZSI6ImNhdGFsb2ciLCJhY3Rpb25zIjpbIioiXX1dLCJleHAiOjE0NTQyODQ3MzQsImlhdCI6MTQ1NDI4MTEzNCwibmJmIjoxNDU0MjgxMTM0LCJhdWQiOiJkZW1vX3JlZ2lzdHJ5In0.QYGsEkuFv5Mpg2_2oov3KylQcYZEhXJXGKB_ahDCmya4MUnyprRISFfk3Eovvc5OgGWUQx5-Gl7eSBidVI0z7K29wUV7ITL5prnbwg5pIjxJAYLkzBCmouiAyE24Uxy2vkVtDTicWsWT7H54Ou_v2umv7bQe6JB3t6vYsmb3taiDUI_RTWxfSOp7OK1n6UVFEEUHiV57wP3aWZ60A379a9ZP6sEHKhEi306OvXPyaz804KFH7sTqbSMYf9DP_Gy8Jh04Tw9zKmClk-byct8Hspelw1JytbsQonlKwV9OH30DTCjgaWyNiavTTdfpRmiDRRMRsROjw2JLL8ZMMTZEhQ
让我们探究一下JWT格式的token的每个部分是如何生成的。
头部(HEADER)
一个典型的JWT的头部在编码之前看上去类似下面这样(已经为了可读性进行了适当的缩进):
{
"typ": "JWT",
"alg": "RS256",
"kid": "ABCD:EFGH:IJKL:MNOP:QRST:UVWX:YZ23:4567:ABCD:EFGH:IJKL:MNOP"
}
“typ” 字段指定了token的格式,通常情况下都会被设置成“JWT”. "alg"字段指定了用于生成token的签名的算法。简便起见,我们的OAuth服务仅仅支持RS256算法。下面是算法字段可以使用的值的一份清单:
最后还有一点非常重要,就是“kid”字段是由公钥生成的唯一的ID,让我们略感沮丧的是,它是如何生成却没有详细的文档说明。直到我们深入的探究了下Docker镜像仓库在Github的源码我们才真正的解决了这个问题。所以,简而言之,Docker镜像仓库的“kid”字段应该按照如下实现:
对使用DER格式编码的公钥用SHA256算法求哈希,取其前240位。这一步的结果可以被编码成12组base32组,具体类似下面这样,
ABCD:EFGH:IJKL:MNOP:QRST:UVWX:YZ23:4567:ABCD:EFGH:IJKL:MNOP
你可以从 这里找到我们关于这一步的算法实现**。
现在,我们已经具备了所需要的所有字段,可以开始创建实际的头部了:从请求头中移除所有的空白;将上一部的结果进行base64格式的url安全编码进而生成token的第一部分。
声明(CLAIM)
这一部分也被称为JWT的payload,JWT的token的声明部分通常看上去类似这样:
{
"iss"字段指明了token的来源,通常来说就是OAuth服务器的完整的域名。“aud”字段则指明了token的使用者,而这个一般是docker镜像仓库的完整域名。“exp”字段则是用来标记token的过期时间的,它是unix时间格式的。“nbf”字段,也就是not before字段,则用来标明这个token在某个时间之后才是有效的。“iat”字段,即“issue at”字段,指的是token的创建时间。
“access”字段用来表示token具备何种权限。在我们的示例中,token都具备向代码仓库samalba/my-app推送和拉取的权限。OAuth服务器应当在第一次访问docker镜像仓库时基于请求的权限来生成这个字段。
还有一点需要强调的是,声明字段中不应该有空白字符,而其结果接下来会通过base64模式的安全编码来生成token的第二部分。
签名(SIGNATURE)
签名的生成过程是这样的,1.先对头部进行base64编码,2. 再对声明字段进行base64编码, 3. 将前面两部分用'.'字符连接后,配合指定的key再通过指定的算法求哈希。具体的伪代码如下:
RS256(base64_urlsafe_encode(header) + “.” + base64_urlsafe_encode(claim), key)
处理后的结果,也就是签名,会被追加为token的最后一部分。
结论
实现我们自己的OAuth服务并不是一件简单的事情,我们希望这篇博文能会成为你一个很好的出发点,这样就不用重复解决我们遇到的问题了。不用犹豫了,赶紧尝试下博文中的提供的OAuth服务吧,也欢迎提交变更和改进。