diff --git a/src/common/utils/test/test.go b/src/common/utils/test/test.go index ad8048296..12527c736 100644 --- a/src/common/utils/test/test.go +++ b/src/common/utils/test/test.go @@ -128,6 +128,7 @@ func GetUnitTestConfig() map[string]interface{} { common.WithClair: "true", common.TokenServiceURL: "http://core:8080/service/token", common.RegistryURL: fmt.Sprintf("http://%s:5000", ipAddress), + common.ReadOnly: false, } } diff --git a/src/core/api/api_test.go b/src/core/api/api_test.go index 10ac55523..e7522329e 100644 --- a/src/core/api/api_test.go +++ b/src/core/api/api_test.go @@ -28,7 +28,6 @@ import ( "github.com/goharbor/harbor/src/chartserver" "github.com/goharbor/harbor/src/common" - "github.com/astaxie/beego" "github.com/dghubble/sling" "github.com/goharbor/harbor/src/common/dao" "github.com/goharbor/harbor/src/common/dao/project" @@ -143,7 +142,7 @@ func handle(r *testingRequest) (*httptest.ResponseRecorder, error) { } resp := httptest.NewRecorder() - beego.BeeApp.Handlers.ServeHTTP(resp, req) + handler.ServeHTTP(resp, req) return resp, nil } diff --git a/src/core/api/harborapi_test.go b/src/core/api/harborapi_test.go index ed9e5d9ed..3938a962e 100644 --- a/src/core/api/harborapi_test.go +++ b/src/core/api/harborapi_test.go @@ -19,6 +19,7 @@ import ( "bytes" "encoding/json" "fmt" + "github.com/goharbor/harbor/src/server/middleware/security" "io/ioutil" "log" "net/http" @@ -40,7 +41,6 @@ import ( _ "github.com/goharbor/harbor/src/core/auth/db" _ "github.com/goharbor/harbor/src/core/auth/ldap" "github.com/goharbor/harbor/src/core/config" - "github.com/goharbor/harbor/src/core/filter" "github.com/goharbor/harbor/src/pkg/notification" "github.com/goharbor/harbor/src/replication/model" "github.com/goharbor/harbor/src/testing/apitests/apilib" @@ -54,6 +54,7 @@ const ( ) var admin, unknownUsr, testUser *usrInfo +var handler http.Handler type testapi struct { basePath string @@ -92,10 +93,6 @@ func init() { beego.BConfig.WebConfig.Session.SessionOn = true beego.TestBeegoInit(apppath) - filter.Init() - beego.InsertFilter("/api/*", beego.BeforeStatic, filter.SessionCheck) - beego.InsertFilter("/*", beego.BeforeRouter, filter.SecurityFilter) - beego.Router("/api/health", &HealthAPI{}, "get:CheckHealth") beego.Router("/api/search/", &SearchAPI{}) beego.Router("/api/projects/", &ProjectAPI{}, "get:List;post:Post;head:Head") @@ -218,6 +215,8 @@ func init() { // Init mock jobservice mockServer := test.NewJobServiceServer() defer mockServer.Close() + + handler = security.Middleware()(beego.BeeApp.Handlers) } func request0(_sling *sling.Sling, acceptHeader string, authInfo ...usrInfo) (int, http.Header, []byte, error) { @@ -230,7 +229,7 @@ func request0(_sling *sling.Sling, acceptHeader string, authInfo ...usrInfo) (in req.SetBasicAuth(authInfo[0].Name, authInfo[0].Passwd) } w := httptest.NewRecorder() - beego.BeeApp.Handlers.ServeHTTP(w, req) + handler.ServeHTTP(w, req) body, err := ioutil.ReadAll(w.Body) return w.Code, w.Header(), body, err diff --git a/src/core/api/user.go b/src/core/api/user.go index e7414f17a..052981dfe 100644 --- a/src/core/api/user.go +++ b/src/core/api/user.go @@ -29,7 +29,7 @@ import ( "github.com/goharbor/harbor/src/common/utils" "github.com/goharbor/harbor/src/common/utils/log" "github.com/goharbor/harbor/src/core/config" - "github.com/goharbor/harbor/src/core/filter" + "github.com/goharbor/harbor/src/internal" "github.com/goharbor/harbor/src/pkg/permission/types" ) @@ -324,7 +324,7 @@ func (ua *UserAPI) Post() { return } - if !ua.IsAdmin && !filter.ReqCarriesSession(ua.Ctx.Request) { + if !ua.IsAdmin && !internal.GetCarrySession(ua.Ctx.Request.Context()) { ua.SendForbiddenError(errors.New("self-registration cannot be triggered via API")) return } diff --git a/src/core/auth/authenticator.go b/src/core/auth/authenticator.go index 46788ead4..f883ca7fb 100644 --- a/src/core/auth/authenticator.go +++ b/src/core/auth/authenticator.go @@ -212,17 +212,15 @@ func SearchGroup(groupKey string) (*models.UserGroup, error) { // SearchAndOnBoardUser ... Search user and OnBoard user, if user exist, return the ID of current user. func SearchAndOnBoardUser(username string) (int, error) { user, err := SearchUser(username) - if user == nil { - return 0, ErrorUserNotExist - } if err != nil { return 0, err } - if user != nil { - err = OnBoardUser(user) - if err != nil { - return 0, err - } + if user == nil { + return 0, ErrorUserNotExist + } + err = OnBoardUser(user) + if err != nil { + return 0, err } return user.UserID, nil } diff --git a/src/core/controllers/base.go b/src/core/controllers/base.go index 714306a75..a776cca2d 100644 --- a/src/core/controllers/base.go +++ b/src/core/controllers/base.go @@ -17,7 +17,6 @@ package controllers import ( "bytes" "context" - "github.com/goharbor/harbor/src/core/api" "html/template" "net" "net/http" @@ -34,9 +33,10 @@ import ( "github.com/goharbor/harbor/src/common/utils" email_util "github.com/goharbor/harbor/src/common/utils/email" "github.com/goharbor/harbor/src/common/utils/log" + "github.com/goharbor/harbor/src/core/api" "github.com/goharbor/harbor/src/core/auth" "github.com/goharbor/harbor/src/core/config" - "github.com/goharbor/harbor/src/core/filter" + "github.com/goharbor/harbor/src/internal" ) // CommonController handles request from UI that doesn't expect a page, such as /SwitchLanguage /logout ... @@ -60,8 +60,7 @@ type messageDetail struct { } func redirectForOIDC(ctx context.Context, username string) bool { - am, _ := ctx.Value(filter.AuthModeKey).(string) - if am != common.OIDCAuth { + if internal.GetAuthMode(ctx) != common.OIDCAuth { return false } u, err := dao.GetUser(models.User{Username: username}) diff --git a/src/core/controllers/controllers_test.go b/src/core/controllers/controllers_test.go index f3f1f6555..2b8414e01 100644 --- a/src/core/controllers/controllers_test.go +++ b/src/core/controllers/controllers_test.go @@ -17,6 +17,7 @@ import ( "context" "fmt" "github.com/goharbor/harbor/src/core/middlewares" + "github.com/goharbor/harbor/src/internal" "net/http" "net/http/httptest" "os" @@ -30,7 +31,6 @@ import ( "github.com/goharbor/harbor/src/common/models" utilstest "github.com/goharbor/harbor/src/common/utils/test" "github.com/goharbor/harbor/src/core/config" - "github.com/goharbor/harbor/src/core/filter" "github.com/stretchr/testify/assert" ) @@ -86,9 +86,9 @@ func TestUserResettable(t *testing.T) { } func TestRedirectForOIDC(t *testing.T) { - ctx := context.WithValue(context.Background(), filter.AuthModeKey, common.DBAuth) + ctx := internal.WithAuthMode(context.Background(), common.DBAuth) assert.False(t, redirectForOIDC(ctx, "nonexist")) - ctx = context.WithValue(context.Background(), filter.AuthModeKey, common.OIDCAuth) + ctx = internal.WithAuthMode(context.Background(), common.OIDCAuth) assert.True(t, redirectForOIDC(ctx, "nonexist")) assert.False(t, redirectForOIDC(ctx, "admin")) diff --git a/src/core/filter/mediatype.go b/src/core/filter/mediatype.go deleted file mode 100644 index a91c18590..000000000 --- a/src/core/filter/mediatype.go +++ /dev/null @@ -1,48 +0,0 @@ -// Copyright 2018 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 filter - -import ( - "net/http" - "strings" - - beegoctx "github.com/astaxie/beego/context" - - hlog "github.com/goharbor/harbor/src/common/utils/log" -) - -// MediaTypeFilter filters the POST request, it returns 415 if the content type of the request -// doesn't match the preset ones. -func MediaTypeFilter(mediaType ...string) func(*beegoctx.Context) { - return func(ctx *beegoctx.Context) { - filterContentType(ctx.Request, ctx.ResponseWriter, mediaType...) - } -} - -func filterContentType(req *http.Request, resp http.ResponseWriter, mediaType ...string) { - if req.Method != http.MethodPost { - return - } - v := req.Header.Get("Content-Type") - mimeType := strings.Split(v, ";")[0] - hlog.Debugf("Mimetype of incoming request %s: %s", req.RequestURI, mimeType) - - for _, t := range mediaType { - if t == mimeType { - return - } - } - resp.WriteHeader(http.StatusUnsupportedMediaType) -} diff --git a/src/core/filter/mediatype_test.go b/src/core/filter/mediatype_test.go deleted file mode 100644 index 0f55ce8a4..000000000 --- a/src/core/filter/mediatype_test.go +++ /dev/null @@ -1,42 +0,0 @@ -// 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 filter - -import ( - "github.com/stretchr/testify/assert" - "net/http" - "net/http/httptest" - "testing" -) - -func TestMediaTypeFilter(t *testing.T) { - assert := assert.New(t) - getReq := httptest.NewRequest(http.MethodGet, "/the/path", nil) - rec := httptest.NewRecorder() - filterContentType(getReq, rec, "application/json") - assert.Equal(http.StatusOK, rec.Code) - - postReq := httptest.NewRequest(http.MethodPost, "/the/path", nil) - postReq.Header.Set("Content-Type", "text/html") - rec2 := httptest.NewRecorder() - filterContentType(postReq, rec2, "application/json") - assert.Equal(http.StatusUnsupportedMediaType, rec2.Code) - postReq2 := httptest.NewRequest(http.MethodPost, "/the/path", nil) - postReq2.Header.Set("Content-Type", "application/json; charset=utf-8") - rec3 := httptest.NewRecorder() - filterContentType(postReq2, rec3, "application/json") - assert.Equal(http.StatusOK, rec3.Code) - -} diff --git a/src/core/filter/security.go b/src/core/filter/security.go deleted file mode 100644 index a122134a8..000000000 --- a/src/core/filter/security.go +++ /dev/null @@ -1,399 +0,0 @@ -// Copyright 2018 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 filter - -import ( - "context" - "fmt" - "net/http" - "strings" - - beegoctx "github.com/astaxie/beego/context" - "github.com/goharbor/harbor/src/common" - "github.com/goharbor/harbor/src/common/api" - "github.com/goharbor/harbor/src/common/dao" - "github.com/goharbor/harbor/src/common/dao/group" - "github.com/goharbor/harbor/src/common/models" - secstore "github.com/goharbor/harbor/src/common/secret" - "github.com/goharbor/harbor/src/common/security" - "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/utils/log" - "github.com/goharbor/harbor/src/common/utils/oidc" - "github.com/goharbor/harbor/src/core/auth" - "github.com/goharbor/harbor/src/core/config" - "github.com/goharbor/harbor/src/pkg/authproxy" - "github.com/goharbor/harbor/src/pkg/robot" - pkg_token "github.com/goharbor/harbor/src/pkg/token" - robot_claim "github.com/goharbor/harbor/src/pkg/token/claims/robot" -) - -// ContextValueKey for content value -type ContextValueKey string - -type pathMethod struct { - path string - method string -} - -const ( - // AuthModeKey is context key for auth mode - AuthModeKey ContextValueKey = "harbor_auth_mode" -) - -var ( - reqCtxModifiers []ReqCtxModifier -) - -// Init ReqCtxMofiers list -func Init() { - reqCtxModifiers = []ReqCtxModifier{ - &configCtxModifier{}, - &secretReqCtxModifier{config.SecretStore}, - &oidcCliReqCtxModifier{}, - &idTokenReqCtxModifier{}, - &authProxyReqCtxModifier{}, - &robotAuthReqCtxModifier{}, - &basicAuthReqCtxModifier{}, - &sessionReqCtxModifier{}, - &unauthorizedReqCtxModifier{}} -} - -// SecurityFilter authenticates the request and passes a security context -// and a project manager with it which can be used to do some authN & authZ -func SecurityFilter(ctx *beegoctx.Context) { - if ctx == nil { - return - } - - req := ctx.Request - if req == nil { - return - } - - // add security context and project manager to request context - for _, modifier := range reqCtxModifiers { - if modifier.Modify(ctx) { - break - } - } -} - -// ReqCtxModifier modifies the context of request -type ReqCtxModifier interface { - Modify(*beegoctx.Context) bool -} - -// configCtxModifier populates to the configuration values to context, which are to be read by subsequent -// filters. -type configCtxModifier struct { -} - -func (c *configCtxModifier) Modify(ctx *beegoctx.Context) bool { - m, err := config.AuthMode() - if err != nil { - log.Warningf("Failed to get auth mode, err: %v", err) - } - addToReqContext(ctx.Request, AuthModeKey, m) - return false -} - -type secretReqCtxModifier struct { - store *secstore.Store -} - -func (s *secretReqCtxModifier) Modify(ctx *beegoctx.Context) bool { - scrt := secstore.FromRequest(ctx.Request) - if len(scrt) == 0 { - return false - } - log.Debug("got secret from request") - - log.Debug("creating a secret security context...") - securCtx := secret.NewSecurityContext(scrt, s.store) - - setSecurCtx(ctx.Request, securCtx) - - return true -} - -type robotAuthReqCtxModifier struct{} - -func (r *robotAuthReqCtxModifier) Modify(ctx *beegoctx.Context) bool { - robotName, robotTk, ok := ctx.Request.BasicAuth() - if !ok { - return false - } - if !strings.HasPrefix(robotName, common.RobotPrefix) { - return false - } - rClaims := &robot_claim.Claim{} - opt := pkg_token.DefaultTokenOptions() - rtk, err := pkg_token.Parse(opt, robotTk, rClaims) - if err != nil { - log.Errorf("failed to decrypt robot token, %v", err) - return false - } - // Do authn for robot account, as Harbor only stores the token ID, just validate the ID and disable. - ctr := robot.RobotCtr - robot, err := ctr.GetRobotAccount(rtk.Claims.(*robot_claim.Claim).TokenID) - if err != nil { - log.Errorf("failed to get robot %s: %v", robotName, err) - return false - } - if robot == nil { - log.Error("the token provided doesn't exist.") - 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, rtk.Claims.(*robot_claim.Claim).Access) - setSecurCtx(ctx.Request, securCtx) - return true -} - -type oidcCliReqCtxModifier struct{} - -func (oc *oidcCliReqCtxModifier) Modify(ctx *beegoctx.Context) bool { - path := ctx.Request.URL.Path - if path != "/service/token" && - !strings.HasPrefix(path, "/v2") && - !strings.HasPrefix(path, "/chartrepo/") && - !strings.HasPrefix(path, fmt.Sprintf("/api/%s/chartrepo/", api.APIVersion)) { - log.Debug("OIDC CLI modifier only handles request by docker CLI or helm CLI") - return false - } - if ctx.Request.Context().Value(AuthModeKey).(string) != common.OIDCAuth { - return false - } - username, secret, ok := ctx.Request.BasicAuth() - if !ok { - return false - } - user, err := oidc.VerifySecret(ctx.Request.Context(), username, secret) - if err != nil { - log.Errorf("Failed to verify secret: %v", err) - return false - } - pm := config.GlobalProjectMgr - sc := local.NewSecurityContext(user, pm) - setSecurCtx(ctx.Request, sc) - return true -} - -type idTokenReqCtxModifier struct{} - -func (it *idTokenReqCtxModifier) Modify(ctx *beegoctx.Context) bool { - req := ctx.Request - if req.Context().Value(AuthModeKey).(string) != common.OIDCAuth { - return false - } - if !strings.HasPrefix(ctx.Request.URL.Path, "/api") { - return false - } - h := req.Header.Get("Authorization") - token := strings.Split(h, "Bearer") - if len(token) < 2 { - return false - } - claims, err := oidc.VerifyToken(req.Context(), strings.TrimSpace(token[1])) - if err != nil { - log.Warningf("Failed to verify token, error: %v", err) - return false - } - u, err := dao.GetUserBySubIss(claims.Subject, claims.Issuer) - if err != nil { - log.Warningf("Failed to get user based on token claims, error: %v", err) - return false - } - if u == nil { - log.Warning("User matches token's claims is not onboarded.") - return false - } - settings, err := config.OIDCSetting() - if err != nil { - log.Errorf("Failed to get OIDC settings, error: %v", err) - } - if groupNames, ok := oidc.GroupsFromClaims(claims, settings.GroupsClaim); ok { - groups := models.UserGroupsFromName(groupNames, common.OIDCGroupType) - u.GroupIDs, err = group.PopulateGroup(groups) - if err != nil { - log.Errorf("Failed to get group ID list for OIDC user: %s, error: %v", u.Username, err) - return false - } - } - pm := config.GlobalProjectMgr - sc := local.NewSecurityContext(u, pm) - setSecurCtx(ctx.Request, sc) - return true -} - -type authProxyReqCtxModifier struct{} - -func (ap *authProxyReqCtxModifier) Modify(ctx *beegoctx.Context) bool { - if ctx.Request.Context().Value(AuthModeKey).(string) != common.HTTPAuth { - return false - } - - // only support docker login - if ctx.Request.URL.Path != "/service/token" { - log.Debug("Auth proxy modifier only handles docker login request.") - return false - } - - proxyUserName, proxyPwd, ok := ctx.Request.BasicAuth() - if !ok { - return false - } - - rawUserName, match := ap.matchAuthProxyUserName(proxyUserName) - if !match { - log.Errorf("User name %s doesn't meet the auth proxy name pattern", proxyUserName) - return false - } - httpAuthProxyConf, err := config.HTTPAuthProxySetting() - if err != nil { - log.Errorf("fail to get auth proxy settings, %v", err) - return false - } - tokenReviewStatus, err := authproxy.TokenReview(proxyPwd, httpAuthProxyConf) - if err != nil { - log.Errorf("fail to review token, %v", err) - return false - } - if rawUserName != tokenReviewStatus.User.Username { - log.Errorf("user name doesn't match with token: %s", rawUserName) - return false - } - user, err := dao.GetUser(models.User{ - Username: rawUserName, - }) - if err != nil { - log.Errorf("fail to get user: %s, error: %v", rawUserName, err) - return false - } - if user == nil { // onboard user if it's not yet onboarded. - uid, err := auth.SearchAndOnBoardUser(rawUserName) - if err != nil { - log.Errorf("Failed to search and onboard user, username: %s, error: %v", rawUserName, err) - return false - } - user, err = dao.GetUser(models.User{ - UserID: uid, - }) - if err != nil { - log.Errorf("Fail to get user, name: %s, ID: %d, error: %v", rawUserName, uid, err) - return false - } - } - u2, err := authproxy.UserFromReviewStatus(tokenReviewStatus) - if err != nil { - log.Errorf("Failed to get user information from token review status, error: %v", err) - return false - } - user.GroupIDs = u2.GroupIDs - pm := config.GlobalProjectMgr - log.Debug("creating local database security context for auth proxy...") - securCtx := local.NewSecurityContext(user, pm) - setSecurCtx(ctx.Request, securCtx) - return true -} - -func (ap *authProxyReqCtxModifier) matchAuthProxyUserName(name string) (string, bool) { - if !strings.HasPrefix(name, common.AuthProxyUserNamePrefix) { - return "", false - } - return strings.Replace(name, common.AuthProxyUserNamePrefix, "", -1), true -} - -type basicAuthReqCtxModifier struct{} - -func (b *basicAuthReqCtxModifier) Modify(ctx *beegoctx.Context) bool { - username, password, ok := ctx.Request.BasicAuth() - if !ok { - return false - } - log.Debug("got user information via basic auth") - - user, err := auth.Login(models.AuthModel{ - Principal: username, - Password: password, - }) - if err != nil { - log.Errorf("failed to authenticate %s: %v", username, err) - return false - } - if user == nil { - log.Debug("basic auth user is nil") - return false - } - pm := config.GlobalProjectMgr - log.Debug("creating local database security context...") - securCtx := local.NewSecurityContext(user, pm) - setSecurCtx(ctx.Request, securCtx) - return true -} - -type sessionReqCtxModifier struct{} - -func (s *sessionReqCtxModifier) Modify(ctx *beegoctx.Context) bool { - userInterface := ctx.Input.Session("user") - if userInterface == nil { - log.Debug("can not get user information from session") - return false - } - log.Debug("got user information from session") - user, ok := userInterface.(models.User) - if !ok { - log.Info("can not get user information from session") - return false - } - pm := config.GlobalProjectMgr - log.Debug("creating local database security context...") - securityCtx := local.NewSecurityContext(&user, pm) - - setSecurCtx(ctx.Request, securityCtx) - - return true -} - -// use this one as the last modifier in the modifier list for unauthorized request -type unauthorizedReqCtxModifier struct{} - -func (u *unauthorizedReqCtxModifier) Modify(ctx *beegoctx.Context) bool { - log.Debug("user information is nil") - pm := config.GlobalProjectMgr - log.Debug("creating local database security context...") - securCtx := local.NewSecurityContext(nil, pm) - setSecurCtx(ctx.Request, securCtx) - return true -} - -func setSecurCtx(req *http.Request, ctx security.Context) { - *req = *(req.WithContext(security.NewContext(req.Context(), ctx))) -} - -func addToReqContext(req *http.Request, key, value interface{}) { - *req = *(req.WithContext(context.WithValue(req.Context(), key, value))) -} diff --git a/src/core/filter/security_test.go b/src/core/filter/security_test.go deleted file mode 100644 index d9095228a..000000000 --- a/src/core/filter/security_test.go +++ /dev/null @@ -1,436 +0,0 @@ -// Copyright 2018 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 filter - -import ( - "context" - "log" - "net/http" - "net/http/httptest" - "net/url" - "os" - "path/filepath" - "testing" - "time" - - "github.com/goharbor/harbor/src/common/utils/oidc" - "github.com/stretchr/testify/require" - - "github.com/astaxie/beego" - beegoctx "github.com/astaxie/beego/context" - "github.com/astaxie/beego/session" - config2 "github.com/goharbor/harbor/src/common/config" - "github.com/goharbor/harbor/src/common/dao" - "github.com/goharbor/harbor/src/common/models" - commonsecret "github.com/goharbor/harbor/src/common/secret" - "github.com/goharbor/harbor/src/common/security" - "github.com/goharbor/harbor/src/common/security/local" - "github.com/goharbor/harbor/src/common/security/secret" - "github.com/goharbor/harbor/src/common/utils/test" - _ "github.com/goharbor/harbor/src/core/auth/authproxy" - _ "github.com/goharbor/harbor/src/core/auth/db" - _ "github.com/goharbor/harbor/src/core/auth/ldap" - "github.com/goharbor/harbor/src/core/config" - "github.com/stretchr/testify/assert" - - "github.com/goharbor/harbor/src/common" - fiter_test "github.com/goharbor/harbor/src/core/filter/test" -) - -func TestMain(m *testing.M) { - // initialize beego session manager - conf := &session.ManagerConfig{ - CookieName: beego.BConfig.WebConfig.Session.SessionName, - Gclifetime: beego.BConfig.WebConfig.Session.SessionGCMaxLifetime, - ProviderConfig: filepath.ToSlash(beego.BConfig.WebConfig.Session.SessionProviderConfig), - Secure: beego.BConfig.Listen.EnableHTTPS, - EnableSetCookie: beego.BConfig.WebConfig.Session.SessionAutoSetCookie, - Domain: beego.BConfig.WebConfig.Session.SessionDomain, - CookieLifeTime: beego.BConfig.WebConfig.Session.SessionCookieLifeTime, - } - - var err error - beego.GlobalSessions, err = session.NewManager("memory", conf) - if err != nil { - log.Fatalf("failed to create session manager: %v", err) - } - config.Init() - test.InitDatabaseFromEnv() - - config.Upload(test.GetUnitTestConfig()) - Init() - - os.Exit(m.Run()) -} - -func TestSecurityFilter(t *testing.T) { - // nil request - ctx, err := newContext(nil) - if err != nil { - t.Fatalf("failed to create context: %v", err) - } - SecurityFilter(ctx) - assert.Nil(t, securityContext(ctx)) - - // the pattern of request needs security check - 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) - } - - ctx, err = newContext(req) - if err != nil { - t.Fatalf("failed to crate context: %v", err) - } - SecurityFilter(ctx) - assert.NotNil(t, securityContext(ctx)) -} - -func TestConfigCtxModifier(t *testing.T) { - req, err := http.NewRequest(http.MethodGet, - "http://127.0.0.1/api/projects/", nil) - require.Nil(t, err) - conf := map[string]interface{}{ - common.AUTHMode: common.OIDCAuth, - common.OIDCName: "test", - common.OIDCEndpoint: "https://accounts.google.com", - common.OIDCVerifyCert: "true", - common.OIDCScope: "openid, profile, offline_access", - common.OIDCGroupsClaim: "groups", - common.OIDCCLientID: "client", - common.OIDCClientSecret: "secret", - common.ExtEndpoint: "https://harbor.test", - } - config.InitWithSettings(conf) - ctx, err := newContext(req) - m := &configCtxModifier{} - f := m.Modify(ctx) - assert.False(t, f) - assert.Equal(t, common.OIDCAuth, req.Context().Value(AuthModeKey).(string)) -} - -func TestSecretReqCtxModifier(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) - } - commonsecret.AddToRequest(req, "secret") - ctx, err := newContext(req) - if err != nil { - t.Fatalf("failed to crate context: %v", err) - } - - modifier := &secretReqCtxModifier{} - modified := modifier.Modify(ctx) - assert.True(t, modified) - assert.IsType(t, &secret.SecurityContext{}, - securityContext(ctx)) -} - -func TestOIDCCliReqCtxModifier(t *testing.T) { - conf := map[string]interface{}{ - common.AUTHMode: common.OIDCAuth, - common.OIDCName: "test", - common.OIDCEndpoint: "https://accounts.google.com", - common.OIDCVerifyCert: "true", - common.OIDCScope: "openid, profile, offline_access", - common.OIDCCLientID: "client", - common.OIDCClientSecret: "secret", - common.ExtEndpoint: "https://harbor.test", - } - - kp := &config2.PresetKeyProvider{Key: "naa4JtarA1Zsc3uY"} - config.InitWithSettings(conf, kp) - - modifier := &oidcCliReqCtxModifier{} - req1, err := http.NewRequest(http.MethodGet, - "http://127.0.0.1/api/projects/", nil) - require.Nil(t, err) - ctx1, err := newContext(req1) - require.Nil(t, err) - assert.False(t, modifier.Modify(ctx1)) - req2, err := http.NewRequest(http.MethodGet, "http://127.0.0.1/service/token", nil) - require.Nil(t, err) - addToReqContext(req2, AuthModeKey, common.OIDCAuth) - ctx2, err := newContext(req2) - require.Nil(t, err) - assert.False(t, modifier.Modify(ctx2)) - username := "oidcModiferTester" - password := "oidcSecret" - u := &models.User{ - Username: username, - Email: "testtest@test.org", - Password: "12345678", - } - id, err := dao.Register(*u) - require.Nil(t, err) - oidc.SetHardcodeVerifierForTest(password) - req3, err := http.NewRequest(http.MethodGet, "http://127.0.0.1/service/token", nil) - require.Nil(t, err) - req3.SetBasicAuth(username, password) - addToReqContext(req3, AuthModeKey, common.OIDCAuth) - ctx3, err := newContext(req3) - assert.True(t, modifier.Modify(ctx3)) - o := dao.GetOrmer() - _, err = o.Delete(&models.User{UserID: int(id)}) - assert.Nil(t, err) -} - -func TestIdTokenReqCtxModifier(t *testing.T) { - bc := context.Background() - it := &idTokenReqCtxModifier{} - r1, err := http.NewRequest(http.MethodGet, - "http://127.0.0.1/chartrepo/", nil) - require.Nil(t, err) - req1 := r1.WithContext(context.WithValue(bc, AuthModeKey, common.DBAuth)) - ctx1, err := newContext(req1) - require.Nil(t, err) - assert.False(t, it.Modify(ctx1)) - - req2 := r1.WithContext(context.WithValue(bc, AuthModeKey, common.OIDCAuth)) - ctx2, err := newContext(req2) - require.Nil(t, err) - assert.False(t, it.Modify(ctx2)) - - r2, err := http.NewRequest(http.MethodGet, - "http://127.0.0.1/api/projects/", nil) - require.Nil(t, err) - req3 := r2.WithContext(context.WithValue(bc, AuthModeKey, common.OIDCAuth)) - ctx3, err := newContext(req3) - require.Nil(t, err) - assert.False(t, it.Modify(ctx3)) -} - -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 TestAuthProxyReqCtxModifier(t *testing.T) { - - server, err := fiter_test.NewAuthProxyTestServer() - assert.Nil(t, err) - defer server.Close() - - c := map[string]interface{}{ - common.HTTPAuthProxySkipSearch: "true", - common.HTTPAuthProxyVerifyCert: "false", - common.HTTPAuthProxyEndpoint: "https://auth.proxy/suffix", - common.HTTPAuthProxyTokenReviewEndpoint: server.URL, - common.AUTHMode: common.HTTPAuth, - } - - config.Upload(c) - v, e := config.HTTPAuthProxySetting() - assert.Nil(t, e) - assert.Equal(t, *v, models.HTTPAuthProxy{ - Endpoint: "https://auth.proxy/suffix", - SkipSearch: true, - VerifyCert: false, - TokenReviewEndpoint: server.URL, - }) - - // No onboard - req, err := http.NewRequest(http.MethodGet, - "http://127.0.0.1/service/token", nil) - if err != nil { - t.Fatalf("failed to create request: %v", req) - } - req.SetBasicAuth("tokenreview$administrator@vsphere.local", "reviEwt0k3n") - addToReqContext(req, AuthModeKey, common.HTTPAuth) - ctx, err := newContext(req) - if err != nil { - t.Fatalf("failed to create context: %v", err) - } - - modifier := &authProxyReqCtxModifier{} - modified := modifier.Modify(ctx) - assert.True(t, modified) - -} - -func TestBasicAuthReqCtxModifier(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("admin", "Harbor12345") - - ctx, err := newContext(req) - if err != nil { - t.Fatalf("failed to crate context: %v", err) - } - - modifier := &basicAuthReqCtxModifier{} - modified := modifier.Modify(ctx) - assert.True(t, modified) - - sc := securityContext(ctx) - assert.IsType(t, &local.SecurityContext{}, sc) - s := sc.(security.Context) - assert.Equal(t, "admin", s.GetUsername()) -} - -func TestSessionReqCtxModifier(t *testing.T) { - user := models.User{ - Username: "admin", - UserID: 1, - Email: "admin@example.com", - SysAdminFlag: true, - } - 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) - } - store, err := beego.GlobalSessions.SessionStart(httptest.NewRecorder(), req) - if err != nil { - t.Fatalf("failed to create session store: %v", err) - } - if err = store.Set("user", user); err != nil { - t.Fatalf("failed to set session: %v", err) - } - - addSessionIDToCookie(req, store.SessionID()) - addToReqContext(req, AuthModeKey, common.DBAuth) - ctx, err := newContext(req) - if err != nil { - t.Fatalf("failed to create context: %v", err) - } - - modifier := &sessionReqCtxModifier{} - modified := modifier.Modify(ctx) - - assert.True(t, modified) - sc := securityContext(ctx) - assert.IsType(t, &local.SecurityContext{}, sc) - s := sc.(security.Context) - assert.Equal(t, "admin", s.GetUsername()) - assert.True(t, s.IsSysAdmin()) -} - -func TestSessionReqCtxModifierFailed(t *testing.T) { - user := "admin" - 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) - } - store, err := beego.GlobalSessions.SessionStart(httptest.NewRecorder(), req) - if err != nil { - t.Fatalf("failed to create session store: %v", err) - } - if err = store.Set("user", user); err != nil { - t.Fatalf("failed to set session: %v", err) - } - - 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) - } - addSessionIDToCookie(req, store.SessionID()) - addToReqContext(req, AuthModeKey, common.DBAuth) - ctx, err := newContext(req) - if err != nil { - t.Fatalf("failed to crate context: %v", err) - } - modifier := &sessionReqCtxModifier{} - modified := modifier.Modify(ctx) - - assert.False(t, modified) - -} - -// TODO add test case -func TestTokenReqCtxModifier(t *testing.T) { - -} - -func TestUnauthorizedReqCtxModifier(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) - } - - ctx, err := newContext(req) - if err != nil { - t.Fatalf("failed to crate context: %v", err) - } - - modifier := &unauthorizedReqCtxModifier{} - modified := modifier.Modify(ctx) - assert.True(t, modified) - - sc := securityContext(ctx) - assert.NotNil(t, sc) - s := sc.(security.Context) - assert.False(t, s.IsAuthenticated()) -} - -func newContext(req *http.Request) (*beegoctx.Context, error) { - var err error - ctx := beegoctx.NewContext() - ctx.Reset(httptest.NewRecorder(), req) - if req != nil { - ctx.Input.CruSession, err = beego.GlobalSessions.SessionStart(ctx.ResponseWriter, req) - } - return ctx, err -} - -func addSessionIDToCookie(req *http.Request, sessionID string) { - cookie := &http.Cookie{ - Name: beego.BConfig.WebConfig.Session.SessionName, - Value: url.QueryEscape(sessionID), - Path: "/", - HttpOnly: true, - Secure: beego.BConfig.Listen.EnableHTTPS, - Domain: beego.BConfig.WebConfig.Session.SessionDomain, - } - cookie.MaxAge = beego.BConfig.WebConfig.Session.SessionCookieLifeTime - cookie.Expires = time.Now().Add( - time.Duration( - beego.BConfig.WebConfig.Session.SessionCookieLifeTime) * time.Second) - req.AddCookie(cookie) -} - -func securityContext(ctx *beegoctx.Context) interface{} { - if ctx.Request == nil { - return nil - } - - c, ok := security.FromContext(ctx.Request.Context()) - if !ok { - log.Printf("failed to get security context") - return nil - } - return c -} diff --git a/src/core/filter/sessionchecker.go b/src/core/filter/sessionchecker.go deleted file mode 100644 index 99f9d0060..000000000 --- a/src/core/filter/sessionchecker.go +++ /dev/null @@ -1,30 +0,0 @@ -package filter - -import ( - "context" - beegoctx "github.com/astaxie/beego/context" - "github.com/goharbor/harbor/src/common/utils/log" - "github.com/goharbor/harbor/src/core/config" - "net/http" -) - -// SessionReqKey is the key in the context of a request to mark the request carries session when reaching the backend -const SessionReqKey ContextValueKey = "harbor_with_session_req" - -// SessionCheck is a filter to mark the requests that carries a session id, it has to be registered as -// "beego.BeforeStatic" because beego will modify the request after execution of these filters, all requests will -// appear to have a session id cookie. -func SessionCheck(ctx *beegoctx.Context) { - req := ctx.Request - _, err := req.Cookie(config.SessionCookieName) - if err == nil { - ctx.Request = req.WithContext(context.WithValue(req.Context(), SessionReqKey, true)) - log.Debugf("Mark the request as with-session: %s %s", req.Method, req.URL.RawPath) - } -} - -// ReqCarriesSession verifies if the request carries session when -func ReqCarriesSession(req *http.Request) bool { - r, ok := req.Context().Value(SessionReqKey).(bool) - return ok && r -} diff --git a/src/core/filter/sessionchecker_test.go b/src/core/filter/sessionchecker_test.go deleted file mode 100644 index c33bc647e..000000000 --- a/src/core/filter/sessionchecker_test.go +++ /dev/null @@ -1,16 +0,0 @@ -package filter - -import ( - beegoctx "github.com/astaxie/beego/context" - "github.com/stretchr/testify/assert" - "net/http" - "testing" -) - -func TestReqHasNoSession(t *testing.T) { - req, _ := http.NewRequest("POST", "https://127.0.0.1:8080/api/users", nil) - ctx := beegoctx.NewContext() - ctx.Request = req - SessionCheck(ctx) - assert.False(t, ReqCarriesSession(ctx.Request)) -} diff --git a/src/core/main.go b/src/core/main.go index bee68f79c..5d56225ea 100755 --- a/src/core/main.go +++ b/src/core/main.go @@ -38,7 +38,6 @@ import ( _ "github.com/goharbor/harbor/src/core/auth/oidc" _ "github.com/goharbor/harbor/src/core/auth/uaa" "github.com/goharbor/harbor/src/core/config" - "github.com/goharbor/harbor/src/core/filter" "github.com/goharbor/harbor/src/core/middlewares" "github.com/goharbor/harbor/src/core/service/token" "github.com/goharbor/harbor/src/migration" @@ -156,10 +155,6 @@ func main() { log.Info("initializing notification...") notification.Init() - filter.Init() - beego.InsertFilter("/api/*", beego.BeforeStatic, filter.SessionCheck) - beego.InsertFilter("/*", beego.BeforeRouter, filter.SecurityFilter) - server.RegisterRoutes() if common_http.InternalTLSEnabled() { diff --git a/src/core/middlewares/middlewares.go b/src/core/middlewares/middlewares.go index c0926e614..c0fd0753b 100644 --- a/src/core/middlewares/middlewares.go +++ b/src/core/middlewares/middlewares.go @@ -15,6 +15,9 @@ package middlewares import ( + "github.com/goharbor/harbor/src/server/middleware/csrf" + "github.com/goharbor/harbor/src/server/middleware/log" + "github.com/goharbor/harbor/src/server/middleware/requestid" "net/http" "path" "regexp" @@ -23,12 +26,11 @@ import ( "github.com/astaxie/beego" "github.com/docker/distribution/reference" "github.com/goharbor/harbor/src/server/middleware" - "github.com/goharbor/harbor/src/server/middleware/csrf" - "github.com/goharbor/harbor/src/server/middleware/log" "github.com/goharbor/harbor/src/server/middleware/notification" "github.com/goharbor/harbor/src/server/middleware/orm" "github.com/goharbor/harbor/src/server/middleware/readonly" - "github.com/goharbor/harbor/src/server/middleware/requestid" + "github.com/goharbor/harbor/src/server/middleware/security" + "github.com/goharbor/harbor/src/server/middleware/session" "github.com/goharbor/harbor/src/server/middleware/transaction" ) @@ -74,9 +76,11 @@ func legacyAPISkipper(r *http.Request) bool { // MiddleWares returns global middlewares func MiddleWares() []beego.MiddleWare { return []beego.MiddleWare{ - csrf.Middleware(), requestid.Middleware(), log.Middleware(), + session.Middleware(), + csrf.Middleware(), + security.Middleware(), readonly.Middleware(readonlySkippers...), orm.Middleware(legacyAPISkipper), // notification must ahead of transaction ensure the DB transaction execution complete diff --git a/src/internal/context.go b/src/internal/context.go index fd421f0f6..e12040a7d 100644 --- a/src/internal/context.go +++ b/src/internal/context.go @@ -22,6 +22,8 @@ type contextKey string const ( contextKeyAPIVersion contextKey = "apiVersion" contextKeyArtifactInfo contextKey = "artifactInfo" + contextKeyAuthMode contextKey = "authMode" + contextKeyCarrySession contextKey = "carrySession" ) // ArtifactInfo wraps the artifact info extracted from the request to "/v2/" @@ -60,7 +62,7 @@ func GetAPIVersion(ctx context.Context) string { version := "" value := getFromContext(ctx, contextKeyAPIVersion) if value != nil { - version = value.(string) + version, _ = value.(string) } return version } @@ -74,7 +76,37 @@ func WithArtifactInfo(ctx context.Context, art ArtifactInfo) context.Context { func GetArtifactInfo(ctx context.Context) (art ArtifactInfo) { value := getFromContext(ctx, contextKeyArtifactInfo) if value != nil { - art = value.(ArtifactInfo) + art, _ = value.(ArtifactInfo) } return } + +// WithAuthMode returns a context with auth mode set +func WithAuthMode(ctx context.Context, mode string) context.Context { + return setToContext(ctx, contextKeyAuthMode, mode) +} + +// GetAuthMode gets the auth mode from the context +func GetAuthMode(ctx context.Context) string { + mode := "" + value := getFromContext(ctx, contextKeyAuthMode) + if value != nil { + mode, _ = value.(string) + } + return mode +} + +// WithCarrySession returns a context with "carry session" set that indicates whether the request carries session or not +func WithCarrySession(ctx context.Context, carrySession bool) context.Context { + return setToContext(ctx, contextKeyCarrySession, carrySession) +} + +// GetCarrySession gets the "carry session" from the context indicates whether the request carries session or not +func GetCarrySession(ctx context.Context) bool { + carrySession := false + value := getFromContext(ctx, contextKeyCarrySession) + if value != nil { + carrySession, _ = value.(bool) + } + return carrySession +} diff --git a/src/server/middleware/csrf/csrf.go b/src/server/middleware/csrf/csrf.go index 8ec8ff5a8..47a1d2d5b 100644 --- a/src/server/middleware/csrf/csrf.go +++ b/src/server/middleware/csrf/csrf.go @@ -9,6 +9,7 @@ import ( "github.com/goharbor/harbor/src/common/utils" "github.com/goharbor/harbor/src/common/utils/log" "github.com/goharbor/harbor/src/core/config" + "github.com/goharbor/harbor/src/internal" ierror "github.com/goharbor/harbor/src/internal/error" serror "github.com/goharbor/harbor/src/server/error" "github.com/goharbor/harbor/src/server/middleware" @@ -78,14 +79,10 @@ func Middleware() func(handler http.Handler) http.Handler { // csrfSkipper makes sure only some of the uris accessed by non-UI client can skip the csrf check func csrfSkipper(req *http.Request) bool { path := req.URL.Path - // We can check the cookie directly b/c the filter and controllerRegistry is executed after middleware, so no session - // cookie is added by beego. - _, err := req.Cookie(config.SessionCookieName) - hasSession := err == nil if (strings.HasPrefix(path, "/v2/") || strings.HasPrefix(path, "/api/") || strings.HasPrefix(path, "/chartrepo/") || - strings.HasPrefix(path, "/service/")) && !hasSession { + strings.HasPrefix(path, "/service/")) && !internal.GetCarrySession(req.Context()) { return true } return false diff --git a/src/server/middleware/security/auth_proxy.go b/src/server/middleware/security/auth_proxy.go new file mode 100644 index 000000000..fb431dfff --- /dev/null +++ b/src/server/middleware/security/auth_proxy.go @@ -0,0 +1,104 @@ +// 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 security + +import ( + "net/http" + "strings" + + "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/security" + "github.com/goharbor/harbor/src/common/security/local" + "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/internal" + "github.com/goharbor/harbor/src/pkg/authproxy" +) + +type authProxy struct{} + +func (a *authProxy) Generate(req *http.Request) security.Context { + log := log.G(req.Context()) + if internal.GetAuthMode(req.Context()) != common.HTTPAuth { + return nil + } + // only support docker login + if req.URL.Path != "/service/token" { + return nil + } + proxyUserName, proxyPwd, ok := req.BasicAuth() + if !ok { + return nil + } + rawUserName, match := a.matchAuthProxyUserName(proxyUserName) + if !match { + log.Errorf("user name %s doesn't meet the auth proxy name pattern", proxyUserName) + return nil + } + httpAuthProxyConf, err := config.HTTPAuthProxySetting() + if err != nil { + log.Errorf("failed to get auth proxy settings: %v", err) + return nil + } + tokenReviewStatus, err := authproxy.TokenReview(proxyPwd, httpAuthProxyConf) + if err != nil { + log.Errorf("failed to review token: %v", err) + return nil + } + if rawUserName != tokenReviewStatus.User.Username { + log.Errorf("user name doesn't match with token: %s", rawUserName) + return nil + } + user, err := dao.GetUser(models.User{ + Username: rawUserName, + }) + if err != nil { + log.Errorf("failed to get user %s: %v", rawUserName, err) + return nil + } + if user == nil { + // onboard user if it's not yet onboarded. + uid, err := auth.SearchAndOnBoardUser(rawUserName) + if err != nil { + log.Errorf("failed to search and onboard user %s: %v", rawUserName, err) + return nil + } + user, err = dao.GetUser(models.User{ + UserID: uid, + }) + if err != nil { + log.Errorf("failed to get user, name: %s, ID: %d: %v", rawUserName, uid, err) + return nil + } + } + u2, err := authproxy.UserFromReviewStatus(tokenReviewStatus) + if err != nil { + log.Errorf("failed to get user information from token review status: %v", err) + return nil + } + user.GroupIDs = u2.GroupIDs + log.Debugf("an auth proxy security context generated for request %s %s", req.Method, req.URL.Path) + return local.NewSecurityContext(user, config.GlobalProjectMgr) +} + +func (a *authProxy) matchAuthProxyUserName(name string) (string, bool) { + if !strings.HasPrefix(name, common.AuthProxyUserNamePrefix) { + return "", false + } + return strings.Replace(name, common.AuthProxyUserNamePrefix, "", -1), true +} diff --git a/src/core/filter/test/server.go b/src/server/middleware/security/auth_proxy_test.go similarity index 52% rename from src/core/filter/test/server.go rename to src/server/middleware/security/auth_proxy_test.go index e2f25b30d..318b651bc 100644 --- a/src/core/filter/test/server.go +++ b/src/server/middleware/security/auth_proxy_test.go @@ -1,17 +1,75 @@ -package test +// 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 security import ( "encoding/json" "fmt" + "github.com/goharbor/harbor/src/common" + "github.com/goharbor/harbor/src/common/models" + "github.com/goharbor/harbor/src/common/utils/test" + _ "github.com/goharbor/harbor/src/core/auth/authproxy" + "github.com/goharbor/harbor/src/core/config" + "github.com/goharbor/harbor/src/internal" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" "io/ioutil" "k8s.io/api/authentication/v1beta1" "net/http" "net/http/httptest" "net/url" + "testing" ) +func TestAuthProxy(t *testing.T) { + config.Init() + test.InitDatabaseFromEnv() + authProxy := &authProxy{} + + server, err := newAuthProxyTestServer() + require.Nil(t, err) + defer server.Close() + + c := map[string]interface{}{ + common.HTTPAuthProxySkipSearch: "true", + common.HTTPAuthProxyVerifyCert: "false", + common.HTTPAuthProxyEndpoint: "https://auth.proxy/suffix", + common.HTTPAuthProxyTokenReviewEndpoint: server.URL, + common.AUTHMode: common.HTTPAuth, + } + config.Upload(c) + v, e := config.HTTPAuthProxySetting() + require.Nil(t, e) + assert.Equal(t, *v, models.HTTPAuthProxy{ + Endpoint: "https://auth.proxy/suffix", + SkipSearch: true, + VerifyCert: false, + TokenReviewEndpoint: server.URL, + }) + + // No onboard + req, err := http.NewRequest(http.MethodGet, "http://127.0.0.1/service/token", nil) + require.Nil(t, err) + req = req.WithContext(internal.WithAuthMode(req.Context(), common.HTTPAuth)) + req.SetBasicAuth("tokenreview$administrator@vsphere.local", "reviEwt0k3n") + ctx := authProxy.Generate(req) + assert.NotNil(t, ctx) +} + // NewAuthProxyTestServer mocks a https server for auth proxy. -func NewAuthProxyTestServer() (*httptest.Server, error) { +func newAuthProxyTestServer() (*httptest.Server, error) { const webhookPath = "/authproxy/tokenreview" serveHTTP := func(w http.ResponseWriter, r *http.Request) { diff --git a/src/server/middleware/security/basic_auth.go b/src/server/middleware/security/basic_auth.go new file mode 100644 index 000000000..bda28869a --- /dev/null +++ b/src/server/middleware/security/basic_auth.go @@ -0,0 +1,50 @@ +// 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 security + +import ( + "net/http" + + "github.com/goharbor/harbor/src/common/models" + "github.com/goharbor/harbor/src/common/security" + "github.com/goharbor/harbor/src/common/security/local" + "github.com/goharbor/harbor/src/common/utils/log" + "github.com/goharbor/harbor/src/core/auth" + "github.com/goharbor/harbor/src/core/config" +) + +type basicAuth struct{} + +func (b *basicAuth) Generate(req *http.Request) security.Context { + log := log.G(req.Context()) + username, password, ok := req.BasicAuth() + if !ok { + return nil + } + user, err := auth.Login(models.AuthModel{ + Principal: username, + Password: password, + }) + if err != nil { + log.Errorf("failed to authenticate %s: %v", username, err) + return nil + } + if user == nil { + log.Debug("basic auth user is nil") + return nil + } + log.Debugf("a basic auth security context generated for request %s %s", req.Method, req.URL.Path) + return local.NewSecurityContext(user, config.GlobalProjectMgr) +} diff --git a/src/server/middleware/security/basic_auth_test.go b/src/server/middleware/security/basic_auth_test.go new file mode 100644 index 000000000..dd3e23e97 --- /dev/null +++ b/src/server/middleware/security/basic_auth_test.go @@ -0,0 +1,32 @@ +// 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 security + +import ( + _ "github.com/goharbor/harbor/src/core/auth/db" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "net/http" + "testing" +) + +func TestBasicAuth(t *testing.T) { + basicAuth := &basicAuth{} + req, err := http.NewRequest(http.MethodGet, "http://127.0.0.1/api/projects/", nil) + require.Nil(t, err) + req.SetBasicAuth("admin", "Harbor12345") + ctx := basicAuth.Generate(req) + assert.NotNil(t, ctx) +} diff --git a/src/server/middleware/security/idtoken.go b/src/server/middleware/security/idtoken.go new file mode 100644 index 000000000..19449d3b5 --- /dev/null +++ b/src/server/middleware/security/idtoken.go @@ -0,0 +1,77 @@ +// 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 security + +import ( + "net/http" + "strings" + + "github.com/goharbor/harbor/src/common" + "github.com/goharbor/harbor/src/common/dao" + "github.com/goharbor/harbor/src/common/dao/group" + "github.com/goharbor/harbor/src/common/models" + "github.com/goharbor/harbor/src/common/security" + "github.com/goharbor/harbor/src/common/security/local" + "github.com/goharbor/harbor/src/common/utils/log" + "github.com/goharbor/harbor/src/common/utils/oidc" + "github.com/goharbor/harbor/src/core/config" + "github.com/goharbor/harbor/src/internal" +) + +type idToken struct{} + +func (i *idToken) Generate(req *http.Request) security.Context { + log := log.G(req.Context()) + if internal.GetAuthMode(req.Context()) != common.OIDCAuth { + return nil + } + 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])) + if err != nil { + log.Warningf("failed to verify token: %v", err) + return nil + } + u, err := dao.GetUserBySubIss(claims.Subject, claims.Issuer) + if err != nil { + log.Warningf("failed to get user based on token claims: %v", err) + return nil + } + if u == nil { + log.Warning("user matches token's claims is not onboarded.") + return nil + } + settings, err := config.OIDCSetting() + if err != nil { + log.Errorf("failed to get OIDC settings: %v", err) + return nil + } + if groupNames, ok := oidc.GroupsFromClaims(claims, settings.GroupsClaim); ok { + groups := models.UserGroupsFromName(groupNames, common.OIDCGroupType) + u.GroupIDs, err = group.PopulateGroup(groups) + if err != nil { + log.Errorf("failed to get group ID list for OIDC user %s: %v", u.Username, err) + return nil + } + } + log.Debugf("an ID token security context generated for request %s %s", req.Method, req.URL.Path) + return local.NewSecurityContext(u, config.GlobalProjectMgr) +} diff --git a/src/server/middleware/security/idtoken_test.go b/src/server/middleware/security/idtoken_test.go new file mode 100644 index 000000000..73f79e1e7 --- /dev/null +++ b/src/server/middleware/security/idtoken_test.go @@ -0,0 +1,48 @@ +// 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 security + +import ( + "github.com/goharbor/harbor/src/common" + "github.com/goharbor/harbor/src/internal" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "net/http" + "testing" +) + +func TestIDToken(t *testing.T) { + idToken := &idToken{} + + // not the OIDC mode + req, err := http.NewRequest(http.MethodGet, "http://127.0.0.1/api/projects/", nil) + require.Nil(t, err) + ctx := idToken.Generate(req) + assert.Nil(t, ctx) + + // not the candidate request + req, err = http.NewRequest(http.MethodGet, "http://127.0.0.1/chartrepo/", nil) + require.Nil(t, err) + req = req.WithContext(internal.WithAuthMode(req.Context(), common.DBAuth)) + ctx = idToken.Generate(req) + assert.Nil(t, ctx) + + // contains no authorization header + req, err = http.NewRequest(http.MethodGet, "http://127.0.0.1/api/projects/", nil) + require.Nil(t, err) + req = req.WithContext(internal.WithAuthMode(req.Context(), common.OIDCAuth)) + ctx = idToken.Generate(req) + assert.Nil(t, ctx) +} diff --git a/src/server/middleware/security/oidc_cli.go b/src/server/middleware/security/oidc_cli.go new file mode 100644 index 000000000..d784584e3 --- /dev/null +++ b/src/server/middleware/security/oidc_cli.go @@ -0,0 +1,58 @@ +// 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 security + +import ( + "fmt" + "net/http" + "strings" + + "github.com/goharbor/harbor/src/common" + "github.com/goharbor/harbor/src/common/api" + "github.com/goharbor/harbor/src/common/security" + "github.com/goharbor/harbor/src/common/security/local" + "github.com/goharbor/harbor/src/common/utils/log" + "github.com/goharbor/harbor/src/common/utils/oidc" + "github.com/goharbor/harbor/src/core/config" + "github.com/goharbor/harbor/src/internal" +) + +type oidcCli struct{} + +func (o *oidcCli) Generate(req *http.Request) security.Context { + log := log.G(req.Context()) + path := req.URL.Path + // only handles request by docker CLI or helm CLI + if path != "/service/token" && + !strings.HasPrefix(path, "/v2") && + !strings.HasPrefix(path, "/chartrepo/") && + !strings.HasPrefix(path, fmt.Sprintf("/api/%s/chartrepo/", api.APIVersion)) { + return nil + } + if internal.GetAuthMode(req.Context()) != common.OIDCAuth { + return nil + } + username, secret, ok := req.BasicAuth() + if !ok { + return nil + } + user, err := oidc.VerifySecret(req.Context(), username, secret) + if err != nil { + log.Errorf("failed to verify secret: %v", err) + return nil + } + log.Debugf("an OIDC CLI security context generated for request %s %s", req.Method, req.URL.Path) + return local.NewSecurityContext(user, config.GlobalProjectMgr) +} diff --git a/src/server/middleware/security/oidc_cli_test.go b/src/server/middleware/security/oidc_cli_test.go new file mode 100644 index 000000000..203920298 --- /dev/null +++ b/src/server/middleware/security/oidc_cli_test.go @@ -0,0 +1,49 @@ +// 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 security + +import ( + "github.com/goharbor/harbor/src/common" + "github.com/goharbor/harbor/src/common/utils/oidc" + "github.com/goharbor/harbor/src/internal" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "net/http" + "testing" +) + +func TestOIDCCli(t *testing.T) { + oidcCli := &oidcCli{} + // not the candidate request + req, err := http.NewRequest(http.MethodGet, "http://127.0.0.1/api/projects/", nil) + require.Nil(t, err) + ctx := oidcCli.Generate(req) + assert.Nil(t, ctx) + + // the auth mode isn't OIDC + req, err = http.NewRequest(http.MethodGet, "http://127.0.0.1/service/token", nil) + require.Nil(t, err) + ctx = oidcCli.Generate(req) + assert.Nil(t, ctx) + + // pass + username := "oidcModiferTester" + password := "oidcSecret" + oidc.SetHardcodeVerifierForTest(password) + req = req.WithContext(internal.WithAuthMode(req.Context(), common.OIDCAuth)) + req.SetBasicAuth(username, password) + ctx = oidcCli.Generate(req) + assert.NotNil(t, ctx) +} diff --git a/src/server/middleware/security/robot.go b/src/server/middleware/security/robot.go new file mode 100644 index 000000000..2266a7fb5 --- /dev/null +++ b/src/server/middleware/security/robot.go @@ -0,0 +1,70 @@ +// 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 security + +import ( + "net/http" + "strings" + + "github.com/goharbor/harbor/src/common" + "github.com/goharbor/harbor/src/common/security" + robotCtx "github.com/goharbor/harbor/src/common/security/robot" + "github.com/goharbor/harbor/src/common/utils/log" + "github.com/goharbor/harbor/src/core/config" + pkgrobot "github.com/goharbor/harbor/src/pkg/robot" + pkg_token "github.com/goharbor/harbor/src/pkg/token" + robot_claim "github.com/goharbor/harbor/src/pkg/token/claims/robot" +) + +type robot struct{} + +func (r *robot) Generate(req *http.Request) security.Context { + log := log.G(req.Context()) + robotName, robotTk, ok := req.BasicAuth() + if !ok { + return nil + } + if !strings.HasPrefix(robotName, common.RobotPrefix) { + return nil + } + rClaims := &robot_claim.Claim{} + opt := pkg_token.DefaultTokenOptions() + rtk, err := pkg_token.Parse(opt, robotTk, rClaims) + if err != nil { + log.Errorf("failed to decrypt robot token: %v", err) + return nil + } + // Do authn for robot account, as Harbor only stores the token ID, just validate the ID and disable. + ctr := pkgrobot.RobotCtr + robot, err := ctr.GetRobotAccount(rtk.Claims.(*robot_claim.Claim).TokenID) + if err != nil { + log.Errorf("failed to get robot %s: %v", robotName, err) + return nil + } + if robot == nil { + log.Error("the token provided doesn't exist.") + return nil + } + if robotName != robot.Name { + log.Errorf("failed to authenticate : %v", robotName) + return nil + } + if robot.Disabled { + log.Errorf("the robot account %s is disabled", robot.Name) + return nil + } + log.Debugf("a robot security context generated for request %s %s", req.Method, req.URL.Path) + return robotCtx.NewSecurityContext(robot, config.GlobalProjectMgr, rtk.Claims.(*robot_claim.Claim).Access) +} diff --git a/src/server/middleware/security/robot_test.go b/src/server/middleware/security/robot_test.go new file mode 100644 index 000000000..e01f2cc71 --- /dev/null +++ b/src/server/middleware/security/robot_test.go @@ -0,0 +1,31 @@ +// 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 security + +import ( + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "net/http" + "testing" +) + +func TestRobot(t *testing.T) { + robot := &robot{} + req, err := http.NewRequest(http.MethodGet, "http://127.0.0.1/api/projects/", nil) + require.Nil(t, err) + req.SetBasicAuth("robot$test1", "Harbor12345") + ctx := robot.Generate(req) + assert.Nil(t, ctx) +} diff --git a/src/server/middleware/security/secret.go b/src/server/middleware/security/secret.go new file mode 100644 index 000000000..6cb27b060 --- /dev/null +++ b/src/server/middleware/security/secret.go @@ -0,0 +1,37 @@ +// 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 security + +import ( + "net/http" + + commonsecret "github.com/goharbor/harbor/src/common/secret" + "github.com/goharbor/harbor/src/common/security" + securitysecret "github.com/goharbor/harbor/src/common/security/secret" + "github.com/goharbor/harbor/src/common/utils/log" + "github.com/goharbor/harbor/src/core/config" +) + +type secret struct{} + +func (s *secret) Generate(req *http.Request) security.Context { + log := log.G(req.Context()) + sec := commonsecret.FromRequest(req) + if len(sec) == 0 { + return nil + } + log.Debugf("a secret security context generated for request %s %s", req.Method, req.URL.Path) + return securitysecret.NewSecurityContext(sec, config.SecretStore) +} diff --git a/src/server/middleware/security/secret_test.go b/src/server/middleware/security/secret_test.go new file mode 100644 index 000000000..fcaef7c10 --- /dev/null +++ b/src/server/middleware/security/secret_test.go @@ -0,0 +1,38 @@ +// 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 security + +import ( + commonsecret "github.com/goharbor/harbor/src/common/secret" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "net/http" + "testing" +) + +func TestSecret(t *testing.T) { + secret := secret{} + + // contains no secret + req, err := http.NewRequest(http.MethodGet, "http://127.0.0.1/api/projects/", nil) + require.Nil(t, err) + ctx := secret.Generate(req) + assert.Nil(t, ctx) + + // contains secret + commonsecret.AddToRequest(req, "secret") + ctx = secret.Generate(req) + assert.NotNil(t, ctx) +} diff --git a/src/server/middleware/security/security.go b/src/server/middleware/security/security.go new file mode 100644 index 000000000..c30c1cef3 --- /dev/null +++ b/src/server/middleware/security/security.go @@ -0,0 +1,64 @@ +// 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 security + +import ( + "net/http" + + "github.com/goharbor/harbor/src/common/security" + "github.com/goharbor/harbor/src/common/utils/log" + "github.com/goharbor/harbor/src/core/config" + "github.com/goharbor/harbor/src/internal" +) + +var ( + generators = []generator{ + &secret{}, + &oidcCli{}, + &idToken{}, + &authProxy{}, + &robot{}, + &basicAuth{}, + &session{}, + &unauthorized{}, + } +) + +// security context generator +type generator interface { + Generate(req *http.Request) security.Context +} + +// Middleware returns a security context middleware that populates the security context into the request context +func Middleware() func(http.Handler) http.Handler { + return func(handler http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + log := log.G(r.Context()) + mode, err := config.AuthMode() + if err == nil { + r = r.WithContext(internal.WithAuthMode(r.Context(), mode)) + } else { + log.Warningf("failed to get auth mode: %v", err) + } + for _, generator := range generators { + if ctx := generator.Generate(r); ctx != nil { + r = r.WithContext(security.NewContext(r.Context(), ctx)) + break + } + } + handler.ServeHTTP(w, r) + }) + } +} diff --git a/src/server/middleware/security/security_test.go b/src/server/middleware/security/security_test.go new file mode 100644 index 000000000..5f2a1f5ed --- /dev/null +++ b/src/server/middleware/security/security_test.go @@ -0,0 +1,37 @@ +// 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 security + +import ( + "github.com/goharbor/harbor/src/common/security" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "net/http" + "testing" +) + +func TestSecurity(t *testing.T) { + var ctx security.Context + var exist bool + generators = []generator{&unauthorized{}} + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + ctx, exist = security.FromContext(r.Context()) + }) + req, err := http.NewRequest("POST", "http://127.0.0.1:8080/api/users", nil) + require.Nil(t, err) + Middleware()(handler).ServeHTTP(nil, req) + require.True(t, exist) + assert.NotNil(t, ctx) +} diff --git a/src/server/middleware/security/session.go b/src/server/middleware/security/session.go new file mode 100644 index 000000000..cb6511f8e --- /dev/null +++ b/src/server/middleware/security/session.go @@ -0,0 +1,49 @@ +// 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 security + +import ( + "net/http" + "net/http/httptest" + + "github.com/astaxie/beego" + "github.com/goharbor/harbor/src/common/models" + "github.com/goharbor/harbor/src/common/security" + "github.com/goharbor/harbor/src/common/security/local" + "github.com/goharbor/harbor/src/common/utils/log" + "github.com/goharbor/harbor/src/core/config" +) + +type session struct{} + +func (s *session) Generate(req *http.Request) security.Context { + log := log.G(req.Context()) + store, err := beego.GlobalSessions.SessionStart(httptest.NewRecorder(), req) + if err != nil { + log.Errorf("failed to get the session store for request: %v", err) + return nil + } + userInterface := store.Get("user") + if userInterface == nil { + return nil + } + user, ok := userInterface.(models.User) + if !ok { + log.Warning("can not convert the user in session to user model") + return nil + } + log.Debugf("a session security context generated for request %s %s", req.Method, req.URL.Path) + return local.NewSecurityContext(&user, config.GlobalProjectMgr) +} diff --git a/src/server/middleware/security/session_test.go b/src/server/middleware/security/session_test.go new file mode 100644 index 000000000..2ca3c3918 --- /dev/null +++ b/src/server/middleware/security/session_test.go @@ -0,0 +1,60 @@ +// 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 security + +import ( + "github.com/astaxie/beego" + beegosession "github.com/astaxie/beego/session" + "github.com/goharbor/harbor/src/common/models" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "net/http" + "net/http/httptest" + "path/filepath" + "testing" +) + +func TestSession(t *testing.T) { + var err error + // initialize beego session manager + conf := &beegosession.ManagerConfig{ + CookieName: beego.BConfig.WebConfig.Session.SessionName, + Gclifetime: beego.BConfig.WebConfig.Session.SessionGCMaxLifetime, + ProviderConfig: filepath.ToSlash(beego.BConfig.WebConfig.Session.SessionProviderConfig), + Secure: beego.BConfig.Listen.EnableHTTPS, + EnableSetCookie: beego.BConfig.WebConfig.Session.SessionAutoSetCookie, + Domain: beego.BConfig.WebConfig.Session.SessionDomain, + CookieLifeTime: beego.BConfig.WebConfig.Session.SessionCookieLifeTime, + } + beego.GlobalSessions, err = beegosession.NewManager("memory", conf) + require.Nil(t, err) + + user := models.User{ + Username: "admin", + UserID: 1, + Email: "admin@example.com", + SysAdminFlag: true, + } + req, err := http.NewRequest(http.MethodGet, "http://127.0.0.1/api/projects/", nil) + require.Nil(t, err) + store, err := beego.GlobalSessions.SessionStart(httptest.NewRecorder(), req) + require.Nil(t, err) + err = store.Set("user", user) + require.Nil(t, err) + + session := &session{} + ctx := session.Generate(req) + assert.NotNil(t, ctx) +} diff --git a/src/server/middleware/security/unauthorized.go b/src/server/middleware/security/unauthorized.go new file mode 100644 index 000000000..342e5c13c --- /dev/null +++ b/src/server/middleware/security/unauthorized.go @@ -0,0 +1,31 @@ +// 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 security + +import ( + "net/http" + + "github.com/goharbor/harbor/src/common/security" + "github.com/goharbor/harbor/src/common/security/local" + "github.com/goharbor/harbor/src/common/utils/log" + "github.com/goharbor/harbor/src/core/config" +) + +type unauthorized struct{} + +func (u *unauthorized) Generate(req *http.Request) security.Context { + log.G(req.Context()).Debugf("an unauthorized security context generated for request %s %s", req.Method, req.URL.Path) + return local.NewSecurityContext(nil, config.GlobalProjectMgr) +} diff --git a/src/server/middleware/security/unauthorized_test.go b/src/server/middleware/security/unauthorized_test.go new file mode 100644 index 000000000..bca7b12b1 --- /dev/null +++ b/src/server/middleware/security/unauthorized_test.go @@ -0,0 +1,30 @@ +// 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 security + +import ( + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "net/http" + "testing" +) + +func TestUnauthorized(t *testing.T) { + unauthorized := &unauthorized{} + req, err := http.NewRequest(http.MethodGet, "http://127.0.0.1/api/projects/", nil) + require.Nil(t, err) + ctx := unauthorized.Generate(req) + assert.NotNil(t, ctx) +} diff --git a/src/server/middleware/session/session.go b/src/server/middleware/session/session.go new file mode 100644 index 000000000..486386be5 --- /dev/null +++ b/src/server/middleware/session/session.go @@ -0,0 +1,37 @@ +// 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 session + +import ( + "github.com/goharbor/harbor/src/core/config" + "github.com/goharbor/harbor/src/internal" + "net/http" +) + +// Middleware returns a session middleware that populates the information indicates whether +// the request carries session or not +func Middleware() func(http.Handler) http.Handler { + return func(handler http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // We can check the cookie directly b/c the filter and controllerRegistry is executed after middleware, so no session + // cookie is added by beego. + _, err := r.Cookie(config.SessionCookieName) + if err == nil { + r = r.WithContext(internal.WithCarrySession(r.Context(), true)) + } + handler.ServeHTTP(w, r) + }) + } +} diff --git a/src/server/middleware/session/session_test.go b/src/server/middleware/session/session_test.go new file mode 100644 index 000000000..c14db6081 --- /dev/null +++ b/src/server/middleware/session/session_test.go @@ -0,0 +1,58 @@ +// 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 session + +import ( + "github.com/astaxie/beego" + beegosession "github.com/astaxie/beego/session" + "github.com/goharbor/harbor/src/core/config" + "github.com/goharbor/harbor/src/internal" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "net/http" + "net/http/httptest" + "path/filepath" + "testing" +) + +func TestSession(t *testing.T) { + carrySession := false + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + carrySession = internal.GetCarrySession(r.Context()) + }) + // no session + req, err := http.NewRequest("POST", "http://127.0.0.1:8080/api/users", nil) + require.Nil(t, err) + Middleware()(handler).ServeHTTP(nil, req) + assert.False(t, carrySession) + + // contains session + beego.BConfig.WebConfig.Session.SessionName = config.SessionCookieName + conf := &beegosession.ManagerConfig{ + CookieName: beego.BConfig.WebConfig.Session.SessionName, + Gclifetime: beego.BConfig.WebConfig.Session.SessionGCMaxLifetime, + ProviderConfig: filepath.ToSlash(beego.BConfig.WebConfig.Session.SessionProviderConfig), + Secure: beego.BConfig.Listen.EnableHTTPS, + EnableSetCookie: beego.BConfig.WebConfig.Session.SessionAutoSetCookie, + Domain: beego.BConfig.WebConfig.Session.SessionDomain, + CookieLifeTime: beego.BConfig.WebConfig.Session.SessionCookieLifeTime, + } + beego.GlobalSessions, err = beegosession.NewManager("memory", conf) + require.Nil(t, err) + _, err = beego.GlobalSessions.SessionStart(httptest.NewRecorder(), req) + require.Nil(t, err) + Middleware()(handler).ServeHTTP(nil, req) + assert.True(t, carrySession) +}