mirror of
https://github.com/goharbor/harbor
synced 2025-04-16 18:42:23 +00:00
Merge pull request #11406 from reasonerjt/reenable-token-auth-for-cli-new
Reenable token auth for cli
This commit is contained in:
commit
db10720e80
115
src/common/security/v2token/context.go
Normal file
115
src/common/security/v2token/context.go
Normal file
|
@ -0,0 +1,115 @@
|
|||
package v2token
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
registry_token "github.com/docker/distribution/registry/auth/token"
|
||||
"github.com/goharbor/harbor/src/common/models"
|
||||
"github.com/goharbor/harbor/src/common/rbac"
|
||||
"github.com/goharbor/harbor/src/common/security"
|
||||
"github.com/goharbor/harbor/src/lib/log"
|
||||
"github.com/goharbor/harbor/src/pkg/permission/types"
|
||||
"github.com/goharbor/harbor/src/pkg/project"
|
||||
)
|
||||
|
||||
// tokenSecurityCtx is used for check permission of an internal signed token.
|
||||
// The intention for this guy is only for support CLI push/pull. It should not be used in other scenario without careful review
|
||||
// Each request should have a different instance of tokenSecurityCtx
|
||||
type tokenSecurityCtx struct {
|
||||
logger *log.Logger
|
||||
name string
|
||||
accessMap map[string]map[types.Action]struct{}
|
||||
pm project.Manager
|
||||
}
|
||||
|
||||
func (t *tokenSecurityCtx) Name() string {
|
||||
return "v2token"
|
||||
}
|
||||
|
||||
func (t *tokenSecurityCtx) IsAuthenticated() bool {
|
||||
return len(t.name) > 0
|
||||
}
|
||||
|
||||
func (t *tokenSecurityCtx) GetUsername() string {
|
||||
return t.name
|
||||
}
|
||||
|
||||
func (t *tokenSecurityCtx) IsSysAdmin() bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func (t *tokenSecurityCtx) IsSolutionUser() bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func (t *tokenSecurityCtx) GetMyProjects() ([]*models.Project, error) {
|
||||
return []*models.Project{}, nil
|
||||
}
|
||||
|
||||
func (t *tokenSecurityCtx) GetProjectRoles(projectIDOrName interface{}) []int {
|
||||
return []int{}
|
||||
}
|
||||
|
||||
func (t *tokenSecurityCtx) Can(action types.Action, resource types.Resource) bool {
|
||||
if !strings.HasSuffix(resource.String(), rbac.ResourceRepository.String()) {
|
||||
return false
|
||||
}
|
||||
ns, ok := rbac.ProjectNamespaceParse(resource)
|
||||
if !ok {
|
||||
t.logger.Warningf("Failed to get namespace from resource: %s", resource)
|
||||
return false
|
||||
}
|
||||
pid, ok := ns.Identity().(int64)
|
||||
if !ok {
|
||||
t.logger.Warningf("Failed to get project id from namespace: %s", ns)
|
||||
return false
|
||||
}
|
||||
p, err := t.pm.Get(pid)
|
||||
if err != nil {
|
||||
t.logger.Warningf("Failed to get project, id: %d, error: %v", pid, err)
|
||||
return false
|
||||
}
|
||||
actions, ok := t.accessMap[p.Name]
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
_, hasAction := actions[action]
|
||||
return hasAction
|
||||
}
|
||||
|
||||
// New creates instance of token security context based on access list and name
|
||||
func New(ctx context.Context, name string, access []*registry_token.ResourceActions) security.Context {
|
||||
logger := log.G(ctx)
|
||||
m := make(map[string]map[types.Action]struct{})
|
||||
for _, ac := range access {
|
||||
if ac.Type != "repository" {
|
||||
logger.Debugf("dropped unsupported type '%s' in token", ac.Type)
|
||||
continue
|
||||
}
|
||||
l := strings.Split(ac.Name, "/")
|
||||
if len(l) < 1 {
|
||||
logger.Debugf("Unable to get project name from resource %s, drop the access", ac.Name)
|
||||
continue
|
||||
}
|
||||
actionMap := make(map[types.Action]struct{})
|
||||
for _, a := range ac.Actions {
|
||||
if a == "pull" || a == "*" {
|
||||
actionMap[rbac.ActionPull] = struct{}{}
|
||||
}
|
||||
if a == "push" || a == "*" {
|
||||
actionMap[rbac.ActionPush] = struct{}{}
|
||||
}
|
||||
if a == "scanner-pull" {
|
||||
actionMap[rbac.ActionScannerPull] = struct{}{}
|
||||
}
|
||||
}
|
||||
m[l[0]] = actionMap
|
||||
}
|
||||
return &tokenSecurityCtx{
|
||||
logger: logger,
|
||||
name: name,
|
||||
accessMap: m,
|
||||
pm: project.New(),
|
||||
}
|
||||
}
|
97
src/common/security/v2token/context_test.go
Normal file
97
src/common/security/v2token/context_test.go
Normal file
|
@ -0,0 +1,97 @@
|
|||
package v2token
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/docker/distribution/registry/auth/token"
|
||||
"github.com/goharbor/harbor/src/common/models"
|
||||
"github.com/goharbor/harbor/src/common/rbac"
|
||||
"github.com/goharbor/harbor/src/pkg/permission/types"
|
||||
"github.com/goharbor/harbor/src/testing/pkg/project"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"golang.org/x/net/context"
|
||||
)
|
||||
|
||||
func TestAll(t *testing.T) {
|
||||
mgr := &project.FakeManager{}
|
||||
mgr.On("Get", int64(1)).Return(&models.Project{ProjectID: 1, Name: "library"}, nil)
|
||||
mgr.On("Get", int64(2)).Return(&models.Project{ProjectID: 2, Name: "test"}, nil)
|
||||
mgr.On("Get", int64(3)).Return(&models.Project{ProjectID: 3, Name: "development"}, nil)
|
||||
|
||||
access := []*token.ResourceActions{
|
||||
{
|
||||
Type: "repository",
|
||||
Name: "library/ubuntu",
|
||||
Actions: []string{
|
||||
"pull",
|
||||
"push",
|
||||
"scanner-pull",
|
||||
},
|
||||
},
|
||||
{
|
||||
Type: "repository",
|
||||
Name: "test/golang",
|
||||
Actions: []string{
|
||||
"pull",
|
||||
"*",
|
||||
},
|
||||
},
|
||||
{
|
||||
Type: "cnab",
|
||||
Name: "development/cnab",
|
||||
Actions: []string{
|
||||
"pull",
|
||||
"push",
|
||||
},
|
||||
},
|
||||
}
|
||||
sc := New(context.Background(), "jack", access)
|
||||
tsc := sc.(*tokenSecurityCtx)
|
||||
tsc.pm = mgr
|
||||
|
||||
cases := []struct {
|
||||
resource types.Resource
|
||||
action types.Action
|
||||
expect bool
|
||||
}{
|
||||
{
|
||||
resource: rbac.NewProjectNamespace(1).Resource(rbac.ResourceRepository),
|
||||
action: rbac.ActionPush,
|
||||
expect: true,
|
||||
},
|
||||
{
|
||||
resource: rbac.NewProjectNamespace(1).Resource(rbac.ResourceRepository),
|
||||
action: rbac.ActionScannerPull,
|
||||
expect: true,
|
||||
},
|
||||
{
|
||||
resource: rbac.NewProjectNamespace(2).Resource(rbac.ResourceRepository),
|
||||
action: rbac.ActionPush,
|
||||
expect: true,
|
||||
},
|
||||
{
|
||||
resource: rbac.NewProjectNamespace(2).Resource(rbac.ResourceRepository),
|
||||
action: rbac.ActionScannerPull,
|
||||
expect: false,
|
||||
},
|
||||
{
|
||||
resource: rbac.NewProjectNamespace(3).Resource(rbac.ResourceRepository),
|
||||
action: rbac.ActionPush,
|
||||
expect: false,
|
||||
},
|
||||
{
|
||||
resource: rbac.NewProjectNamespace(2).Resource(rbac.ResourceArtifact),
|
||||
action: rbac.ActionPush,
|
||||
expect: false,
|
||||
},
|
||||
{
|
||||
resource: rbac.NewProjectNamespace(1).Resource(rbac.ResourceRepository),
|
||||
action: rbac.ActionCreate,
|
||||
expect: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
assert.Equal(t, c.expect, sc.Can(c.action, c.resource))
|
||||
}
|
||||
}
|
|
@ -33,7 +33,8 @@ import (
|
|||
)
|
||||
|
||||
const (
|
||||
issuer = "harbor-token-issuer"
|
||||
// Issuer is the issuer of the internal token service in Harbor for registry
|
||||
Issuer = "harbor-token-issuer"
|
||||
)
|
||||
|
||||
var privateKey string
|
||||
|
@ -110,7 +111,7 @@ func MakeToken(username, service string, access []*token.ResourceActions) (*mode
|
|||
return nil, err
|
||||
}
|
||||
|
||||
tk, expiresIn, issuedAt, err := makeTokenCore(issuer, username, service, expiration, access, pk)
|
||||
tk, expiresIn, issuedAt, err := makeTokenCore(Issuer, username, service, expiration, access, pk)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
|
|
@ -44,7 +44,7 @@ func Middleware() func(http.Handler) http.Handler {
|
|||
securityCtx, ok := security.FromContext(ctx)
|
||||
// only authenticated robot account with scanner pull access can bypass.
|
||||
if ok && securityCtx.IsAuthenticated() &&
|
||||
securityCtx.Name() == "robot" &&
|
||||
(securityCtx.Name() == "robot" || securityCtx.Name() == "v2token") &&
|
||||
securityCtx.Can(rbac.ActionScannerPull, rbac.NewProjectNamespace(pro.ProjectID).Resource(rbac.ResourceRepository)) {
|
||||
// the artifact is pulling by the scanner, skip the checking
|
||||
logger.Debugf("artifact %s@%s is pulling by the scanner, skip the checking", af.Repository, af.Digest)
|
||||
|
|
|
@ -40,12 +40,7 @@ func (i *idToken) Generate(req *http.Request) security.Context {
|
|||
if !strings.HasPrefix(req.URL.Path, "/api") {
|
||||
return nil
|
||||
}
|
||||
h := req.Header.Get("Authorization")
|
||||
token := strings.Split(h, "Bearer")
|
||||
if len(token) < 2 {
|
||||
return nil
|
||||
}
|
||||
claims, err := oidc.VerifyToken(req.Context(), strings.TrimSpace(token[1]))
|
||||
claims, err := oidc.VerifyToken(req.Context(), bearerToken(req))
|
||||
if err != nil {
|
||||
log.Warningf("failed to verify token: %v", err)
|
||||
return nil
|
||||
|
|
|
@ -27,6 +27,7 @@ var (
|
|||
generators = []generator{
|
||||
&secret{},
|
||||
&oidcCli{},
|
||||
&v2Token{},
|
||||
&idToken{},
|
||||
&authProxy{},
|
||||
&robot{},
|
||||
|
|
18
src/server/middleware/security/utils.go
Normal file
18
src/server/middleware/security/utils.go
Normal file
|
@ -0,0 +1,18 @@
|
|||
package security
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func bearerToken(req *http.Request) string {
|
||||
if req == nil {
|
||||
return ""
|
||||
}
|
||||
h := req.Header.Get("Authorization")
|
||||
token := strings.Split(h, "Bearer")
|
||||
if len(token) < 2 {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(token[1])
|
||||
}
|
40
src/server/middleware/security/utils_test.go
Normal file
40
src/server/middleware/security/utils_test.go
Normal file
|
@ -0,0 +1,40 @@
|
|||
package security
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestBearerToken(t *testing.T) {
|
||||
req1, _ := http.NewRequest(http.MethodHead, "/api", nil)
|
||||
req1.Header.Set("Authorization", "Bearer token")
|
||||
req2, _ := http.NewRequest(http.MethodPut, "/api", nil)
|
||||
req2.SetBasicAuth("", "")
|
||||
req3, _ := http.NewRequest(http.MethodPut, "/api", nil)
|
||||
cases := []struct {
|
||||
request *http.Request
|
||||
token string
|
||||
}{
|
||||
{
|
||||
request: req1,
|
||||
token: "token",
|
||||
},
|
||||
{
|
||||
request: req2,
|
||||
token: "",
|
||||
},
|
||||
{
|
||||
request: req3,
|
||||
token: "",
|
||||
},
|
||||
{
|
||||
request: nil,
|
||||
token: "",
|
||||
},
|
||||
}
|
||||
for _, c := range cases {
|
||||
assert.Equal(t, c.token, bearerToken(c.request))
|
||||
}
|
||||
}
|
64
src/server/middleware/security/v2_token.go
Normal file
64
src/server/middleware/security/v2_token.go
Normal file
|
@ -0,0 +1,64 @@
|
|||
package security
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/dgrijalva/jwt-go"
|
||||
registry_token "github.com/docker/distribution/registry/auth/token"
|
||||
"github.com/goharbor/harbor/src/common/security"
|
||||
"github.com/goharbor/harbor/src/common/security/v2token"
|
||||
svc_token "github.com/goharbor/harbor/src/core/service/token"
|
||||
"github.com/goharbor/harbor/src/lib/log"
|
||||
"github.com/goharbor/harbor/src/pkg/token"
|
||||
)
|
||||
|
||||
type v2TokenClaims struct {
|
||||
jwt.StandardClaims
|
||||
Access []*registry_token.ResourceActions `json:"access"`
|
||||
}
|
||||
|
||||
func (vtc *v2TokenClaims) Valid() error {
|
||||
if err := vtc.StandardClaims.Valid(); err != nil {
|
||||
return err
|
||||
}
|
||||
if !vtc.VerifyAudience(svc_token.Registry, true) {
|
||||
return fmt.Errorf("invalid token audience: %s", vtc.Audience)
|
||||
}
|
||||
if !vtc.VerifyIssuer(svc_token.Issuer, true) {
|
||||
return fmt.Errorf("invalid token issuer: %s", vtc.Issuer)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type v2Token struct{}
|
||||
|
||||
func (vt *v2Token) Generate(req *http.Request) security.Context {
|
||||
logger := log.G(req.Context())
|
||||
if !strings.HasPrefix(req.URL.Path, "/v2") {
|
||||
return nil
|
||||
}
|
||||
tokenStr := bearerToken(req)
|
||||
if len(tokenStr) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
opt := token.DefaultTokenOptions()
|
||||
cl := &v2TokenClaims{}
|
||||
t, err := token.Parse(opt, tokenStr, cl)
|
||||
if err != nil {
|
||||
logger.Warningf("failed to decode bearer token: %v", err)
|
||||
return nil
|
||||
}
|
||||
if err := t.Claims.Valid(); err != nil {
|
||||
logger.Warningf("failed to decode bearer token: %v", err)
|
||||
return nil
|
||||
}
|
||||
claims, ok := t.Claims.(*v2TokenClaims)
|
||||
if !ok {
|
||||
logger.Warningf("invalid token claims.")
|
||||
return nil
|
||||
}
|
||||
return v2token.New(req.Context(), claims.Subject, claims.Access)
|
||||
}
|
34
src/server/middleware/security/v2_token_test.go
Normal file
34
src/server/middleware/security/v2_token_test.go
Normal file
|
@ -0,0 +1,34 @@
|
|||
package security
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/goharbor/harbor/src/core/config"
|
||||
"github.com/goharbor/harbor/src/core/service/token"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
registry_token "github.com/docker/distribution/registry/auth/token"
|
||||
)
|
||||
|
||||
func TestGenerate(t *testing.T) {
|
||||
config.Init()
|
||||
vt := &v2Token{}
|
||||
req1, _ := http.NewRequest(http.MethodHead, "/api/2.0/", nil)
|
||||
assert.Nil(t, vt.Generate(req1))
|
||||
req2, _ := http.NewRequest(http.MethodGet, "/v2/library/ubuntu/manifests/v1.0", nil)
|
||||
req2.Header.Set("Authorization", "Bearer 123")
|
||||
assert.Nil(t, vt.Generate(req2))
|
||||
mt, err := token.MakeToken("admin", "none", []*registry_token.ResourceActions{})
|
||||
require.Nil(t, err)
|
||||
req3 := req2.Clone(req2.Context())
|
||||
req3.Header.Set("Authorization", fmt.Sprintf("Bearer %s", mt.Token))
|
||||
assert.Nil(t, vt.Generate(req3))
|
||||
req4 := req3.Clone(req3.Context())
|
||||
mt2, err2 := token.MakeToken("admin", token.Registry, []*registry_token.ResourceActions{})
|
||||
require.Nil(t, err2)
|
||||
req4.Header.Set("Authorization", fmt.Sprintf("Bearer %s", mt2.Token))
|
||||
assert.NotNil(t, vt.Generate(req4))
|
||||
}
|
101
src/server/middleware/v2auth/access.go
Normal file
101
src/server/middleware/v2auth/access.go
Normal file
|
@ -0,0 +1,101 @@
|
|||
package v2auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/goharbor/harbor/src/common/rbac"
|
||||
"github.com/goharbor/harbor/src/lib"
|
||||
"github.com/goharbor/harbor/src/lib/log"
|
||||
"github.com/goharbor/harbor/src/server/middleware"
|
||||
)
|
||||
|
||||
type target int
|
||||
|
||||
const (
|
||||
login target = iota
|
||||
catalog
|
||||
repository
|
||||
)
|
||||
|
||||
func (t target) String() string {
|
||||
return []string{"login", "catalog", "repository"}[t]
|
||||
}
|
||||
|
||||
type access struct {
|
||||
target target
|
||||
name string
|
||||
action rbac.Action
|
||||
}
|
||||
|
||||
func (a access) scopeStr(ctx context.Context) string {
|
||||
logger := log.G(ctx)
|
||||
if a.target != repository {
|
||||
// Currently we do not support providing a token to list catalog
|
||||
return ""
|
||||
}
|
||||
act := ""
|
||||
if a.action == rbac.ActionPull {
|
||||
act = "pull"
|
||||
} else if a.action == rbac.ActionPush {
|
||||
act = "pull,push"
|
||||
} else if a.action == rbac.ActionDelete {
|
||||
act = "delete"
|
||||
} else {
|
||||
logger.Warningf("Invalid action in access: %s, returning empty scope", a.action)
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf("repository:%s:%s", a.name, act)
|
||||
}
|
||||
|
||||
func getAction(req *http.Request) rbac.Action {
|
||||
actions := map[string]rbac.Action{
|
||||
http.MethodPost: rbac.ActionPush,
|
||||
http.MethodPatch: rbac.ActionPush,
|
||||
http.MethodPut: rbac.ActionPush,
|
||||
http.MethodGet: rbac.ActionPull,
|
||||
http.MethodHead: rbac.ActionPull,
|
||||
http.MethodDelete: rbac.ActionDelete,
|
||||
}
|
||||
if action, ok := actions[req.Method]; ok {
|
||||
return action
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func accessList(req *http.Request) []access {
|
||||
l := make([]access, 0, 4)
|
||||
if req.URL.Path == "/v2/" {
|
||||
l = append(l, access{
|
||||
target: login,
|
||||
})
|
||||
return l
|
||||
}
|
||||
if len(middleware.V2CatalogURLRe.FindStringSubmatch(req.URL.Path)) == 1 {
|
||||
l = append(l, access{
|
||||
target: catalog,
|
||||
})
|
||||
return l
|
||||
}
|
||||
none := lib.ArtifactInfo{}
|
||||
if a := lib.GetArtifactInfo(req.Context()); a != none {
|
||||
action := getAction(req)
|
||||
if action == "" {
|
||||
return l
|
||||
}
|
||||
l = append(l, access{
|
||||
target: repository,
|
||||
name: a.Repository,
|
||||
action: action,
|
||||
})
|
||||
if req.Method == http.MethodPost && a.BlobMountRepository != "" { // need pull access for blob mount
|
||||
l = append(l, access{
|
||||
target: repository,
|
||||
name: a.BlobMountRepository,
|
||||
action: rbac.ActionPull,
|
||||
})
|
||||
}
|
||||
}
|
||||
return l
|
||||
}
|
147
src/server/middleware/v2auth/access_test.go
Normal file
147
src/server/middleware/v2auth/access_test.go
Normal file
|
@ -0,0 +1,147 @@
|
|||
package v2auth
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/goharbor/harbor/src/common/rbac"
|
||||
"github.com/goharbor/harbor/src/lib"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"golang.org/x/net/context"
|
||||
)
|
||||
|
||||
func TestAccessList(t *testing.T) {
|
||||
req1, _ := http.NewRequest(http.MethodGet, "https://registry.test/v2/", nil)
|
||||
req2, _ := http.NewRequest(http.MethodGet, "https://registry.test/v2/_catalog", nil)
|
||||
req3, _ := http.NewRequest(http.MethodPost, "https://registry.test/v2/library/ubuntu/blobs/uploads/?mount=sha256:08e4a417ff4e3913d8723a05cc34055db01c2fd165b588e049c5bad16ce6094f&from=base/ubuntu", nil)
|
||||
ctx3 := lib.WithArtifactInfo(context.Background(), lib.ArtifactInfo{
|
||||
Repository: "library/ubuntu",
|
||||
BlobMountRepository: "base/ubuntu",
|
||||
BlobMountDigest: "sha256:08e4a417ff4e3913d8723a05cc34055db01c2fd165b588e049c5bad16ce6094f",
|
||||
})
|
||||
req3 = req3.WithContext(ctx3)
|
||||
req4, _ := http.NewRequest(http.MethodGet, "https://registry.test/v2/goharbor/registry/manifests/v1.0", nil)
|
||||
req4d, _ := http.NewRequest(http.MethodDelete, "https://registry.test/v2/goharbor/registry/manifests/v1.0", nil)
|
||||
ctx4 := lib.WithArtifactInfo(context.Background(), lib.ArtifactInfo{
|
||||
Repository: "goharbor/registry",
|
||||
Tag: "v1.0",
|
||||
Reference: "v1.0",
|
||||
})
|
||||
req4 = req4.WithContext(ctx4)
|
||||
req4d = req4d.WithContext(ctx4)
|
||||
req5, _ := http.NewRequest(http.MethodGet, "https://registry.test/api/v2.0/users", nil)
|
||||
cases := []struct {
|
||||
input *http.Request
|
||||
expect []access
|
||||
}{
|
||||
{
|
||||
input: req1,
|
||||
expect: []access{{
|
||||
target: login,
|
||||
}},
|
||||
},
|
||||
{
|
||||
input: req2,
|
||||
expect: []access{{
|
||||
target: catalog,
|
||||
}},
|
||||
},
|
||||
{
|
||||
input: req3,
|
||||
expect: []access{{
|
||||
target: repository,
|
||||
name: "library/ubuntu",
|
||||
action: rbac.ActionPush,
|
||||
},
|
||||
{
|
||||
target: repository,
|
||||
name: "base/ubuntu",
|
||||
action: rbac.ActionPull,
|
||||
}},
|
||||
},
|
||||
{
|
||||
input: req4,
|
||||
expect: []access{{
|
||||
target: repository,
|
||||
name: "goharbor/registry",
|
||||
action: rbac.ActionPull,
|
||||
}},
|
||||
},
|
||||
{
|
||||
input: req4d,
|
||||
expect: []access{{
|
||||
target: repository,
|
||||
name: "goharbor/registry",
|
||||
action: rbac.ActionDelete,
|
||||
}},
|
||||
},
|
||||
{
|
||||
input: req5,
|
||||
expect: []access{},
|
||||
},
|
||||
}
|
||||
for _, c := range cases {
|
||||
assert.Equal(t, c.expect, accessList(c.input))
|
||||
}
|
||||
}
|
||||
|
||||
func TestScopeStr(t *testing.T) {
|
||||
cases := []struct {
|
||||
acs access
|
||||
scope string
|
||||
}{
|
||||
{
|
||||
acs: access{
|
||||
target: login,
|
||||
},
|
||||
scope: "",
|
||||
},
|
||||
{
|
||||
acs: access{
|
||||
target: catalog,
|
||||
},
|
||||
scope: "",
|
||||
},
|
||||
{
|
||||
acs: access{
|
||||
target: repository,
|
||||
name: "goharbor/registry",
|
||||
action: rbac.ActionPull,
|
||||
},
|
||||
scope: "repository:goharbor/registry:pull",
|
||||
},
|
||||
{
|
||||
acs: access{
|
||||
target: repository,
|
||||
name: "library/golang",
|
||||
action: rbac.ActionPush,
|
||||
},
|
||||
scope: "repository:library/golang:pull,push",
|
||||
},
|
||||
{
|
||||
acs: access{
|
||||
target: repository,
|
||||
name: "library/golang",
|
||||
action: rbac.ActionDelete,
|
||||
},
|
||||
scope: "repository:library/golang:delete",
|
||||
},
|
||||
{
|
||||
acs: access{
|
||||
target: repository,
|
||||
name: "library/golang",
|
||||
action: rbac.ActionList,
|
||||
},
|
||||
scope: "",
|
||||
},
|
||||
{
|
||||
acs: access{},
|
||||
scope: "",
|
||||
},
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
a := c.acs
|
||||
assert.Equal(t, c.scope, a.scopeStr(context.Background()))
|
||||
}
|
||||
}
|
|
@ -17,59 +17,54 @@ package v2auth
|
|||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/goharbor/harbor/src/common/rbac"
|
||||
"github.com/goharbor/harbor/src/common/security"
|
||||
"github.com/goharbor/harbor/src/core/config"
|
||||
"github.com/goharbor/harbor/src/core/promgr"
|
||||
"github.com/goharbor/harbor/src/lib"
|
||||
"github.com/goharbor/harbor/src/core/service/token"
|
||||
"github.com/goharbor/harbor/src/lib/errors"
|
||||
"github.com/goharbor/harbor/src/lib/log"
|
||||
serror "github.com/goharbor/harbor/src/server/error"
|
||||
"github.com/goharbor/harbor/src/server/middleware"
|
||||
)
|
||||
|
||||
const authHeader = "Authorization"
|
||||
|
||||
type reqChecker struct {
|
||||
pm promgr.ProjectManager
|
||||
}
|
||||
|
||||
func (rc *reqChecker) check(req *http.Request) error {
|
||||
func (rc *reqChecker) check(req *http.Request) (string, error) {
|
||||
securityCtx, ok := security.FromContext(req.Context())
|
||||
if !ok {
|
||||
return fmt.Errorf("the security context got from request is nil")
|
||||
return "", fmt.Errorf("the security context got from request is nil")
|
||||
}
|
||||
none := lib.ArtifactInfo{}
|
||||
if a := lib.GetArtifactInfo(req.Context()); a != none {
|
||||
action := getAction(req)
|
||||
if action == "" {
|
||||
return nil
|
||||
al := accessList(req)
|
||||
|
||||
for _, a := range al {
|
||||
if a.target == login && !securityCtx.IsAuthenticated() {
|
||||
return getChallenge(req, al), errors.New("unauthorized")
|
||||
}
|
||||
log.Debugf("action: %s, repository: %s", action, a.Repository)
|
||||
pid, err := rc.projectID(a.ProjectName)
|
||||
if err != nil {
|
||||
return err
|
||||
if a.target == catalog && !securityCtx.IsSysAdmin() {
|
||||
return getChallenge(req, al), fmt.Errorf("unauthorized to list catalog")
|
||||
}
|
||||
resource := rbac.NewProjectNamespace(pid).Resource(rbac.ResourceRepository)
|
||||
if !securityCtx.Can(action, resource) {
|
||||
return fmt.Errorf("unauthorized to access repository: %s, action: %s", a.Repository, action)
|
||||
}
|
||||
if req.Method == http.MethodPost && a.BlobMountProjectName != "" { // check permission for the source of blob mount
|
||||
pid, err := rc.projectID(a.BlobMountProjectName)
|
||||
if a.target == repository && req.Header.Get(authHeader) == "" && req.Method == http.MethodHead { // make sure 401 is returned for CLI HEAD, see #11271
|
||||
return getChallenge(req, al), fmt.Errorf("authorize header needed to send HEAD to repository")
|
||||
} else if a.target == repository {
|
||||
pn := strings.Split(a.name, "/")[0]
|
||||
pid, err := rc.projectID(pn)
|
||||
if err != nil {
|
||||
return err
|
||||
return "", err
|
||||
}
|
||||
resource := rbac.NewProjectNamespace(pid).Resource(rbac.ResourceRepository)
|
||||
if !securityCtx.Can(rbac.ActionPull, resource) {
|
||||
return fmt.Errorf("unauthorized to access repository from which to mount blob: %s, action: %s", a.BlobMountRepository, rbac.ActionPull)
|
||||
if !securityCtx.Can(a.action, resource) {
|
||||
return getChallenge(req, al), fmt.Errorf("unauthorized to access repository: %s, action: %s", a.name, a.action)
|
||||
}
|
||||
}
|
||||
} else if len(middleware.V2CatalogURLRe.FindStringSubmatch(req.URL.Path)) == 1 && !securityCtx.IsSysAdmin() {
|
||||
return fmt.Errorf("unauthorized to list catalog")
|
||||
} else if req.URL.Path == "/v2/" && !securityCtx.IsAuthenticated() {
|
||||
return errors.New("unauthorized")
|
||||
}
|
||||
return nil
|
||||
return "", nil
|
||||
}
|
||||
|
||||
func (rc *reqChecker) projectID(name string) (int64, error) {
|
||||
|
@ -83,25 +78,31 @@ func (rc *reqChecker) projectID(name string) (int64, error) {
|
|||
return p.ProjectID, nil
|
||||
}
|
||||
|
||||
func getAction(req *http.Request) rbac.Action {
|
||||
pushActions := map[string]struct{}{
|
||||
http.MethodPost: {},
|
||||
http.MethodDelete: {},
|
||||
http.MethodPatch: {},
|
||||
http.MethodPut: {},
|
||||
func getChallenge(req *http.Request, accessList []access) string {
|
||||
logger := log.G(req.Context())
|
||||
auth := req.Header.Get(authHeader)
|
||||
if len(auth) > 0 {
|
||||
// Return basic auth challenge by default
|
||||
return `Basic realm="harbor"`
|
||||
}
|
||||
pullActions := map[string]struct{}{
|
||||
http.MethodGet: {},
|
||||
http.MethodHead: {},
|
||||
// No auth header, treat it as CLI and redirect to token service
|
||||
ep, err := config.ExtEndpoint()
|
||||
if err != nil {
|
||||
logger.Errorf("failed to get the external endpoint, error: %v", err)
|
||||
}
|
||||
if _, ok := pushActions[req.Method]; ok {
|
||||
return rbac.ActionPush
|
||||
tokenSvc := fmt.Sprintf("%s/service/token", strings.TrimSuffix(ep, "/"))
|
||||
scope := ""
|
||||
for _, a := range accessList {
|
||||
if len(scope) > 0 {
|
||||
scope += " "
|
||||
}
|
||||
scope += a.scopeStr(req.Context())
|
||||
}
|
||||
if _, ok := pullActions[req.Method]; ok {
|
||||
return rbac.ActionPull
|
||||
challenge := fmt.Sprintf(`Bearer realm="%s",service="%s"`, tokenSvc, token.Registry)
|
||||
if len(scope) > 0 {
|
||||
challenge = fmt.Sprintf(`%s,scope="%s"`, challenge, scope)
|
||||
}
|
||||
return ""
|
||||
|
||||
return challenge
|
||||
}
|
||||
|
||||
var (
|
||||
|
@ -120,10 +121,10 @@ func Middleware() func(http.Handler) http.Handler {
|
|||
})
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
|
||||
if err := checker.check(req); err != nil {
|
||||
if challenge, err := checker.check(req); err != nil {
|
||||
// the header is needed for "docker manifest" commands: https://github.com/docker/cli/issues/989
|
||||
rw.Header().Set("Docker-Distribution-Api-Version", "registry/2.0")
|
||||
rw.Header().Set("Www-Authenticate", `Basic realm="harbor"`)
|
||||
rw.Header().Set("Www-Authenticate", challenge)
|
||||
serror.SendError(rw, errors.UnauthorizedError(err).WithMessage(err.Error()))
|
||||
return
|
||||
}
|
||||
|
|
|
@ -23,9 +23,11 @@ import (
|
|||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/goharbor/harbor/src/common"
|
||||
"github.com/goharbor/harbor/src/common/models"
|
||||
"github.com/goharbor/harbor/src/common/rbac"
|
||||
"github.com/goharbor/harbor/src/common/security"
|
||||
"github.com/goharbor/harbor/src/core/config"
|
||||
"github.com/goharbor/harbor/src/core/promgr/metamgr"
|
||||
"github.com/goharbor/harbor/src/lib"
|
||||
"github.com/goharbor/harbor/src/pkg/permission/types"
|
||||
|
@ -84,6 +86,10 @@ func TestMain(m *testing.M) {
|
|||
checker = reqChecker{
|
||||
pm: mockPM{},
|
||||
}
|
||||
conf := map[string]interface{}{
|
||||
common.ExtEndpoint: "https://harbor.test",
|
||||
}
|
||||
config.InitWithSettings(conf)
|
||||
if rc := m.Run(); rc != 0 {
|
||||
os.Exit(rc)
|
||||
}
|
||||
|
@ -158,6 +164,7 @@ func TestMiddleware(t *testing.T) {
|
|||
ctx5 := lib.WithArtifactInfo(baseCtx, ar5)
|
||||
req1a, _ := http.NewRequest(http.MethodGet, "/v2/project_1/hello-world/manifest/v1", nil)
|
||||
req1b, _ := http.NewRequest(http.MethodDelete, "/v2/project_1/hello-world/manifest/v1", nil)
|
||||
req1c, _ := http.NewRequest(http.MethodHead, "/v2/project_1/hello-world/manifest/v1", nil)
|
||||
req2, _ := http.NewRequest(http.MethodGet, "/v2/library/ubuntu/manifest/14.04", nil)
|
||||
req3, _ := http.NewRequest(http.MethodGet, "/v2/_catalog", nil)
|
||||
req4, _ := http.NewRequest(http.MethodPost, "/v2/project_1/ubuntu/blobs/uploads/mount=?mount=sha256:08e4a417ff4e3913d8723a05cc34055db01c2fd165b588e049c5bad16ce6094f&from=project_2/ubuntu", nil)
|
||||
|
@ -174,7 +181,11 @@ func TestMiddleware(t *testing.T) {
|
|||
},
|
||||
{
|
||||
input: req1b.WithContext(ctx1),
|
||||
status: http.StatusOK,
|
||||
status: http.StatusUnauthorized,
|
||||
},
|
||||
{
|
||||
input: req1c.WithContext(ctx1),
|
||||
status: http.StatusUnauthorized,
|
||||
},
|
||||
{
|
||||
input: req2.WithContext(ctx2),
|
||||
|
@ -204,3 +215,68 @@ func TestMiddleware(t *testing.T) {
|
|||
assert.Equal(t, c.status, rec.Result().StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetChallenge(t *testing.T) {
|
||||
req1, _ := http.NewRequest(http.MethodGet, "https://registry.test/v2/", nil)
|
||||
req1x := req1.Clone(req1.Context())
|
||||
req1x.SetBasicAuth("u", "p")
|
||||
req2, _ := http.NewRequest(http.MethodGet, "https://registry.test/v2/_catalog", nil)
|
||||
req2x := req2.Clone(req2.Context())
|
||||
req2x.Header.Set("Authorization", "Bearer xx")
|
||||
req3, _ := http.NewRequest(http.MethodPost, "https://registry.test/v2/project_1/ubuntu/blobs/uploads/mount=?mount=sha256:08e4a417ff4e3913d8723a05cc34055db01c2fd165b588e049c5bad16ce6094f&from=project_2/ubuntu", nil)
|
||||
req3 = req3.WithContext(lib.WithArtifactInfo(context.Background(), lib.ArtifactInfo{
|
||||
Repository: "project_1/ubuntu",
|
||||
Reference: "14.04",
|
||||
ProjectName: "project_1",
|
||||
BlobMountRepository: "project_2/ubuntu",
|
||||
BlobMountProjectName: "project_2",
|
||||
BlobMountDigest: "sha256:08e4a417ff4e3913d8723a05cc34055db01c2fd165b588e049c5bad16ce6094f",
|
||||
}))
|
||||
req3x := req3.Clone(req3.Context())
|
||||
req3x.SetBasicAuth("", "")
|
||||
req4, _ := http.NewRequest(http.MethodGet, "https://registry.test/v2/project_1/hello-world/manifests/v1", nil)
|
||||
req4 = req4.WithContext(lib.WithArtifactInfo(context.Background(), lib.ArtifactInfo{
|
||||
Repository: "project_1/hello-world",
|
||||
Reference: "v1",
|
||||
ProjectName: "project_1",
|
||||
}))
|
||||
|
||||
cases := []struct {
|
||||
request *http.Request
|
||||
challenge string
|
||||
}{
|
||||
{
|
||||
request: req1,
|
||||
challenge: `Bearer realm="https://harbor.test/service/token",service="harbor-registry"`,
|
||||
},
|
||||
{
|
||||
request: req1x,
|
||||
challenge: `Basic realm="harbor"`,
|
||||
},
|
||||
{
|
||||
request: req2,
|
||||
challenge: `Bearer realm="https://harbor.test/service/token",service="harbor-registry"`,
|
||||
},
|
||||
{
|
||||
request: req2x,
|
||||
challenge: `Basic realm="harbor"`,
|
||||
},
|
||||
{
|
||||
request: req3,
|
||||
challenge: `Bearer realm="https://harbor.test/service/token",service="harbor-registry",scope="repository:project_1/ubuntu:pull,push repository:project_2/ubuntu:pull"`,
|
||||
},
|
||||
{
|
||||
request: req3x,
|
||||
challenge: `Basic realm="harbor"`,
|
||||
},
|
||||
{
|
||||
request: req4,
|
||||
challenge: `Bearer realm="https://harbor.test/service/token",service="harbor-registry",scope="repository:project_1/hello-world:pull"`,
|
||||
},
|
||||
}
|
||||
for _, c := range cases {
|
||||
acs := accessList(c.request)
|
||||
assert.Equal(t, c.challenge, getChallenge(c.request, acs))
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -73,7 +73,7 @@ func Middleware() func(http.Handler) http.Handler {
|
|||
|
||||
securityCtx, ok := security.FromContext(ctx)
|
||||
if ok &&
|
||||
securityCtx.Name() == "robot" &&
|
||||
(securityCtx.Name() == "robot" || securityCtx.Name() == "v2token") &&
|
||||
securityCtx.Can(rbac.ActionScannerPull, rbac.NewProjectNamespace(proj.ProjectID).Resource(rbac.ResourceRepository)) {
|
||||
// the artifact is pulling by the scanner, skip the checking
|
||||
logger.Debugf("artifact %s@%s is pulling by the scanner, skip the checking", art.RepositoryName, art.Digest)
|
||||
|
|
|
@ -120,10 +120,10 @@ class TestProjects(unittest.TestCase):
|
|||
self.project.disable_project_robot_account(TestProjects.project_ra_id_a, robot_id, True, **TestProjects.USER_RA_CLIENT)
|
||||
|
||||
print "#13. Pull image(ImagePA) from project(PA) by robot account(RA), it must be not successful;"
|
||||
pull_harbor_image(harbor_server, robot_account.name, robot_account.token, TestProjects.repo_name_in_project_a, tag_a, expected_login_error_message = "401 Unauthorized")
|
||||
pull_harbor_image(harbor_server, robot_account.name, robot_account.token, TestProjects.repo_name_in_project_a, tag_a, expected_login_error_message = "unauthorized: authentication required")
|
||||
|
||||
print "#14. Push image(ImageRA) to project(PA) by robot account(RA), it must be not successful;"
|
||||
push_image_to_project(TestProjects.project_ra_name_a, harbor_server, robot_account.name, robot_account.token, image_robot_account, tag, expected_login_error_message = "401 Unauthorized")
|
||||
push_image_to_project(TestProjects.project_ra_name_a, harbor_server, robot_account.name, robot_account.token, image_robot_account, tag, expected_login_error_message = "unauthorized: authentication required")
|
||||
|
||||
print "#15. Delete robot account(RA), it must be not successful."
|
||||
self.project.delete_project_robot_account(TestProjects.project_ra_id_a, robot_id, **TestProjects.USER_RA_CLIENT)
|
||||
|
|
Loading…
Reference in New Issue
Block a user