mirror of
https://github.com/goharbor/harbor.git
synced 2024-12-22 16:48:30 +01:00
* Add robot account authn & authz implementation.
This commit is to add the jwt token service, and do the authn & authz for robot account. Signed-off-by: wang yan <wangyan@vmware.com>
This commit is contained in:
parent
923432a172
commit
71f37fb820
@ -36,6 +36,7 @@ env:
|
||||
- REDIS_HOST: localhost
|
||||
- REG_VERSION: v2.6.2
|
||||
- UI_BUILDER_VERSION: 1.6.0
|
||||
- TOKEN_PRIVATE_KEY_PATH: "/home/travis/gopath/src/github.com/goharbor/harbor/tests/private_key.pem"
|
||||
addons:
|
||||
apt:
|
||||
sources:
|
||||
|
@ -119,6 +119,8 @@ const (
|
||||
DefaultPortalURL = "http://portal"
|
||||
DefaultRegistryCtlURL = "http://registryctl:8080"
|
||||
DefaultClairHealthCheckServerURL = "http://clair:6061"
|
||||
// Use this prefix to distinguish harbor user, the prefix contains a special character($), so it cannot be registered as a harbor user.
|
||||
RobotPrefix = "robot$"
|
||||
)
|
||||
|
||||
// Shared variable, not allowed to modify
|
||||
|
@ -16,6 +16,7 @@ package models
|
||||
|
||||
import (
|
||||
"github.com/astaxie/beego/validation"
|
||||
"github.com/goharbor/harbor/src/common/rbac"
|
||||
"time"
|
||||
)
|
||||
|
||||
@ -45,10 +46,10 @@ type RobotQuery struct {
|
||||
|
||||
// RobotReq ...
|
||||
type RobotReq struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Disabled bool `json:"disabled"`
|
||||
Access []*ResourceActions `json:"access"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Disabled bool `json:"disabled"`
|
||||
Policy []*rbac.Policy `json:"access"`
|
||||
}
|
||||
|
||||
// Valid put request validation
|
||||
|
61
src/common/rbac/project/robot.go
Normal file
61
src/common/rbac/project/robot.go
Normal file
@ -0,0 +1,61 @@
|
||||
package project
|
||||
|
||||
import "github.com/goharbor/harbor/src/common/rbac"
|
||||
|
||||
// robotContext the context interface for the robot
|
||||
type robotContext interface {
|
||||
// Index whether the robot is authenticated
|
||||
IsAuthenticated() bool
|
||||
// GetUsername returns the name of robot
|
||||
GetUsername() string
|
||||
// GetPolicy get the rbac policies from security context
|
||||
GetPolicies() []*rbac.Policy
|
||||
}
|
||||
|
||||
// robot implement the rbac.User interface for project robot account
|
||||
type robot struct {
|
||||
ctx robotContext
|
||||
namespace rbac.Namespace
|
||||
}
|
||||
|
||||
// GetUserName get the robot name.
|
||||
func (r *robot) GetUserName() string {
|
||||
return r.ctx.GetUsername()
|
||||
}
|
||||
|
||||
// GetPolicies ...
|
||||
func (r *robot) GetPolicies() []*rbac.Policy {
|
||||
policies := []*rbac.Policy{}
|
||||
|
||||
var publicProjectPolicies []*rbac.Policy
|
||||
if r.namespace.IsPublic() {
|
||||
publicProjectPolicies = policiesForPublicProjectRobot(r.namespace)
|
||||
}
|
||||
if len(publicProjectPolicies) > 0 {
|
||||
for _, policy := range publicProjectPolicies {
|
||||
policies = append(policies, policy)
|
||||
}
|
||||
}
|
||||
|
||||
tokenPolicies := r.ctx.GetPolicies()
|
||||
if len(tokenPolicies) > 0 {
|
||||
for _, policy := range tokenPolicies {
|
||||
policies = append(policies, policy)
|
||||
}
|
||||
}
|
||||
|
||||
return policies
|
||||
}
|
||||
|
||||
// GetRoles robot has no definition of role, always return nil here.
|
||||
func (r *robot) GetRoles() []rbac.Role {
|
||||
return nil
|
||||
}
|
||||
|
||||
// NewRobot ...
|
||||
func NewRobot(ctx robotContext, namespace rbac.Namespace) rbac.User {
|
||||
return &robot{
|
||||
ctx: ctx,
|
||||
namespace: namespace,
|
||||
}
|
||||
}
|
38
src/common/rbac/project/robot_test.go
Normal file
38
src/common/rbac/project/robot_test.go
Normal file
@ -0,0 +1,38 @@
|
||||
package project
|
||||
|
||||
import (
|
||||
"github.com/goharbor/harbor/src/common/rbac"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type fakeRobotContext struct {
|
||||
username string
|
||||
isSysAdmin bool
|
||||
}
|
||||
|
||||
var (
|
||||
robotCtx = &fakeRobotContext{username: "robot$tester", isSysAdmin: true}
|
||||
)
|
||||
|
||||
func (ctx *fakeRobotContext) IsAuthenticated() bool {
|
||||
return ctx.username != ""
|
||||
}
|
||||
|
||||
func (ctx *fakeRobotContext) GetUsername() string {
|
||||
return ctx.username
|
||||
}
|
||||
|
||||
func (ctx *fakeRobotContext) IsSysAdmin() bool {
|
||||
return ctx.IsAuthenticated() && ctx.isSysAdmin
|
||||
}
|
||||
|
||||
func (ctx *fakeRobotContext) GetPolicies() []*rbac.Policy {
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestGetPolicies(t *testing.T) {
|
||||
namespace := rbac.NewProjectNamespace("library", false)
|
||||
robot := NewRobot(robotCtx, namespace)
|
||||
assert.NotNil(t, robot.GetPolicies())
|
||||
}
|
@ -19,6 +19,12 @@ import (
|
||||
)
|
||||
|
||||
var (
|
||||
// subresource policies for public project
|
||||
// robot account can only access docker pull for the public project.
|
||||
publicProjectPoliciesRobot = []*rbac.Policy{
|
||||
{Resource: ResourceImage, Action: ActionPull},
|
||||
}
|
||||
|
||||
// subresource policies for public project
|
||||
publicProjectPolicies = []*rbac.Policy{
|
||||
{Resource: ResourceImage, Action: ActionPull},
|
||||
@ -30,6 +36,20 @@ var (
|
||||
}
|
||||
)
|
||||
|
||||
func policiesForPublicProjectRobot(namespace rbac.Namespace) []*rbac.Policy {
|
||||
policies := []*rbac.Policy{}
|
||||
|
||||
for _, policy := range publicProjectPoliciesRobot {
|
||||
policies = append(policies, &rbac.Policy{
|
||||
Resource: namespace.Resource(policy.Resource),
|
||||
Action: policy.Action,
|
||||
Effect: policy.Effect,
|
||||
})
|
||||
}
|
||||
|
||||
return policies
|
||||
}
|
||||
|
||||
func policiesForPublicProject(namespace rbac.Namespace) []*rbac.Policy {
|
||||
policies := []*rbac.Policy{}
|
||||
|
||||
|
112
src/common/security/robot/context.go
Normal file
112
src/common/security/robot/context.go
Normal file
@ -0,0 +1,112 @@
|
||||
// Copyright Project Harbor Authors
|
||||
//
|
||||
// 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 robot
|
||||
|
||||
import (
|
||||
"github.com/goharbor/harbor/src/common/models"
|
||||
"github.com/goharbor/harbor/src/common/rbac"
|
||||
"github.com/goharbor/harbor/src/common/rbac/project"
|
||||
"github.com/goharbor/harbor/src/core/promgr"
|
||||
)
|
||||
|
||||
// SecurityContext implements security.Context interface based on database
|
||||
type SecurityContext struct {
|
||||
robot *models.Robot
|
||||
pm promgr.ProjectManager
|
||||
policy []*rbac.Policy
|
||||
}
|
||||
|
||||
// NewSecurityContext ...
|
||||
func NewSecurityContext(robot *models.Robot, pm promgr.ProjectManager, policy []*rbac.Policy) *SecurityContext {
|
||||
return &SecurityContext{
|
||||
robot: robot,
|
||||
pm: pm,
|
||||
policy: policy,
|
||||
}
|
||||
}
|
||||
|
||||
// IsAuthenticated returns true if the user has been authenticated
|
||||
func (s *SecurityContext) IsAuthenticated() bool {
|
||||
return s.robot != nil
|
||||
}
|
||||
|
||||
// GetUsername returns the username of the authenticated user
|
||||
// It returns null if the user has not been authenticated
|
||||
func (s *SecurityContext) GetUsername() string {
|
||||
if !s.IsAuthenticated() {
|
||||
return ""
|
||||
}
|
||||
return s.robot.Name
|
||||
}
|
||||
|
||||
// IsSysAdmin robot cannot be a system admin
|
||||
func (s *SecurityContext) IsSysAdmin() bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// IsSolutionUser robot cannot be a system admin
|
||||
func (s *SecurityContext) IsSolutionUser() bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// HasReadPerm returns whether the user has read permission to the project
|
||||
func (s *SecurityContext) HasReadPerm(projectIDOrName interface{}) bool {
|
||||
isPublicProject, _ := s.pm.IsPublic(projectIDOrName)
|
||||
return s.Can(project.ActionPull, rbac.NewProjectNamespace(projectIDOrName, isPublicProject).Resource(project.ResourceImage))
|
||||
}
|
||||
|
||||
// HasWritePerm returns whether the user has write permission to the project
|
||||
func (s *SecurityContext) HasWritePerm(projectIDOrName interface{}) bool {
|
||||
isPublicProject, _ := s.pm.IsPublic(projectIDOrName)
|
||||
return s.Can(project.ActionPush, rbac.NewProjectNamespace(projectIDOrName, isPublicProject).Resource(project.ResourceImage))
|
||||
}
|
||||
|
||||
// HasAllPerm returns whether the user has all permissions to the project
|
||||
func (s *SecurityContext) HasAllPerm(projectIDOrName interface{}) bool {
|
||||
isPublicProject, _ := s.pm.IsPublic(projectIDOrName)
|
||||
return s.Can(project.ActionPushPull, rbac.NewProjectNamespace(projectIDOrName, isPublicProject).Resource(project.ResourceImage))
|
||||
}
|
||||
|
||||
// GetMyProjects no implementation
|
||||
func (s *SecurityContext) GetMyProjects() ([]*models.Project, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// GetPolicies get access infor from the token and convert it to the rbac policy
|
||||
func (s *SecurityContext) GetPolicies() []*rbac.Policy {
|
||||
return s.policy
|
||||
}
|
||||
|
||||
// GetProjectRoles no implementation
|
||||
func (s *SecurityContext) GetProjectRoles(projectIDOrName interface{}) []int {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Can returns whether the robot can do action on resource
|
||||
func (s *SecurityContext) Can(action rbac.Action, resource rbac.Resource) bool {
|
||||
ns, err := resource.GetNamespace()
|
||||
if err == nil {
|
||||
switch ns.Kind() {
|
||||
case "project":
|
||||
projectIDOrName := ns.Identity()
|
||||
isPublicProject, _ := s.pm.IsPublic(projectIDOrName)
|
||||
projectNamespace := rbac.NewProjectNamespace(projectIDOrName, isPublicProject)
|
||||
robot := project.NewRobot(s, projectNamespace)
|
||||
return rbac.HasPermission(robot, resource, action)
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
197
src/common/security/robot/context_test.go
Normal file
197
src/common/security/robot/context_test.go
Normal file
@ -0,0 +1,197 @@
|
||||
// Copyright Project Harbor Authors
|
||||
//
|
||||
// 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 robot
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/goharbor/harbor/src/common/dao"
|
||||
"github.com/goharbor/harbor/src/common/models"
|
||||
"github.com/goharbor/harbor/src/common/rbac"
|
||||
"github.com/goharbor/harbor/src/common/utils/log"
|
||||
"github.com/goharbor/harbor/src/core/promgr"
|
||||
"github.com/goharbor/harbor/src/core/promgr/pmsdriver/local"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
var (
|
||||
private = &models.Project{
|
||||
Name: "testrobot",
|
||||
OwnerID: 1,
|
||||
}
|
||||
pm = promgr.NewDefaultProjectManager(local.NewDriver(), true)
|
||||
)
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
dbHost := os.Getenv("POSTGRESQL_HOST")
|
||||
if len(dbHost) == 0 {
|
||||
log.Fatalf("environment variable POSTGRES_HOST is not set")
|
||||
}
|
||||
dbUser := os.Getenv("POSTGRESQL_USR")
|
||||
if len(dbUser) == 0 {
|
||||
log.Fatalf("environment variable POSTGRES_USR is not set")
|
||||
}
|
||||
dbPortStr := os.Getenv("POSTGRESQL_PORT")
|
||||
if len(dbPortStr) == 0 {
|
||||
log.Fatalf("environment variable POSTGRES_PORT is not set")
|
||||
}
|
||||
dbPort, err := strconv.Atoi(dbPortStr)
|
||||
if err != nil {
|
||||
log.Fatalf("invalid POSTGRESQL_PORT: %v", err)
|
||||
}
|
||||
|
||||
dbPassword := os.Getenv("POSTGRESQL_PWD")
|
||||
dbDatabase := os.Getenv("POSTGRESQL_DATABASE")
|
||||
if len(dbDatabase) == 0 {
|
||||
log.Fatalf("environment variable POSTGRESQL_DATABASE is not set")
|
||||
}
|
||||
|
||||
database := &models.Database{
|
||||
Type: "postgresql",
|
||||
PostGreSQL: &models.PostGreSQL{
|
||||
Host: dbHost,
|
||||
Port: dbPort,
|
||||
Username: dbUser,
|
||||
Password: dbPassword,
|
||||
Database: dbDatabase,
|
||||
},
|
||||
}
|
||||
|
||||
log.Infof("POSTGRES_HOST: %s, POSTGRES_USR: %s, POSTGRES_PORT: %d, POSTGRES_PWD: %s\n", dbHost, dbUser, dbPort, dbPassword)
|
||||
|
||||
if err := dao.InitDatabase(database); err != nil {
|
||||
log.Fatalf("failed to initialize database: %v", err)
|
||||
}
|
||||
|
||||
// add project
|
||||
id, err := dao.AddProject(*private)
|
||||
if err != nil {
|
||||
log.Fatalf("failed to add project: %v", err)
|
||||
}
|
||||
private.ProjectID = id
|
||||
defer dao.DeleteProject(id)
|
||||
|
||||
os.Exit(m.Run())
|
||||
}
|
||||
|
||||
func TestIsAuthenticated(t *testing.T) {
|
||||
// unauthenticated
|
||||
ctx := NewSecurityContext(nil, nil, nil)
|
||||
assert.False(t, ctx.IsAuthenticated())
|
||||
|
||||
// authenticated
|
||||
ctx = NewSecurityContext(&models.Robot{
|
||||
Name: "test",
|
||||
Disabled: false,
|
||||
}, nil, nil)
|
||||
assert.True(t, ctx.IsAuthenticated())
|
||||
}
|
||||
|
||||
func TestGetUsername(t *testing.T) {
|
||||
// unauthenticated
|
||||
ctx := NewSecurityContext(nil, nil, nil)
|
||||
assert.Equal(t, "", ctx.GetUsername())
|
||||
|
||||
// authenticated
|
||||
ctx = NewSecurityContext(&models.Robot{
|
||||
Name: "test",
|
||||
Disabled: false,
|
||||
}, nil, nil)
|
||||
assert.Equal(t, "test", ctx.GetUsername())
|
||||
}
|
||||
|
||||
func TestIsSysAdmin(t *testing.T) {
|
||||
// unauthenticated
|
||||
ctx := NewSecurityContext(nil, nil, nil)
|
||||
assert.False(t, ctx.IsSysAdmin())
|
||||
|
||||
// authenticated, non admin
|
||||
ctx = NewSecurityContext(&models.Robot{
|
||||
Name: "test",
|
||||
Disabled: false,
|
||||
}, nil, nil)
|
||||
assert.False(t, ctx.IsSysAdmin())
|
||||
}
|
||||
|
||||
func TestIsSolutionUser(t *testing.T) {
|
||||
ctx := NewSecurityContext(nil, nil, nil)
|
||||
assert.False(t, ctx.IsSolutionUser())
|
||||
}
|
||||
|
||||
func TestHasReadPerm(t *testing.T) {
|
||||
|
||||
rbacPolicy := &rbac.Policy{
|
||||
Resource: "/project/testrobot/image",
|
||||
Action: "pull",
|
||||
}
|
||||
policies := []*rbac.Policy{}
|
||||
policies = append(policies, rbacPolicy)
|
||||
robot := &models.Robot{
|
||||
Name: "test_robot_1",
|
||||
Description: "desc",
|
||||
}
|
||||
|
||||
ctx := NewSecurityContext(robot, pm, policies)
|
||||
assert.True(t, ctx.HasReadPerm(private.Name))
|
||||
}
|
||||
|
||||
func TestHasWritePerm(t *testing.T) {
|
||||
|
||||
rbacPolicy := &rbac.Policy{
|
||||
Resource: "/project/testrobot/image",
|
||||
Action: "push",
|
||||
}
|
||||
policies := []*rbac.Policy{}
|
||||
policies = append(policies, rbacPolicy)
|
||||
robot := &models.Robot{
|
||||
Name: "test_robot_2",
|
||||
Description: "desc",
|
||||
}
|
||||
|
||||
ctx := NewSecurityContext(robot, pm, policies)
|
||||
assert.True(t, ctx.HasWritePerm(private.Name))
|
||||
}
|
||||
|
||||
func TestHasAllPerm(t *testing.T) {
|
||||
rbacPolicy := &rbac.Policy{
|
||||
Resource: "/project/testrobot/image",
|
||||
Action: "push+pull",
|
||||
}
|
||||
policies := []*rbac.Policy{}
|
||||
policies = append(policies, rbacPolicy)
|
||||
robot := &models.Robot{
|
||||
Name: "test_robot_3",
|
||||
Description: "desc",
|
||||
}
|
||||
|
||||
ctx := NewSecurityContext(robot, pm, policies)
|
||||
assert.True(t, ctx.HasAllPerm(private.Name))
|
||||
}
|
||||
|
||||
func TestGetMyProjects(t *testing.T) {
|
||||
ctx := NewSecurityContext(nil, nil, nil)
|
||||
projects, err := ctx.GetMyProjects()
|
||||
require.Nil(t, err)
|
||||
assert.Nil(t, projects)
|
||||
}
|
||||
|
||||
func TestGetProjectRoles(t *testing.T) {
|
||||
ctx := NewSecurityContext(nil, nil, nil)
|
||||
roles := ctx.GetProjectRoles("test")
|
||||
assert.Nil(t, roles)
|
||||
}
|
30
src/common/token/claims.go
Normal file
30
src/common/token/claims.go
Normal file
@ -0,0 +1,30 @@
|
||||
package token
|
||||
|
||||
import (
|
||||
"github.com/dgrijalva/jwt-go"
|
||||
"github.com/goharbor/harbor/src/common/rbac"
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
// RobotClaims implements the interface of jwt.Claims
|
||||
type RobotClaims struct {
|
||||
jwt.StandardClaims
|
||||
TokenID int64 `json:"id"`
|
||||
ProjectID int64 `json:"pid"`
|
||||
Policy []*rbac.Policy `json:"access"`
|
||||
}
|
||||
|
||||
// Valid valid the claims "tokenID, projectID and access".
|
||||
func (rc RobotClaims) Valid() error {
|
||||
|
||||
if rc.TokenID < 0 {
|
||||
return errors.New("Token id must an valid INT")
|
||||
}
|
||||
if rc.ProjectID < 0 {
|
||||
return errors.New("Project id must an valid INT")
|
||||
}
|
||||
if rc.Policy == nil {
|
||||
return errors.New("The access info cannot be nil")
|
||||
}
|
||||
return nil
|
||||
}
|
68
src/common/token/claims_test.go
Normal file
68
src/common/token/claims_test.go
Normal file
@ -0,0 +1,68 @@
|
||||
package token
|
||||
|
||||
import (
|
||||
"github.com/goharbor/harbor/src/common/rbac"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestValid(t *testing.T) {
|
||||
|
||||
rbacPolicy := &rbac.Policy{
|
||||
Resource: "/project/libray/repository",
|
||||
Action: "pull",
|
||||
}
|
||||
policies := []*rbac.Policy{}
|
||||
policies = append(policies, rbacPolicy)
|
||||
|
||||
rClaims := &RobotClaims{
|
||||
TokenID: 1,
|
||||
ProjectID: 2,
|
||||
Policy: policies,
|
||||
}
|
||||
assert.Nil(t, rClaims.Valid())
|
||||
}
|
||||
|
||||
func TestUnValidTokenID(t *testing.T) {
|
||||
|
||||
rbacPolicy := &rbac.Policy{
|
||||
Resource: "/project/libray/repository",
|
||||
Action: "pull",
|
||||
}
|
||||
policies := []*rbac.Policy{}
|
||||
policies = append(policies, rbacPolicy)
|
||||
|
||||
rClaims := &RobotClaims{
|
||||
TokenID: -1,
|
||||
ProjectID: 2,
|
||||
Policy: policies,
|
||||
}
|
||||
assert.NotNil(t, rClaims.Valid())
|
||||
}
|
||||
|
||||
func TestUnValidProjectID(t *testing.T) {
|
||||
|
||||
rbacPolicy := &rbac.Policy{
|
||||
Resource: "/project/libray/repository",
|
||||
Action: "pull",
|
||||
}
|
||||
policies := []*rbac.Policy{}
|
||||
policies = append(policies, rbacPolicy)
|
||||
|
||||
rClaims := &RobotClaims{
|
||||
TokenID: 1,
|
||||
ProjectID: -2,
|
||||
Policy: policies,
|
||||
}
|
||||
assert.NotNil(t, rClaims.Valid())
|
||||
}
|
||||
|
||||
func TestUnValidPolicy(t *testing.T) {
|
||||
|
||||
rClaims := &RobotClaims{
|
||||
TokenID: 1,
|
||||
ProjectID: 2,
|
||||
Policy: nil,
|
||||
}
|
||||
assert.NotNil(t, rClaims.Valid())
|
||||
}
|
78
src/common/token/htoken.go
Normal file
78
src/common/token/htoken.go
Normal file
@ -0,0 +1,78 @@
|
||||
package token
|
||||
|
||||
import (
|
||||
"crypto/ecdsa"
|
||||
"crypto/rsa"
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/dgrijalva/jwt-go"
|
||||
"github.com/goharbor/harbor/src/common/utils/log"
|
||||
"time"
|
||||
)
|
||||
|
||||
// HToken ...
|
||||
type HToken struct {
|
||||
jwt.Token
|
||||
}
|
||||
|
||||
// NewWithClaims ...
|
||||
func NewWithClaims(claims *RobotClaims) *HToken {
|
||||
rClaims := &RobotClaims{
|
||||
TokenID: claims.TokenID,
|
||||
ProjectID: claims.ProjectID,
|
||||
Policy: claims.Policy,
|
||||
StandardClaims: jwt.StandardClaims{
|
||||
ExpiresAt: time.Now().Add(DefaultOptions.TTL).Unix(),
|
||||
Issuer: DefaultOptions.Issuer,
|
||||
},
|
||||
}
|
||||
return &HToken{
|
||||
Token: *jwt.NewWithClaims(DefaultOptions.SignMethod, rClaims),
|
||||
}
|
||||
}
|
||||
|
||||
// SignedString get the SignedString.
|
||||
func (htk *HToken) SignedString() (string, error) {
|
||||
key, err := DefaultOptions.GetKey()
|
||||
if err != nil {
|
||||
return "", nil
|
||||
}
|
||||
raw, err := htk.Token.SignedString(key)
|
||||
if err != nil {
|
||||
log.Debugf(fmt.Sprintf("failed to issue token %v", err))
|
||||
return "", err
|
||||
}
|
||||
return raw, err
|
||||
}
|
||||
|
||||
// ParseWithClaims ...
|
||||
func ParseWithClaims(rawToken string, claims jwt.Claims) (*HToken, error) {
|
||||
key, err := DefaultOptions.GetKey()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
token, err := jwt.ParseWithClaims(rawToken, claims, func(token *jwt.Token) (interface{}, error) {
|
||||
if token.Method.Alg() != DefaultOptions.SignMethod.Alg() {
|
||||
return nil, errors.New("invalid signing method")
|
||||
}
|
||||
switch k := key.(type) {
|
||||
case *rsa.PrivateKey:
|
||||
return &k.PublicKey, nil
|
||||
case *ecdsa.PrivateKey:
|
||||
return &k.PublicKey, nil
|
||||
default:
|
||||
return key, nil
|
||||
}
|
||||
})
|
||||
if err != nil {
|
||||
log.Errorf(fmt.Sprintf("parse token error, %v", err))
|
||||
return nil, err
|
||||
}
|
||||
if !token.Valid {
|
||||
log.Errorf(fmt.Sprintf("invalid jwt token, %v", token))
|
||||
return nil, err
|
||||
}
|
||||
return &HToken{
|
||||
Token: *token,
|
||||
}, nil
|
||||
}
|
89
src/common/token/htoken_test.go
Normal file
89
src/common/token/htoken_test.go
Normal file
@ -0,0 +1,89 @@
|
||||
package token
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/goharbor/harbor/src/common/rbac"
|
||||
"github.com/goharbor/harbor/src/common/utils/log"
|
||||
"github.com/goharbor/harbor/src/common/utils/test"
|
||||
"github.com/goharbor/harbor/src/core/config"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"os"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
server, err := test.NewAdminserver(nil)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
defer server.Close()
|
||||
|
||||
if err := os.Setenv("ADMINSERVER_URL", server.URL); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
if err := config.Init(); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
result := m.Run()
|
||||
if result != 0 {
|
||||
os.Exit(result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewWithClaims(t *testing.T) {
|
||||
rbacPolicy := &rbac.Policy{
|
||||
Resource: "/project/libray/repository",
|
||||
Action: "pull",
|
||||
}
|
||||
policies := []*rbac.Policy{}
|
||||
policies = append(policies, rbacPolicy)
|
||||
|
||||
policy := &RobotClaims{
|
||||
TokenID: 123,
|
||||
ProjectID: 321,
|
||||
Policy: policies,
|
||||
}
|
||||
token := NewWithClaims(policy)
|
||||
|
||||
assert.Equal(t, token.Header["alg"], "RS256")
|
||||
assert.Equal(t, token.Header["typ"], "JWT")
|
||||
|
||||
}
|
||||
|
||||
func TestSignedString(t *testing.T) {
|
||||
rbacPolicy := &rbac.Policy{
|
||||
Resource: "/project/library/repository",
|
||||
Action: "pull",
|
||||
}
|
||||
policies := []*rbac.Policy{}
|
||||
policies = append(policies, rbacPolicy)
|
||||
|
||||
policy := &RobotClaims{
|
||||
TokenID: 123,
|
||||
ProjectID: 321,
|
||||
Policy: policies,
|
||||
}
|
||||
|
||||
keyPath, err := DefaultOptions.GetKey()
|
||||
if err != nil {
|
||||
log.Infof(fmt.Sprintf("get key error, %v", err))
|
||||
}
|
||||
log.Infof(fmt.Sprintf("get the key path, %s, ", keyPath))
|
||||
|
||||
token := NewWithClaims(policy)
|
||||
rawTk, err := token.SignedString()
|
||||
|
||||
assert.Nil(t, err)
|
||||
assert.NotNil(t, rawTk)
|
||||
}
|
||||
|
||||
func TestParseWithClaims(t *testing.T) {
|
||||
rawTk := "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJJRCI6MTIzLCJQcm9qZWN0SUQiOjAsIkFjY2VzcyI6W3siUmVzb3VyY2UiOiIvcHJvamVjdC9saWJyYXkvcmVwb3NpdG9yeSIsIkFjdGlvbiI6InB1bGwiLCJFZmZlY3QiOiIifV0sIlN0YW5kYXJkQ2xhaW1zIjp7ImV4cCI6MTU0ODE0MDIyOSwiaXNzIjoiaGFyYm9yLXRva2VuLWlzc3VlciJ9fQ.Jc3qSKN4SJVUzAvBvemVpRcSOZaHlu0Avqms04qzPm4ru9-r9IRIl3mnSkI6m9XkzLUeJ7Kiwyw63ghngnVKw_PupeclOGC6s3TK5Cfmo4h-lflecXjZWwyy-dtH_e7Us_ItS-R3nXDJtzSLEpsGHCcAj-1X2s93RB2qD8LNSylvYeDezVkTzqRzzfawPJheKKh9JTrz-3eUxCwQard9-xjlwvfUYULoHTn9npNAUq4-jqhipW4uE8HL-ym33AGF57la8U0RO11hmDM5K8-PiYknbqJ_oONeS3HBNym2pEFeGjtTv2co213wl4T5lemlg4SGolMBuJ03L7_beVZ0o-MKTkKDqDwJalb6_PM-7u3RbxC9IzJMiwZKIPnD3FvV10iPxUUQHaH8Jz5UZ2pFIhi_8BNnlBfT0JOPFVYATtLjHMczZelj2YvAeR1UHBzq3E0jPpjjwlqIFgaHCaN_KMwEvadTo_Fi2sEH4pNGP7M3yehU_72oLJQgF4paJarsmEoij6ZtPs6xekBz1fccVitq_8WNIz9aeCUdkUBRwI5QKw1RdW4ua-w74ld5MZStWJA8veyoLkEb_Q9eq2oAj5KWFjJbW5-ltiIfM8gxKflsrkWAidYGcEIYcuXr7UdqEKXxtPiWM0xb3B91ovYvO5402bn3f9-UGtlcestxNHA"
|
||||
rClaims := &RobotClaims{}
|
||||
_, _ = ParseWithClaims(rawTk, rClaims)
|
||||
assert.Equal(t, int64(123), rClaims.TokenID)
|
||||
assert.Equal(t, int64(0), rClaims.ProjectID)
|
||||
assert.Equal(t, "/project/libray/repository", rClaims.Policy[0].Resource.String())
|
||||
}
|
83
src/common/token/options.go
Normal file
83
src/common/token/options.go
Normal file
@ -0,0 +1,83 @@
|
||||
package token
|
||||
|
||||
import (
|
||||
"crypto/rsa"
|
||||
"fmt"
|
||||
"github.com/dgrijalva/jwt-go"
|
||||
"github.com/goharbor/harbor/src/common/utils/log"
|
||||
"github.com/goharbor/harbor/src/core/config"
|
||||
"io/ioutil"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
ttl = 60 * time.Minute
|
||||
issuer = "harbor-token-issuer"
|
||||
signedMethod = "RS256"
|
||||
)
|
||||
|
||||
var (
|
||||
privateKey = config.TokenPrivateKeyPath()
|
||||
// DefaultOptions ...
|
||||
DefaultOptions = NewOptions()
|
||||
)
|
||||
|
||||
// Options ...
|
||||
type Options struct {
|
||||
SignMethod jwt.SigningMethod
|
||||
PublicKey []byte
|
||||
PrivateKey []byte
|
||||
TTL time.Duration
|
||||
Issuer string
|
||||
}
|
||||
|
||||
// NewOptions ...
|
||||
func NewOptions() *Options {
|
||||
privateKey, err := ioutil.ReadFile(privateKey)
|
||||
if err != nil {
|
||||
log.Errorf(fmt.Sprintf("failed to read private key %v", err))
|
||||
return nil
|
||||
}
|
||||
opt := &Options{
|
||||
SignMethod: jwt.GetSigningMethod(signedMethod),
|
||||
PrivateKey: privateKey,
|
||||
Issuer: issuer,
|
||||
TTL: ttl,
|
||||
}
|
||||
return opt
|
||||
}
|
||||
|
||||
// GetKey ...
|
||||
func (o *Options) GetKey() (interface{}, error) {
|
||||
var err error
|
||||
var privateKey *rsa.PrivateKey
|
||||
var publicKey *rsa.PublicKey
|
||||
|
||||
switch o.SignMethod.(type) {
|
||||
case *jwt.SigningMethodRSA, *jwt.SigningMethodRSAPSS:
|
||||
if len(o.PrivateKey) > 0 {
|
||||
privateKey, err = jwt.ParseRSAPrivateKeyFromPEM(o.PrivateKey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if len(o.PublicKey) > 0 {
|
||||
publicKey, err = jwt.ParseRSAPublicKeyFromPEM(o.PublicKey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if privateKey == nil {
|
||||
if publicKey != nil {
|
||||
return publicKey, nil
|
||||
}
|
||||
return nil, fmt.Errorf("key is provided")
|
||||
}
|
||||
if publicKey != nil && publicKey.E != privateKey.E && publicKey.N.Cmp(privateKey.N) != 0 {
|
||||
return nil, fmt.Errorf("the public key and private key are not match")
|
||||
}
|
||||
return privateKey, nil
|
||||
default:
|
||||
return nil, fmt.Errorf(fmt.Sprintf("unsupported sign method, %s", o.SignMethod))
|
||||
}
|
||||
}
|
23
src/common/token/options_test.go
Normal file
23
src/common/token/options_test.go
Normal file
@ -0,0 +1,23 @@
|
||||
package token
|
||||
|
||||
import (
|
||||
"github.com/dgrijalva/jwt-go"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestNewOptions(t *testing.T) {
|
||||
defaultOpt := DefaultOptions
|
||||
assert.NotNil(t, defaultOpt)
|
||||
assert.Equal(t, defaultOpt.SignMethod, jwt.GetSigningMethod("RS256"))
|
||||
assert.Equal(t, defaultOpt.Issuer, "harbor-token-issuer")
|
||||
assert.Equal(t, defaultOpt.TTL, 60*time.Minute)
|
||||
}
|
||||
|
||||
func TestGetKey(t *testing.T) {
|
||||
defaultOpt := DefaultOptions
|
||||
key, err := defaultOpt.GetKey()
|
||||
assert.Nil(t, err)
|
||||
assert.NotNil(t, key)
|
||||
}
|
@ -16,16 +16,14 @@ package api
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/goharbor/harbor/src/common"
|
||||
"github.com/goharbor/harbor/src/common/dao"
|
||||
"github.com/goharbor/harbor/src/common/models"
|
||||
"github.com/goharbor/harbor/src/common/token"
|
||||
"net/http"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
// User this prefix to distinguish harbor user,
|
||||
// The prefix contains a specific character($), so it cannot be registered as a harbor user.
|
||||
const robotPrefix = "robot$"
|
||||
|
||||
// RobotAPI ...
|
||||
type RobotAPI struct {
|
||||
BaseController
|
||||
@ -98,17 +96,14 @@ func (r *RobotAPI) Prepare() {
|
||||
func (r *RobotAPI) Post() {
|
||||
var robotReq models.RobotReq
|
||||
r.DecodeJSONReq(&robotReq)
|
||||
createdName := common.RobotPrefix + robotReq.Name
|
||||
|
||||
createdName := robotPrefix + robotReq.Name
|
||||
|
||||
// first to add a robot account, and get its id.
|
||||
robot := models.Robot{
|
||||
Name: createdName,
|
||||
Description: robotReq.Description,
|
||||
ProjectID: r.project.ProjectID,
|
||||
// TODO: use token service to generate token per access information
|
||||
Token: "this is a placeholder",
|
||||
}
|
||||
|
||||
id, err := dao.AddRobot(&robot)
|
||||
if err != nil {
|
||||
if err == dao.ErrDupRows {
|
||||
@ -119,11 +114,28 @@ func (r *RobotAPI) Post() {
|
||||
return
|
||||
}
|
||||
|
||||
robotRep := models.RobotRep{
|
||||
Name: robot.Name,
|
||||
Token: robot.Token,
|
||||
// generate the token, and return it with response data.
|
||||
// token is not stored in the database.
|
||||
rClaims := &token.RobotClaims{
|
||||
TokenID: id,
|
||||
ProjectID: r.project.ProjectID,
|
||||
Policy: robotReq.Policy,
|
||||
}
|
||||
token := token.NewWithClaims(rClaims)
|
||||
rawTk, err := token.SignedString()
|
||||
if err != nil {
|
||||
r.HandleInternalServerError(fmt.Sprintf("failed to create token for robot account, %v", err))
|
||||
err := dao.DeleteRobot(id)
|
||||
if err != nil {
|
||||
r.HandleInternalServerError(fmt.Sprintf("failed to delete the robot account: %d, %v", id, err))
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
robotRep := models.RobotRep{
|
||||
Name: robot.Name,
|
||||
Token: rawTk,
|
||||
}
|
||||
r.Redirect(http.StatusCreated, strconv.FormatInt(id, 10))
|
||||
r.Data["json"] = robotRep
|
||||
r.ServeJSON()
|
||||
|
@ -53,6 +53,11 @@ func TestConfig(t *testing.T) {
|
||||
if err := os.Setenv("KEY_PATH", secretKeyPath); err != nil {
|
||||
t.Fatalf("failed to set env %s: %v", "KEY_PATH", err)
|
||||
}
|
||||
oriKeyPath := os.Getenv("TOKEN_PRIVATE_KEY_PATH")
|
||||
if err := os.Setenv("TOKEN_PRIVATE_KEY_PATH", ""); err != nil {
|
||||
t.Fatalf("failed to set env %s: %v", "TOKEN_PRIVATE_KEY_PATH", err)
|
||||
}
|
||||
defer os.Setenv("TOKEN_PRIVATE_KEY_PATH", oriKeyPath)
|
||||
|
||||
if err := Init(); err != nil {
|
||||
t.Fatalf("failed to initialize configurations: %v", err)
|
||||
|
@ -22,18 +22,23 @@ import (
|
||||
|
||||
beegoctx "github.com/astaxie/beego/context"
|
||||
"github.com/docker/distribution/reference"
|
||||
"github.com/goharbor/harbor/src/common"
|
||||
"github.com/goharbor/harbor/src/common/dao"
|
||||
"github.com/goharbor/harbor/src/common/models"
|
||||
secstore "github.com/goharbor/harbor/src/common/secret"
|
||||
"github.com/goharbor/harbor/src/common/security"
|
||||
admr "github.com/goharbor/harbor/src/common/security/admiral"
|
||||
"github.com/goharbor/harbor/src/common/security/admiral/authcontext"
|
||||
"github.com/goharbor/harbor/src/common/security/local"
|
||||
robotCtx "github.com/goharbor/harbor/src/common/security/robot"
|
||||
"github.com/goharbor/harbor/src/common/security/secret"
|
||||
"github.com/goharbor/harbor/src/common/token"
|
||||
"github.com/goharbor/harbor/src/common/utils/log"
|
||||
"github.com/goharbor/harbor/src/core/auth"
|
||||
"github.com/goharbor/harbor/src/core/config"
|
||||
"github.com/goharbor/harbor/src/core/promgr"
|
||||
"github.com/goharbor/harbor/src/core/promgr/pmsdriver/admiral"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ContextValueKey for content value
|
||||
@ -95,6 +100,7 @@ func Init() {
|
||||
// standalone
|
||||
reqCtxModifiers = []ReqCtxModifier{
|
||||
&secretReqCtxModifier{config.SecretStore},
|
||||
&robotAuthReqCtxModifier{},
|
||||
&basicAuthReqCtxModifier{},
|
||||
&sessionReqCtxModifier{},
|
||||
&unauthorizedReqCtxModifier{}}
|
||||
@ -147,6 +153,50 @@ func (s *secretReqCtxModifier) Modify(ctx *beegoctx.Context) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
type robotAuthReqCtxModifier struct{}
|
||||
|
||||
func (r *robotAuthReqCtxModifier) Modify(ctx *beegoctx.Context) bool {
|
||||
robotName, robotTk, ok := ctx.Request.BasicAuth()
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
log.Debug("got robot information via token auth")
|
||||
if !strings.HasPrefix(robotName, common.RobotPrefix) {
|
||||
return false
|
||||
}
|
||||
rClaims := &token.RobotClaims{}
|
||||
htk := &token.HToken{}
|
||||
htk, err := token.ParseWithClaims(robotTk, rClaims)
|
||||
if err != nil {
|
||||
log.Errorf("failed to decrypt robot token, %v", err)
|
||||
return false
|
||||
}
|
||||
log.Infof(fmt.Sprintf("got robot token header, %v", htk.Header))
|
||||
// Do authn for robot account, as Harbor only stores the token ID, just validate the ID and disable.
|
||||
robot, err := dao.GetRobotByID(htk.Claims.(*token.RobotClaims).TokenID)
|
||||
if err != nil {
|
||||
log.Errorf("failed to get robot %s: %v", robotName, err)
|
||||
return false
|
||||
}
|
||||
if robot == nil {
|
||||
log.Error("the token is not valid.")
|
||||
return false
|
||||
}
|
||||
if robotName != robot.Name {
|
||||
log.Errorf("failed to authenticate : %v", robotName)
|
||||
return false
|
||||
}
|
||||
if robot.Disabled {
|
||||
log.Errorf("the robot account %s is disabled", robot.Name)
|
||||
return false
|
||||
}
|
||||
log.Debug("creating robot account security context...")
|
||||
pm := config.GlobalProjectMgr
|
||||
securCtx := robotCtx.NewSecurityContext(robot, pm, htk.Claims.(*token.RobotClaims).Policy)
|
||||
setSecurCtxAndPM(ctx.Request, securCtx, pm)
|
||||
return true
|
||||
}
|
||||
|
||||
type basicAuthReqCtxModifier struct{}
|
||||
|
||||
func (b *basicAuthReqCtxModifier) Modify(ctx *beegoctx.Context) bool {
|
||||
|
@ -122,6 +122,23 @@ func TestSecretReqCtxModifier(t *testing.T) {
|
||||
assert.NotNil(t, projectManager(ctx))
|
||||
}
|
||||
|
||||
func TestRobotReqCtxModifier(t *testing.T) {
|
||||
req, err := http.NewRequest(http.MethodGet,
|
||||
"http://127.0.0.1/api/projects/", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create request: %v", req)
|
||||
}
|
||||
req.SetBasicAuth("robot$test1", "Harbor12345")
|
||||
ctx, err := newContext(req)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to crate context: %v", err)
|
||||
}
|
||||
|
||||
modifier := &robotAuthReqCtxModifier{}
|
||||
modified := modifier.Modify(ctx)
|
||||
assert.False(t, modified)
|
||||
}
|
||||
|
||||
func TestBasicAuthReqCtxModifier(t *testing.T) {
|
||||
req, err := http.NewRequest(http.MethodGet,
|
||||
"http://127.0.0.1/api/projects/", nil)
|
||||
|
51
tests/private_key.pem
Normal file
51
tests/private_key.pem
Normal file
@ -0,0 +1,51 @@
|
||||
-----BEGIN RSA PRIVATE KEY-----
|
||||
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
|
||||
-----END RSA PRIVATE KEY-----
|
Loading…
Reference in New Issue
Block a user