diff --git a/cmd/kube-apiserver/app/server.go b/cmd/kube-apiserver/app/server.go index b94870402ca..7e0cea908c7 100644 --- a/cmd/kube-apiserver/app/server.go +++ b/cmd/kube-apiserver/app/server.go @@ -85,6 +85,7 @@ type APIServer struct { TokenAuthFile string ServiceAccountKeyFile string ServiceAccountLookup bool + KeystoneURL string AuthorizationMode string AuthorizationPolicyFile string AdmissionControl string @@ -188,6 +189,7 @@ func (s *APIServer) AddFlags(fs *pflag.FlagSet) { fs.StringVar(&s.TokenAuthFile, "token-auth-file", s.TokenAuthFile, "If set, the file that will be used to secure the secure port of the API server via token authentication.") fs.StringVar(&s.ServiceAccountKeyFile, "service-account-key-file", s.ServiceAccountKeyFile, "File containing PEM-encoded x509 RSA private or public key, used to verify ServiceAccount tokens. If unspecified, --tls-private-key-file is used.") fs.BoolVar(&s.ServiceAccountLookup, "service-account-lookup", s.ServiceAccountLookup, "If true, validate ServiceAccount tokens exist in etcd as part of authentication.") + fs.StringVar(&s.KeystoneURL, "experimental-keystone-url", s.KeystoneURL, "If passed, activates the keystone authentication plugin") fs.StringVar(&s.AuthorizationMode, "authorization-mode", s.AuthorizationMode, "Selects how to do authorization on the secure port. One of: "+strings.Join(apiserver.AuthorizationModeChoices, ",")) fs.StringVar(&s.AuthorizationPolicyFile, "authorization-policy-file", s.AuthorizationPolicyFile, "File with authorization policy in csv format, used with --authorization-mode=ABAC, on the secure port.") fs.StringVar(&s.AdmissionControl, "admission-control", s.AdmissionControl, "Ordered list of plug-ins to do admission control of resources into cluster. Comma-delimited list of: "+strings.Join(admission.GetPlugins(), ", ")) @@ -334,7 +336,7 @@ func (s *APIServer) Run(_ []string) error { glog.Warning("no RSA key provided, service account token authentication disabled") } } - authenticator, err := apiserver.NewAuthenticator(s.BasicAuthFile, s.ClientCAFile, s.TokenAuthFile, s.ServiceAccountKeyFile, s.ServiceAccountLookup, etcdStorage) + authenticator, err := apiserver.NewAuthenticator(s.BasicAuthFile, s.ClientCAFile, s.TokenAuthFile, s.ServiceAccountKeyFile, s.ServiceAccountLookup, etcdStorage, s.KeystoneURL) if err != nil { glog.Fatalf("Invalid Authentication Config: %v", err) } diff --git a/docs/admin/authentication.md b/docs/admin/authentication.md index 0b263421c91..0248a715ef7 100644 --- a/docs/admin/authentication.md +++ b/docs/admin/authentication.md @@ -64,6 +64,15 @@ and is a csv file with 3 columns: password, user name, user id. When using basic authentication from an http client, the apiserver expects an `Authorization` header with a value of `Basic BASE64ENCODED(USER:PASSWORD)`. +**Keystone authentication** is enabled by passing the `--experimental-keystone-url=` +option to the apiserver during startup. The plugin is implemented in +`plugin/pkg/auth/authenticator/request/keystone/keystone.go`. +For details on how to use keystone to manage projects and users, refer to the +[Keystone documentation](http://docs.openstack.org/developer/keystone/). Please note that +this plugin is still experimental which means it is subject to changes. +Please refer to the [discussion](https://github.com/GoogleCloudPlatform/kubernetes/pull/11798#issuecomment-129655212) +and the [blueprint](https://github.com/GoogleCloudPlatform/kubernetes/issues/11626) for more details + ## Plugin Development We plan for the Kubernetes API server to issue tokens diff --git a/hack/verify-flags/known-flags.txt b/hack/verify-flags/known-flags.txt index 5f339f8073e..57cf9439c03 100644 --- a/hack/verify-flags/known-flags.txt +++ b/hack/verify-flags/known-flags.txt @@ -80,6 +80,7 @@ executor-cgroup-prefix executor-logv executor-path executor-suicide-timeout +experimental-keystone-url experimental-prefix external-hostname failover-timeout diff --git a/pkg/apiserver/authn.go b/pkg/apiserver/authn.go index 638447c6920..fde9c5b8bb8 100644 --- a/pkg/apiserver/authn.go +++ b/pkg/apiserver/authn.go @@ -26,13 +26,14 @@ import ( "k8s.io/kubernetes/pkg/util" "k8s.io/kubernetes/plugin/pkg/auth/authenticator/password/passwordfile" "k8s.io/kubernetes/plugin/pkg/auth/authenticator/request/basicauth" + "k8s.io/kubernetes/plugin/pkg/auth/authenticator/request/keystone" "k8s.io/kubernetes/plugin/pkg/auth/authenticator/request/union" "k8s.io/kubernetes/plugin/pkg/auth/authenticator/request/x509" "k8s.io/kubernetes/plugin/pkg/auth/authenticator/token/tokenfile" ) // NewAuthenticator returns an authenticator.Request or an error -func NewAuthenticator(basicAuthFile, clientCAFile, tokenFile, serviceAccountKeyFile string, serviceAccountLookup bool, storage storage.Interface) (authenticator.Request, error) { +func NewAuthenticator(basicAuthFile, clientCAFile, tokenFile, serviceAccountKeyFile string, serviceAccountLookup bool, storage storage.Interface, keystoneURL string) (authenticator.Request, error) { var authenticators []authenticator.Request if len(basicAuthFile) > 0 { @@ -67,6 +68,14 @@ func NewAuthenticator(basicAuthFile, clientCAFile, tokenFile, serviceAccountKeyF authenticators = append(authenticators, serviceAccountAuth) } + if len(keystoneURL) > 0 { + keystoneAuth, err := newAuthenticatorFromKeystoneURL(keystoneURL) + if err != nil { + return nil, err + } + authenticators = append(authenticators, keystoneAuth) + } + switch len(authenticators) { case 0: return nil, nil @@ -133,3 +142,13 @@ func newAuthenticatorFromClientCAFile(clientCAFile string) (authenticator.Reques return x509.New(opts, x509.CommonNameUserConversion), nil } + +// newAuthenticatorFromTokenFile returns an authenticator.Request or an error +func newAuthenticatorFromKeystoneURL(keystoneConfigFile string) (authenticator.Request, error) { + keystoneAuthenticator, err := keystone.NewKeystoneAuthenticator(keystoneConfigFile) + if err != nil { + return nil, err + } + + return basicauth.New(keystoneAuthenticator), nil +} diff --git a/plugin/pkg/auth/authenticator/request/keystone/doc.go b/plugin/pkg/auth/authenticator/request/keystone/doc.go new file mode 100644 index 00000000000..7f73f753e62 --- /dev/null +++ b/plugin/pkg/auth/authenticator/request/keystone/doc.go @@ -0,0 +1,20 @@ +/* +Copyright 2014 The Kubernetes Authors All rights reserved. + +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 keystone provide authentication via keystone. +// For details //about keystone and how to use the plugin, refer to +// https://github.com/GoogleCloudPlatform/kubernetes/blob/oidc/docs/admin/authentication.md +package keystone diff --git a/plugin/pkg/auth/authenticator/request/keystone/keystone.go b/plugin/pkg/auth/authenticator/request/keystone/keystone.go new file mode 100644 index 00000000000..cf0335ac02a --- /dev/null +++ b/plugin/pkg/auth/authenticator/request/keystone/keystone.go @@ -0,0 +1,61 @@ +/* +Copyright 2015 The Kubernetes Authors All rights reserved. + +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 keystone + +import ( + "errors" + "strings" + + "github.com/golang/glog" + "github.com/rackspace/gophercloud" + "github.com/rackspace/gophercloud/openstack" + "k8s.io/kubernetes/pkg/auth/user" +) + +// Keystone authenticator contacts openstack keystone to validate user's credentials passed in the request. +// The keystone endpoint is passed during apiserver startup +type KeystoneAuthenticator struct { + authURL string +} + +func (keystoneAuthenticator *KeystoneAuthenticator) AuthenticatePassword(username string, password string) (user.Info, bool, error) { + opts := gophercloud.AuthOptions{ + IdentityEndpoint: keystoneAuthenticator.authURL, + Username: username, + Password: password, + } + + _, err := openstack.AuthenticatedClient(opts) + if err != nil { + glog.Info("Failed: Starting openstack authenticate client") + return nil, false, errors.New("Failed to authenticate") + } + + return &user.DefaultInfo{Name: username}, true, nil +} + +// New returns a request authenticator that validates credentials using openstack keystone +func NewKeystoneAuthenticator(authURL string) (*KeystoneAuthenticator, error) { + if !strings.HasPrefix(authURL, "https") { + return nil, errors.New("Auth URL should be secure and start with https") + } + if authURL == "" { + return nil, errors.New("Auth URL is empty") + } + + return &KeystoneAuthenticator{authURL}, nil +} diff --git a/plugin/pkg/auth/authenticator/request/keystone/keystone_test.go b/plugin/pkg/auth/authenticator/request/keystone/keystone_test.go new file mode 100644 index 00000000000..6437b40dc4e --- /dev/null +++ b/plugin/pkg/auth/authenticator/request/keystone/keystone_test.go @@ -0,0 +1,147 @@ +/* +Copyright 2014 The Kubernetes Authors All rights reserved. + +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 keystone + +import ( + "encoding/base64" + "net/http" + "testing" + + "k8s.io/kubernetes/pkg/auth/user" + "k8s.io/kubernetes/plugin/pkg/auth/authenticator/request/basicauth" +) + +type testKeystoneAuthenticator struct { + User user.Info + OK bool + Err error +} + +func (osClient *testKeystoneAuthenticator) AuthenticatePassword(username string, password string) (user.Info, bool, error) { + + userPasswordMap := map[string]string{ + "user1": "password1", + "user2": "password2", + "user3": "password3", + "user4": "password4", + "user5": "password5", + "user6": "password6", + "user7": "password7", + "user8": "password8", + "user9": "password9", + } + + if userPasswordMap[username] == password { + return &user.DefaultInfo{Name: username}, true, nil + } + return nil, false, nil +} + +func TestKeystoneAuth(t *testing.T) { + + testCases := map[string]struct { + Header string + keystoneAuthenticator testKeystoneAuthenticator + + ExpectedCalled bool + ExpectedUsername string + ExpectedPassword string + + ExpectedUser string + ExpectedOK bool + ExpectedErr bool + }{ + "no header": { + Header: "", + }, + "non-basic header": { + Header: "Bearer foo", + }, + "empty value basic header": { + Header: "Basic", + }, + "whitespace value basic header": { + Header: "Basic ", + }, + "non base-64 basic header": { + Header: "Basic !@#$", + ExpectedErr: true, + }, + "malformed basic header": { + Header: "Basic " + base64.StdEncoding.EncodeToString([]byte("user_without_password")), + ExpectedErr: true, + }, + "empty password basic header": { + Header: "Basic " + base64.StdEncoding.EncodeToString([]byte("user1:")), + ExpectedOK: false, + }, + "valid basic header": { + Header: "Basic " + base64.StdEncoding.EncodeToString([]byte("user1:password1:withcolon")), + ExpectedOK: false, + ExpectedErr: false, + }, + "password auth returned user": { + Header: "Basic " + base64.StdEncoding.EncodeToString([]byte("user1:password1")), + ExpectedCalled: true, + ExpectedUsername: "user1", + ExpectedPassword: "password1", + ExpectedOK: true, + }, + "password auth returned error": { + Header: "Basic " + base64.StdEncoding.EncodeToString([]byte("user1:password2")), + ExpectedCalled: true, + ExpectedUsername: "user1", + ExpectedPassword: "password1", + ExpectedErr: false, + ExpectedOK: false, + }, + } + + for k, testCase := range testCases { + + ksAuth := testCase.keystoneAuthenticator + + auth := basicauth.New(&ksAuth) + + req, _ := http.NewRequest("GET", "/", nil) + if testCase.Header != "" { + req.Header.Set("Authorization", testCase.Header) + } + + user, ok, err := auth.AuthenticateRequest(req) + + if testCase.ExpectedErr && err == nil { + t.Errorf("%s: Expected error, got none", k) + continue + } + if !testCase.ExpectedErr && err != nil { + t.Errorf("%s: Did not expect error, got err:%v", k, err) + continue + } + if testCase.ExpectedOK != ok { + t.Errorf("%s: Expected ok=%v, got %v", k, testCase.ExpectedOK, ok) + continue + } + + if testCase.ExpectedOK { + if testCase.ExpectedUsername != user.GetName() { + t.Errorf("%s: Expected user.name=%v, got %v", k, testCase.ExpectedUsername, user.GetName()) + continue + } + } + } +}