由于工作原因,最近研究了下 Docker Registry V2 (以下内容如无特别说明,Registry 均指 Docker Registry V2)的私有化(这里所指的私有化并不是指的私有仓库,而是在私有云里面面向内部的共有仓库,并且对外隔离的)的定制化开发。
Registry 是 Docker 官方提供的镜像仓库,官方的 Docker Hub 就是基于 Registry 的,Registry 分为 V1 和 V2,V1 是我大爱的 Python 的写的,V2 是我现在大爱的 Golang 开发的,由于 V1 已经被官方抛弃了,所以目前的定制化是基于 V2 版本的。
#认证结构 Registry 提供了三种认证方式,本篇中的实现均是基于 token 模式下的。Registry 提供了松耦合的 Registry 系统,将 Auth Server 与 Registry 的 push/pull 给分离开来了,将 Auth Server 的实现交由用户来实现,个人觉得真是太赞了。
上图是官方认证结构图,用户在启动 Registry 并且配置 auth.token
的认证方式时,当用户进行 push/pull/login
的操作时,会到 Auth Server 去认证,由 Auth Server 来判断是否授权通过。基于这一模式,就可以在私有化 Registry 中控制不同的用户(项目)有不同的权限。
auth.token.issuer
: token 发行人,用来在 Auth Server 识别身份auth.token.realm
: Auth Server 的 URIauth.token.rootcertbundle
: 公钥的绝对路径auth.token.service
: 认证的 service,当多个 Registry 公用一个 Auth Server 时,可以区分来自哪里
上述的 auth.token.rootcertbundle
可以使用 openssl
来生成,在 Token 认证体系里是需要自己生成私钥和公钥来做 token 的认证的。
# 生成私钥
openssl genrsa -out ./private_key.pem 4096
# 生成公钥
openssl req -new -x509 -key ./private_key.pem -out ./root.crt -days 3650 -subj /C=CN/ST=state/L=CN/O=cloverstd/OU=cloverstd\ unit/CN=hui.lu/emailAddress=i@hui.lu
上述操作后,会在当前目录生成 private_key.pem
和 root.crt
在启动后可以试下 docker pull 127.0.0.1:5000/nginx
这个命令,Docker 会返回 getsockopt: connection refused
的错误,因为我们的 Auth Server 还没有启动起来。
当执行 pull/login/push
这三个需要认证的命令时,Auth Server 会收到一条 GET
请求,并且会有一些参数。
在上述配置中,我配置的 auth.token.realm
是 http://11.11.11.1:8080/service/token
,当我执行 docker pull 127.0.0.1:5000/nginx
时,Auth Server 会收到下面这样的请求
GET /service/token?scope=repository:nginx:pull&service=token-service HTTP/1.1
Content-Length:
User-Agent: docker/1.12.0 go/go1.6.3 git-commit/8eab29e kernel/4.4.15-moby os/linux arch/amd64 UpstreamClient(Docker-Client/1.12.0 (darwin))
Connection: close
Host: 11.11.11.1:8080
Content-Type:
Accept-Encoding: gzip
这是因为 Docker 首先会发一条请求过来确认是否需要认证,这里服务器要给正确的相应,告诉 Docker 是否需要认证,正确的要求 Docker 提供认证服务的请求应该如下
HTTP/1.1 401 Unauthorized
Content-Type: application/json; charset=utf-8
Docker-Distribution-Api-Version: registry/2.0
Www-Authenticate: Bearer realm="http://11.11.11.1:8080/service/token",service="token-service",scope="repository:nginx:pull,push"
Date: Thu, 10 Sep 2015 19:32:31 GMT
Content-Length: 235
Strict-Transport-Security: max-age=31536000
{"errors":[{"code":"UNAUTHORIZED","message":"access to the requested resource is not authorized","detail":[{"Type":"repository","Name":"samalba/my-app","Action":"pull"},{"Type":"repository","Name":"samalba/my-app","Action":"push"}]}]}
这里有以下俩要注意
Www-Authenticate
这里要指出 Auth Server 的地址,还有支持的 scope 操作- 在 body 里需要给出错误的原因
- HTTP Status Code 必须为 401
其实我测了下发现,并没那么严格的要求,只要 HTTP Status Code 为 401 并且 body 的格式相对正确就够了。
然后,Docker 会提示输入用户名和密码,这里我输入 test 和 test,然后服务器会收到跟上述类似的请求,只是多了一个 HTTP Basic Auth 的头 Authorization
。通过 base64 解码一下就可以拿到用户名和密码,然后在这里就可以通过各种验证手段(数据库查询等)来验证用户的身份信息了,身份信息认证过了之后,就要认证用户是否有权操作他所要操作的 scope
了
scope
根据官方的说明,可能会有多个 scope
作为 URL 参数传送过来,所以这里需要获取到的是一个 scope
的数组,在很多框架里都有实现获取同名参数,作为一个数组。
scope
由 Name
、Type
和 Actions
组成,并且由 :
分割开来,直接 split(':')
一下就可以得到解析过后的 scope
了,一般来说,scope
会是以下的样子
{
"scopes": [{
"type": "repository",
"name": "nginx",
"actions": ["pull", "push"]
}]
}
然后就可以根据 name
和 actions
来选择用户是否有权操作了,在 Registry 里并没有很明确的用户私有空间这样的概念,通常我们所指定 Registry,例如 127.0.0.1:5000/test/nginx:1.9
是指在 127.0.0.1:5000
这个 Registry 里面,test 用户下的 nginx 镜像的 1.9 版本,其实在 Registry 里,并没有 test 这个用户空间,这里的是 test 完全是 Auth Server 可以自定义的一种命名空间而已,在 Resgitry 里,/test/nginx:1.9
就相当于一段唯一的路由而已。
iss
前期通过registry.yaml
配置文件里的issuser
sub
当前操作的帐号或者说用户名aud
前期通过registry.yaml
配置文件里的service
exp
、iat
和nbf
表示的是 token 时间相关的jti
是一段随机字符串access
就是上述解析过后的scope
der_public_key = public_key.public_bytes(Encoding.DER, PublicFormat.SubjectPublicKeyInfo)
sha256 = hashlib.sha256(der_public_key)
base32_payload = base64.b32encode(sha256.digest()[:30]) # 240bits / 8
res = ":".join(
[base32_payload[i:i+4] for i in xrange(0, 48, 4)]
)
最后的 `res` 也就是 `kid` 应该是这样的类型的 `PYYO:TEWU:V7JH:26JV:AQTZ:LJC3:SXVJ:XGHA:34F2:2LAQ:ZRMK:Z7Q6`。
<div id="jwt-token"></div>
### token
拿到 `kid` 和 `claim` 后就可以来生成 token 了
```python
token = jwt.encode(claim, private_key, algorithm='RS256', headers=headers)
### 响应 Docker 客户端
然后将 token 返回给 Docker 即可,注意设置 `Content-Type` 为 `application/json`。
## 具体实现代码
下面是使用 Python 的 `Flask` 实现的
```python
from flask import Flask, request, jsonify, make_response, json
import base64
import jwt
from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat, load_pem_private_key
import time
import os
import hashlib
app = Flask(name)
SERVICE = 'token-service'
ISSUER = 'registry-token-issuer'
class DockerRegistryAuth(object):
def __init__(self, account, scopes, service, issuer, private_key_path, token_expires=300):
self.account = account
self.issuer = issuer
self.scopes = scopes
self.access = self.get_access_by_scopes(scopes)
self.service = service
self.private_key_path = private_key_path
self.token_expires = token_expires
@property
def private_key(self):
if getattr(self, '_private_key_content', None):
return self._private_key_content
with open(self.private_key_path, 'r') as fp:
setattr(self, '_private_key_content', fp.read())
return self._private_key_content
@property
def public_key(self):
private_key = load_pem_private_key(
self.private_key,
password=None,
backend=default_backend()
)
_public_key = private_key.public_key()
return _public_key
def check_service(self, service):
return self.service == service
def get_token(self):
now = int(time.time())
claim = {
'iss': self.issuer,
'sub': self.account,
'aud': self.service,
'exp': now + self.token_expires,
'nbf': now,
'iat': now,
'jti': base64.b64encode(os.urandom(1024)),
'access': self.access
}
headers = {
'kid': self.get_kid()
}
token = jwt.encode(claim, self.private_key, algorithm='RS256', headers=headers)
return {
'token': token,
'issued_at': now,
'expires_in': now + self.token_expires
}
def get_kid(self):
der_public_key = self.public_key.public_bytes(Encoding.DER, PublicFormat.SubjectPublicKeyInfo)
sha256 = hashlib.sha256(der_public_key)
base32_payload = base64.b32encode(sha256.digest()[:30]) # 240bits / 8
return ":".join(
[base32_payload[i:i+4] for i in xrange(0, 48, 4)]
)
def get_login_info(self, authorization):
if not authorization:
return None
auth_info = authorization
if authorization.startswith('Basic'):
auth_info = authorization[5:]
user_info = base64.b64decode(auth_info)
self.username, self.password = user_info.split(':')
return {
'username': self.username,
'password': self.password,
}
def get_access_by_scopes(self, scopes):
access = list()
if not scopes:
return access
for scope in scopes:
type_, name, actions = scope.split(':')
access.append({
'type': type_,
'name': name,
'actions': actions.split(',')
})
return access
def unauthorized401(access, message=None, code=None):
detail = list()
for scope in access:
for action in scope['actions']:
detail.append({
"Action": action,
"Name": scope['name'],
"Type": scope['type']
})
data = {
"errors": [
{
"code": code or "UNAUTHORIZED",
"detail": detail,
"message": message or "access to the requested resource is not authorized"
}
]
}
resp = make_response(json.dumps(data), 401)
resp.headers['Content-Type'] = 'application/json; charset=utf-8'
resp.headers['Docker-Distribution-Api-Version'] = 'registry/2.0'
resp.headers['Www-Authenticate'] = 'Bearer realm="https://auth.docker.io/token",service="registry.docker.io",scope="repository:samalba/my-app:pull,push"'
return resp
@app.route('/service/token')
def service_token():
print request.headers
scopes = request.args.getlist('scope')
account = request.args.get('account')
client_id = request.args.get('client_id')
service = request.args.get('service')
http_base_auth = request.headers.get('Authorization')
registry_auth = DockerRegistryAuth(account, scopes, SERVICE, ISSUER, './private_key.pem')
if not registry_auth.check_service(service):
return unauthorized401(registry_auth.access, 'service not be allowed.')
user = registry_auth.get_login_info(http_base_auth)
if user:
if not (user['username'] == 'test' and user['password'] == 'test'):
return unauthorized401(registry_auth.access, 'incorrect username or password')
res = registry_auth.get_token()
return jsonify(
token=res['token'],
)
return unauthorized401(registry_auth.access)
if name == 'main':
app.run(host='0.0.0.0', port=8080, debug=True)
<div id="ref"></div>
# 参考
* https://github.com/vmware/harbor
* https://docs.docker.com/registry
* https://github.com/itrp/docker-token-auth
* https://cryptography.io/en/latest/hazmat/primitives/asymmetric/rsa/
* https://pyjwt.readthedocs.io/en/latest/