/ Docker

Docker Registry V2 Auth Server

由于工作原因,最近研究了下 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 的实现交由用户来实现,个人觉得真是太赞了。
v2-registry-auth
上图是官方认证结构图,用户在启动 Registry 并且配置 auth.token 的认证方式时,当用户进行 push/pull/login 的操作时,会到 Auth Server 去认证,由 Auth Server 来判断是否授权通过。基于这一模式,就可以在私有化 Registry 中控制不同的用户(项目)有不同的权限。

# 认证流程
## 开启 Registry 认证模式 Registry 的认证模式必须在启动 Registry 时指定 `auth.token` 相关的配置才行,下面是配置示例,具体配置项,可以参考 [Registry Configuration](https://docs.docker.com/registry/configuration/) ```yaml version: 0.1 log: level: debug fields: service: registry storage: cache: layerinfo: inmemory filesystem: rootdirectory: /storage maintenance: uploadpurging: enabled: false delete: enabled: true http: addr: :5000 secret: placeholder auth: token: issuer: registry-token-issuer realm: http://11.11.11.1:8080/service/token rootcertbundle: /etc/registry/root.crt service: token-service ``` 上面的 `auth.token` 就指定了认证是启用 Token 模式来认证,下面是四个字段的说明
  • auth.token.issuer : token 发行人,用来在 Auth Server 识别身份
  • auth.token.realm : Auth Server 的 URI
  • auth.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.pemroot.crt

### 启动 Registry 假设上述的 Registry 的配置文件为 `registry.yaml` 也在当前目录,那么就可以以下列的方式启动 Registry 了 ```shell docker run -it --rm \ -p 5000:5000 \ -v `pwd`/root.crt:/etc/registry/root.crt:ro \ -v `pwd`/registry.yaml:/etc/docker/registry/config.yml:ro \ registry:2 ``` 这里并没有用 `-d`,主要是为了调试方便。

在启动后可以试下 docker pull 127.0.0.1:5000/nginx 这个命令,Docker 会返回 getsockopt: connection refused 的错误,因为我们的 Auth Server 还没有启动起来。

## Auth Server

当执行 pull/login/push 这三个需要认证的命令时,Auth Server 会收到一条 GET 请求,并且会有一些参数。

在上述配置中,我配置的 auth.token.realmhttp://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 的数组,在很多框架里都有实现获取同名参数,作为一个数组。

scopeNameTypeActions 组成,并且由 : 分割开来,直接 split(':') 一下就可以得到解析过后的 scope 了,一般来说,scope 会是以下的样子

{
    "scopes": [{
        "type": "repository",
        "name": "nginx",
        "actions": ["pull", "push"]
    }]
}

然后就可以根据 nameactions 来选择用户是否有权操作了,在 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 就相当于一段唯一的路由而已。

### 生成 jwt token 当权限也通过之后,就是要生成 token 告诉 Docker 一切准备就绪了,Registry 的 token 是使用 [jwt](https://jwt.io/) 生成的。上面我们生成的私钥,在这里就需要用到了,具体的 jwt 原理这里就不复述了,这里只说明生成 token 的流程。
#### Claim 首先是 jwt 的 Claim 构成,如下所示 ```python 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)), # TODO "access": self.access } ``` 其中部分字段的说明如下
  • iss 前期通过 registry.yaml 配置文件里的 issuser
  • sub 当前操作的帐号或者说用户名
  • aud 前期通过 registry.yaml 配置文件里的 service
  • expiatnbf 表示的是 token 时间相关的
  • jti 是一段随机字符串
  • access 就是上述解析过后的 scope
#### Headers 然后就是 jwt 的 Headers 部分了,关键就是 `kid` 的生成,这里的 `kid` 是通过公钥的 `DER` 编码,然后截取 `DER` 编码的 `SHA256` 编码的 digest 的前 240bits,也就是 前 30 位的字符串,然后再通过 base32 编码,并且4个4个用 `:` 分割开来,具体如下 ```python private_key = open('./private_key.pem').read() private_key = load_pem_private_key( private_key, password=None, backend=default_backend() ) public_key = private_key.public_key()

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/