diff --git a/make/common/templates/notary/server-config.json b/make/common/templates/notary/server-config.json index 2ae921725..5cbd42a87 100644 --- a/make/common/templates/notary/server-config.json +++ b/make/common/templates/notary/server-config.json @@ -20,8 +20,8 @@ "type": "token", "options": { "realm": "$token_endpoint/service/token", - "service": "token-service", - "issuer": "registry-token-issuer", + "service": "harbor-registry", + "issuer": "harbor-token-issuer", "rootcertbundle": "/config/root.crt" } } diff --git a/make/common/templates/registry/config.yml b/make/common/templates/registry/config.yml index faabe10cd..9049c6fa9 100644 --- a/make/common/templates/registry/config.yml +++ b/make/common/templates/registry/config.yml @@ -20,10 +20,10 @@ http: addr: localhost:5001 auth: token: - issuer: registry-token-issuer + issuer: harbor-token-issuer realm: $ui_url/service/token rootcertbundle: /etc/registry/root.crt - service: token-service + service: harbor-registry notifications: endpoints: diff --git a/src/ui/main.go b/src/ui/main.go index 14770c41b..a793b539b 100644 --- a/src/ui/main.go +++ b/src/ui/main.go @@ -31,6 +31,7 @@ import ( _ "github.com/vmware/harbor/src/ui/auth/db" _ "github.com/vmware/harbor/src/ui/auth/ldap" "github.com/vmware/harbor/src/ui/config" + "github.com/vmware/harbor/src/ui/service/token" ) const ( @@ -78,7 +79,7 @@ func main() { log.Fatalf("failed to initialize configurations: %v", err) } log.Info("configurations initialization completed") - + token.InitCreators() database, err := config.Database() if err != nil { log.Fatalf("failed to get database configuration: %v", err) diff --git a/src/ui/service/token/authutils.go b/src/ui/service/token/authutils.go index 8f577dfa3..c2d67a265 100644 --- a/src/ui/service/token/authutils.go +++ b/src/ui/service/token/authutils.go @@ -33,7 +33,7 @@ import ( ) const ( - issuer = "registry-token-issuer" + issuer = "harbor-token-issuer" ) var privateKey string @@ -74,84 +74,31 @@ func GetResourceActions(scopes []string) []*token.ResourceActions { return res } -// FilterAccess modify the action list in access based on permission -func FilterAccess(username string, a *token.ResourceActions) { - - if a.Type == "registry" && a.Name == "catalog" { - log.Infof("current access, type: %s, name:%s, actions:%v \n", a.Type, a.Name, a.Actions) - return - } - - //clear action list to assign to new acess element after perm check. - a.Actions = []string{} - if a.Type == "repository" { - repoSplit := strings.Split(a.Name, "/") - repoLength := len(repoSplit) - if repoLength > 1 { //Only check the permission when the requested image has a namespace, i.e. project - var projectName string - registryURL, err := config.ExtEndpoint() - if err != nil { - log.Errorf("failed to get domain name: %v", err) - return - } - registryURL = strings.Split(registryURL, "://")[1] - if repoSplit[0] == registryURL { - projectName = repoSplit[1] - log.Infof("Detected Registry URL in Project Name. Assuming this is a notary request and setting Project Name as %s\n", projectName) - } else { - projectName = repoSplit[0] - } - var permission string - if len(username) > 0 { - isAdmin, err := dao.IsAdminRole(username) - if err != nil { - log.Errorf("Error occurred in IsAdminRole: %v", err) - } - if isAdmin { - exist, err := dao.ProjectExists(projectName) - if err != nil { - log.Errorf("Error occurred in CheckExistProject: %v", err) - return - } - if exist { - permission = "RWM" - } else { - permission = "" - log.Infof("project %s does not exist, set empty permission for admin\n", projectName) - } - } else { - permission, err = dao.GetPermission(username, projectName) - if err != nil { - log.Errorf("Error occurred in GetPermission: %v", err) - return - } - } - } - if strings.Contains(permission, "W") { - a.Actions = append(a.Actions, "push") - } - if strings.Contains(permission, "M") { - a.Actions = append(a.Actions, "*") - } - if strings.Contains(permission, "R") || dao.IsProjectPublic(projectName) { - a.Actions = append(a.Actions, "pull") - } - } - } - log.Infof("current access, type: %s, name:%s, actions:%v \n", a.Type, a.Name, a.Actions) -} - // GenTokenForUI is for the UI process to call, so it won't establish a https connection from UI to proxy. -func GenTokenForUI(username string, service string, scopes []string) (token string, expiresIn int, issuedAt *time.Time, err error) { +func GenTokenForUI(username string, service string, scopes []string) (string, int, *time.Time, error) { + isAdmin, err := dao.IsAdminRole(username) + if err != nil { + return "", 0, nil, err + } + f := &repositoryFilter{ + parser: &basicParser{}, + } + u := userInfo{ + name: username, + allPerm: isAdmin, + } access := GetResourceActions(scopes) for _, a := range access { - FilterAccess(username, a) + err = f.filter(u, a) + if err != nil { + return "", 0, nil, err + } } - return MakeToken(username, service, access) + return MakeRawToken(username, service, access) } -// MakeToken makes a valid jwt token based on parms. -func MakeToken(username, service string, access []*token.ResourceActions) (token string, expiresIn int, issuedAt *time.Time, err error) { +// MakeRawToken makes a valid jwt token based on parms. +func MakeRawToken(username, service string, access []*token.ResourceActions) (token string, expiresIn int, issuedAt *time.Time, err error) { pk, err := libtrust.LoadKeyFile(privateKey) if err != nil { return "", 0, nil, err @@ -169,6 +116,36 @@ func MakeToken(username, service string, access []*token.ResourceActions) (token return rs, expiresIn, issuedAt, nil } +// TokenJSON represents the json to be returned to docker/notary client +type TokenJSON struct { + Token string `json: token` + ExpiresIn int `json: expires_in` + issuedAt string `json: issued_at` +} + +// MakeToken returns a json that can be consumed by docker/notary client +func MakeToken(username, service string, access []*token.ResourceActions) (*TokenJSON, error) { + raw, expires, issued, err := MakeRawToken(username, service, access) + if err != nil { + return nil, err + } + return &TokenJSON{raw, expires, issued.Format(time.RFC3339)}, nil +} + +func permToActions(p string) []string { + res := []string{} + if strings.Contains(p, "W") { + res = append(res, "push") + } + if strings.Contains(p, "M") { + res = append(res, "*") + } + if strings.Contains(p, "R") { + res = append(res, "pull") + } + return res +} + //make token core func makeTokenCore(issuer, subject, audience string, expiration int, access []*token.ResourceActions, signingKey libtrust.PrivateKey) (t *token.Token, expiresIn int, issuedAt *time.Time, err error) { diff --git a/src/ui/service/token/creator.go b/src/ui/service/token/creator.go new file mode 100644 index 000000000..1b2c8343f --- /dev/null +++ b/src/ui/service/token/creator.go @@ -0,0 +1,238 @@ +/* + Copyright (c) 2016 VMware, Inc. All Rights Reserved. + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +package token + +import ( + "fmt" + "github.com/docker/distribution/registry/auth/token" + "github.com/vmware/harbor/src/common/dao" + "github.com/vmware/harbor/src/common/utils/log" + "github.com/vmware/harbor/src/ui/config" + "net/http" + "strings" +) + +var creatorMap map[string]TokenCreator + +const ( + notary = "harbor-notary" + registry = "harbor-registry" +) + +//InitCreators initialize the token creators for different services +func InitCreators() { + creatorMap = make(map[string]TokenCreator) + ext, err := config.ExtEndpoint() + if err != nil { + log.Warningf("Failed to get ext enpoint, err: %v, the token service will not be functional with notary requests", err) + } else { + creatorMap[notary] = &generalTokenCreator{ + validators: []ReqValidator{ + &basicAuthValidator{}, + }, + service: notary, + filterMap: map[string]accessFilter{ + "repository": &repositoryFilter{ + parser: &endpointParser{ + endpoint: strings.Split(ext, "//")[1], + }, + }, + }, + } + } + + creatorMap[registry] = &generalTokenCreator{ + validators: []ReqValidator{ + &secretValidator{config.JobserviceSecret()}, + &basicAuthValidator{}, + }, + service: registry, + filterMap: map[string]accessFilter{ + "repository": &repositoryFilter{ + //Workaround, had to use same service for both notary and registry + parser: &endpointParser{ + endpoint: ext, + }, + }, + "registry": ®istryFilter{}, + }, + } +} + +// TokenCreator creates a token ready to be served based on the http request. +type TokenCreator interface { + create(r *http.Request) (*TokenJSON, error) +} + +type imageParser interface { + parse(s string) (*image, error) +} + +type image struct { + namespace string + repo string + tag string +} + +type basicParser struct{} + +func (b basicParser) parse(s string) (*image, error) { + return parseImg(s) +} + +type endpointParser struct { + endpoint string +} + +func (e endpointParser) parse(s string) (*image, error) { + repo := strings.SplitN(s, "/", 2) + if len(repo) < 2 { + return nil, fmt.Errorf("Unable to parse image from string: %s", s) + } + //Workaround, need to use endpoint Parser to handle both cases. + if strings.ContainsRune(repo[0], '.') { + if repo[0] != e.endpoint { + return nil, fmt.Errorf("Mismatch endpoint from string: %s, expected endpoint: %s", s, e.endpoint) + } + return parseImg(repo[1]) + } else { + return parseImg(s) + } +} + +//build Image accepts a string like library/ubuntu:14.04 and build a image struct +func parseImg(s string) (*image, error) { + repo := strings.SplitN(s, "/", 2) + if len(repo) < 2 { + return nil, fmt.Errorf("Unable to parse image from string: %s", s) + } + i := strings.SplitN(repo[1], ":", 2) + res := &image{ + namespace: repo[0], + repo: i[0], + } + if len(i) == 2 { + res.tag = i[1] + } + return res, nil +} + +// An accessFilter will filter access based on userinfo +type accessFilter interface { + filter(user userInfo, a *token.ResourceActions) error +} + +type registryFilter struct { +} + +func (reg registryFilter) filter(user userInfo, a *token.ResourceActions) error { + //Do not filter if the request is to access registry catalog + if a.Name != "catalog" { + return fmt.Errorf("Unable to handle, type: %s, name: %s", a.Type, a.Name) + } + return nil +} + +//repositoryFilter filters the access based on Harbor's permission model +type repositoryFilter struct { + parser imageParser +} + +func (rep repositoryFilter) filter(user userInfo, a *token.ResourceActions) error { + //clear action list to assign to new acess element after perm check. + a.Actions = []string{} + img, err := rep.parser.parse(a.Name) + if err != nil { + return err + } + project := img.namespace + permission := "" + if user.allPerm { + exist, err := dao.ProjectExists(project) + if err != nil { + log.Errorf("Error occurred in CheckExistProject: %v", err) + //just leave empty permission + return nil + } + if exist { + permission = "RWM" + } else { + log.Infof("project %s does not exist, set empty permission for admin\n", project) + } + } else { + permission, err = dao.GetPermission(user.name, project) + if err != nil { + log.Errorf("Error occurred in GetPermission: %v", err) + //just leave empty permission + return nil + } + if dao.IsProjectPublic(project) { + permission += "R" + } + } + a.Actions = permToActions(permission) + return nil +} + +type generalTokenCreator struct { + validators []ReqValidator + service string + filterMap map[string]accessFilter +} + +type unauthorizedError struct{} + +func (e *unauthorizedError) Error() string { + return "Unauthorized" +} + +func (g generalTokenCreator) create(r *http.Request) (*TokenJSON, error) { + var user *userInfo + var err error + var scopes []string + scopeParm := r.URL.Query()["scope"] + if len(scopeParm) > 0 { + scopes = strings.Split(r.URL.Query()["scope"][0], " ") + } + log.Debugf("scopes: %v", scopes) + for _, v := range g.validators { + user, err = v.validate(r) + if user != nil { + break + } + if err != nil { + return nil, err + } + } + if user == nil { + if len(scopes) == 0 { + return nil, &unauthorizedError{} + } + user = &userInfo{} + } + access := GetResourceActions(scopes) + for _, a := range access { + f, ok := g.filterMap[a.Type] + if !ok { + log.Warningf("No filter found for access type: %s, skip.", a.Type) + } + err = f.filter(*user, a) + if err != nil { + return nil, err + } + } + return MakeToken(user.name, g.service, access) +} diff --git a/src/ui/service/token/token.go b/src/ui/service/token/token.go index b144e322a..a5c564929 100644 --- a/src/ui/service/token/token.go +++ b/src/ui/service/token/token.go @@ -16,17 +16,11 @@ package token import ( + "fmt" "net/http" - "time" - - "github.com/vmware/harbor/src/common/models" - "github.com/vmware/harbor/src/common/utils/log" - "github.com/vmware/harbor/src/ui/auth" - "github.com/vmware/harbor/src/ui/config" - svc_utils "github.com/vmware/harbor/src/ui/service/utils" "github.com/astaxie/beego" - "github.com/docker/distribution/registry/auth/token" + "github.com/vmware/harbor/src/common/utils/log" ) // Handler handles request on /service/token, which is the auth provider for registry. @@ -37,63 +31,26 @@ type Handler struct { // Get handles GET request, it checks the http header for user credentials // and parse service and scope based on docker registry v2 standard, // checkes the permission agains local DB and generates jwt token. + func (h *Handler) Get() { - - var uid, password, username string request := h.Ctx.Request + log.Debugf("URL for token request: %s", request.URL.String()) service := h.GetString("service") - scopes := h.GetStrings("scope") - access := GetResourceActions(scopes) - log.Infof("request url: %v", request.URL.String()) - - if svc_utils.VerifySecret(request, config.JobserviceSecret()) { - log.Debugf("Will grant all access as this request is from job service with legal secret.") - username = "job-service-user" - } else { - uid, password, _ = request.BasicAuth() - log.Debugf("uid for logging: %s", uid) - user := authenticate(uid, password) - if user == nil { - log.Warningf("login request with invalid credentials in token service, uid: %s", uid) - if len(scopes) == 0 { - h.CustomAbort(http.StatusUnauthorized, "") - } - } else { - username = user.Username - } - log.Debugf("username for filtering access: %s.", username) - for _, a := range access { - FilterAccess(username, a) - } + tokenCreator, ok := creatorMap[service] + if !ok { + errMsg := fmt.Sprintf("Unable to handle service: %s", service) + log.Errorf(errMsg) + h.CustomAbort(http.StatusBadRequest, errMsg) } - h.serveToken(username, service, access) -} - -func (h *Handler) serveToken(username, service string, access []*token.ResourceActions) { - writer := h.Ctx.ResponseWriter - //create token - rawToken, expiresIn, issuedAt, err := MakeToken(username, service, access) + token, err := tokenCreator.create(request) if err != nil { - log.Errorf("Failed to make token, error: %v", err) - writer.WriteHeader(http.StatusInternalServerError) - return + if _, ok := err.(*unauthorizedError); ok { + h.CustomAbort(http.StatusUnauthorized, "") + } + log.Errorf("Unexpected error when creating the token, error: %v", err) + h.CustomAbort(http.StatusInternalServerError, "") } - tk := make(map[string]interface{}) - tk["token"] = rawToken - tk["expires_in"] = expiresIn - tk["issued_at"] = issuedAt.Format(time.RFC3339) - h.Data["json"] = tk + h.Data["json"] = token h.ServeJSON() -} -func authenticate(principal, password string) *models.User { - user, err := auth.Login(models.AuthModel{ - Principal: principal, - Password: password, - }) - if err != nil { - log.Errorf("Error occurred in UserLogin: %v", err) - return nil - } - return user } diff --git a/src/ui/service/token/token_test.go b/src/ui/service/token/token_test.go index 499fb56f2..12cfcd4bf 100644 --- a/src/ui/service/token/token_test.go +++ b/src/ui/service/token/token_test.go @@ -9,21 +9,28 @@ import ( "crypto/x509" "encoding/pem" "fmt" + "github.com/vmware/harbor/src/common/utils/test" + "github.com/vmware/harbor/src/ui/config" "io/ioutil" "os" "path" "runtime" "testing" - - "github.com/vmware/harbor/src/common/utils/log" - "github.com/vmware/harbor/src/ui/config" ) func TestMain(m *testing.M) { - if err := config.Init(); err != nil { - log.Fatalf("failed to initialize configurations: %v", err) + server, err := test.NewAdminserver(nil) + if err != nil { + panic(err) } + defer server.Close() + if err := os.Setenv("ADMIN_SERVER_URL", server.URL); err != nil { + panic(err) + } + if err := config.Init(); err != nil { + panic(err) + } result := m.Run() if result != 0 { os.Exit(result) @@ -87,10 +94,11 @@ func TestMakeToken(t *testing.T) { }} svc := "harbor-registry" u := "tester" - tokenString, _, _, err := MakeToken(u, svc, ra) + tokenJSON, err := MakeToken(u, svc, ra) if err != nil { t.Errorf("Error while making token: %v", err) } + tokenString := tokenJSON.Token //t.Logf("privatekey: %s, crt: %s", tokenString, crt) pubKey, err := getPublicKey(crt) if err != nil { @@ -102,13 +110,74 @@ func TestMakeToken(t *testing.T) { } return pubKey, nil }) - t.Logf("validity: %v", tok.Valid) + t.Logf("Token validity: %v", tok.Valid) if err != nil { t.Errorf("Error while parsing the token: %v", err) } claims := tok.Claims.(*harborClaims) - t.Logf("claims: %+v", *claims) assert.Equal(t, *(claims.Access[0]), *(ra[0]), "Access mismatch") assert.Equal(t, claims.Audience, svc, "Audience mismatch") - +} + +func TestPermToActions(t *testing.T) { + perm1 := "RWM" + perm2 := "MRR" + perm3 := "" + expect1 := []string{"push", "*", "pull"} + expect2 := []string{"*", "pull"} + expect3 := []string{} + res1 := permToActions(perm1) + res2 := permToActions(perm2) + res3 := permToActions(perm3) + assert.Equal(t, res1, expect1, fmt.Sprintf("actions mismatch for permission: %s", perm1)) + assert.Equal(t, res2, expect2, fmt.Sprintf("actions mismatch for permission: %s", perm2)) + assert.Equal(t, res3, expect3, fmt.Sprintf("actions mismatch for permission: %s", perm3)) +} + +type parserTestRec struct { + input string + expect image + expectError bool +} + +func TestBasicParser(t *testing.T) { + testList := []parserTestRec{parserTestRec{"library/ubuntu:14.04", image{"library", "ubuntu", "14.04"}, false}, + parserTestRec{"test/hello", image{"test", "hello", ""}, false}, + parserTestRec{"myimage:14.04", image{}, true}, + parserTestRec{"org/team/img", image{"org", "team/img", ""}, false}, + } + + p := &basicParser{} + for _, rec := range testList { + r, err := p.parse(rec.input) + if rec.expectError { + assert.Error(t, err, "Expected error for input: %s", rec.input) + } else { + assert.Nil(t, err, "Expected no error for input: %s", rec.input) + assert.Equal(t, rec.expect, *r, "result mismatch for input: %s", rec.input) + } + } +} + +func TestEndpointParser(t *testing.T) { + p := &endpointParser{ + "10.117.4.142:5000", + } + testList := []parserTestRec{parserTestRec{"10.117.4.142:5000/library/ubuntu:14.04", image{"library", "ubuntu", "14.04"}, false}, + parserTestRec{"myimage:14.04", image{}, true}, + parserTestRec{"10.117.4.142:80/library/myimage:14.04", image{}, true}, + //Test the temp workaround + parserTestRec{"library/myimage:14.04", image{"library", "myimage", "14.04"}, false}, + parserTestRec{"10.117.4.142:5000/myimage:14.04", image{}, true}, + parserTestRec{"10.117.4.142:5000/org/team/img", image{"org", "team/img", ""}, false}, + } + for _, rec := range testList { + r, err := p.parse(rec.input) + if rec.expectError { + assert.Error(t, err, "Expected error for input: %s", rec.input) + } else { + assert.Nil(t, err, "Expected no error for input: %s", rec.input) + assert.Equal(t, rec.expect, *r, "result mismatch for input: %s", rec.input) + } + } } diff --git a/src/ui/service/token/validator.go b/src/ui/service/token/validator.go new file mode 100644 index 000000000..3eb001bdc --- /dev/null +++ b/src/ui/service/token/validator.go @@ -0,0 +1,84 @@ +/* + Copyright (c) 2016 VMware, Inc. All Rights Reserved. + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +package token + +import ( + "github.com/vmware/harbor/src/common/dao" + "github.com/vmware/harbor/src/common/models" + "github.com/vmware/harbor/src/common/utils/log" + "github.com/vmware/harbor/src/ui/auth" + svc_utils "github.com/vmware/harbor/src/ui/service/utils" + "net/http" +) + +//For filtering permission by token creators. +type userInfo struct { + name string + allPerm bool +} + +//Validate request based on different rules and returns userInfo +type ReqValidator interface { + validate(req *http.Request) (*userInfo, error) +} + +type secretValidator struct { + secret string +} + +var jobServiceUserInfo userInfo + +func init() { + jobServiceUserInfo = userInfo{ + name: "job-service-user", + allPerm: true, + } +} + +func (sv secretValidator) validate(r *http.Request) (*userInfo, error) { + if svc_utils.VerifySecret(r, sv.secret) { + return &jobServiceUserInfo, nil + } + return nil, nil +} + +type basicAuthValidator struct { +} + +func (ba basicAuthValidator) validate(r *http.Request) (*userInfo, error) { + uid, password, _ := r.BasicAuth() + user, err := auth.Login(models.AuthModel{ + Principal: uid, + Password: password, + }) + if err != nil { + log.Errorf("Error occurred in UserLogin: %v", err) + return nil, err + } + if user == nil { + log.Warningf("Invalid credentials for uid: %s", uid) + return nil, nil + } + isAdmin, err := dao.IsAdminRole(user.UserID) + if err != nil { + log.Errorf("Error occurred in IsAdminRole: %v", err) + } + info := &userInfo{ + name: user.Username, + allPerm: isAdmin, + } + return info, nil +}