聊聊Harbor请求registry组件API的处理过程

Harbor是什么

Harbor是由VMware中国研发团队负责开发的开源企业级Registry,可帮助用户迅速搭建企业级的Registry服务。Harbor在docker distribution的基础上增加了一些安全、访问控制、管理的功能以满足企业对于镜像仓库的需求。一些关于Harbor的介绍:
用Harbor实现容器镜像仓库的管理和运维
Harbor:开源企业级容器Registry架构简介
用Swagger调用Harbor Registry的REST API
基于Harbor和CephFS搭建高可用Private Registry
解决登录Harbor Registry时鉴权失败的问题
Harbor和reigstry组件之间的验证采用了docker registry v2的token方式,token的service服务集成在Harbor中。相关token验证出现于docker login、请求registry api等情况下。以下的讨论中,我们主要是通过请求Harbor API,触发Harbor请求registry API,以此来研究Harbor请求registry组件API的处理过程。因此,我们需要先解决调用Harbor API的身份验证问题,再进一步研究token验证原理。
注意:以下的讨论均以Harbor tags v1.1.2为准,查看源代码的时候请注意切换tags

调用Harbor API的身份验证

Harbor的API可以使用Swagger查看,具体请查看Harbor的github文档。当我们调用Harbor的API时,Harbor会从request中获取用户信息。以下以https://your-harbor-domain/api/repositories/repo_name/tags为例进行说明,该API是获取名为repo_name的镜像的所有tag。镜像的tag信息是没有存放在数据库中的,我们向Harbor服务发起该请求,Harbor需要向registry组件发送API请求。
该API会被src/ui/api/repository.goGetTags()函数所处理,当名为repo_name的镜像所属的项目为私有项目时,会检查用户的身份:

1
2
3
4
5
6
if project.Public == 0 {
userID := ra.ValidateUser()
if !checkProjectPermission(userID, project.ProjectID) {
ra.CustomAbort(http.StatusForbidden, "")
}
}

ValidateUser()函数定义于src/common/api/base.go,其调用GetUserIDForRequest()函数,最终从request中读取basic auth信息:

1
2
func (b *BaseAPI) GetUserIDForRequest() (int, bool, bool) {
username, password, ok := b.Ctx.Request.BasicAuth()

查看go源码,go/src/net/http/request.go

1
2
3
4
5
6
7
func (r *Request) BasicAuth() (username, password string, ok bool) {
auth := r.Header.Get("Authorization")
if auth == "" {
return
}
return parseBasicAuth(auth)
}

因此,可以得知Harbor支持http的basic auth方式。要在发送请求的时候添加HTTP Basic Authentication认证信息到请求中,有两种方法:
一是在请求头中添加Authorization:
Authorization: "Basic 用户名和密码的base64加密字符串"
二是在url中添加用户名和密码:
http://userName:password@your-api-url
从上面的代码片段可以看到,第二种方式并不适用Harbor,这也是postman使用第二种方式访问API不成功的原因。

token验证原理

为了更好地研究Harbor在token验证做了哪些工作,我们先看一下docker login的过程,以下内容参考自从源码看Docker Registry v2中的Token认证实现机制

docker login的过程

1、docker client接受用户的输入命令,将命令转化为调用engine-api的RegistryLogin方法;
2、在api的RegistryLogin方法中通过http调用registry中的auth方法;
3、在auth方法中由于是v2版本的,所以会调用loginV2方法;
4、在loginV2方法中会进行调用/v2/接口,该接口会对请求进行认证。此时的请求中并没有包含token信息,认证失败,会返回401错误,同时会在header中返回去哪里认证的服务器的地址;
5、registry client收到该返回结果后,便会去返回的认证服务器那里进去认证,向认证服务器发送的request的header中包含有加密的用户名和密码;
6、认证服务器从header中获取到加密的用户名和密码,这个时候就可以结合实际的认证系统进行认证;
7、认证成功后,需要生成一个token,并返回;
8、registry client会拿着返回的token再次尝试向registry server发生请求,这次由于带有token,请求验证成功,返回状态码为200;
9、docker client端接受到返回的状态码为200,说明操作成功,控制台会出现“Login Succeeded”。

Harbor所使用的是原生的docker registry v2,当我们通过Harbor的api来间接访问registry v2 API时,需要先通过Harbor的basic auth验证,之后Harbor会构造一个新请求来访问registry v2 API。Harbor构造新请求访问registry v2 API过程中涉及到的验证类似于docker login,但又有所不同,下面我们将通过追踪源码实现来阐述这一过程。

测试用例

url:https://your-harbor-domain/api/repositories/repo_name/tags
说明:获取名为repo_name的镜像的所有tag。镜像的tag信息是没有存放在数据库中的,我们向Harbor服务发起该请求,Harbor需要向registry组件发送API请求:your-registry-host:port/v2/library/alpine/tags/list

Harbor实现概述

Harbor的实现流程大概是:向registry组件发送your-registry-host:port/v2/请求,registry组件配置了token验证,因此会返回401错误,并将token service相关信息附带在header中返回。Harbor解析出token service相关信息之后,将其与token验证的相关Authorizer数据结构一起封装到AuthorizerStore中,AuthorizerStore会被封装到Transport中,Transport最终作为http.Client的transport成员实例。transport是golang http请求的承载者,可参考Go 标准库剖析 1(transport http 请求的承载者)。自定义实现的transport需要实现RoundTrip方法,Harbor封装的Transport实现了该接口,因此向registry组件发送http请求时,会调用到自定义的RoundTrip方法,进而先调用Authorizer来生成满足条件的token,追加到request header中,然后才是真正地向registry组件发送请求。
接下来我们将分为几部分进行讲述:Harbor对数据结构的封装、Harbor向registry发送ping请求、Harbor生成token。

Harbor对数据结构的封装

以下是请求https://your-harbor-domain/api/repositories/repo_name/tags的函数调用过程:
先创建repository client,然后通过repository client访问registry:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
//src/ui/api/repository.go
func (ra *RepositoryAPI) GetTags() {
client, err := ra.initRepositoryClient(repoName)
tags, err := listTag(client)

//src/ui/api/repository.go
func (ra *RepositoryAPI) initRepositoryClient(repoName string) (r *registry.Repository, err error) {
username, password, ok := ra.Ctx.Request.BasicAuth()
if ok {
return newRepositoryClient(endpoint, !verify, username, password,
repoName, "repository", repoName, "pull", "push", "*")
}

//src/ui/api/repository.go
func listTag(client *registry.Repository) ([]string, error) {
ts, err := client.ListTag()

创建repository client的过程包括创建TokenAuthorizer、AuthorizerStore、RepositoryClient:

1
2
3
4
5
6
//src/ui/api/repository.go
func newRepositoryClient(endpoint string, insecure bool, username, password, repository, scopeType, scopeName string, scopeActions ...string) (*registry.Repository, error) {
credential := auth.NewBasicAuthCredential(username, password)
authorizer := auth.NewStandardTokenAuthorizer(credential, insecure, config.InternalTokenServiceEndpoint(), scopeType, scopeName, scopeActions...)
store, err := auth.NewAuthorizerStore(endpoint, insecure, authorizer)
client, err := registry.NewRepositoryWithModifiers(repository, endpoint, insecure, store)

创建的TokenAuthorizer数据结构包括:标准go http transport、basic auth数据结构、token service服务url、scope、token生成包装函数。其中,scope是生成token所需要的数据结构之一,scope的type为”repository”,name为url的镜像名,actions为pull/push/*。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
//src/common/utils/registry/auth/tokenauthorizer.go
func NewStandardTokenAuthorizer(credential Credential, insecure bool, tokenServiceEndpoint string, scopeType, scopeName string, scopeActions ...string) Authorizer {
authorizer := &standardTokenAuthorizer{
client: &http.Client{
Transport: registry.GetHTTPTransport(insecure),
Timeout: 30 * time.Second,
},
credential: credential,
tokenServiceEndpoint: tokenServiceEndpoint,
}
authorizer.scope = &scope{
Type: scopeType,
Name: scopeName,
Actions: scopeActions,
}
authorizer.tg = authorizer.generateToken

TokenAuthorizer将被封装到AuthorizerStore中,此外,AuthorizerStore数据结构还包括:ping registry组件的url结构(由your-registry-host:port/v2/构建)、pingyour-registry-host:port/v2/之后解析出来的challenges参数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
//src/common/utils/registry/auth/authorizer.go
func NewAuthorizerStore(endpoint string, insecure bool, authorizers ...Authorizer) (*AuthorizerStore, error) {
client := &http.Client{
Transport: registry.GetHTTPTransport(insecure),
Timeout: 30 * time.Second,
}

pingURL := buildPingURL(endpoint)
resp, err := client.Get(pingURL)

challenges := ParseChallengeFromResponse(resp)
ping, err := url.Parse(pingURL)
return &AuthorizerStore{
authorizers: authorizers,
ping: ping,
challenges: challenges,
}, nil

AuthorizerStore将被封装到自定义的transport中,该transport实现了标准go http transport定义的接口,并将作为http.Client的transport成员实例。http.Client又被封装到Repository数据结构中,Repository实例被当成RepositoryClient返回:

1
2
3
4
5
6
7
8
//src/common/utils/registry/repository.go
func NewRepositoryWithModifiers(name, endpoint string, insecure bool, modifiers ...Modifier) (*Repository, error) {
transport := NewTransport(GetHTTPTransport(insecure), modifiers...)
return NewRepository(name, endpoint, &http.Client{
Transport: transport,
// for transferring large image, OS will handle i/o timeout
// Timeout: 30 * time.Second,
})

以上就是Harbor封装数据结构以实现访问registry前先构造token的过程,当调用client.ListTag()时,将会调用到自定义的transport,从而调用TokenAuthorizer的相关方法。

Harbor向registry发送ping请求

上述NewAuthorizerStore函数的过程会向registry组件发送your-registry-host:port/v2/请求,该接口会对请求进行认证,此时的请求中并没有包含token信息,认证失败,会返回401错误。
NewAuthorizerStore函数调用ParseChallengeFromResponse函数对response header进行解析,其中,challenge是docker registry v2定义的:

1
2
3
//src/common/utils/registry/auth/challenge.go
func ParseChallengeFromResponse(resp *http.Response) []challenge.Challenge {
challenges := challenge.ResponseChallenges(resp)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
//https://github.com/docker/distribution/blob/master/registry/client/auth/challenge/authchallenge.go
func ResponseChallenges(resp *http.Response) []Challenge {
if resp.StatusCode == http.StatusUnauthorized {
// Parse the WWW-Authenticate Header and store the challenges
// on this endpoint object.
return parseAuthHeader(resp.Header)
}

func parseAuthHeader(header http.Header) []Challenge {
challenges := []Challenge{}
for _, h := range header[http.CanonicalHeaderKey("WWW-Authenticate")] {
v, p := parseValueAndParams(h)
if v != "" {
challenges = append(challenges, Challenge{Scheme: v, Parameters: p})
}
}
return challenges
}

使用postman请求your-registry-host:port/v2/,查看response header:

可知,registry会告知客户端token service的url地址,以及对应的service名。

Harbor生成token

Harbor通过封装数据结构,自定义transport,实现了在请求registry API前构造token的过程。下面将详细阐述其实现过程。

1
2
3
func (r *Repository) ListTag() ([]string, error) {
req, err := http.NewRequest("GET", buildTagListURL(r.Endpoint.String(), r.Name), nil)
resp, err := r.client.Do(req)

client就是http.Client实例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
//https://github.com/golang/go/blob/release-branch.go1.7/src/net/http/client.go
func (c *Client) Do(req *Request) (*Response, error) {
method := valueOrDefault(req.Method, "GET")
if method == "GET" || method == "HEAD" {
return c.doFollowingRedirects(req, shouldRedirectGet)
}

func (c *Client) doFollowingRedirects(req *Request, shouldRedirect func(int) bool) (*Response, error) {
if resp, err = c.send(req, deadline); err != nil {

func (c *Client) send(req *Request, deadline time.Time) (*Response, error) {
resp, err := send(req, c.transport(), deadline)

func send(ireq *Request, rt RoundTripper, deadline time.Time) (*Response, error) {
resp, err := rt.RoundTrip(req)

可以看出,http.Client.transport()方法获取的实例是RoundTripper接口类型,该接口定义如下:

1
2
3
4
//https://github.com/golang/go/blob/release-branch.go1.7/src/net/http/client.go
type RoundTripper interface {
RoundTrip(*Request) (*Response, error)
}

Harbor自定义的transport正因为实现了该接口,所以在执行上述Do方法时可以执行自定义的RoundTrip函数:

1
2
3
4
5
6
7
8
//src/common/utils/registry/transport.go
func (t *Transport) RoundTrip(req *http.Request) (*http.Response, error) {
for _, modifier := range t.modifiers {
if err := modifier.Modify(req); err != nil {
return nil, err
}
}
resp, err := t.transport.RoundTrip(req)

modifier即上面提到的AuthorizerStore,Harbor调用了Modify方法之后才会调用标准go http transport的RoundTrip方法来发送请求。我们看一下Modify方法的定义:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
//src/common/utils/registry/auth/authorizer.go
func (a *AuthorizerStore) Modify(req *http.Request) error {
//only handle the requests sent to registry
v2Index := strings.Index(req.URL.Path, "/v2/")
if v2Index == -1 {
return nil
}

for _, challenge := range a.challenges {
for _, authorizer := range a.authorizers {
if authorizer.Scheme() == challenge.Scheme {
if err := authorizer.Authorize(req, challenge.Parameters); err != nil {
return err
}
}
}
}

可以看出,封装到AuthorizerStore的TokenAuthorizer将被调用,我们看一下Authorize方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
//src/common/utils/registry/auth/tokenauthorizer.go
func (t *tokenAuthorizer) Authorize(req *http.Request, params map[string]string) error {
var scopes []*scope
var token string

if t.scope != nil {
scopes = append(scopes, t.scope)
}

scopeStrs := []string{}
for _, scope := range scopes {
scopeStrs = append(scopeStrs, scope.string())
}
to, expiresIn, _, err := t.tg(params["realm"], params["service"], scopeStrs)
if err != nil {
return err
}
token = to

req.Header.Add(http.CanonicalHeaderKey("Authorization"), fmt.Sprintf("Bearer %s", token))

Authorize方法所做的工作就是处理一下scope,然后调用tg函数(其实是个函数指针)生成token,并将生成的token写入到request header中。还记得上面提到的scope吗,它将被转换为字符串后作为参数:

1
2
3
//src/common/utils/registry/auth/tokenauthorizer.go
func (s *scope) string() string {
return fmt.Sprintf("%s:%s:%s", s.Type, s.Name, strings.Join(s.Actions, ","))

上面的方法中,tg是一个函数指针,其指向在封装TokenAuthorizer的时候就被指定了,指向了tokenAuthorizer.generateToken函数,很明显,该函数就是token生成的具体实现,我们查看一下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
//src/common/utils/registry/auth/tokenauthorizer.go
func (u *usernameTokenAuthorizer) generateToken(realm, service string, scopes []string) (token string, expiresIn int, issuedAt *time.Time, err error) {
token, expiresIn, issuedAt, err = token_util.RegistryTokenForUI(u.username, service, scopes)

//src/ui/service/token/authutils.go
func RegistryTokenForUI(username string, service string, scopes []string) (string, int, *time.Time, error) {
return genTokenForUI(username, service, scopes, registryFilterMap)

//src/ui/service/token/authutils.go
func genTokenForUI(username string, service string, scopes []string, filters map[string]accessFilter) (string, int, *time.Time, error) {
isAdmin, err := dao.IsAdminRole(username)
if err != nil {
return "", 0, nil, err
}
access := GetResourceActions(scopes)
err = filterAccess(access, u, filters)
if err != nil {
return "", 0, nil, err
}
return MakeRawToken(username, service, access)

//src/ui/service/token/authutils.go
func MakeRawToken(username, service string, access []*token.ResourceActions) (token string, expiresIn int, issuedAt *time.Time, err error) {
pk, err := libtrust.LoadKeyFile(privateKey)
expiration, err := config.TokenExpiration()
tk, expiresIn, issuedAt, err := makeTokenCore(issuer, username, service, expiration, access, pk)
rs := fmt.Sprintf("%s.%s", tk.Raw, base64UrlEncode(tk.Signature))
return rs, expiresIn, issuedAt, nil

上述genTokenForUI会对用户身份进行检查,只有是管理员角色才能生成token,此外还有其他的请求过滤检查,这里没有贴出代码。makeTokenCore函数是生成token的关键函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
//src/ui/service/token/authutils.go
func makeTokenCore(issuer, subject, audience string, expiration int,
access []*token.ResourceActions, signingKey libtrust.PrivateKey) (t *token.Token, expiresIn int, issuedAt *time.Time, err error) {

joseHeader := &token.Header{
Type: "JWT",
SigningAlg: "RS256",
KeyID: signingKey.KeyID(),
}

jwtID, err := randString(16)

now := time.Now().UTC()
issuedAt = &now
expiresIn = expiration * 60

claimSet := &token.ClaimSet{
Issuer: issuer,
Subject: subject,
Audience: audience,
Expiration: now.Add(time.Duration(expiration) * time.Minute).Unix(),
NotBefore: now.Unix(),
IssuedAt: now.Unix(),
JWTID: jwtID,
Access: access,
}

var joseHeaderBytes, claimSetBytes []byte

if joseHeaderBytes, err = json.Marshal(joseHeader); err != nil {
return nil, 0, nil, fmt.Errorf("unable to marshal jose header: %s", err)
}
if claimSetBytes, err = json.Marshal(claimSet); err != nil {
return nil, 0, nil, fmt.Errorf("unable to marshal claim set: %s", err)
}

encodedJoseHeader := base64UrlEncode(joseHeaderBytes)
encodedClaimSet := base64UrlEncode(claimSetBytes)
payload := fmt.Sprintf("%s.%s", encodedJoseHeader, encodedClaimSet)

var signatureBytes []byte
if signatureBytes, _, err = signingKey.Sign(strings.NewReader(payload), crypto.SHA256); err != nil {
return nil, 0, nil, fmt.Errorf("unable to sign jwt payload: %s", err)
}

signature := base64UrlEncode(signatureBytes)
tokenString := fmt.Sprintf("%s.%s", payload, signature)
t, err = token.NewToken(tokenString)
return
}

嗯,是的,其实Harbor也没有生成token的算法过程,docker registry定义了token的格式,并提供了配置认证服务器的地址。因此只要按照定义的格式来准备数据,就可以调用registry的相关函数来生成token:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
//https://github.com/docker/distribution/blob/master/registry/auth/token/token.go
func NewToken(rawToken string) (*Token, error) {
parts := strings.Split(rawToken, TokenSeparator)
if len(parts) != 3 {
return nil, ErrMalformedToken
}

var (
rawHeader, rawClaims = parts[0], parts[1]
headerJSON, claimsJSON []byte
err error
)

if headerJSON, err = joseBase64UrlDecode(rawHeader); err != nil {
return nil, ErrMalformedToken
}

if claimsJSON, err = joseBase64UrlDecode(rawClaims); err != nil {
return nil, ErrMalformedToken
}

token := new(Token)
token.Header = new(Header)
token.Claims = new(ClaimSet)

token.Raw = strings.Join(parts[:2], TokenSeparator)
if token.Signature, err = joseBase64UrlDecode(parts[2]); err != nil {
return nil, ErrMalformedToken
}

if err = json.Unmarshal(headerJSON, token.Header); err != nil {
return nil, ErrMalformedToken
}

if err = json.Unmarshal(claimsJSON, token.Claims); err != nil {
return nil, ErrMalformedToken
}

return token, nil
}

从代码中,我们可以发现,token是通过JWT(JSON Web Token)来实现的,主要部分是joseHeader和claimSet。joseHeader和claimSet结构体数据转换成字符串之后,进行连合,构成payload字符串,payload再由私钥进行加密签名,得到签名字符串。最后,payload和签名字符串进行连合,传递给token.NewToken函数来生成token。
在Harbor中,joseHeader描述了token的类型以及使用的hash算法,除此之外还加入了KeyID这一认证相关的信息,KeyID是采用github.com/docker/libtrust根据公钥生成。
ClaimSet包含了进行认证的必要信息,具体包括:

1
2
3
4
5
6
Issuer:代表了发起请求的实体,是一个大小写敏感的字符串。Harbor中该值为"harbor-token-issuer"。
Subject:代表的是JWT的主题,该字段要求在上下文中或者是全局唯一,可以放入用户帐号;Harbor中该值为用户帐号。
Audience:代表的是JWT希望的接受者;Harbor中该值为registry配置的service名,即"harbor-registry"。
Expiration Time:代表的是token的过期时间;Harbor中该值默认为30分钟。
NotBefore:代表的是JWT不能早于该时间来处理;
IssuedAt:代表的是JWT的签发时间。

docker registry在此基础上还增加2个字段:

1
2
– JWTID: 这是一个基于base64加密的随机长度的字符串;
– Access:代表了访问和操作权限,Harbor中该值根据scope字符串转换为对应数据结构得到。

有了这些信息,就可以生成满足要求的token,进而附加到request header中,成功请求registey v2 API。token service会使用私钥进行签名,registry则会使用公钥进行验证,因此,Harbor的ui和registry需要使用配对的公私钥。
注:token认证实现可以参考从源码看Docker Registry v2中的Token认证实现机制

总结

以上就是Harbor请求registry组件API大概的处理过程。由此可以看出,在Harbor请求registry API的过程中,ping请求your-registry-host:port/v2/所得到的service url其实在这个过程中并没有起到作用,但这个service url在docker login的时候则会起到作用。因此,我们最好是对比docker login的过程来理解API请求的过程,方能理解得更深刻。
最后的一点提醒,以上源代码皆是代码片段,详情请查看github Harbor和registry V2源码。以Harbor tags v1.1.2为准,查看源代码的时候请注意切换tags,谨记。

显示 Gitment 评论