From 20db0e737b5703cc69af50f08dd9d7a91eb69185 Mon Sep 17 00:00:00 2001 From: Daniel Jiang Date: Fri, 11 Jan 2019 18:16:50 +0800 Subject: [PATCH] Provide HTTP authenticator An HTTP authenticator verifies the credentials by sending a POST request to an HTTP endpoint. After successful authentication he will be onboarded to Harbor's local DB and assigned a role in a project. This commit provides the initial implementation. Currently one limitation is that we don't have clear definition about how we would "search" a user via this HTTP authenticator, a flag for "alway onboard" is provided to skip the search, otherwise, a user has to login first before he can be assigned a role in Harbor. Signed-off-by: Daniel Jiang --- src/common/const.go | 1 + src/core/auth/authenticator.go | 2 +- src/core/auth/authproxy/auth.go | 143 ++++++++++++++++++++++++ src/core/auth/authproxy/auth_test.go | 144 +++++++++++++++++++++++++ src/core/auth/authproxy/test/server.go | 49 +++++++++ src/core/auth/uaa/uaa.go | 2 +- src/core/main.go | 1 + 7 files changed, 340 insertions(+), 2 deletions(-) create mode 100644 src/core/auth/authproxy/auth.go create mode 100644 src/core/auth/authproxy/auth_test.go create mode 100644 src/core/auth/authproxy/test/server.go diff --git a/src/common/const.go b/src/common/const.go index d6ecda41b..8bb1297d6 100644 --- a/src/common/const.go +++ b/src/common/const.go @@ -19,6 +19,7 @@ const ( DBAuth = "db_auth" LDAPAuth = "ldap_auth" UAAAuth = "uaa_auth" + HTTPAuth = "http_auth" ProCrtRestrEveryone = "everyone" ProCrtRestrAdmOnly = "adminonly" LDAPScopeBase = 0 diff --git a/src/core/auth/authenticator.go b/src/core/auth/authenticator.go index 83393f0c0..48641b37b 100644 --- a/src/core/auth/authenticator.go +++ b/src/core/auth/authenticator.go @@ -123,7 +123,7 @@ func Register(name string, h AuthenticateHelper) { return } registry[name] = h - log.Debugf("Registered authencation helper for auth mode: %s", name) + log.Debugf("Registered authentication helper for auth mode: %s", name) } // Login authenticates user credentials based on setting. diff --git a/src/core/auth/authproxy/auth.go b/src/core/auth/authproxy/auth.go new file mode 100644 index 000000000..a87e567d7 --- /dev/null +++ b/src/core/auth/authproxy/auth.go @@ -0,0 +1,143 @@ +// 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 authproxy + +import ( + "crypto/tls" + "fmt" + "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/utils/log" + "github.com/goharbor/harbor/src/core/auth" + "io/ioutil" + "net/http" + "os" + "strings" + "sync" +) + +// Auth implements HTTP authenticator the required attributes. +// The attribute Endpoint is the HTTP endpoint to which the POST request should be issued for authentication +type Auth struct { + auth.DefaultAuthenticateHelper + sync.Mutex + Endpoint string + SkipCertVerify bool + AlwaysOnboard bool + client *http.Client +} + +// Authenticate issues http POST request to Endpoint if it returns 200 the authentication is considered success. +func (a *Auth) Authenticate(m models.AuthModel) (*models.User, error) { + a.ensure() + req, err := http.NewRequest(http.MethodPost, a.Endpoint, nil) + if err != nil { + return nil, fmt.Errorf("failed to send request, error: %v", err) + } + req.SetBasicAuth(m.Principal, m.Password) + resp, err := a.client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + if resp.StatusCode == http.StatusOK { + return &models.User{Username: m.Principal}, nil + } else if resp.StatusCode == http.StatusUnauthorized { + return nil, auth.ErrAuth{} + } else { + data, err := ioutil.ReadAll(resp.Body) + if err != nil { + log.Warningf("Failed to read response body, error: %v", err) + } + return nil, fmt.Errorf("failed to authenticate, status code: %d, text: %s", resp.StatusCode, string(data)) + } + +} + +// OnBoardUser delegates to dao pkg to insert/update data in DB. +func (a *Auth) OnBoardUser(u *models.User) error { + return dao.OnBoardUser(u) +} + +// PostAuthenticate generates the user model and on board the user. +func (a *Auth) PostAuthenticate(u *models.User) error { + if res, _ := dao.GetUser(*u); res != nil { + return nil + } + if err := a.fillInModel(u); err != nil { + return err + } + return a.OnBoardUser(u) +} + +// SearchUser - TODO: Remove this workaround when #6767 is fixed. +// When the flag is set it always return the default model without searching +func (a *Auth) SearchUser(username string) (*models.User, error) { + a.ensure() + var queryCondition = models.User{ + Username: username, + } + u, err := dao.GetUser(queryCondition) + if err != nil { + return nil, err + } + if a.AlwaysOnboard && u == nil { + u = &models.User{Username: username} + if err := a.fillInModel(u); err != nil { + return nil, err + } + } + return u, nil +} + +func (a *Auth) fillInModel(u *models.User) error { + if strings.TrimSpace(u.Username) == "" { + return fmt.Errorf("username cannot be empty") + } + u.Realname = u.Username + u.Password = "1234567ab" + u.Comment = "By Authproxy" + if strings.Contains(u.Username, "@") { + u.Email = u.Username + } else { + u.Email = fmt.Sprintf("%s@placeholder.com", u.Username) + } + return nil +} + +func (a *Auth) ensure() { + a.Lock() + defer a.Unlock() + if a.Endpoint == "" { + a.Endpoint = os.Getenv("AUTHPROXY_ENDPOINT") + a.SkipCertVerify = strings.EqualFold(os.Getenv("AUTHPROXY_SKIP_CERT_VERIFY"), "true") + a.AlwaysOnboard = strings.EqualFold(os.Getenv("AUTHPROXY_ALWAYS_ONBOARD"), "true") + } + if a.client == nil { + tr := &http.Transport{ + TLSClientConfig: &tls.Config{ + InsecureSkipVerify: a.SkipCertVerify, + }, + } + a.client = &http.Client{ + Transport: tr, + } + } +} + +func init() { + auth.Register(common.HTTPAuth, &Auth{}) +} diff --git a/src/core/auth/authproxy/auth_test.go b/src/core/auth/authproxy/auth_test.go new file mode 100644 index 000000000..9c0c81cbd --- /dev/null +++ b/src/core/auth/authproxy/auth_test.go @@ -0,0 +1,144 @@ +// 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 authproxy + +import ( + "github.com/goharbor/harbor/src/common/models" + "github.com/goharbor/harbor/src/core/auth" + "github.com/goharbor/harbor/src/core/auth/authproxy/test" + "github.com/stretchr/testify/assert" + "net/http/httptest" + "os" + "testing" +) + +var mockSvr *httptest.Server +var a *Auth +var pwd = "1234567ab" +var cmt = "By Authproxy" + +func TestMain(m *testing.M) { + mockSvr = test.NewMockServer(map[string]string{"jt": "pp", "Admin@vsphere.local": "Admin!23"}) + defer mockSvr.Close() + a = &Auth{ + Endpoint: mockSvr.URL + "/test/login", + SkipCertVerify: true, + } + rc := m.Run() + if rc != 0 { + os.Exit(rc) + } +} + +func TestAuth_Authenticate(t *testing.T) { + t.Log("auth endpoint: ", a.Endpoint) + type output struct { + user models.User + err error + } + type tc struct { + input models.AuthModel + expect output + } + suite := []tc{ + { + input: models.AuthModel{ + Principal: "jt", Password: "pp"}, + expect: output{ + user: models.User{ + Username: "jt", + }, + err: nil, + }, + }, + { + input: models.AuthModel{ + Principal: "Admin@vsphere.local", + Password: "Admin!23", + }, + expect: output{ + user: models.User{ + Username: "Admin@vsphere.local", + // Email: "Admin@placeholder.com", + // Password: pwd, + // Comment: fmt.Sprintf(cmtTmpl, path.Join(mockSvr.URL, "/test/login")), + }, + err: nil, + }, + }, + { + input: models.AuthModel{ + Principal: "jt", + Password: "ppp", + }, + expect: output{ + err: auth.ErrAuth{}, + }, + }, + } + assert := assert.New(t) + for _, c := range suite { + r, e := a.Authenticate(c.input) + if c.expect.err == nil { + assert.Nil(e) + assert.Equal(c.expect.user, *r) + } else { + assert.Nil(r) + assert.NotNil(e) + if _, ok := e.(auth.ErrAuth); ok { + assert.IsType(auth.ErrAuth{}, e) + } + } + } +} + +/* TODO: Enable this case after adminserver refactor is merged. +func TestAuth_PostAuthenticate(t *testing.T) { + type tc struct { + input *models.User + expect models.User + } + suite := []tc{ + { + input: &models.User{ + Username: "jt", + }, + expect: models.User{ + Username: "jt", + Email: "jt@placeholder.com", + Realname: "jt", + Password: pwd, + Comment: fmt.Sprintf(cmtTmpl, mockSvr.URL+"/test/login"), + }, + }, + { + input: &models.User{ + Username: "Admin@vsphere.local", + }, + expect: models.User{ + Username: "Admin@vsphere.local", + Email: "jt@placeholder.com", + Realname: "Admin@vsphere.local", + Password: pwd, + Comment: fmt.Sprintf(cmtTmpl, mockSvr.URL+"/test/login"), + }, + }, + } + for _, c := range suite { + a.PostAuthenticate(c.input) + assert.Equal(t, c.expect, *c.input) + } +} +*/ diff --git a/src/core/auth/authproxy/test/server.go b/src/core/auth/authproxy/test/server.go new file mode 100644 index 000000000..b11ec17aa --- /dev/null +++ b/src/core/auth/authproxy/test/server.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 test + +import ( + "net/http" + "net/http/httptest" +) + +type authHandler struct { + m map[string]string +} + +// ServeHTTP handles HTTP requests +func (ah *authHandler) ServeHTTP(rw http.ResponseWriter, req *http.Request) { + if req.Method != http.MethodPost { + http.Error(rw, "", http.StatusMethodNotAllowed) + } + if u, p, ok := req.BasicAuth(); !ok { + // Simulate a service error + http.Error(rw, "", http.StatusInternalServerError) + } else if pass, ok := ah.m[u]; !ok || pass != p { + http.Error(rw, "", http.StatusUnauthorized) + } else { + _, e := rw.Write([]byte(`{"session_id": "hgx59wuWI3b0jcbtidv5mU1YCp-DOQ9NKR1iYKACdKCvbVn7"}`)) + if e != nil { + panic(e) + } + } +} + +// NewMockServer creates the mock server for testing +func NewMockServer(creds map[string]string) *httptest.Server { + mux := http.NewServeMux() + mux.Handle("/test/login", &authHandler{m: creds}) + return httptest.NewTLSServer(mux) +} diff --git a/src/core/auth/uaa/uaa.go b/src/core/auth/uaa/uaa.go index 0b3bb9243..b4889302c 100644 --- a/src/core/auth/uaa/uaa.go +++ b/src/core/auth/uaa/uaa.go @@ -63,7 +63,7 @@ func (u *Auth) Authenticate(m models.AuthModel) (*models.User, error) { func (u *Auth) OnBoardUser(user *models.User) error { user.Username = strings.TrimSpace(user.Username) if len(user.Username) == 0 { - return fmt.Errorf("The Username is empty") + return fmt.Errorf("the Username is empty") } if len(user.Password) == 0 { user.Password = "1234567ab" diff --git a/src/core/main.go b/src/core/main.go index a34878668..b9c2dea66 100644 --- a/src/core/main.go +++ b/src/core/main.go @@ -28,6 +28,7 @@ import ( "github.com/goharbor/harbor/src/common/utils" "github.com/goharbor/harbor/src/common/utils/log" "github.com/goharbor/harbor/src/core/api" + _ "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/auth/uaa"