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 <jiangd@vmware.com>
This commit is contained in:
Daniel Jiang 2019-03-28 17:35:13 +08:00
parent 4b82b0e364
commit 587acd33ad
6 changed files with 143 additions and 30 deletions

View File

@ -135,5 +135,5 @@ const (
CoreConfigPath = "/api/internal/configurations"
RobotTokenDuration = "robot_token_duration"
OIDCCallbackPath = "/c/oidc_callback"
OIDCCallbackPath = "/c/oidc/callback"
)

View File

@ -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)
}

View File

@ -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())
}

View File

@ -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)
}

View File

@ -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
}

View File

@ -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{})