From 587acd33ad90794a382053db55165ec4a72a1db9 Mon Sep 17 00:00:00 2001 From: Daniel Jiang Date: Thu, 28 Mar 2019 17:35:13 +0800 Subject: [PATCH] Add callback controller for OIDC This commit add callback controller to handle the redirection from successful OIDC authentication. For E2E case this requires callback controller to kick off onboard process, which will be covered in subsequent commits. Signed-off-by: Daniel Jiang --- src/common/const.go | 2 +- src/common/utils/oidc/helper.go | 91 ++++++++++++++++++++++------ src/common/utils/oidc/helper_test.go | 9 ++- src/core/config/config_test.go | 2 +- src/core/controllers/oidc.go | 64 +++++++++++++++++-- src/core/router.go | 5 +- 6 files changed, 143 insertions(+), 30 deletions(-) diff --git a/src/common/const.go b/src/common/const.go index bdce1179bd..9147599096 100644 --- a/src/common/const.go +++ b/src/common/const.go @@ -135,5 +135,5 @@ const ( CoreConfigPath = "/api/internal/configurations" RobotTokenDuration = "robot_token_duration" - OIDCCallbackPath = "/c/oidc_callback" + OIDCCallbackPath = "/c/oidc/callback" ) diff --git a/src/common/utils/oidc/helper.go b/src/common/utils/oidc/helper.go index f7928571d4..75a45f6b10 100644 --- a/src/common/utils/oidc/helper.go +++ b/src/common/utils/oidc/helper.go @@ -16,12 +16,15 @@ package oidc import ( "context" + "crypto/tls" + "errors" "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" + "net/http" "strings" "sync" "sync/atomic" @@ -32,14 +35,20 @@ const googleEndpoint = "https://accounts.google.com" type providerHelper struct { sync.Mutex - ep atomic.Value + ep endpoint instance atomic.Value setting atomic.Value } +type endpoint struct { + url string + skipCertVerify bool +} + func (p *providerHelper) get() (*gooidc.Provider, error) { if p.instance.Load() != nil { - if p.ep.Load().(string) != p.setting.Load().(models.OIDCSetting).Endpoint { + s := p.setting.Load().(models.OIDCSetting) + if s.Endpoint != p.ep.url || s.SkipCertVerify != p.ep.skipCertVerify { // relevant settings have changed, need to re-create provider. if err := p.create(); err != nil { return nil, err } @@ -48,7 +57,7 @@ func (p *providerHelper) get() (*gooidc.Provider, error) { p.Lock() defer p.Unlock() if p.instance.Load() == nil { - if err := p.loadConf(); err != nil { + if err := p.reload(); err != nil { return nil, err } if err := p.create(); err != nil { @@ -56,43 +65,67 @@ func (p *providerHelper) get() (*gooidc.Provider, error) { } go func() { for { - if err := p.loadConf(); err != nil { - log.Warningf(err.Error()) + if err := p.reload(); err != nil { + log.Warningf("Failed to refresh configuration, error: %v", err) } 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() +func (p *providerHelper) reload() error { + conf, err := config.OIDCSetting() if err != nil { return fmt.Errorf("failed to load OIDC setting: %v", err) } - p.setting.Store(*c) + p.setting.Store(*conf) 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 + if p.setting.Load() == nil { + return errors.New("the configuration is not loaded") + } + s := p.setting.Load().(models.OIDCSetting) + var client *http.Client + if s.SkipCertVerify { + client = &http.Client{ + Transport: insecureTransport, + } + } else { + client = &http.Client{} + } + ctx := context.Background() + gooidc.ClientContext(ctx, client) + provider, err := gooidc.NewProvider(ctx, s.Endpoint) + if err != nil { + return fmt.Errorf("failed to create OIDC provider, error: %v", err) } - p.ep.Store(s.Endpoint) p.instance.Store(provider) + p.ep = endpoint{ + url: s.Endpoint, + skipCertVerify: s.SkipCertVerify, + } return nil } var provider = &providerHelper{} +var insecureTransport = &http.Transport{ + TLSClientConfig: &tls.Config{ + InsecureSkipVerify: true, + }, +} + +// Token wraps the attributes of a oauth2 token plus the attribute of ID token +type Token struct { + *oauth2.Token + IDToken string `json:"id_token"` +} + func getOauthConf() (*oauth2.Config, error) { p, err := provider.get() if err != nil { @@ -129,3 +162,27 @@ func AuthCodeURL(state string) (string, error) { } return conf.AuthCodeURL(state), nil } + +// ExchangeToken get the token from token provider via the code +func ExchangeToken(ctx context.Context, code string) (*Token, error) { + oauth, err := getOauthConf() + if err != nil { + log.Errorf("Failed to get OAuth configuration, error: %v", err) + return nil, err + } + oauthToken, err := oauth.Exchange(ctx, code) + if err != nil { + return nil, err + } + return &Token{Token: oauthToken, IDToken: oauthToken.Extra("id_token").(string)}, nil +} + +// VerifyToken verifies the ID token based on the OIDC settings +func VerifyToken(ctx context.Context, rawIDToken string) (*gooidc.IDToken, error) { + p, err := provider.get() + if err != nil { + return nil, err + } + verifier := p.Verifier(&gooidc.Config{ClientID: provider.setting.Load().(models.OIDCSetting).ClientID}) + return verifier.Verify(ctx, rawIDToken) +} diff --git a/src/common/utils/oidc/helper_test.go b/src/common/utils/oidc/helper_test.go index 57c6b65c01..6e4f357e2c 100644 --- a/src/common/utils/oidc/helper_test.go +++ b/src/common/utils/oidc/helper_test.go @@ -44,24 +44,23 @@ func TestMain(m *testing.M) { os.Exit(result) } } - func TestHelperLoadConf(t *testing.T) { testP := &providerHelper{} assert.Nil(t, testP.setting.Load()) - err := testP.loadConf() + err := testP.reload() assert.Nil(t, err) assert.Equal(t, "test", testP.setting.Load().(models.OIDCSetting).Name) - assert.Nil(t, testP.ep.Load()) + assert.Equal(t, endpoint{}, testP.ep) } func TestHelperCreate(t *testing.T) { testP := &providerHelper{} - err := testP.loadConf() + err := testP.reload() 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.EqualValues(t, "https://accounts.google.com", testP.ep.url) assert.NotNil(t, testP.instance.Load()) } diff --git a/src/core/config/config_test.go b/src/core/config/config_test.go index 82de10f287..be69533e42 100644 --- a/src/core/config/config_test.go +++ b/src/core/config/config_test.go @@ -260,6 +260,6 @@ func TestOIDCSetting(t *testing.T) { assert.True(t, v.SkipCertVerify) assert.Equal(t, "client", v.ClientID) assert.Equal(t, "secret", v.ClientSecret) - assert.Equal(t, "https://harbor.test/c/oidc_callback", v.RedirectURL) + assert.Equal(t, "https://harbor.test/c/oidc/callback", v.RedirectURL) assert.ElementsMatch(t, []string{"openid", "profile"}, v.Scope) } diff --git a/src/core/controllers/oidc.go b/src/core/controllers/oidc.go index a8dfc91d3d..7ba18580d0 100644 --- a/src/core/controllers/oidc.go +++ b/src/core/controllers/oidc.go @@ -24,21 +24,75 @@ import ( "net/http" ) +const idTokenKey = "oidc_id_token" +const stateKey = "oidc_state" + // OIDCController handles requests for OIDC login, callback and user onboard type OIDCController struct { api.BaseController } +type oidcUserData struct { + Issuer string `json:"iss"` + Subject string `json:"sub"` + Username string `json:"name"` + Email string `json:"email"` +} + +// Prepare include public code path for call request handler of OIDCController +func (oc *OIDCController) Prepare() { + if mode, _ := config.AuthMode(); mode != common.OIDCAuth { + oc.CustomAbort(http.StatusPreconditionFailed, fmt.Sprintf("Auth Mode: %s is not OIDC based.", mode)) + } +} + // 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()) + state := utils.GenerateRandomString() + url, err := oidc.AuthCodeURL(state) if err != nil { oc.RenderFormatedError(http.StatusInternalServerError, err) + return } + oc.SetSession(stateKey, state) // Force to use the func 'Redirect' of beego.Controller oc.Controller.Redirect(url, http.StatusFound) } + +// Callback handles redirection from OIDC provider. It will exchange the token and +// kick off onboard if needed. +func (oc *OIDCController) Callback() { + if oc.Ctx.Request.URL.Query().Get("state") != oc.GetSession(stateKey) { + oc.RenderError(http.StatusBadRequest, "State mismatch.") + return + } + code := oc.Ctx.Request.URL.Query().Get("code") + ctx := oc.Ctx.Request.Context() + token, err := oidc.ExchangeToken(ctx, code) + if err != nil { + oc.RenderFormatedError(http.StatusInternalServerError, err) + return + } + idToken, err := oidc.VerifyToken(ctx, token.IDToken) + if err != nil { + oc.RenderFormatedError(http.StatusInternalServerError, err) + return + } + d := &oidcUserData{} + err = idToken.Claims(d) + if err != nil { + oc.RenderFormatedError(http.StatusInternalServerError, err) + return + } + oc.SetSession(idTokenKey, token.IDToken) + // TODO: check and trigger onboard popup or redirect user to project page + oc.Data["json"] = d + oc.ServeFormatted() +} + +// Onboard handles the request to onboard an user authenticated via OIDC provider +func (oc *OIDCController) Onboard() { + oc.RenderError(http.StatusNotImplemented, "") + return + +} diff --git a/src/core/router.go b/src/core/router.go index 4ccae2e0f1..0feaebb4d8 100644 --- a/src/core/router.go +++ b/src/core/router.go @@ -15,6 +15,7 @@ package main import ( + "github.com/goharbor/harbor/src/common" "github.com/goharbor/harbor/src/core/api" "github.com/goharbor/harbor/src/core/config" "github.com/goharbor/harbor/src/core/controllers" @@ -37,7 +38,9 @@ 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") + beego.Router("/c/oidc/login", &controllers.OIDCController{}, "get:RedirectLogin") + beego.Router("/c/oidc/onboard", &controllers.OIDCController{}, "post:Onboard") + beego.Router(common.OIDCCallbackPath, &controllers.OIDCController{}, "get:Callback") // API: beego.Router("/api/projects/:pid([0-9]+)/members/?:pmid([0-9]+)", &api.ProjectMemberAPI{})