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

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 了

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 生成的。上面我们生成的私钥,在这里就需要用到了,具体的 jwt 原理这里就不复述了,这里只说明生成 token 的流程。

Claim

首先是 jwt 的 Claim 构成,如下所示

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个用 : 分割开来,具体如下

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

token

拿到 kidclaim 后就可以来生成 token 了

token = jwt.encode(claim, private_key, algorithm='RS256', headers=headers)  

响应 Docker 客户端

然后将 token 返回给 Docker 即可,注意设置 Content-Typeapplication/json

具体实现代码

下面是使用 Python 的 Flask 实现的

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)

参考