mirror of
https://github.com/goharbor/harbor.git
synced 2024-11-23 10:45:45 +01:00
Merge pull request #7257 from reasonerjt/oidc-controllers-p2
Add callback controller for OIDC
This commit is contained in:
commit
8d6299fed9
@ -135,5 +135,5 @@ const (
|
||||
CoreConfigPath = "/api/internal/configurations"
|
||||
RobotTokenDuration = "robot_token_duration"
|
||||
|
||||
OIDCCallbackPath = "/c/oidc_callback"
|
||||
OIDCCallbackPath = "/c/oidc/callback"
|
||||
)
|
||||
|
@ -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)
|
||||
}
|
||||
|
@ -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())
|
||||
}
|
||||
|
||||
|
@ -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)
|
||||
}
|
||||
|
@ -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
|
||||
|
||||
}
|
||||
|
@ -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{})
|
||||
|
Loading…
Reference in New Issue
Block a user