refactor token service

This commit is contained in:
Tan Jiang 2017-02-26 19:53:13 +08:00
parent 01e105090e
commit 7620cd3b86
8 changed files with 472 additions and 146 deletions

View File

@ -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"
}
}

View File

@ -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:

View File

@ -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)

View File

@ -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) {

View File

@ -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": &registryFilter{},
},
}
}
// 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)
}

View File

@ -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
}

View File

@ -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)
}
}
}

View File

@ -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
}