diff --git a/src/Gopkg.lock b/src/Gopkg.lock index 8a4d497cb..42da4319e 100644 --- a/src/Gopkg.lock +++ b/src/Gopkg.lock @@ -100,6 +100,14 @@ revision = "542e16cac74562eefac970a7d0d1467640d1f1cb" version = "v1.7.0" +[[projects]] + digest = "1:f6e5e1bc64c2908167e6aa9a1fe0c084d515132a1c63ad5b6c84036aa06dc0c1" + name = "github.com/coreos/go-oidc" + packages = ["."] + pruneopts = "UT" + revision = "1180514eaf4d9f38d0d19eef639a1d695e066e72" + version = "v2.0.0" + [[projects]] digest = "1:a2c1d0e43bd3baaa071d1b9ed72c27d78169b2b269f71c105ac4ba34b1be4a39" name = "github.com/davecgh/go-spew" @@ -404,6 +412,17 @@ revision = "792786c7400a136282c1664665ae0a8db921c6c2" version = "v1.0.0" +[[projects]] + branch = "master" + digest = "1:bd9efe4e0b0f768302a1e2f0c22458149278de533e521206e5ddc71848c269a0" + name = "github.com/pquerna/cachecontrol" + packages = [ + ".", + "cacheobject", + ] + pruneopts = "UT" + revision = "1555304b9b35fdd2b425bccf1a5613677705e7d0" + [[projects]] digest = "1:3f68283c56d93b885f33c679708079e834815138649e9f59ffbc572c2993e0f8" name = "github.com/robfig/cron" @@ -433,10 +452,12 @@ version = "v1.2.0" [[projects]] - digest = "1:9c94d918a2ac65f60d6b7895b2e9612e4554b40ee2446f2f807cadb3e57da7e2" + digest = "1:ab3259b9f5008a18ff8c1cc34623eccce354f3a9faf5b409983cd6717d64b40b" name = "golang.org/x/crypto" packages = [ "cast5", + "ed25519", + "ed25519/internal/edwards25519", "openpgp", "openpgp/armor", "openpgp/clearsign", @@ -529,6 +550,18 @@ revision = "8168ee085ee43257585e50c6441aadf54ecb2c9f" version = "v2.5.0" +[[projects]] + digest = "1:c0c30f47f9c16f227ba82f0bdfd14fa968453c30b7677a07903b3b4f34b98d49" + name = "gopkg.in/square/go-jose.v2" + packages = [ + ".", + "cipher", + "json", + ] + pruneopts = "UT" + revision = "628223f44a71f715d2881ea69afc795a1e9c01be" + version = "v2.3.0" + [[projects]] digest = "1:2a81c6e126d36ad027328cffaa4888fc3be40f09dc48028d1f93705b718130b9" name = "gopkg.in/yaml.v2" @@ -685,6 +718,7 @@ "github.com/casbin/casbin/model", "github.com/casbin/casbin/persist", "github.com/casbin/casbin/util", + "github.com/coreos/go-oidc", "github.com/dghubble/sling", "github.com/dgrijalva/jwt-go", "github.com/docker/distribution", diff --git a/src/Gopkg.toml b/src/Gopkg.toml index c1e85d0df..145dc2681 100644 --- a/src/Gopkg.toml +++ b/src/Gopkg.toml @@ -112,6 +112,10 @@ ignored = ["github.com/goharbor/harbor/tests*"] name = "github.com/robfig/cron" version = "=1.0" +[[constraint]] + name = "github.com/coreos/go-oidc" + version = "=2.0.0" + [[constraint]] name = "gopkg.in/yaml.v2" version = "=2.1.1" diff --git a/src/common/config/manager.go b/src/common/config/manager.go index fb70b1b3d..0df6eaa47 100644 --- a/src/common/config/manager.go +++ b/src/common/config/manager.go @@ -17,6 +17,7 @@ package config import ( "fmt" "os" + "sync" "github.com/goharbor/harbor/src/common" "github.com/goharbor/harbor/src/common/config/metadata" @@ -52,6 +53,7 @@ func NewRESTCfgManager(configURL, secret string) *CfgManager { // InMemoryDriver driver for unit testing type InMemoryDriver struct { + sync.Mutex cfgMap map[string]interface{} } @@ -59,11 +61,19 @@ type InMemoryDriver struct { // it should be invoked before get any user scope config // for system scope config, because it is immutable, no need to call this method func (d *InMemoryDriver) Load() (map[string]interface{}, error) { - return d.cfgMap, nil + d.Lock() + defer d.Unlock() + res := make(map[string]interface{}) + for k, v := range d.cfgMap { + res[k] = v + } + return res, nil } // Save only save user config setting to driver, for example: database, REST func (d *InMemoryDriver) Save(cfg map[string]interface{}) error { + d.Lock() + defer d.Unlock() for k, v := range cfg { d.cfgMap[k] = v } diff --git a/src/common/config/metadata/type.go b/src/common/config/metadata/type.go index b9fb36d3e..6ed790c97 100644 --- a/src/common/config/metadata/type.go +++ b/src/common/config/metadata/type.go @@ -61,11 +61,11 @@ type AuthModeType struct { } func (t *AuthModeType) validate(str string) error { - if str == common.LDAPAuth || str == common.DBAuth || str == common.UAAAuth || str == common.HTTPAuth { + if str == common.LDAPAuth || str == common.DBAuth || str == common.UAAAuth || str == common.HTTPAuth || str == common.OIDCAuth { return nil } - return fmt.Errorf("invalid %s, shoud be one of %s, %s, %s, %s", - common.AUTHMode, common.DBAuth, common.LDAPAuth, common.UAAAuth, common.HTTPAuth) + return fmt.Errorf("invalid %s, shoud be one of %s, %s, %s, %s, %s", + common.AUTHMode, common.DBAuth, common.LDAPAuth, common.UAAAuth, common.HTTPAuth, common.OIDCAuth) } // ProjectCreationRestrictionType ... diff --git a/src/common/utils/oidc/helper.go b/src/common/utils/oidc/helper.go new file mode 100644 index 000000000..f7928571d --- /dev/null +++ b/src/common/utils/oidc/helper.go @@ -0,0 +1,131 @@ +// 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 oidc + +import ( + "context" + "fmt" + gooidc "github.com/coreos/go-oidc" + "github.com/goharbor/harbor/src/common/models" + "github.com/goharbor/harbor/src/common/utils/log" + "github.com/goharbor/harbor/src/core/config" + "golang.org/x/oauth2" + "strings" + "sync" + "sync/atomic" + "time" +) + +const googleEndpoint = "https://accounts.google.com" + +type providerHelper struct { + sync.Mutex + ep atomic.Value + instance atomic.Value + setting atomic.Value +} + +func (p *providerHelper) get() (*gooidc.Provider, error) { + if p.instance.Load() != nil { + if p.ep.Load().(string) != p.setting.Load().(models.OIDCSetting).Endpoint { + if err := p.create(); err != nil { + return nil, err + } + } + } else { + p.Lock() + defer p.Unlock() + if p.instance.Load() == nil { + if err := p.loadConf(); err != nil { + return nil, err + } + if err := p.create(); err != nil { + return nil, err + } + go func() { + for { + if err := p.loadConf(); err != nil { + log.Warningf(err.Error()) + } + time.Sleep(3 * time.Second) + } + }() + } + } + + return p.instance.Load().(*gooidc.Provider), nil + +} + +func (p *providerHelper) loadConf() error { + var c *models.OIDCSetting + c, err := config.OIDCSetting() + if err != nil { + return fmt.Errorf("failed to load OIDC setting: %v", err) + } + p.setting.Store(*c) + return nil +} + +func (p *providerHelper) create() error { + bc := context.Background() + s := p.setting.Load().(models.OIDCSetting) + provider, err := gooidc.NewProvider(bc, s.Endpoint) + if err != nil { + return err + } + p.ep.Store(s.Endpoint) + p.instance.Store(provider) + return nil +} + +var provider = &providerHelper{} + +func getOauthConf() (*oauth2.Config, error) { + p, err := provider.get() + if err != nil { + return nil, err + } + setting := provider.setting.Load().(models.OIDCSetting) + scopes := []string{} + for _, sc := range setting.Scope { + if strings.HasPrefix(p.Endpoint().AuthURL, googleEndpoint) && sc == gooidc.ScopeOfflineAccess { + log.Warningf("Dropped unsupported scope: %s ", sc) + continue + } + scopes = append(scopes, sc) + } + return &oauth2.Config{ + ClientID: setting.ClientID, + ClientSecret: setting.ClientSecret, + Scopes: scopes, + RedirectURL: setting.RedirectURL, + Endpoint: p.Endpoint(), + }, nil +} + +// AuthCodeURL returns the URL for OIDC provider's consent page. The state should be verified when user is redirected +// back to Harbor. +func AuthCodeURL(state string) (string, error) { + conf, err := getOauthConf() + if err != nil { + log.Errorf("Failed to get OAuth configuration, error: %v", err) + return "", err + } + if strings.HasPrefix(conf.Endpoint.AuthURL, googleEndpoint) { + return conf.AuthCodeURL(state, oauth2.AccessTypeOffline), nil + } + return conf.AuthCodeURL(state), nil +} diff --git a/src/common/utils/oidc/helper_test.go b/src/common/utils/oidc/helper_test.go new file mode 100644 index 000000000..57c6b65c0 --- /dev/null +++ b/src/common/utils/oidc/helper_test.go @@ -0,0 +1,99 @@ +// 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 oidc + +import ( + "github.com/goharbor/harbor/src/common" + "github.com/goharbor/harbor/src/common/models" + "github.com/goharbor/harbor/src/core/config" + "github.com/stretchr/testify/assert" + "net/url" + "os" + "strings" + "testing" + "time" +) + +func TestMain(m *testing.M) { + conf := map[string]interface{}{ + common.OIDCName: "test", + common.OIDCEndpoint: "https://accounts.google.com", + common.OIDCSkipCertVerify: "false", + common.OIDCScope: "openid, profile, offline_access", + common.OIDCCLientID: "client", + common.OIDCClientSecret: "secret", + common.ExtEndpoint: "https://harbor.test", + } + + config.InitWithSettings(conf) + + result := m.Run() + if result != 0 { + os.Exit(result) + } +} + +func TestHelperLoadConf(t *testing.T) { + testP := &providerHelper{} + assert.Nil(t, testP.setting.Load()) + err := testP.loadConf() + assert.Nil(t, err) + assert.Equal(t, "test", testP.setting.Load().(models.OIDCSetting).Name) + assert.Nil(t, testP.ep.Load()) +} + +func TestHelperCreate(t *testing.T) { + testP := &providerHelper{} + err := testP.loadConf() + assert.Nil(t, err) + assert.Nil(t, testP.instance.Load()) + err = testP.create() + assert.Nil(t, err) + assert.EqualValues(t, "https://accounts.google.com", testP.ep.Load().(string)) + assert.NotNil(t, testP.instance.Load()) +} + +func TestHelperGet(t *testing.T) { + testP := &providerHelper{} + p, err := testP.get() + assert.Nil(t, err) + assert.Equal(t, "https://oauth2.googleapis.com/token", p.Endpoint().TokenURL) + + update := map[string]interface{}{ + common.OIDCName: "test", + common.OIDCEndpoint: "https://accounts.google.com", + common.OIDCSkipCertVerify: "false", + common.OIDCScope: "openid, profile, offline_access", + common.OIDCCLientID: "client", + common.OIDCClientSecret: "new-secret", + common.ExtEndpoint: "https://harbor.test", + } + config.GetCfgManager().UpdateConfig(update) + + t.Log("Sleep for 5 seconds") + time.Sleep(5 * time.Second) + assert.Equal(t, "new-secret", testP.setting.Load().(models.OIDCSetting).ClientSecret) +} + +func TestAuthCodeURL(t *testing.T) { + res, err := AuthCodeURL("random") + assert.Nil(t, err) + u, err := url.ParseRequestURI(res) + assert.Nil(t, err) + q, err := url.ParseQuery(u.RawQuery) + assert.Nil(t, err) + assert.Equal(t, "offline", q.Get("access_type")) + assert.False(t, strings.Contains(q.Get("scope"), "offline_access")) +} diff --git a/src/core/controllers/oidc.go b/src/core/controllers/oidc.go new file mode 100644 index 000000000..a8dfc91d3 --- /dev/null +++ b/src/core/controllers/oidc.go @@ -0,0 +1,44 @@ +// 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 controllers + +import ( + "fmt" + "github.com/goharbor/harbor/src/common" + "github.com/goharbor/harbor/src/common/utils" + "github.com/goharbor/harbor/src/common/utils/oidc" + "github.com/goharbor/harbor/src/core/api" + "github.com/goharbor/harbor/src/core/config" + "net/http" +) + +// OIDCController handles requests for OIDC login, callback and user onboard +type OIDCController struct { + api.BaseController +} + +// RedirectLogin redirect user's browser to OIDC provider's login page +func (oc *OIDCController) RedirectLogin() { + if mode, _ := config.AuthMode(); mode != common.OIDCAuth { + oc.RenderError(http.StatusPreconditionFailed, fmt.Sprintf("Auth Mode: %s is not OIDC based.", mode)) + return + } + url, err := oidc.AuthCodeURL(utils.GenerateRandomString()) + if err != nil { + oc.RenderFormatedError(http.StatusInternalServerError, err) + } + // Force to use the func 'Redirect' of beego.Controller + oc.Controller.Redirect(url, http.StatusFound) +} diff --git a/src/core/router.go b/src/core/router.go index b18a187c4..4ccae2e0f 100644 --- a/src/core/router.go +++ b/src/core/router.go @@ -37,6 +37,7 @@ func initRouters() { beego.Router("/c/reset", &controllers.CommonController{}, "post:ResetPassword") beego.Router("/c/userExists", &controllers.CommonController{}, "post:UserExists") beego.Router("/c/sendEmail", &controllers.CommonController{}, "get:SendResetEmail") + beego.Router("/c/oidc_login", &controllers.OIDCController{}, "get:RedirectLogin") // API: beego.Router("/api/projects/:pid([0-9]+)/members/?:pmid([0-9]+)", &api.ProjectMemberAPI{}) diff --git a/src/vendor/github.com/coreos/go-oidc/.gitignore b/src/vendor/github.com/coreos/go-oidc/.gitignore new file mode 100644 index 000000000..c96f2f47b --- /dev/null +++ b/src/vendor/github.com/coreos/go-oidc/.gitignore @@ -0,0 +1,2 @@ +/bin +/gopath diff --git a/src/vendor/github.com/coreos/go-oidc/.travis.yml b/src/vendor/github.com/coreos/go-oidc/.travis.yml new file mode 100644 index 000000000..f2f3c9c81 --- /dev/null +++ b/src/vendor/github.com/coreos/go-oidc/.travis.yml @@ -0,0 +1,16 @@ +language: go + +go: + - 1.7.5 + - 1.8 + +install: + - go get -v -t github.com/coreos/go-oidc/... + - go get golang.org/x/tools/cmd/cover + - go get github.com/golang/lint/golint + +script: + - ./test + +notifications: + email: false diff --git a/src/vendor/github.com/coreos/go-oidc/CONTRIBUTING.md b/src/vendor/github.com/coreos/go-oidc/CONTRIBUTING.md new file mode 100644 index 000000000..6662073a8 --- /dev/null +++ b/src/vendor/github.com/coreos/go-oidc/CONTRIBUTING.md @@ -0,0 +1,71 @@ +# How to Contribute + +CoreOS projects are [Apache 2.0 licensed](LICENSE) and accept contributions via +GitHub pull requests. This document outlines some of the conventions on +development workflow, commit message formatting, contact points and other +resources to make it easier to get your contribution accepted. + +# Certificate of Origin + +By contributing to this project you agree to the Developer Certificate of +Origin (DCO). This document was created by the Linux Kernel community and is a +simple statement that you, as a contributor, have the legal right to make the +contribution. See the [DCO](DCO) file for details. + +# Email and Chat + +The project currently uses the general CoreOS email list and IRC channel: +- Email: [coreos-dev](https://groups.google.com/forum/#!forum/coreos-dev) +- IRC: #[coreos](irc://irc.freenode.org:6667/#coreos) IRC channel on freenode.org + +Please avoid emailing maintainers found in the MAINTAINERS file directly. They +are very busy and read the mailing lists. + +## Getting Started + +- Fork the repository on GitHub +- Read the [README](README.md) for build and test instructions +- Play with the project, submit bugs, submit patches! + +## Contribution Flow + +This is a rough outline of what a contributor's workflow looks like: + +- Create a topic branch from where you want to base your work (usually master). +- Make commits of logical units. +- Make sure your commit messages are in the proper format (see below). +- Push your changes to a topic branch in your fork of the repository. +- Make sure the tests pass, and add any new tests as appropriate. +- Submit a pull request to the original repository. + +Thanks for your contributions! + +### Format of the Commit Message + +We follow a rough convention for commit messages that is designed to answer two +questions: what changed and why. The subject line should feature the what and +the body of the commit should describe the why. + +``` +scripts: add the test-cluster command + +this uses tmux to setup a test cluster that you can easily kill and +start for debugging. + +Fixes #38 +``` + +The format can be described more formally as follows: + +``` +: + + + +