diff --git a/cmd/controller-manager/app/controllers.go b/cmd/controller-manager/app/controllers.go index c1d52f8f6..27b7d48db 100644 --- a/cmd/controller-manager/app/controllers.go +++ b/cmd/controller-manager/app/controllers.go @@ -25,9 +25,10 @@ import ( "kubesphere.io/kubesphere/pkg/controller/storage/snapshotclass" + "kubesphere.io/kubesphere/pkg/apiserver/authentication" + iamv1alpha2 "kubesphere.io/api/iam/v1alpha2" - authoptions "kubesphere.io/kubesphere/pkg/apiserver/authentication/options" "kubesphere.io/kubesphere/pkg/controller/certificatesigningrequest" "kubesphere.io/kubesphere/pkg/controller/cluster" "kubesphere.io/kubesphere/pkg/controller/clusterrolebinding" @@ -62,7 +63,7 @@ func addControllers( s3Client s3.Interface, ldapClient ldapclient.Interface, options *k8s.KubernetesOptions, - authenticationOptions *authoptions.AuthenticationOptions, + authenticationOptions *authentication.Options, multiClusterOptions *multicluster.Options, networkOptions *network.Options, serviceMeshEnabled bool, diff --git a/cmd/controller-manager/app/options/options.go b/cmd/controller-manager/app/options/options.go index 3e78e4382..239fbb806 100644 --- a/cmd/controller-manager/app/options/options.go +++ b/cmd/controller-manager/app/options/options.go @@ -21,6 +21,8 @@ import ( "strings" "time" + "kubesphere.io/kubesphere/pkg/apiserver/authentication" + "k8s.io/apimachinery/pkg/labels" "github.com/spf13/pflag" @@ -28,7 +30,6 @@ import ( cliflag "k8s.io/component-base/cli/flag" "k8s.io/klog" - authoptions "kubesphere.io/kubesphere/pkg/apiserver/authentication/options" "kubesphere.io/kubesphere/pkg/simple/client/devops/jenkins" "kubesphere.io/kubesphere/pkg/simple/client/gateway" "kubesphere.io/kubesphere/pkg/simple/client/k8s" @@ -44,7 +45,7 @@ type KubeSphereControllerManagerOptions struct { KubernetesOptions *k8s.KubernetesOptions DevopsOptions *jenkins.Options S3Options *s3.Options - AuthenticationOptions *authoptions.AuthenticationOptions + AuthenticationOptions *authentication.Options LdapOptions *ldapclient.Options OpenPitrixOptions *openpitrix.Options NetworkOptions *network.Options @@ -75,7 +76,7 @@ func NewKubeSphereControllerManagerOptions() *KubeSphereControllerManagerOptions NetworkOptions: network.NewNetworkOptions(), MultiClusterOptions: multicluster.NewOptions(), ServiceMeshOptions: servicemesh.NewServiceMeshOptions(), - AuthenticationOptions: authoptions.NewAuthenticateOptions(), + AuthenticationOptions: authentication.NewOptions(), GatewayOptions: gateway.NewGatewayOptions(), LeaderElection: &leaderelection.LeaderElectionConfig{ LeaseDuration: 30 * time.Second, diff --git a/cmd/ks-apiserver/app/options/options.go b/cmd/ks-apiserver/app/options/options.go index dbce7cafc..91e4b23f1 100644 --- a/cmd/ks-apiserver/app/options/options.go +++ b/cmd/ks-apiserver/app/options/options.go @@ -21,6 +21,8 @@ import ( "flag" "fmt" + "kubesphere.io/kubesphere/pkg/apiserver/authentication/token" + "k8s.io/client-go/kubernetes/scheme" cliflag "k8s.io/component-base/cli/flag" "k8s.io/klog" @@ -238,6 +240,11 @@ func (s *ServerRunOptions) NewAPIServer(stopCh <-chan struct{}) (*apiserver.APIS klog.Fatalf("unable to create controller runtime client: %v", err) } + apiServer.Issuer, err = token.NewIssuer(s.AuthenticationOptions) + if err != nil { + klog.Fatalf("unable to create issuer: %v", err) + } + apiServer.Server = server return apiServer, nil diff --git a/pkg/apiserver/apiserver.go b/pkg/apiserver/apiserver.go index c05c41c9a..8a64adea3 100644 --- a/pkg/apiserver/apiserver.go +++ b/pkg/apiserver/apiserver.go @@ -24,6 +24,10 @@ import ( rt "runtime" "time" + "kubesphere.io/kubesphere/pkg/apiserver/authentication/token" + + "kubesphere.io/kubesphere/pkg/apiserver/authorization" + "kubesphere.io/api/notification/v2beta1" openpitrixv2alpha1 "kubesphere.io/kubesphere/pkg/kapis/openpitrix/v2alpha1" @@ -48,13 +52,12 @@ import ( audit "kubesphere.io/kubesphere/pkg/apiserver/auditing" "kubesphere.io/kubesphere/pkg/apiserver/authentication/authenticators/basic" - "kubesphere.io/kubesphere/pkg/apiserver/authentication/authenticators/jwttoken" + "kubesphere.io/kubesphere/pkg/apiserver/authentication/authenticators/jwt" "kubesphere.io/kubesphere/pkg/apiserver/authentication/request/anonymous" "kubesphere.io/kubesphere/pkg/apiserver/authentication/request/basictoken" "kubesphere.io/kubesphere/pkg/apiserver/authentication/request/bearertoken" "kubesphere.io/kubesphere/pkg/apiserver/authorization/authorizer" "kubesphere.io/kubesphere/pkg/apiserver/authorization/authorizerfactory" - authorizationoptions "kubesphere.io/kubesphere/pkg/apiserver/authorization/options" "kubesphere.io/kubesphere/pkg/apiserver/authorization/path" "kubesphere.io/kubesphere/pkg/apiserver/authorization/rbac" unionauthorizer "kubesphere.io/kubesphere/pkg/apiserver/authorization/union" @@ -107,23 +110,10 @@ import ( utilnet "kubesphere.io/kubesphere/pkg/utils/net" ) -const ( - // ApiRootPath defines the root path of all KubeSphere apis. - ApiRootPath = "/kapis" - - // MimeMergePatchJson is the mime header used in merge request - MimeMergePatchJson = "application/merge-patch+json" - - // - MimeJsonPatchJson = "application/json-patch+json" -) - type APIServer struct { - // number of kubesphere apiserver ServerCount int - // Server *http.Server Config *apiserverconfig.Config @@ -146,13 +136,10 @@ type APIServer struct { MetricsClient monitoring.Interface - // LoggingClient logging.Client - // DevopsClient devops.Interface - // S3Client s3.Interface SonarClient sonarqube.SonarInterface @@ -165,6 +152,10 @@ type APIServer struct { // controller-runtime cache RuntimeCache runtimecache.Cache + + // entity that issues tokens + Issuer token.Issuer + // controller-runtime client RuntimeClient runtimeclient.Client } @@ -178,7 +169,6 @@ func (s *APIServer) PrepareRun(stopCh <-chan struct{}) error { }) s.installKubeSphereAPIs() - s.installMetricsAPI() s.container.Filter(monitorRequest) @@ -246,19 +236,12 @@ func (s *APIServer) installKubeSphereAPIs() { group.New(s.InformerFactory, s.KubernetesClient.KubeSphere(), s.KubernetesClient.Kubernetes()), rbacAuthorizer)) + userLister := s.InformerFactory.KubeSphereSharedInformerFactory().Iam().V1alpha2().Users().Lister() urlruntime.Must(oauth.AddToContainer(s.container, imOperator, - auth.NewTokenOperator( - s.CacheClient, - s.Config.AuthenticationOptions), - auth.NewPasswordAuthenticator( - s.KubernetesClient.KubeSphere(), - s.InformerFactory.KubeSphereSharedInformerFactory().Iam().V1alpha2().Users().Lister(), - s.Config.AuthenticationOptions), - auth.NewOAuthAuthenticator(s.KubernetesClient.KubeSphere(), - s.InformerFactory.KubeSphereSharedInformerFactory(), - s.Config.AuthenticationOptions), - auth.NewLoginRecorder(s.KubernetesClient.KubeSphere(), - s.InformerFactory.KubeSphereSharedInformerFactory().Iam().V1alpha2().Users().Lister()), + auth.NewTokenOperator(s.CacheClient, s.Issuer, s.Config.AuthenticationOptions), + auth.NewPasswordAuthenticator(userLister, s.Config.AuthenticationOptions), + auth.NewOAuthAuthenticator(userLister, s.Config.AuthenticationOptions), + auth.NewLoginRecorder(s.KubernetesClient.KubeSphere(), userLister), s.Config.AuthenticationOptions)) urlruntime.Must(servicemeshv1alpha2.AddToContainer(s.Config.ServiceMeshOptions, s.container, s.KubernetesClient.Kubernetes(), s.CacheClient)) urlruntime.Must(networkv1alpha2.AddToContainer(s.container, s.Config.NetworkOptions.WeaveScopeHost)) @@ -330,13 +313,13 @@ func (s *APIServer) buildHandlerChain(stopCh <-chan struct{}) { var authorizers authorizer.Authorizer switch s.Config.AuthorizationOptions.Mode { - case authorizationoptions.AlwaysAllow: + case authorization.AlwaysAllow: authorizers = authorizerfactory.NewAlwaysAllowAuthorizer() - case authorizationoptions.AlwaysDeny: + case authorization.AlwaysDeny: authorizers = authorizerfactory.NewAlwaysDenyAuthorizer() default: fallthrough - case authorizationoptions.RBAC: + case authorization.RBAC: excludedPaths := []string{"/oauth/*", "/kapis/config.kubesphere.io/*", "/kapis/version", "/kapis/metrics"} pathAuthorizer, _ := path.NewAuthorizer(excludedPaths) amOperator := am.NewReadOnlyOperator(s.InformerFactory) @@ -349,16 +332,17 @@ func (s *APIServer) buildHandlerChain(stopCh <-chan struct{}) { handler = filters.WithMultipleClusterDispatcher(handler, clusterDispatcher) } - loginRecorder := auth.NewLoginRecorder(s.KubernetesClient.KubeSphere(), - s.InformerFactory.KubeSphereSharedInformerFactory().Iam().V1alpha2().Users().Lister()) + userLister := s.InformerFactory.KubeSphereSharedInformerFactory().Iam().V1alpha2().Users().Lister() + loginRecorder := auth.NewLoginRecorder(s.KubernetesClient.KubeSphere(), userLister) + // authenticators are unordered authn := unionauth.New(anonymous.NewAuthenticator(), - basictoken.New(basic.NewBasicAuthenticator(auth.NewPasswordAuthenticator(s.KubernetesClient.KubeSphere(), - s.InformerFactory.KubeSphereSharedInformerFactory().Iam().V1alpha2().Users().Lister(), - s.Config.AuthenticationOptions), loginRecorder)), - bearertoken.New(jwttoken.NewTokenAuthenticator(auth.NewTokenOperator(s.CacheClient, + basictoken.New(basic.NewBasicAuthenticator(auth.NewPasswordAuthenticator(userLister, s.Config.AuthenticationOptions), - s.InformerFactory.KubeSphereSharedInformerFactory().Iam().V1alpha2().Users().Lister()))) + loginRecorder)), + bearertoken.New(jwt.NewTokenAuthenticator( + auth.NewTokenOperator(s.CacheClient, s.Issuer, s.Config.AuthenticationOptions), + userLister))) handler = filters.WithAuthentication(handler, authn) handler = filters.WithRequestInfo(handler, requestInfoResolver) diff --git a/pkg/apiserver/authentication/authenticators/basic/basic.go b/pkg/apiserver/authentication/authenticators/basic/basic.go index 69a711c02..76ee94cbd 100644 --- a/pkg/apiserver/authentication/authenticators/basic/basic.go +++ b/pkg/apiserver/authentication/authenticators/basic/basic.go @@ -49,7 +49,7 @@ func NewBasicAuthenticator(authenticator auth.PasswordAuthenticator, loginRecord } func (t *basicAuthenticator) AuthenticatePassword(ctx context.Context, username, password string) (*authenticator.Response, bool, error) { - authenticated, provider, err := t.authenticator.Authenticate(username, password) + authenticated, provider, err := t.authenticator.Authenticate(ctx, username, password) if err != nil { if t.loginRecorder != nil && err == auth.IncorrectPasswordError { var sourceIP, userAgent string diff --git a/pkg/apiserver/authentication/authenticators/jwttoken/jwt_token.go b/pkg/apiserver/authentication/authenticators/jwt/jwt.go similarity index 82% rename from pkg/apiserver/authentication/authenticators/jwttoken/jwt_token.go rename to pkg/apiserver/authentication/authenticators/jwt/jwt.go index 5f5dde6e1..76108cc18 100644 --- a/pkg/apiserver/authentication/authenticators/jwttoken/jwt_token.go +++ b/pkg/apiserver/authentication/authenticators/jwt/jwt.go @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -package jwttoken +package jwt import ( "context" @@ -48,31 +48,27 @@ func NewTokenAuthenticator(tokenOperator auth.TokenManagementInterface, userList } func (t *tokenAuthenticator) AuthenticateToken(ctx context.Context, token string) (*authenticator.Response, bool, error) { - providedUser, err := t.tokenOperator.Verify(token) + verified, err := t.tokenOperator.Verify(token) if err != nil { klog.Warning(err) return nil, false, err } - if providedUser.GetName() == iamv1alpha2.PreRegistrationUser { + if verified.User.GetName() == iamv1alpha2.PreRegistrationUser { return &authenticator.Response{ - User: &user.DefaultInfo{ - Name: providedUser.GetName(), - Extra: providedUser.GetExtra(), - Groups: providedUser.GetGroups(), - }, + User: verified.User, }, true, nil } - dbUser, err := t.userLister.Get(providedUser.GetName()) + u, err := t.userLister.Get(verified.User.GetName()) if err != nil { return nil, false, err } return &authenticator.Response{ User: &user.DefaultInfo{ - Name: dbUser.GetName(), - Groups: append(dbUser.Spec.Groups, user.AllAuthenticated), + Name: u.GetName(), + Groups: append(u.Spec.Groups, user.AllAuthenticated), }, }, true, nil } diff --git a/pkg/apiserver/authentication/identityprovider/aliyunidaas/idaas.go b/pkg/apiserver/authentication/identityprovider/aliyunidaas/idaas.go index 3a9659eab..9f35f8f3d 100644 --- a/pkg/apiserver/authentication/identityprovider/aliyunidaas/idaas.go +++ b/pkg/apiserver/authentication/identityprovider/aliyunidaas/idaas.go @@ -17,10 +17,10 @@ limitations under the License. package aliyunidaas import ( - "context" "encoding/json" "errors" "io/ioutil" + "net/http" "github.com/mitchellh/mapstructure" @@ -120,13 +120,16 @@ func (a idaasIdentity) GetEmail() string { return a.Email } -func (a *aliyunIDaaS) IdentityExchange(code string) (identityprovider.Identity, error) { - token, err := a.Config.Exchange(context.TODO(), code) +func (a *aliyunIDaaS) IdentityExchangeCallback(req *http.Request) (identityprovider.Identity, error) { + // OAuth2 callback, see also https://tools.ietf.org/html/rfc6749#section-4.1.2 + code := req.URL.Query().Get("code") + ctx := req.Context() + token, err := a.Config.Exchange(ctx, code) if err != nil { return nil, err } - resp, err := oauth2.NewClient(context.TODO(), oauth2.StaticTokenSource(token)).Get(a.Endpoint.UserInfoURL) + resp, err := oauth2.NewClient(ctx, oauth2.StaticTokenSource(token)).Get(a.Endpoint.UserInfoURL) if err != nil { return nil, err } diff --git a/pkg/apiserver/authentication/identityprovider/aliyunidaas/idaas_test.go b/pkg/apiserver/authentication/identityprovider/aliyunidaas/idaas_test.go index 8febe023c..95bd402d0 100644 --- a/pkg/apiserver/authentication/identityprovider/aliyunidaas/idaas_test.go +++ b/pkg/apiserver/authentication/identityprovider/aliyunidaas/idaas_test.go @@ -53,7 +53,7 @@ endpoint: userInfoUrl: "https://xxxxx.login.aliyunidaas.com/api/bff/v1.2/oauth2/userinfo" authURL: "https://xxxx.login.aliyunidaas.com/oauth/authorize" tokenURL: "https://xxxx.login.aliyunidaas.com/oauth/token" -redirectURL: "http://ks-console/oauth/redirect" +redirectURL: "https://console.kubesphere.io/oauth/redirect/idaas" scopes: - read `)}, @@ -65,7 +65,7 @@ scopes: TokenURL: "https://xxxx.login.aliyunidaas.com/oauth/token", UserInfoURL: "https://xxxxx.login.aliyunidaas.com/api/bff/v1.2/oauth2/userinfo", }, - RedirectURL: "http://ks-console/oauth/redirect", + RedirectURL: "https://console.kubesphere.io/oauth/redirect/idaas", Scopes: []string{"read"}, Config: &oauth2.Config{ ClientID: "xxxx", @@ -75,7 +75,7 @@ scopes: TokenURL: "https://xxxx.login.aliyunidaas.com/oauth/token", AuthStyle: oauth2.AuthStyleAutoDetect, }, - RedirectURL: "http://ks-console/oauth/redirect", + RedirectURL: "https://console.kubesphere.io/oauth/redirect/idaas", Scopes: []string{"read"}, }, }, diff --git a/pkg/apiserver/authentication/identityprovider/cas/cas.go b/pkg/apiserver/authentication/identityprovider/cas/cas.go index e26d2e3f0..97bf32d4d 100644 --- a/pkg/apiserver/authentication/identityprovider/cas/cas.go +++ b/pkg/apiserver/authentication/identityprovider/cas/cas.go @@ -89,10 +89,12 @@ func (f casProviderFactory) Create(options oauth.DynamicOptions) (identityprovid return &cas, nil } -func (c cas) IdentityExchange(ticket string) (identityprovider.Identity, error) { +func (c cas) IdentityExchangeCallback(req *http.Request) (identityprovider.Identity, error) { + // CAS callback, see also https://apereo.github.io/cas/6.3.x/protocol/CAS-Protocol-V2-Specification.html#25-servicevalidate-cas-20 + ticket := req.URL.Query().Get("ticket") resp, err := c.client.ValidateServiceTicket(gocas.ServiceTicket(ticket)) if err != nil { - return nil, fmt.Errorf("cas validate service ticket failed: %v", err) + return nil, fmt.Errorf("cas: failed to validate service ticket : %v", err) } return &casIdentity{User: resp.User}, nil } diff --git a/pkg/apiserver/authentication/identityprovider/github/github.go b/pkg/apiserver/authentication/identityprovider/github/github.go index 2493b67ed..e51df7fed 100644 --- a/pkg/apiserver/authentication/identityprovider/github/github.go +++ b/pkg/apiserver/authentication/identityprovider/github/github.go @@ -167,8 +167,10 @@ func (g githubIdentity) GetEmail() string { return g.Email } -func (g *github) IdentityExchange(code string) (identityprovider.Identity, error) { - ctx := context.TODO() +func (g *github) IdentityExchangeCallback(req *http.Request) (identityprovider.Identity, error) { + // OAuth2 callback, see also https://tools.ietf.org/html/rfc6749#section-4.1.2 + code := req.URL.Query().Get("code") + ctx := req.Context() if g.InsecureSkipVerify { client := &http.Client{ Transport: &http.Transport{ diff --git a/pkg/apiserver/authentication/identityprovider/github/github_test.go b/pkg/apiserver/authentication/identityprovider/github/github_test.go index 223bc83de..09bfa99c1 100644 --- a/pkg/apiserver/authentication/identityprovider/github/github_test.go +++ b/pkg/apiserver/authentication/identityprovider/github/github_test.go @@ -23,6 +23,7 @@ import ( "fmt" "net/http" "net/http/httptest" + "net/url" "testing" "time" @@ -86,7 +87,7 @@ var _ = Describe("GitHub", func() { configYAML := ` clientID: de6ff8bed0304e487b6e clientSecret: 2b70536f79ec8d2939863509d05e2a71c268b9af -redirectURL: "http://ks-console/oauth/redirect" +redirectURL: "https://console.kubesphere.io/oauth/redirect/github" scopes: - user ` @@ -102,7 +103,7 @@ scopes: TokenURL: tokenURL, UserInfoURL: userInfoURL, }, - RedirectURL: "http://ks-console/oauth/redirect", + RedirectURL: "https://console.kubesphere.io/oauth/redirect/github", Scopes: []string{"user"}, Config: &oauth2.Config{ ClientID: "de6ff8bed0304e487b6e", @@ -111,7 +112,7 @@ scopes: AuthURL: authURL, TokenURL: tokenURL, }, - RedirectURL: "http://ks-console/oauth/redirect", + RedirectURL: "https://console.kubesphere.io/oauth/redirect/github", Scopes: []string{"user"}, }, } @@ -121,7 +122,7 @@ scopes: config := oauth.DynamicOptions{ "clientID": "de6ff8bed0304e487b6e", "clientSecret": "2b70536f79ec8d2939863509d05e2a71c268b9af", - "redirectURL": "http://ks-console/oauth/redirect", + "redirectURL": "https://console.kubesphere.io/oauth/redirect/github", "insecureSkipVerify": true, "endpoint": oauth.DynamicOptions{ "authURL": fmt.Sprintf("%s/login/oauth/authorize", githubServer.URL), @@ -135,7 +136,7 @@ scopes: expected := oauth.DynamicOptions{ "clientID": "de6ff8bed0304e487b6e", "clientSecret": "2b70536f79ec8d2939863509d05e2a71c268b9af", - "redirectURL": "http://ks-console/oauth/redirect", + "redirectURL": "https://console.kubesphere.io/oauth/redirect/github", "insecureSkipVerify": true, "endpoint": oauth.DynamicOptions{ "authURL": fmt.Sprintf("%s/login/oauth/authorize", githubServer.URL), @@ -146,7 +147,9 @@ scopes: Expect(config).Should(Equal(expected)) }) It("should login successfully", func() { - identity, err := provider.IdentityExchange("3389") + url, _ := url.Parse("https://console.kubesphere.io/oauth/redirect/test?code=00000") + req := &http.Request{URL: url} + identity, err := provider.IdentityExchangeCallback(req) Expect(err).Should(BeNil()) Expect(identity.GetUserID()).Should(Equal("test")) Expect(identity.GetUsername()).Should(Equal("test")) diff --git a/pkg/apiserver/authentication/identityprovider/identity_provider.go b/pkg/apiserver/authentication/identityprovider/identity_provider.go index af0d77381..2796f9c27 100644 --- a/pkg/apiserver/authentication/identityprovider/identity_provider.go +++ b/pkg/apiserver/authentication/identityprovider/identity_provider.go @@ -35,13 +35,13 @@ var ( // Identity represents the account mapped to kubesphere type Identity interface { - // required + // GetUserID required // Identifier for the End-User at the Issuer. GetUserID() string - // optional + // GetUsername optional // The username which the End-User wishes to be referred to kubesphere. GetUsername() string - // optional + // GetEmail optional GetEmail() string } diff --git a/pkg/apiserver/authentication/identityprovider/identity_provider_test.go b/pkg/apiserver/authentication/identityprovider/identity_provider_test.go index dfd17ba22..e7441b87b 100644 --- a/pkg/apiserver/authentication/identityprovider/identity_provider_test.go +++ b/pkg/apiserver/authentication/identityprovider/identity_provider_test.go @@ -19,6 +19,7 @@ package identityprovider import ( + "net/http" "testing" "kubesphere.io/kubesphere/pkg/apiserver/authentication/oauth" @@ -50,7 +51,7 @@ func (e emptyIdentity) GetEmail() string { return "test@test.com" } -func (e emptyOAuthProvider) IdentityExchange(code string) (Identity, error) { +func (e emptyOAuthProvider) IdentityExchangeCallback(req *http.Request) (Identity, error) { return emptyIdentity{}, nil } diff --git a/pkg/apiserver/authentication/identityprovider/oauth_provider.go b/pkg/apiserver/authentication/identityprovider/oauth_provider.go index 12efc0ae1..29fea1395 100644 --- a/pkg/apiserver/authentication/identityprovider/oauth_provider.go +++ b/pkg/apiserver/authentication/identityprovider/oauth_provider.go @@ -13,20 +13,23 @@ 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 identityprovider import ( + "net/http" + "kubesphere.io/kubesphere/pkg/apiserver/authentication/oauth" ) type OAuthProvider interface { - // IdentityExchange exchange identity from remote server - IdentityExchange(code string) (Identity, error) + // IdentityExchangeCallback handle oauth callback, exchange identity from remote server + IdentityExchangeCallback(req *http.Request) (Identity, error) } type OAuthProviderFactory interface { // Type unique type of the provider Type() string - // Apply the dynamic options from kubesphere-config + // Create Apply the dynamic options Create(options oauth.DynamicOptions) (OAuthProvider, error) } diff --git a/pkg/apiserver/authentication/identityprovider/oidc/oidc.go b/pkg/apiserver/authentication/identityprovider/oidc/oidc.go index c52e70765..cddb5f31a 100644 --- a/pkg/apiserver/authentication/identityprovider/oidc/oidc.go +++ b/pkg/apiserver/authentication/identityprovider/oidc/oidc.go @@ -196,8 +196,10 @@ func (f *oidcProviderFactory) Create(options oauth.DynamicOptions) (identityprov return &oidcProvider, nil } -func (o *oidcProvider) IdentityExchange(code string) (identityprovider.Identity, error) { - ctx := context.TODO() +func (o *oidcProvider) IdentityExchangeCallback(req *http.Request) (identityprovider.Identity, error) { + //OAuth2 callback, see also https://tools.ietf.org/html/rfc6749#section-4.1.2 + code := req.URL.Query().Get("code") + ctx := req.Context() if o.InsecureSkipVerify { client := &http.Client{ Transport: &http.Transport{ diff --git a/pkg/apiserver/authentication/identityprovider/oidc/oidc_test.go b/pkg/apiserver/authentication/identityprovider/oidc/oidc_test.go index 3b6bf7c67..871a7bcf1 100644 --- a/pkg/apiserver/authentication/identityprovider/oidc/oidc_test.go +++ b/pkg/apiserver/authentication/identityprovider/oidc/oidc_test.go @@ -28,6 +28,7 @@ import ( "fmt" "net/http" "net/http/httptest" + "net/url" "strings" "testing" "time" @@ -170,7 +171,7 @@ var _ = Describe("OIDC", func() { "issuer": oidcServer.URL, "clientID": "kubesphere", "clientSecret": "c53e80ab92d48ab12f4e7f1f6976d1bdc996e0d7", - "redirectURL": "http://ks-console/oauth/redirect", + "redirectURL": "https://console.kubesphere.io/oauth/redirect/oidc", "insecureSkipVerify": true, } factory := oidcProviderFactory{} @@ -180,7 +181,7 @@ var _ = Describe("OIDC", func() { "issuer": oidcServer.URL, "clientID": "kubesphere", "clientSecret": "c53e80ab92d48ab12f4e7f1f6976d1bdc996e0d7", - "redirectURL": "http://ks-console/oauth/redirect", + "redirectURL": "https://console.kubesphere.io/oauth/redirect/oidc", "insecureSkipVerify": true, "endpoint": oauth.DynamicOptions{ "authURL": fmt.Sprintf("%s/authorize", oidcServer.URL), @@ -193,7 +194,9 @@ var _ = Describe("OIDC", func() { Expect(config).Should(Equal(expected)) }) It("should login successfully", func() { - identity, err := provider.IdentityExchange("3389") + url, _ := url.Parse("https://console.kubesphere.io/oauth/redirect/oidc?code=00000") + req := &http.Request{URL: url} + identity, err := provider.IdentityExchangeCallback(req) Expect(err).Should(BeNil()) Expect(identity.GetUserID()).Should(Equal("110169484474386276334")) Expect(identity.GetUsername()).Should(Equal("test")) diff --git a/pkg/apiserver/authentication/oauth/error.go b/pkg/apiserver/authentication/oauth/error.go new file mode 100644 index 000000000..2de057863 --- /dev/null +++ b/pkg/apiserver/authentication/oauth/error.go @@ -0,0 +1,128 @@ +/* +Copyright 2020 The KubeSphere 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 oauth + +import "fmt" + +var ( + // ErrorInvalidClient + // Client authentication failed (e.g., unknown client, no + // client authentication included, or unsupported + // authentication method). The authorization server MAY + // return an HTTP 401 (Unauthorized) status code to indicate + // which HTTP authentication schemes are supported. If the + // client attempted to authenticate via the "Authorization" + // request header field, the authorization server MUST + // respond with an HTTP 401 (Unauthorized) status code and + // include the "WWW-Authenticate" response header field + // matching the authentication scheme used by the client. + ErrorInvalidClient = Error{Type: "invalid_client"} + + // ErrorInvalidRequest The request is missing a required parameter, + // includes an unsupported parameter value (other than grant type), + // repeats a parameter, includes multiple credentials, + // utilizes more than one mechanism for authenticating the client, + // or is otherwise malformed. + ErrorInvalidRequest = Error{Type: "invalid_request"} + + // ErrorInvalidGrant + // The provided authorization grant (e.g., authorization code, + // resource owner credentials) or refresh token is invalid, expired, revoked, + // does not match the redirection URI used in the authorization request, + // or was issued to another client. + ErrorInvalidGrant = Error{Type: "invalid_grant"} + + // ErrorUnsupportedGrantType + // The authorization grant type is not supported by the authorization server. + ErrorUnsupportedGrantType = Error{Type: "unsupported_grant_type"} + + ErrorUnsupportedResponseType = Error{Type: "unsupported_response_type"} + + // ErrorUnauthorizedClient + // The authenticated client is not authorized to use this authorization grant type. + ErrorUnauthorizedClient = Error{Type: "unauthorized_client"} + + // ErrorInvalidScope The requested scope is invalid, unknown, malformed, + // or exceeds the scope granted by the resource owner. + ErrorInvalidScope = Error{Type: "invalid_scope"} + + // ErrorLoginRequired The Authorization Server requires End-User authentication. + // This error MAY be returned when the prompt parameter value in the Authentication Request is none, + // but the Authentication Request cannot be completed without displaying a user interface + // for End-User authentication. + ErrorLoginRequired = Error{Type: "login_required"} + + // ErrorServerError + // The authorization server encountered an unexpected + // condition that prevented it from fulfilling the request. + // (This error code is needed because a 500 Internal Server + // Error HTTP status code cannot be returned to the client + // via an HTTP redirect.) + ErrorServerError = Error{Type: "server_error"} +) + +func NewInvalidRequest(error error) Error { + err := ErrorInvalidRequest + err.Description = error.Error() + return err +} + +func NewInvalidScope(error error) Error { + err := ErrorInvalidScope + err.Description = error.Error() + return err +} + +func NewInvalidClient(error error) Error { + err := ErrorInvalidClient + err.Description = error.Error() + return err +} + +func NewInvalidGrant(error error) Error { + err := ErrorInvalidGrant + err.Description = error.Error() + return err +} + +func NewServerError(error error) Error { + err := ErrorServerError + err.Description = error.Error() + return err +} + +// Error wrapped OAuth error Response, for more details: https://datatracker.ietf.org/doc/html/rfc6749#section-5.2 +// The authorization server responds with an HTTP 400 (Bad Request) +// status code (unless specified otherwise) and includes the following +// parameters with the response: +type Error struct { + // Type REQUIRED + // A single ASCII [USASCII] error code from the following: + // Values for the "error" parameter MUST NOT include characters + // outside the set %x20-21 / %x23-5B / %x5D-7E. + Type string `json:"error"` + // Description OPTIONAL. Human-readable ASCII [USASCII] text providing + // additional information, used to assist the client developer in + // understanding the error that occurred. + // Values for the "error_description" parameter MUST NOT include + // characters outside the set %x20-21 / %x23-5B / %x5D-7E. + Description string `json:"error_description,omitempty"` +} + +func (e Error) Error() string { + return fmt.Sprintf("error=\"%s\", error_description=\"%s\"", e.Type, e.Description) +} diff --git a/pkg/apiserver/authentication/oauth/oidc.go b/pkg/apiserver/authentication/oauth/oidc.go new file mode 100644 index 000000000..926e2fb11 --- /dev/null +++ b/pkg/apiserver/authentication/oauth/oidc.go @@ -0,0 +1,64 @@ +/* + + Copyright 2021 The KubeSphere 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 oauth + +import ( + "kubesphere.io/kubesphere/pkg/utils/sliceutil" +) + +const ( + // ScopeOpenID Verify that a scope parameter is present and contains the openid scope value. + // If no openid scope value is present, the request may still be a valid OAuth 2.0 request, + // but is not an OpenID Connect request. + ScopeOpenID = "openid" + // ScopeEmail This scope value requests access to the email and email_verified Claims. + ScopeEmail = "email" + // ScopeProfile This scope value requests access to the End-User's default profile Claims, + // which are: name, family_name, given_name, middle_name, nickname, preferred_username, + // profile, picture, website, gender, birthdate, zoneinfo, locale, and updated_at. + ScopeProfile = "profile" + // ScopePhone This scope value requests access to the phone_number and phone_number_verified Claims. + ScopePhone = "phone" + // ScopeAddress This scope value requests access to the address Claim. + ScopeAddress = "address" + ResponseCode = "code" + ResponseIDToken = "id_token" + ResponseToken = "token" +) + +var ValidScopes = []string{ScopeOpenID, ScopeEmail, ScopeProfile} +var ValidResponseTypes = []string{ResponseCode, ResponseIDToken, ResponseToken} + +func IsValidScopes(scopes []string) bool { + for _, scope := range scopes { + if !sliceutil.HasString(ValidScopes, scope) { + return false + } + } + return true +} + +func IsValidResponseTypes(responseTypes []string) bool { + for _, responseType := range responseTypes { + if !sliceutil.HasString(ValidResponseTypes, responseType) { + return false + } + } + return true +} diff --git a/pkg/apiserver/authentication/oauth/oidc_test.go b/pkg/apiserver/authentication/oauth/oidc_test.go new file mode 100644 index 000000000..a6eeeb47a --- /dev/null +++ b/pkg/apiserver/authentication/oauth/oidc_test.go @@ -0,0 +1,78 @@ +/* + + Copyright 2021 The KubeSphere 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 oauth + +import "testing" + +func TestIsValidResponseTypes(t *testing.T) { + type args struct { + responseTypes []string + } + tests := []struct { + name string + args args + want bool + }{ + { + name: "valid response type", + args: args{responseTypes: []string{"code", "id_token"}}, + want: true, + }, + { + name: "invalid response type", + args: args{responseTypes: []string{"value"}}, + want: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := IsValidResponseTypes(tt.args.responseTypes); got != tt.want { + t.Errorf("IsValidResponseTypes() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestIsValidScopes(t *testing.T) { + type args struct { + scopes []string + } + tests := []struct { + name string + args args + want bool + }{ + { + name: "valid scope", + args: args{scopes: []string{"openid", "email"}}, + want: true, + }, + { + name: "invalid scope", + args: args{scopes: []string{"user"}}, + want: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := IsValidScopes(tt.args.scopes); got != tt.want { + t.Errorf("IsValidScopes() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/pkg/apiserver/authentication/oauth/oauth_options.go b/pkg/apiserver/authentication/oauth/options.go similarity index 90% rename from pkg/apiserver/authentication/oauth/oauth_options.go rename to pkg/apiserver/authentication/oauth/options.go index cf84e858c..0efb3d063 100644 --- a/pkg/apiserver/authentication/oauth/oauth_options.go +++ b/pkg/apiserver/authentication/oauth/options.go @@ -47,6 +47,8 @@ const ( // MappingMethodMixed A user entity can be mapped with multiple identifyProvider. // not supported yet. MappingMethodMixed MappingMethod = "mixed" + + DefaultIssuer string = "kubesphere" ) var ( @@ -56,6 +58,16 @@ var ( ) type Options struct { + // An Issuer Identifier is a case-sensitive URL using the https scheme that contains scheme, + // host, and optionally, port number and path components and no query or fragment components. + Issuer string `json:"issuer,omitempty" yaml:"issuer,omitempty"` + + // RSA private key file used to sign the id token + SignKey string `json:"signKey,omitempty" yaml:"signKey"` + + // Raw RSA private key. Base64 encoded PEM file + SignKeyData string `json:"-,omitempty" yaml:"signKeyData"` + // Register identity providers. IdentityProviders []IdentityProviderOptions `json:"identityProviders,omitempty" yaml:"identityProviders,omitempty"` @@ -79,7 +91,7 @@ type Options struct { AccessTokenInactivityTimeout time.Duration `json:"accessTokenInactivityTimeout" yaml:"accessTokenInactivityTimeout"` } -// the type of key must be string +// DynamicOptions accept dynamic configuration, the type of key MUST be string type DynamicOptions map[string]interface{} func (o DynamicOptions) MarshalJSON() ([]byte, error) { @@ -169,6 +181,9 @@ type Token struct { // if it expires. RefreshToken string `json:"refresh_token,omitempty"` + // ID Token value associated with the authenticated session. + IDToken string `json:"id_token,omitempty"` + // ExpiresIn is the optional expiration second of the access token. ExpiresIn int `json:"expires_in,omitempty"` } @@ -209,7 +224,7 @@ type Client struct { } var ( - // Allow any redirect URI if the redirectURI is defined in request + // AllowAllRedirectURI Allow any redirect URI if the redirectURI is defined in request AllowAllRedirectURI = "*" DefaultTokenMaxAge = time.Second * 86400 DefaultAccessTokenInactivityTimeout = time.Duration(0) @@ -259,10 +274,10 @@ func (c Client) anyRedirectAbleURI() []string { return uris } -func (c Client) ResolveRedirectURL(expectURL string) (string, error) { +func (c Client) ResolveRedirectURL(expectURL string) (*url.URL, error) { // RedirectURIs is empty if len(c.RedirectURIs) == 0 { - return "", ErrorRedirectURLNotAllowed + return nil, ErrorRedirectURLNotAllowed } allowAllRedirectURI := sliceutil.HasString(c.RedirectURIs, AllowAllRedirectURI) redirectAbleURIs := c.anyRedirectAbleURI() @@ -270,20 +285,21 @@ func (c Client) ResolveRedirectURL(expectURL string) (string, error) { if expectURL == "" { // Need to specify at least one RedirectURI if len(redirectAbleURIs) > 0 { - return redirectAbleURIs[0], nil + return url.Parse(redirectAbleURIs[0]) } else { - return "", ErrorRedirectURLNotAllowed + return nil, ErrorRedirectURLNotAllowed } } if allowAllRedirectURI || sliceutil.HasString(redirectAbleURIs, expectURL) { - return expectURL, nil + return url.Parse(expectURL) } - return "", ErrorRedirectURLNotAllowed + return nil, ErrorRedirectURLNotAllowed } func NewOptions() *Options { return &Options{ + Issuer: DefaultIssuer, IdentityProviders: make([]IdentityProviderOptions, 0), Clients: make([]Client, 0), AccessTokenMaxAge: time.Hour * 2, diff --git a/pkg/apiserver/authentication/oauth/oauth_options_test.go b/pkg/apiserver/authentication/oauth/options_test.go similarity index 88% rename from pkg/apiserver/authentication/oauth/oauth_options_test.go rename to pkg/apiserver/authentication/oauth/options_test.go index a279d6133..1d7bfc6a1 100644 --- a/pkg/apiserver/authentication/oauth/oauth_options_test.go +++ b/pkg/apiserver/authentication/oauth/options_test.go @@ -56,16 +56,16 @@ func TestClientResolveRedirectURL(t *testing.T) { t.Fatal(err) } tests := []struct { - Name string - client Client - expectError error - expectURL string + Name string + client Client + wantErr bool + expectURL string }{ { - Name: "default client test", - client: defaultClient, - expectError: nil, - expectURL: "https://localhost:8080/auth/cb", + Name: "default client test", + client: defaultClient, + wantErr: false, + expectURL: "https://localhost:8080/auth/cb", }, { Name: "custom client test", @@ -76,8 +76,8 @@ func TestClientResolveRedirectURL(t *testing.T) { GrantMethod: GrantHandlerAuto, ScopeRestrictions: []string{"full"}, }, - expectError: ErrorRedirectURLNotAllowed, - expectURL: "https://foo.bar.com/oauth/err", + wantErr: true, + expectURL: "https://foo.bar.com/oauth/err", }, { Name: "custom client test", @@ -88,17 +88,18 @@ func TestClientResolveRedirectURL(t *testing.T) { GrantMethod: GrantHandlerAuto, ScopeRestrictions: []string{"full"}, }, - expectError: nil, - expectURL: "https://foo.bar.com/oauth/err2", + wantErr: false, + expectURL: "https://foo.bar.com/oauth/err2", }, } for _, test := range tests { redirectURL, err := test.client.ResolveRedirectURL(test.expectURL) - if err != test.expectError { - t.Errorf("expected error: %s, got: %s", test.expectError, err) + if (err != nil) != test.wantErr { + t.Errorf("ResolveRedirectURL() error = %+v, wantErr %+v", err, test.wantErr) + return } - if test.expectError == nil && test.expectURL != redirectURL { + if redirectURL != nil && test.expectURL != redirectURL.String() { t.Errorf("expected redirect url: %s, got: %s", test.expectURL, redirectURL) } } diff --git a/pkg/apiserver/authentication/options/authenticate_options.go b/pkg/apiserver/authentication/options.go similarity index 81% rename from pkg/apiserver/authentication/options/authenticate_options.go rename to pkg/apiserver/authentication/options.go index 6652afde0..84c0b9000 100644 --- a/pkg/apiserver/authentication/options/authenticate_options.go +++ b/pkg/apiserver/authentication/options.go @@ -1,20 +1,22 @@ /* -Copyright 2020 The KubeSphere 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 + Copyright 2021 The KubeSphere Authors. - http://www.apache.org/licenses/LICENSE-2.0 + 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. -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 options +package authentication import ( "errors" @@ -31,7 +33,7 @@ import ( "kubesphere.io/kubesphere/pkg/apiserver/authentication/oauth" ) -type AuthenticationOptions struct { +type Options struct { // AuthenticateRateLimiter defines under which circumstances we will block user. // A user will be blocked if his/her failed login attempt reaches AuthenticateRateLimiterMaxTries in // AuthenticateRateLimiterDuration for about AuthenticateRateLimiterDuration. For example, @@ -40,7 +42,10 @@ type AuthenticationOptions struct { // A user will be blocked for 10m if he/she logins with incorrect credentials for at least 5 times in 10m. AuthenticateRateLimiterMaxTries int `json:"authenticateRateLimiterMaxTries" yaml:"authenticateRateLimiterMaxTries"` AuthenticateRateLimiterDuration time.Duration `json:"authenticateRateLimiterDuration" yaml:"authenticateRateLimiterDuration"` - // Token verification maximum time difference + // Token verification maximum time difference, default to 10s. + // You should consider allowing a clock skew when checking the time-based values. + // This should be values of a few seconds, and we don’t recommend using more than 30 seconds for this purpose, + // as this would rather indicate problems with the server, rather than a common clock skew. MaximumClockSkew time.Duration `json:"maximumClockSkew" yaml:"maximumClockSkew"` // retention login history, records beyond this amount will be deleted LoginHistoryRetentionPeriod time.Duration `json:"loginHistoryRetentionPeriod" yaml:"loginHistoryRetentionPeriod"` @@ -57,8 +62,8 @@ type AuthenticationOptions struct { KubectlImage string `json:"kubectlImage" yaml:"kubectlImage"` } -func NewAuthenticateOptions() *AuthenticationOptions { - return &AuthenticationOptions{ +func NewOptions() *Options { + return &Options{ AuthenticateRateLimiterMaxTries: 5, AuthenticateRateLimiterDuration: time.Minute * 30, MaximumClockSkew: 10 * time.Second, @@ -71,7 +76,7 @@ func NewAuthenticateOptions() *AuthenticationOptions { } } -func (options *AuthenticationOptions) Validate() []error { +func (options *Options) Validate() []error { var errs []error if len(options.JwtSecret) == 0 { errs = append(errs, errors.New("JWT secret MUST not be empty")) @@ -85,7 +90,7 @@ func (options *AuthenticationOptions) Validate() []error { return errs } -func (options *AuthenticationOptions) AddFlags(fs *pflag.FlagSet, s *AuthenticationOptions) { +func (options *Options) AddFlags(fs *pflag.FlagSet, s *Options) { fs.IntVar(&options.AuthenticateRateLimiterMaxTries, "authenticate-rate-limiter-max-retries", s.AuthenticateRateLimiterMaxTries, "") fs.DurationVar(&options.AuthenticateRateLimiterDuration, "authenticate-rate-limiter-duration", s.AuthenticateRateLimiterDuration, "") fs.BoolVar(&options.MultipleLogin, "multiple-login", s.MultipleLogin, "Allow multiple login with the same account, disable means only one user can login at the same time.") diff --git a/pkg/apiserver/authentication/token/issuer.go b/pkg/apiserver/authentication/token/issuer.go index 43d886d4b..b41e8778e 100644 --- a/pkg/apiserver/authentication/token/issuer.go +++ b/pkg/apiserver/authentication/token/issuer.go @@ -17,24 +17,264 @@ limitations under the License. package token import ( + cryptorand "crypto/rand" + "crypto/rsa" + "crypto/x509" + "encoding/base64" + "encoding/pem" + "errors" + "fmt" + "hash/fnv" + "io/ioutil" "time" + "gopkg.in/square/go-jose.v2" + + "github.com/form3tech-oss/jwt-go" + "k8s.io/klog" + + "kubesphere.io/kubesphere/pkg/apiserver/authentication" + "k8s.io/apiserver/pkg/authentication/user" ) const ( - AccessToken TokenType = "access_token" - RefreshToken TokenType = "refresh_token" - StaticToken TokenType = "static_token" + AccessToken Type = "access_token" + RefreshToken Type = "refresh_token" + StaticToken Type = "static_token" + AuthorizationCode Type = "code" + IDToken Type = "id_token" + headerKeyID string = "kid" + headerAlgorithm string = "alg" ) -type TokenType string +type Type string + +type IssueRequest struct { + User user.Info + ExpiresIn time.Duration + Claims +} + +type VerifiedResponse struct { + User user.Info + Claims +} // Issuer issues token to user, tokens are required to perform mutating requests to resources type Issuer interface { // IssueTo issues a token a User, return error if issuing process failed - IssueTo(user user.Info, tokenType TokenType, expiresIn time.Duration) (string, error) + IssueTo(request *IssueRequest) (string, error) // Verify verifies a token, and return a user info if it's a valid token, otherwise return error - Verify(string) (user.Info, TokenType, error) + Verify(string) (*VerifiedResponse, error) +} + +type Claims struct { + jwt.StandardClaims + // Private Claim Names + // TokenType defined the type of the token + TokenType Type `json:"token_type"` + // Username is user identity same as `sub` + Username string `json:"username"` + // String value used to associate a Client session with an ID Token, and to mitigate replay attacks. + // The value is passed through unmodified from the Authentication Request to the ID Token. + Nonce string `json:"nonce,omitempty"` + // Extra contains the additional information + Extra map[string][]string `json:"extra,omitempty"` +} + +type issuer struct { + // Issuer Identity + name string + // signing access_token and refresh_token + secret []byte + // signing id_token + signKey *jose.JSONWebKey + // Token verification maximum time difference + maximumClockSkew time.Duration +} + +func (s *issuer) IssueTo(request *IssueRequest) (string, error) { + issueAt := time.Now().Unix() + claims := Claims{ + Username: request.User.GetName(), + Extra: request.User.GetExtra(), + TokenType: request.TokenType, + StandardClaims: jwt.StandardClaims{ + IssuedAt: issueAt, + Subject: request.User.GetName(), + Issuer: s.name, + }, + } + + if len(request.Audience) > 0 { + claims.Audience = request.Audience + } + if request.Nonce != "" { + claims.Nonce = request.Nonce + } + if request.ExpiresIn > 0 { + claims.ExpiresAt = claims.IssuedAt + int64(request.ExpiresIn.Seconds()) + } + + var token string + var err error + if request.TokenType == IDToken { + t := jwt.NewWithClaims(jwt.SigningMethodRS256, claims) + t.Header[headerKeyID] = s.signKey.KeyID + token, err = t.SignedString(s.signKey.Key) + } else { + token, err = jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString(s.secret) + } + if err != nil { + klog.Warningf("jwt: failed to issue token: %v", err) + return "", err + } + return token, nil +} + +func (s *issuer) Verify(token string) (*VerifiedResponse, error) { + parser := jwt.Parser{ + ValidMethods: []string{jwt.SigningMethodHS256.Alg(), jwt.SigningMethodRS256.Alg()}, + UseJSONNumber: false, + SkipClaimsValidation: true, + } + + var claims Claims + _, err := parser.ParseWithClaims(token, &claims, s.keyFunc) + if err != nil { + klog.Warningf("jwt: failed to parse token: %v", err) + return nil, err + } + + now := time.Now().Unix() + if claims.VerifyExpiresAt(now, false) == false { + delta := time.Unix(now, 0).Sub(time.Unix(claims.ExpiresAt, 0)) + err = fmt.Errorf("jwt: token is expired by %v", delta) + klog.V(4).Info(err) + return nil, err + } + + // allowing a clock skew when checking the time-based values. + skewedTime := now + int64(s.maximumClockSkew.Seconds()) + if claims.VerifyIssuedAt(skewedTime, false) == false { + err = fmt.Errorf("jwt: token used before issued, iat:%v, now:%v", claims.IssuedAt, now) + klog.Warning(err) + return nil, err + } + + verified := &VerifiedResponse{ + User: &user.DefaultInfo{ + Name: claims.Username, + Extra: claims.Extra, + }, + Claims: claims, + } + + return verified, nil +} + +func (s *issuer) keyFunc(token *jwt.Token) (i interface{}, err error) { + alg, _ := token.Header[headerAlgorithm].(string) + switch alg { + case jwt.SigningMethodHS256.Alg(): + return s.secret, nil + case jwt.SigningMethodRS256.Alg(): + return s.signKey.Key, nil + default: + return nil, fmt.Errorf("unexpect signature algorithm %v", token.Header[headerAlgorithm]) + } +} + +func loadPrivateKey(data []byte) (*rsa.PrivateKey, error) { + block, _ := pem.Decode(data) + if block == nil { + return nil, errors.New("private key not in pem format") + } + key, err := x509.ParsePKCS1PrivateKey(block.Bytes) + if err != nil { + return nil, fmt.Errorf("failed to key file: %v", err) + } + return key, nil +} + +func generatePrivateKeyData() ([]byte, error) { + privateKey, err := rsa.GenerateKey(cryptorand.Reader, 2048) + if err != nil { + return nil, fmt.Errorf("failed to generate private key: %v", err) + } + data := x509.MarshalPKCS1PrivateKey(privateKey) + pemData := pem.EncodeToMemory( + &pem.Block{ + Type: "RSA PRIVATE KEY", + Bytes: data, + }, + ) + return pemData, nil +} + +func loadSignKey(options *authentication.Options) (*rsa.PrivateKey, string, error) { + var signKey *rsa.PrivateKey + var signKeyData []byte + var err error + + if options.OAuthOptions.SignKey != "" { + signKeyData, err = ioutil.ReadFile(options.OAuthOptions.SignKey) + if err != nil { + klog.Errorf("issuer: failed to read private key file %s: %v", options.OAuthOptions.SignKey, err) + return nil, "", err + } + } else if options.OAuthOptions.SignKeyData != "" { + signKeyData, err = base64.StdEncoding.DecodeString(options.OAuthOptions.SignKeyData) + if err != nil { + klog.Errorf("issuer: failed to decode sign key data: %s", err) + return nil, "", err + } + } + + // automatically generate private key + if len(signKeyData) == 0 { + signKeyData, err = generatePrivateKeyData() + if err != nil { + klog.Errorf("issuer: failed to generate private key: %v", err) + return nil, "", err + } + } + + if len(signKeyData) > 0 { + signKey, err = loadPrivateKey(signKeyData) + if err != nil { + klog.Errorf("issuer: failed to load private key from data: %v", err) + } + } + + keyID := fmt.Sprint(fnv32a(signKeyData)) + return signKey, keyID, nil +} + +func NewIssuer(options *authentication.Options) (Issuer, error) { + // TODO(hongming) automatically rotates keys + signKey, keyID, err := loadSignKey(options) + if err != nil { + return nil, err + } + return &issuer{ + name: options.OAuthOptions.Issuer, + secret: []byte(options.JwtSecret), + maximumClockSkew: options.MaximumClockSkew, + signKey: &jose.JSONWebKey{ + Key: signKey, + KeyID: keyID, + Algorithm: jwt.SigningMethodRS256.Alg(), + Use: "sig", + }, + }, nil +} + +// fnv32a hashes using fnv32a algorithm +func fnv32a(data []byte) uint32 { + algorithm := fnv.New32a() + algorithm.Write(data) + return algorithm.Sum32() } diff --git a/pkg/apiserver/authentication/token/issuer_test.go b/pkg/apiserver/authentication/token/issuer_test.go new file mode 100644 index 000000000..e7a8f6620 --- /dev/null +++ b/pkg/apiserver/authentication/token/issuer_test.go @@ -0,0 +1,339 @@ +/* + + Copyright 2021 The KubeSphere 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 token + +import ( + "encoding/base64" + "reflect" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "gopkg.in/square/go-jose.v2" + + "github.com/form3tech-oss/jwt-go" + "k8s.io/apiserver/pkg/authentication/user" + + "kubesphere.io/kubesphere/pkg/apiserver/authentication" + "kubesphere.io/kubesphere/pkg/apiserver/authentication/oauth" +) + +const privateKeyData = ` +-----BEGIN RSA PRIVATE KEY----- +MIIEoQIBAAKCAQEAnDK2bNmX+tBWY/JHll1T1LF3/6RTbJ2qUsvwZVuVP/XbmWeY +vDZyTR+YL6JaqRC/NibphgCV0p6cKZNuoGCEHpS0Ix9ZZwkA8BhwrFwAU0O1Qmrv +v7It3p0Lc9WKN7PBWDQnUIdeSWnAeSbmWETP8Y2e+vG/iusLojPJenEaiOiwzU8p +3CGSh7IPBXF3aUeB3dgJuaiumDuzlp0Oe/xKvWo0faB2hFXi36KaLMpugNcbejKl +R6w3jH5wJjto5XqTEpW4a77K4rt7CFXVGfcbLo+n/5j3oC0lw4KOy7OX0Qf1jY+x +sa1Q+3UoDC02sQRf77uj3eITol8Spoo7wfJqmwIDAQABAoIBAGEArYIT7+p3j+8p ++4NKGlGwlRFR/+0oTSp2NKj9o0bBbMtsJtJcDcgPoveSIDN2jwkWSVhK7MCMd/bp +9H3s8p/7QZO+WEtAsDBrPS4NRLZxChRhTNsD0LC7Xu1k5B2LqLsaSIAeUVPONRYI +Lm0K7wjYJq85iva+2c610p4Tt6LlxuOu41Zw7RAaW8nBoMdQzi19X+hUloogVo7S +hid8gm2KUPY6xF+RpHGQ5OUND0d+2wBkHxbYNRIfYrxCKt8+dLykLzAmm+ScCfyG +jKcNoRwW5s/3ttR7r7hn3whttydkper5YvxM3+EvL83H7JL11KHcHy/yPYv+2IxQ +psvEtIECgYEAykCm/w58pdifLuWG1mwHCkdQ6wCd9gAHIDfaqbFhTcdwGGYXb1xQ +3CHjkkX6rpB3rJReCxlGq01OemVNlpIGLhdnK87aX5pRVn2fHGaMoC2V5RWv3pyE +3gJ41h9FtPX2juKFG9PNiR7FrtKPzQczfh2L1OMpLOXfPgxvo/fXBQsCgYEAxbTz +mibb4F/TBVXMuSL7Pk9hBPlFgFIEUKbiqt+sKQCqSZPGjV5giDfQDGsJ5EqOkRg0 +qlCrKk+DW+d+Pmc4yo58cd2xPnQETholV19+dM3AGiy4BMjeUnJD+Dme7M/fhrlW +IK/1ZErKSZ3nN20qeneIFltm6+4pgQ1HB9KwirECgYAy65wf0xHm32cUc41DJueO +2u2wfPNIIDGrFuTinFoXLwM14V49F0z0X0Pga+X1VUIMHT6gJLj6H/iGMEMciZ8s +s4+yI94u+7dGw1Hv4JG/Mjru9krVDSsWiiDKKA1wxgxRZQ6GNwkkYK78mN7Di/CW +6/Fso9SWDTnrcU4aRifIiQKBgQCQ+kJwVfKCtIIPtX0sfeRzKs5gUVKP6JTVd6tb +1i1u29gDoGPHIt/yw8rCcHOOfsXQzElCY2lA25HeAQFoTVUt5BKJhSIGRBksFKwx +SAt5J6+pAgXnLE0rdDM3gTlzOnQVXS81RRLTeqygEzSMRncR2zll+5ybgcfZpJzj +tbJT4QJ/Y02wfkm1dL/BFg520/otVeuC+Bt+YyWMVs867xLLzFci7tj6ZzlzMorQ +PsSsOHhPx0g+Wl8K2+Edg3FQRZ1m0rQFAZn66jd96u85aA9NH/bw3A3VYUdVJyHh +4ZgZLx9JMCkmRfa7Dp2mzoqGUC1cjNvm722baeMqXpHSXDP2Jg== +-----END RSA PRIVATE KEY----- +` + +func TestNewIssuer(t *testing.T) { + signKeyData := base64.StdEncoding.EncodeToString([]byte(privateKeyData)) + options := &authentication.Options{ + MaximumClockSkew: 10 * time.Second, + JwtSecret: "test-secret", + OAuthOptions: &oauth.Options{ + Issuer: "kubesphere", + SignKeyData: signKeyData, + }, + } + got, err := NewIssuer(options) + if err != nil { + t.Fatal(err) + } + + signKey, keyID, err := loadSignKey(options) + if err != nil { + t.Fatal(err) + } + + want := &issuer{ + name: options.OAuthOptions.Issuer, + secret: []byte(options.JwtSecret), + maximumClockSkew: options.MaximumClockSkew, + signKey: &jose.JSONWebKey{ + Key: signKey, + KeyID: keyID, + Algorithm: jwt.SigningMethodRS256.Alg(), + Use: "sig", + }, + } + if !reflect.DeepEqual(got, want) { + t.Errorf("NewIssuer() got = %v, want %v", got, want) + return + } +} + +func TestNewIssuerGenerateSignKey(t *testing.T) { + options := &authentication.Options{ + MaximumClockSkew: 10 * time.Second, + JwtSecret: "test-secret", + OAuthOptions: &oauth.Options{ + Issuer: "kubesphere", + }, + } + + got, err := NewIssuer(options) + if err != nil { + t.Fatal(err) + } + + iss := got.(*issuer) + assert.NotNil(t, iss.signKey) + assert.NotNil(t, iss.signKey.Key) + assert.NotNil(t, iss.signKey.KeyID) +} + +func Test_issuer_IssueTo(t *testing.T) { + type fields struct { + name string + secret []byte + maximumClockSkew time.Duration + } + type args struct { + request *IssueRequest + } + tests := []struct { + name string + fields fields + args args + want *VerifiedResponse + wantErr bool + }{ + { + name: "token is successfully issued", + fields: fields{ + name: "kubesphere", + secret: []byte("kubesphere"), + maximumClockSkew: 0, + }, + args: args{request: &IssueRequest{ + User: &user.DefaultInfo{ + Name: "user1", + }, + Claims: Claims{ + TokenType: AccessToken, + }, + ExpiresIn: 2 * time.Hour}, + }, + want: &VerifiedResponse{ + User: &user.DefaultInfo{ + Name: "user1", + }, + Claims: Claims{ + Username: "user1", + TokenType: AccessToken, + }, + }, + wantErr: false, + }, + { + name: "token is successfully issued", + fields: fields{ + name: "kubesphere", + secret: []byte("kubesphere"), + maximumClockSkew: 0, + }, + args: args{request: &IssueRequest{ + User: &user.DefaultInfo{ + Name: "user2", + }, + Claims: Claims{ + Username: "user2", + TokenType: RefreshToken, + }, + ExpiresIn: 0}, + }, + want: &VerifiedResponse{ + User: &user.DefaultInfo{ + Name: "user2", + }, + Claims: Claims{ + Username: "user2", + TokenType: RefreshToken, + }, + }, + wantErr: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + s := &issuer{ + name: tt.fields.name, + secret: tt.fields.secret, + maximumClockSkew: tt.fields.maximumClockSkew, + } + token, err := s.IssueTo(tt.args.request) + if (err != nil) != tt.wantErr { + t.Errorf("IssueTo() error = %v, wantErr %v", err, tt.wantErr) + return + } + got, err := s.Verify(token) + if (err != nil) != tt.wantErr { + t.Errorf("Verify() error = %v, wantErr %v", err, tt.wantErr) + return + } + if got == nil { + return + } + assert.Equal(t, got.TokenType, tt.want.TokenType) + assert.Equal(t, got.Issuer, tt.fields.name) + assert.Equal(t, got.Username, tt.want.Username) + assert.Equal(t, got.Subject, tt.want.User.GetName()) + assert.NotZero(t, got.IssuedAt) + }) + } +} + +func Test_issuer_Verify(t *testing.T) { + type fields struct { + name string + secret []byte + maximumClockSkew time.Duration + } + type args struct { + token string + } + tests := []struct { + name string + fields fields + args args + want *VerifiedResponse + wantErr bool + }{ + { + name: "token validation failed", + fields: fields{ + name: "kubesphere", + secret: []byte("kubesphere"), + maximumClockSkew: 0, + }, + args: args{token: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6ImFkbWluIiwidG9rZW5fdHlwZSI6ImFjY2Vzc190b2tlbiIsImV4cCI6MTYzMDY0MDMyMywiaWF0IjoxNjMwNjM2NzIzLCJpc3MiOiJrdWJlc3BoZXJlIiwibmJmIjoxNjMwNjM2NzIzfQ.4ENxyPTIe-BoQfuY5F4Mon5tB3KeV06B4i2JITRlPA8"}, + wantErr: true, + }, + { + name: "token is successfully verified", + fields: fields{ + name: "kubesphere", + secret: []byte("kubesphere"), + maximumClockSkew: 0, + }, + args: args{token: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpYXQiOjE2MzA2MzczOTgsImlzcyI6Imt1YmVzcGhlcmUiLCJzdWIiOiJ1c2VyMiIsInRva2VuX3R5cGUiOiJyZWZyZXNoX3Rva2VuIiwidXNlcm5hbWUiOiJ1c2VyMiJ9.vqPczw4SyytVOQmgaK9ip2dvg2fSQStUUE_Y7Ts45WY"}, + want: &VerifiedResponse{ + User: &user.DefaultInfo{ + Name: "user2", + }, + Claims: Claims{ + Username: "user2", + TokenType: RefreshToken, + }, + }, + wantErr: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + s := &issuer{ + name: tt.fields.name, + secret: tt.fields.secret, + maximumClockSkew: tt.fields.maximumClockSkew, + } + got, err := s.Verify(tt.args.token) + if (err != nil) != tt.wantErr { + t.Errorf("Verify() error = %v, wantErr %v", err, tt.wantErr) + return + } + if got == nil { + return + } + assert.Equal(t, got.TokenType, tt.want.TokenType) + assert.Equal(t, got.Issuer, tt.fields.name) + assert.Equal(t, got.Username, tt.want.Username) + assert.Equal(t, got.Subject, tt.want.User.GetName()) + assert.NotZero(t, got.IssuedAt) + }) + } +} + +func Test_issuer_keyFunc(t *testing.T) { + type fields struct { + name string + secret []byte + maximumClockSkew time.Duration + } + type args struct { + token *jwt.Token + } + tests := []struct { + name string + fields fields + args args + }{ + { + name: "sign key obtained successfully", + fields: fields{ + secret: []byte("kubesphere"), + }, + args: args{token: &jwt.Token{ + Method: jwt.SigningMethodHS256, + Header: map[string]interface{}{"alg": "HS256"}, + }}, + }, + { + name: "sign key obtained successfully", + fields: fields{}, + args: args{token: &jwt.Token{ + Method: jwt.SigningMethodRS256, + Header: map[string]interface{}{"alg": "RS256"}, + }}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + s, err := NewIssuer(authentication.NewOptions()) + if err != nil { + t.Error(err) + return + } + iss := s.(*issuer) + got, err := iss.keyFunc(tt.args.token) + assert.NotNil(t, got) + }) + } +} diff --git a/pkg/apiserver/authentication/token/jwt.go b/pkg/apiserver/authentication/token/jwt.go deleted file mode 100644 index dfa40b48c..000000000 --- a/pkg/apiserver/authentication/token/jwt.go +++ /dev/null @@ -1,103 +0,0 @@ -/* -Copyright 2020 The KubeSphere 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 token - -import ( - "fmt" - "time" - - "github.com/form3tech-oss/jwt-go" - "k8s.io/apiserver/pkg/authentication/user" - "k8s.io/klog" -) - -const ( - DefaultIssuerName = "kubesphere" -) - -type Claims struct { - Username string `json:"username"` - Groups []string `json:"groups,omitempty"` - Extra map[string][]string `json:"extra,omitempty"` - TokenType TokenType `json:"token_type"` - // Currently, we are not using any field in jwt.StandardClaims - jwt.StandardClaims -} - -type jwtTokenIssuer struct { - name string - secret []byte - // Maximum time difference - maximumClockSkew time.Duration -} - -func (s *jwtTokenIssuer) Verify(tokenString string) (user.Info, TokenType, error) { - clm := &Claims{} - // verify token signature and expiration time - _, err := jwt.ParseWithClaims(tokenString, clm, s.keyFunc) - if err != nil { - klog.V(4).Info(err) - return nil, "", err - } - return &user.DefaultInfo{Name: clm.Username, Groups: clm.Groups, Extra: clm.Extra}, clm.TokenType, nil -} - -func (s *jwtTokenIssuer) IssueTo(user user.Info, tokenType TokenType, expiresIn time.Duration) (string, error) { - issueAt := time.Now().Unix() - int64(s.maximumClockSkew.Seconds()) - notBefore := issueAt - clm := &Claims{ - Username: user.GetName(), - Groups: user.GetGroups(), - Extra: user.GetExtra(), - TokenType: tokenType, - StandardClaims: jwt.StandardClaims{ - IssuedAt: issueAt, - Issuer: s.name, - NotBefore: notBefore, - }, - } - - if expiresIn > 0 { - clm.ExpiresAt = clm.IssuedAt + int64(expiresIn.Seconds()) - } - - token := jwt.NewWithClaims(jwt.SigningMethodHS256, clm) - - tokenString, err := token.SignedString(s.secret) - if err != nil { - klog.V(4).Info(err) - return "", err - } - - return tokenString, nil -} - -func (s *jwtTokenIssuer) keyFunc(token *jwt.Token) (i interface{}, err error) { - if _, ok := token.Method.(*jwt.SigningMethodHMAC); ok { - return s.secret, nil - } else { - return nil, fmt.Errorf("expect token signed with HMAC but got %v", token.Header["alg"]) - } -} - -func NewTokenIssuer(secret string, maximumClockSkew time.Duration) Issuer { - return &jwtTokenIssuer{ - name: DefaultIssuerName, - secret: []byte(secret), - maximumClockSkew: maximumClockSkew, - } -} diff --git a/pkg/apiserver/authentication/token/jwt_test.go b/pkg/apiserver/authentication/token/jwt_test.go deleted file mode 100644 index 3b189d9db..000000000 --- a/pkg/apiserver/authentication/token/jwt_test.go +++ /dev/null @@ -1,49 +0,0 @@ -/* -Copyright 2020 The KubeSphere 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 token - -import ( - "testing" - - "github.com/google/go-cmp/cmp" - "k8s.io/apiserver/pkg/authentication/user" -) - -func TestTokenVerifyWithoutCacheValidate(t *testing.T) { - - issuer := NewTokenIssuer("kubesphere", 0) - - admin := &user.DefaultInfo{ - Name: "admin", - } - - tokenString, err := issuer.IssueTo(admin, AccessToken, 0) - - if err != nil { - t.Fatal(err) - } - - got, _, err := issuer.Verify(tokenString) - - if err != nil { - t.Fatal(err) - } - - if diff := cmp.Diff(got, admin); diff != "" { - t.Error("token validate failed") - } -} diff --git a/pkg/apiserver/authorization/options.go b/pkg/apiserver/authorization/options.go new file mode 100644 index 000000000..628674142 --- /dev/null +++ b/pkg/apiserver/authorization/options.go @@ -0,0 +1,56 @@ +/* + + Copyright 2021 The KubeSphere 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 authorization + +import ( + "fmt" + + "github.com/spf13/pflag" + "k8s.io/klog" + + "kubesphere.io/kubesphere/pkg/utils/sliceutil" +) + +type Options struct { + Mode string `json:"mode" yaml:"mode"` +} + +func NewOptions() *Options { + return &Options{Mode: RBAC} +} + +var ( + AlwaysDeny = "AlwaysDeny" + AlwaysAllow = "AlwaysAllow" + RBAC = "RBAC" +) + +func (o *Options) AddFlags(fs *pflag.FlagSet, s *Options) { + fs.StringVar(&o.Mode, "authorization", s.Mode, "Authorization setting, allowed values: AlwaysDeny, AlwaysAllow, RBAC.") +} + +func (o *Options) Validate() []error { + errs := make([]error, 0) + if !sliceutil.HasString([]string{AlwaysAllow, AlwaysDeny, RBAC}, o.Mode) { + err := fmt.Errorf("authorization mode %s not support", o.Mode) + klog.Error(err) + errs = append(errs, err) + } + return errs +} diff --git a/pkg/apiserver/authorization/options/authorization_options.go b/pkg/apiserver/authorization/options/authorization_options.go deleted file mode 100644 index 9b8a6bf44..000000000 --- a/pkg/apiserver/authorization/options/authorization_options.go +++ /dev/null @@ -1,54 +0,0 @@ -/* -Copyright 2020 The KubeSphere 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 options - -import ( - "fmt" - - "github.com/spf13/pflag" - "k8s.io/klog" - - "kubesphere.io/kubesphere/pkg/utils/sliceutil" -) - -type AuthorizationOptions struct { - Mode string `json:"mode" yaml:"mode"` -} - -func NewAuthorizationOptions() *AuthorizationOptions { - return &AuthorizationOptions{Mode: RBAC} -} - -var ( - AlwaysDeny = "AlwaysDeny" - AlwaysAllow = "AlwaysAllow" - RBAC = "RBAC" -) - -func (o *AuthorizationOptions) AddFlags(fs *pflag.FlagSet, s *AuthorizationOptions) { - fs.StringVar(&o.Mode, "authorization", s.Mode, "Authorization setting, allowed values: AlwaysDeny, AlwaysAllow, RBAC.") -} - -func (o *AuthorizationOptions) Validate() []error { - errs := make([]error, 0) - if !sliceutil.HasString([]string{AlwaysAllow, AlwaysDeny, RBAC}, o.Mode) { - err := fmt.Errorf("authorization mode %s not support", o.Mode) - klog.Error(err) - errs = append(errs, err) - } - return errs -} diff --git a/pkg/apiserver/config/config.go b/pkg/apiserver/config/config.go index 369e538ba..64e78cad7 100644 --- a/pkg/apiserver/config/config.go +++ b/pkg/apiserver/config/config.go @@ -21,12 +21,13 @@ import ( "reflect" "strings" + "kubesphere.io/kubesphere/pkg/apiserver/authentication" + "kubesphere.io/kubesphere/pkg/apiserver/authorization" + "github.com/spf13/viper" networkv1alpha1 "kubesphere.io/api/network/v1alpha1" - authoptions "kubesphere.io/kubesphere/pkg/apiserver/authentication/options" - authorizationoptions "kubesphere.io/kubesphere/pkg/apiserver/authorization/options" "kubesphere.io/kubesphere/pkg/simple/client/alerting" "kubesphere.io/kubesphere/pkg/simple/client/auditing" "kubesphere.io/kubesphere/pkg/simple/client/cache" @@ -86,28 +87,28 @@ const ( // Config defines everything needed for apiserver to deal with external services type Config struct { - DevopsOptions *jenkins.Options `json:"devops,omitempty" yaml:"devops,omitempty" mapstructure:"devops"` - SonarQubeOptions *sonarqube.Options `json:"sonarqube,omitempty" yaml:"sonarQube,omitempty" mapstructure:"sonarqube"` - KubernetesOptions *k8s.KubernetesOptions `json:"kubernetes,omitempty" yaml:"kubernetes,omitempty" mapstructure:"kubernetes"` - ServiceMeshOptions *servicemesh.Options `json:"servicemesh,omitempty" yaml:"servicemesh,omitempty" mapstructure:"servicemesh"` - NetworkOptions *network.Options `json:"network,omitempty" yaml:"network,omitempty" mapstructure:"network"` - LdapOptions *ldap.Options `json:"-,omitempty" yaml:"ldap,omitempty" mapstructure:"ldap"` - RedisOptions *cache.Options `json:"redis,omitempty" yaml:"redis,omitempty" mapstructure:"redis"` - S3Options *s3.Options `json:"s3,omitempty" yaml:"s3,omitempty" mapstructure:"s3"` - OpenPitrixOptions *openpitrix.Options `json:"openpitrix,omitempty" yaml:"openpitrix,omitempty" mapstructure:"openpitrix"` - MonitoringOptions *prometheus.Options `json:"monitoring,omitempty" yaml:"monitoring,omitempty" mapstructure:"monitoring"` - LoggingOptions *logging.Options `json:"logging,omitempty" yaml:"logging,omitempty" mapstructure:"logging"` - AuthenticationOptions *authoptions.AuthenticationOptions `json:"authentication,omitempty" yaml:"authentication,omitempty" mapstructure:"authentication"` - AuthorizationOptions *authorizationoptions.AuthorizationOptions `json:"authorization,omitempty" yaml:"authorization,omitempty" mapstructure:"authorization"` - MultiClusterOptions *multicluster.Options `json:"multicluster,omitempty" yaml:"multicluster,omitempty" mapstructure:"multicluster"` - EventsOptions *events.Options `json:"events,omitempty" yaml:"events,omitempty" mapstructure:"events"` - AuditingOptions *auditing.Options `json:"auditing,omitempty" yaml:"auditing,omitempty" mapstructure:"auditing"` - AlertingOptions *alerting.Options `json:"alerting,omitempty" yaml:"alerting,omitempty" mapstructure:"alerting"` - NotificationOptions *notification.Options `json:"notification,omitempty" yaml:"notification,omitempty" mapstructure:"notification"` - KubeEdgeOptions *kubeedge.Options `json:"kubeedge,omitempty" yaml:"kubeedge,omitempty" mapstructure:"kubeedge"` - MeteringOptions *metering.Options `json:"metering,omitempty" yaml:"metering,omitempty" mapstructure:"metering"` - GatewayOptions *gateway.Options `json:"gateway,omitempty" yaml:"gateway,omitempty" mapstructure:"gateway"` - GPUOptions *gpu.Options `json:"gpu,omitempty" yaml:"gpu,omitempty" mapstructure:"gpu"` + DevopsOptions *jenkins.Options `json:"devops,omitempty" yaml:"devops,omitempty" mapstructure:"devops"` + SonarQubeOptions *sonarqube.Options `json:"sonarqube,omitempty" yaml:"sonarQube,omitempty" mapstructure:"sonarqube"` + KubernetesOptions *k8s.KubernetesOptions `json:"kubernetes,omitempty" yaml:"kubernetes,omitempty" mapstructure:"kubernetes"` + ServiceMeshOptions *servicemesh.Options `json:"servicemesh,omitempty" yaml:"servicemesh,omitempty" mapstructure:"servicemesh"` + NetworkOptions *network.Options `json:"network,omitempty" yaml:"network,omitempty" mapstructure:"network"` + LdapOptions *ldap.Options `json:"-,omitempty" yaml:"ldap,omitempty" mapstructure:"ldap"` + RedisOptions *cache.Options `json:"redis,omitempty" yaml:"redis,omitempty" mapstructure:"redis"` + S3Options *s3.Options `json:"s3,omitempty" yaml:"s3,omitempty" mapstructure:"s3"` + OpenPitrixOptions *openpitrix.Options `json:"openpitrix,omitempty" yaml:"openpitrix,omitempty" mapstructure:"openpitrix"` + MonitoringOptions *prometheus.Options `json:"monitoring,omitempty" yaml:"monitoring,omitempty" mapstructure:"monitoring"` + LoggingOptions *logging.Options `json:"logging,omitempty" yaml:"logging,omitempty" mapstructure:"logging"` + AuthenticationOptions *authentication.Options `json:"authentication,omitempty" yaml:"authentication,omitempty" mapstructure:"authentication"` + AuthorizationOptions *authorization.Options `json:"authorization,omitempty" yaml:"authorization,omitempty" mapstructure:"authorization"` + MultiClusterOptions *multicluster.Options `json:"multicluster,omitempty" yaml:"multicluster,omitempty" mapstructure:"multicluster"` + EventsOptions *events.Options `json:"events,omitempty" yaml:"events,omitempty" mapstructure:"events"` + AuditingOptions *auditing.Options `json:"auditing,omitempty" yaml:"auditing,omitempty" mapstructure:"auditing"` + AlertingOptions *alerting.Options `json:"alerting,omitempty" yaml:"alerting,omitempty" mapstructure:"alerting"` + NotificationOptions *notification.Options `json:"notification,omitempty" yaml:"notification,omitempty" mapstructure:"notification"` + KubeEdgeOptions *kubeedge.Options `json:"kubeedge,omitempty" yaml:"kubeedge,omitempty" mapstructure:"kubeedge"` + MeteringOptions *metering.Options `json:"metering,omitempty" yaml:"metering,omitempty" mapstructure:"metering"` + GatewayOptions *gateway.Options `json:"gateway,omitempty" yaml:"gateway,omitempty" mapstructure:"gateway"` + GPUOptions *gpu.Options `json:"gpu,omitempty" yaml:"gpu,omitempty" mapstructure:"gpu"` } // newConfig creates a default non-empty Config @@ -126,8 +127,8 @@ func New() *Config { AlertingOptions: alerting.NewAlertingOptions(), NotificationOptions: notification.NewNotificationOptions(), LoggingOptions: logging.NewLoggingOptions(), - AuthenticationOptions: authoptions.NewAuthenticateOptions(), - AuthorizationOptions: authorizationoptions.NewAuthorizationOptions(), + AuthenticationOptions: authentication.NewOptions(), + AuthorizationOptions: authorization.NewOptions(), MultiClusterOptions: multicluster.NewOptions(), EventsOptions: events.NewEventsOptions(), AuditingOptions: auditing.NewAuditingOptions(), diff --git a/pkg/apiserver/config/config_test.go b/pkg/apiserver/config/config_test.go index 492d1b15a..26250d1da 100644 --- a/pkg/apiserver/config/config_test.go +++ b/pkg/apiserver/config/config_test.go @@ -23,14 +23,15 @@ import ( "testing" "time" + "kubesphere.io/kubesphere/pkg/apiserver/authentication" + "kubesphere.io/kubesphere/pkg/apiserver/authorization" + "github.com/google/go-cmp/cmp" "gopkg.in/yaml.v2" networkv1alpha1 "kubesphere.io/api/network/v1alpha1" "kubesphere.io/kubesphere/pkg/apiserver/authentication/oauth" - authoptions "kubesphere.io/kubesphere/pkg/apiserver/authentication/options" - authorizationoptions "kubesphere.io/kubesphere/pkg/apiserver/authorization/options" "kubesphere.io/kubesphere/pkg/simple/client/alerting" "kubesphere.io/kubesphere/pkg/simple/client/auditing" "kubesphere.io/kubesphere/pkg/simple/client/cache" @@ -142,13 +143,14 @@ func newTestConfig() (*Config, error) { NotificationOptions: ¬ification.Options{ Endpoint: "http://notification.kubesphere-alerting-system.svc:9200", }, - AuthorizationOptions: authorizationoptions.NewAuthorizationOptions(), - AuthenticationOptions: &authoptions.AuthenticationOptions{ + AuthorizationOptions: authorization.NewOptions(), + AuthenticationOptions: &authentication.Options{ AuthenticateRateLimiterMaxTries: 5, AuthenticateRateLimiterDuration: 30 * time.Minute, JwtSecret: "xxxxxx", MultipleLogin: false, OAuthOptions: &oauth.Options{ + Issuer: oauth.DefaultIssuer, IdentityProviders: []oauth.IdentityProviderOptions{}, Clients: []oauth.Client{{ Name: "kubesphere-console-client", diff --git a/pkg/controller/user/user_controller.go b/pkg/controller/user/user_controller.go index a0a66896d..cc67236ab 100644 --- a/pkg/controller/user/user_controller.go +++ b/pkg/controller/user/user_controller.go @@ -31,7 +31,7 @@ import ( "sigs.k8s.io/controller-runtime/pkg/controller" "sigs.k8s.io/controller-runtime/pkg/reconcile" - authoptions "kubesphere.io/kubesphere/pkg/apiserver/authentication/options" + "kubesphere.io/kubesphere/pkg/apiserver/authentication" "k8s.io/apimachinery/pkg/util/validation" @@ -78,7 +78,7 @@ type Reconciler struct { MultiClusterEnabled bool DevopsClient devops.Interface LdapClient ldapclient.Interface - AuthenticationOptions *authoptions.AuthenticationOptions + AuthenticationOptions *authentication.Options Logger logr.Logger Scheme *runtime.Scheme Recorder record.EventRecorder diff --git a/pkg/controller/user/user_controller_test.go b/pkg/controller/user/user_controller_test.go index 4b6186ab9..3c7b5d443 100644 --- a/pkg/controller/user/user_controller_test.go +++ b/pkg/controller/user/user_controller_test.go @@ -26,7 +26,7 @@ import ( "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/watch" - "kubesphere.io/kubesphere/pkg/apiserver/authentication/options" + "kubesphere.io/kubesphere/pkg/apiserver/authentication" "k8s.io/apimachinery/pkg/types" "k8s.io/client-go/kubernetes/scheme" @@ -60,7 +60,7 @@ func newUser(name string) *iamv1alpha2.User { } func TestDoNothing(t *testing.T) { - authenticateOptions := options.NewAuthenticateOptions() + authenticateOptions := authentication.NewOptions() authenticateOptions.AuthenticateRateLimiterMaxTries = 1 authenticateOptions.AuthenticateRateLimiterDuration = 2 * time.Second user := newUser("test") diff --git a/pkg/kapis/oauth/handler.go b/pkg/kapis/oauth/handler.go index 6a32b155e..5f698eb4a 100644 --- a/pkg/kapis/oauth/handler.go +++ b/pkg/kapis/oauth/handler.go @@ -20,6 +20,15 @@ import ( "fmt" "net/http" "net/url" + "strings" + "time" + + "github.com/form3tech-oss/jwt-go" + + "kubesphere.io/kubesphere/pkg/apiserver/authentication/oauth" + "kubesphere.io/kubesphere/pkg/apiserver/authentication/token" + + "kubesphere.io/kubesphere/pkg/apiserver/authentication" "kubesphere.io/kubesphere/pkg/server/errors" @@ -32,7 +41,6 @@ import ( iamv1alpha2 "kubesphere.io/api/iam/v1alpha2" "kubesphere.io/kubesphere/pkg/api" - authoptions "kubesphere.io/kubesphere/pkg/apiserver/authentication/options" "kubesphere.io/kubesphere/pkg/apiserver/query" "kubesphere.io/kubesphere/pkg/apiserver/request" "kubesphere.io/kubesphere/pkg/models/auth" @@ -41,8 +49,9 @@ import ( const ( KindTokenReview = "TokenReview" - passwordGrantType = "password" - refreshTokenGrantType = "refresh_token" + grantTypePassword = "password" + grantTypeRefreshToken = "refresh_token" + grantTypeCode = "code" ) type Spec struct { @@ -75,30 +84,30 @@ func (request *TokenReview) Validate() error { type handler struct { im im.IdentityManagementInterface - options *authoptions.AuthenticationOptions + options *authentication.Options tokenOperator auth.TokenManagementInterface passwordAuthenticator auth.PasswordAuthenticator - oauth2Authenticator auth.OAuthAuthenticator + oauthAuthenticator auth.OAuthAuthenticator loginRecorder auth.LoginRecorder } func newHandler(im im.IdentityManagementInterface, tokenOperator auth.TokenManagementInterface, passwordAuthenticator auth.PasswordAuthenticator, - oauth2Authenticator auth.OAuthAuthenticator, + oauthAuthenticator auth.OAuthAuthenticator, loginRecorder auth.LoginRecorder, - options *authoptions.AuthenticationOptions) *handler { + options *authentication.Options) *handler { return &handler{im: im, tokenOperator: tokenOperator, passwordAuthenticator: passwordAuthenticator, - oauth2Authenticator: oauth2Authenticator, + oauthAuthenticator: oauthAuthenticator, loginRecorder: loginRecorder, options: options} } -// Implement webhook authentication interface +// tokenReview Implement webhook authentication interface // https://kubernetes.io/docs/reference/access-authn-authz/authentication/#webhook-token-authentication -func (h *handler) TokenReview(req *restful.Request, resp *restful.Response) { +func (h *handler) tokenReview(req *restful.Request, resp *restful.Response) { var tokenReview TokenReview err := req.ReadEntity(&tokenReview) @@ -112,12 +121,13 @@ func (h *handler) TokenReview(req *restful.Request, resp *restful.Response) { return } - authenticated, err := h.tokenOperator.Verify(tokenReview.Spec.Token) + verified, err := h.tokenOperator.Verify(tokenReview.Spec.Token) if err != nil { - api.HandleInternalError(resp, req, err) + api.HandleBadRequest(resp, req, err) return } + authenticated := verified.User success := TokenReview{APIVersion: tokenReview.APIVersion, Kind: KindTokenReview, Status: &Status{ @@ -129,78 +139,144 @@ func (h *handler) TokenReview(req *restful.Request, resp *restful.Response) { resp.WriteEntity(success) } -func (h *handler) Authorize(req *restful.Request, resp *restful.Response) { - authenticated, ok := request.UserFrom(req.Request.Context()) - clientId := req.QueryParameter("client_id") - responseType := req.QueryParameter("response_type") - redirectURI := req.QueryParameter("redirect_uri") +// The Authorization Endpoint performs Authentication of the End-User. +func (h *handler) authorize(req *restful.Request, response *restful.Response) { + var scope, responseType, clientID, redirectURI, state, nonce string + scope = req.QueryParameter("scope") + clientID = req.QueryParameter("client_id") + redirectURI = req.QueryParameter("redirect_uri") + //prompt = req.QueryParameter("prompt") + responseType = req.QueryParameter("response_type") + state = req.QueryParameter("state") + nonce = req.QueryParameter("nonce") - conf, err := h.options.OAuthOptions.OAuthClient(clientId) + // Authorization Servers MUST support the use of the HTTP GET and POST methods + // defined in RFC 2616 [RFC2616] at the Authorization Endpoint. + if req.Request.Method == http.MethodPost { + scope, _ = req.BodyParameter("scope") + clientID, _ = req.BodyParameter("client_id") + redirectURI, _ = req.BodyParameter("redirect_uri") + responseType, _ = req.BodyParameter("response_type") + state, _ = req.BodyParameter("state") + nonce, _ = req.BodyParameter("nonce") + } + + oauthClient, err := h.options.OAuthOptions.OAuthClient(clientID) if err != nil { - err := apierrors.NewUnauthorized(fmt.Sprintf("Unauthorized: %s", err)) - api.HandleError(resp, req, err) + response.WriteHeaderAndEntity(http.StatusBadRequest, oauth.NewInvalidClient(err)) return } - if responseType != "token" { - err := apierrors.NewBadRequest(fmt.Sprintf("Response type %s is not supported", responseType)) - api.HandleError(resp, req, err) - return - } - - if !ok { - err := apierrors.NewUnauthorized("Unauthorized") - api.HandleError(resp, req, err) - return - } - - token, err := h.tokenOperator.IssueTo(authenticated) + redirectURL, err := oauthClient.ResolveRedirectURL(redirectURI) if err != nil { - err := apierrors.NewUnauthorized(fmt.Sprintf("Unauthorized: %s", err)) - api.HandleError(resp, req, err) + response.WriteHeaderAndEntity(http.StatusBadRequest, oauth.NewInvalidRequest(err)) return } - redirectURL, err := conf.ResolveRedirectURL(redirectURI) + authenticated, _ := request.UserFrom(req.Request.Context()) + if authenticated == nil || authenticated.GetName() == user.Anonymous { + response.Header().Add("WWW-Authenticate", "Basic") + response.WriteHeaderAndEntity(http.StatusUnauthorized, oauth.ErrorLoginRequired) + return + } + + // If no openid scope value is present, the request may still be a valid OAuth 2.0 request, + // but is not an OpenID Connect request. + var scopes []string + if scope != "" { + scopes = strings.Split(scope, " ") + } + var responseTypes []string + if responseType != "" { + responseTypes = strings.Split(responseType, " ") + } + + // If the resource owner denies the access request or if the request + // fails for reasons other than a missing or invalid redirection URI, + // the authorization server informs the client by adding the following + // parameters to the query component of the redirection URI using the + // "application/x-www-form-urlencoded" format + informsError := func(err oauth.Error) { + values := make(url.Values, 0) + values.Add("error", err.Type) + if err.Description != "" { + values.Add("error_description", err.Description) + } + if state != "" { + values.Add("state", state) + } + redirectURL.RawQuery = values.Encode() + http.Redirect(response.ResponseWriter, req.Request, redirectURL.String(), http.StatusFound) + return + } + + // Other scope values MAY be present. + // Scope values used that are not understood by an implementation SHOULD be ignored. + if !oauth.IsValidScopes(scopes) { + klog.Warningf("Some requested scopes were invalid: %v", scopes) + } + + if !oauth.IsValidResponseTypes(responseTypes) { + err := fmt.Errorf("Some requested response types were invalid") + informsError(oauth.NewInvalidRequest(err)) + return + } + + // TODO(hongming) support Hybrid Flow + // Authorization Code Flow + if responseType == oauth.ResponseCode { + code, err := h.tokenOperator.IssueTo(&token.IssueRequest{ + User: authenticated, + Claims: token.Claims{ + StandardClaims: jwt.StandardClaims{ + Audience: []string{clientID}, + }, + TokenType: token.AuthorizationCode, + Nonce: nonce, + }, + // A maximum authorization code lifetime of 10 minutes is + ExpiresIn: 10 * time.Minute, + }) + if err != nil { + response.WriteHeaderAndEntity(http.StatusInternalServerError, oauth.NewServerError(err)) + } + values := redirectURL.Query() + values.Add("code", code) + redirectURL.RawQuery = values.Encode() + http.Redirect(response, req.Request, redirectURL.String(), http.StatusFound) + } + + // Implicit Flow + if responseType != oauth.ResponseToken { + informsError(oauth.ErrorUnsupportedResponseType) + return + } + + result, err := h.issueTokenTo(authenticated) if err != nil { - err := apierrors.NewUnauthorized(fmt.Sprintf("Unauthorized: %s", err)) - api.HandleError(resp, req, err) - return + response.WriteHeaderAndEntity(http.StatusInternalServerError, oauth.NewServerError(err)) } - redirectURL = fmt.Sprintf("%s#access_token=%s&token_type=Bearer", redirectURL, token.AccessToken) - - if token.ExpiresIn > 0 { - redirectURL = fmt.Sprintf("%s&expires_in=%v", redirectURL, token.ExpiresIn) - } - resp.Header().Set("Content-Type", "text/plain") - http.Redirect(resp, req.Request, redirectURL, http.StatusFound) + values := make(url.Values, 0) + values.Add("access_token", result.AccessToken) + values.Add("refresh_token", result.RefreshToken) + values.Add("token_type", result.TokenType) + values.Add("expires_in", fmt.Sprint(result.ExpiresIn)) + redirectURL.Fragment = values.Encode() + http.Redirect(response, req.Request, redirectURL.String(), http.StatusFound) } -func (h *handler) oauthCallback(req *restful.Request, resp *restful.Response) { +func (h *handler) oauthCallback(req *restful.Request, response *restful.Response) { provider := req.PathParameter("callback") - // OAuth2 callback, see also https://tools.ietf.org/html/rfc6749#section-4.1.2 - code := req.QueryParameter("code") - // CAS callback, see also https://apereo.github.io/cas/6.3.x/protocol/CAS-Protocol-V2-Specification.html#25-servicevalidate-cas-20 - if code == "" { - code = req.QueryParameter("ticket") - } - if code == "" { - err := apierrors.NewUnauthorized("Unauthorized: missing code") - api.HandleError(resp, req, err) + authenticated, provider, err := h.oauthAuthenticator.Authenticate(req.Request.Context(), provider, req.Request) + if err != nil { + api.HandleUnauthorized(response, req, apierrors.NewUnauthorized(fmt.Sprintf("Unauthorized: %s", err))) return } - authenticated, provider, err := h.oauth2Authenticator.Authenticate(provider, code) + result, err := h.issueTokenTo(authenticated) if err != nil { - api.HandleUnauthorized(resp, req, apierrors.NewUnauthorized(fmt.Sprintf("Unauthorized: %s", err))) - return - } - - result, err := h.tokenOperator.IssueTo(authenticated) - if err != nil { - api.HandleInternalError(resp, req, apierrors.NewInternalError(err)) - return + response.WriteHeaderAndEntity(http.StatusInternalServerError, oauth.NewServerError(err)) } requestInfo, _ := request.RequestInfoFrom(req.Request.Context()) @@ -208,10 +284,10 @@ func (h *handler) oauthCallback(req *restful.Request, resp *restful.Response) { klog.Errorf("Failed to record successful login for user %s, error: %v", authenticated.GetName(), err) } - resp.WriteEntity(result) + response.WriteEntity(result) } -func (h *handler) Login(request *restful.Request, response *restful.Response) { +func (h *handler) login(request *restful.Request, response *restful.Response) { var loginRequest LoginRequest err := request.ReadEntity(&loginRequest) if err != nil { @@ -221,29 +297,70 @@ func (h *handler) Login(request *restful.Request, response *restful.Response) { h.passwordGrant(loginRequest.Username, loginRequest.Password, request, response) } -func (h *handler) Token(req *restful.Request, response *restful.Response) { - grantType, err := req.BodyParameter("grant_type") +// To obtain an Access Token, an ID Token, and optionally a Refresh Token, +// the RP (Client) sends a Token Request to the Token Endpoint to obtain a Token Response, +// as described in Section 3.2 of OAuth 2.0 [RFC6749], when using the Authorization Code Flow. +// Communication with the Token Endpoint MUST utilize TLS. +func (h *handler) token(req *restful.Request, response *restful.Response) { + // TODO(hongming) support basic auth + // https://datatracker.ietf.org/doc/html/rfc6749#section-2.3 + clientID, err := req.BodyParameter("client_id") if err != nil { - api.HandleBadRequest(response, req, err) + response.WriteHeaderAndEntity(http.StatusUnauthorized, oauth.NewInvalidClient(err)) return } + clientSecret, err := req.BodyParameter("client_secret") + if err != nil { + response.WriteHeaderAndEntity(http.StatusUnauthorized, oauth.NewInvalidClient(err)) + return + } + + client, err := h.options.OAuthOptions.OAuthClient(clientID) + if err != nil { + oauthError := oauth.NewInvalidClient(err) + response.WriteHeaderAndEntity(http.StatusUnauthorized, oauthError) + return + } + + if client.Secret != clientSecret { + oauthError := oauth.NewInvalidClient(fmt.Errorf("invalid client credential")) + response.WriteHeaderAndEntity(http.StatusUnauthorized, oauthError) + return + } + + grantType, err := req.BodyParameter("grant_type") + if err != nil { + response.WriteHeaderAndEntity(http.StatusBadRequest, oauth.NewInvalidRequest(err)) + return + } + switch grantType { - case passwordGrantType: + case grantTypePassword: username, _ := req.BodyParameter("username") password, _ := req.BodyParameter("password") h.passwordGrant(username, password, req, response) - break - case refreshTokenGrantType: + return + case grantTypeRefreshToken: h.refreshTokenGrant(req, response) - break + return + case grantTypeCode: + h.codeGrant(req, response) + return default: - err := apierrors.NewBadRequest(fmt.Sprintf("Grant type %s is not supported", grantType)) - api.HandleBadRequest(response, req, err) + response.WriteHeaderAndEntity(http.StatusBadRequest, oauth.ErrorUnsupportedGrantType) + return } } +// passwordGrant handle Resource Owner Password Credentials Grant +// for more details: https://datatracker.ietf.org/doc/html/rfc6749#section-4.3 +// The resource owner password credentials grant type is suitable in +// cases where the resource owner has a trust relationship with the client, +// such as the device operating system or a highly privileged application. +// The authorization server should take special care when enabling this +// grant type and only allow it when other flows are not viable. func (h *handler) passwordGrant(username string, password string, req *restful.Request, response *restful.Response) { - authenticated, provider, err := h.passwordAuthenticator.Authenticate(username, password) + authenticated, provider, err := h.passwordAuthenticator.Authenticate(req.Request.Context(), username, password) if err != nil { switch err { case auth.IncorrectPasswordError: @@ -251,45 +368,79 @@ func (h *handler) passwordGrant(username string, password string, req *restful.R if err := h.loginRecorder.RecordLogin(username, iamv1alpha2.Token, provider, requestInfo.SourceIP, requestInfo.UserAgent, err); err != nil { klog.Errorf("Failed to record unsuccessful login attempt for user %s, error: %v", username, err) } - api.HandleUnauthorized(response, req, apierrors.NewUnauthorized(fmt.Sprintf("Unauthorized: %s", err))) + response.WriteHeaderAndEntity(http.StatusBadRequest, oauth.NewInvalidGrant(err)) return case auth.RateLimitExceededError: - api.HandleTooManyRequests(response, req, apierrors.NewTooManyRequestsError(fmt.Sprintf("Unauthorized: %s", err))) + response.WriteHeaderAndEntity(http.StatusBadRequest, oauth.NewInvalidGrant(err)) return default: - api.HandleInternalError(response, req, apierrors.NewInternalError(err)) + response.WriteHeaderAndEntity(http.StatusInternalServerError, oauth.NewServerError(err)) return } } - result, err := h.tokenOperator.IssueTo(authenticated) + result, err := h.issueTokenTo(authenticated) if err != nil { - api.HandleInternalError(response, req, apierrors.NewInternalError(err)) - return + response.WriteHeaderAndEntity(http.StatusInternalServerError, oauth.NewServerError(err)) } requestInfo, _ := request.RequestInfoFrom(req.Request.Context()) if err = h.loginRecorder.RecordLogin(authenticated.GetName(), iamv1alpha2.Token, provider, requestInfo.SourceIP, requestInfo.UserAgent, nil); err != nil { - klog.Errorf("Failed to record successful login for user %s, error: %v", username, err) + klog.Errorf("Failed to record successful login for user %s, error: %v", authenticated.GetName(), err) } response.WriteEntity(result) } +func (h *handler) issueTokenTo(authenticated user.Info) (*oauth.Token, error) { + accessToken, err := h.tokenOperator.IssueTo(&token.IssueRequest{ + User: authenticated, + Claims: token.Claims{TokenType: token.AccessToken}, + ExpiresIn: h.options.OAuthOptions.AccessTokenMaxAge, + }) + if err != nil { + return nil, err + } + refreshToken, err := h.tokenOperator.IssueTo(&token.IssueRequest{ + User: authenticated, + Claims: token.Claims{TokenType: token.RefreshToken}, + ExpiresIn: h.options.OAuthOptions.AccessTokenMaxAge + h.options.OAuthOptions.AccessTokenInactivityTimeout, + }) + if err != nil { + return nil, err + } + + result := oauth.Token{ + AccessToken: accessToken, + // The OAuth 2.0 token_type response parameter value MUST be Bearer, + // as specified in OAuth 2.0 Bearer Token Usage [RFC6750] + TokenType: "Bearer", + RefreshToken: refreshToken, + ExpiresIn: int(h.options.OAuthOptions.AccessTokenMaxAge.Seconds()), + } + return &result, nil +} + func (h *handler) refreshTokenGrant(req *restful.Request, response *restful.Response) { refreshToken, err := req.BodyParameter("refresh_token") if err != nil { - api.HandleBadRequest(response, req, apierrors.NewBadRequest(err.Error())) + response.WriteHeaderAndEntity(http.StatusBadRequest, oauth.NewInvalidRequest(err)) return } - authenticated, err := h.tokenOperator.Verify(refreshToken) + verified, err := h.tokenOperator.Verify(refreshToken) if err != nil { - err := apierrors.NewUnauthorized(fmt.Sprintf("Unauthorized: %s", err)) - api.HandleUnauthorized(response, req, apierrors.NewUnauthorized(err.Error())) + response.WriteHeaderAndEntity(http.StatusBadRequest, oauth.NewInvalidGrant(err)) return } + if verified.TokenType != token.RefreshToken { + err = fmt.Errorf("ivalid token type %v want %v", verified.TokenType, token.RefreshToken) + response.WriteHeaderAndEntity(http.StatusBadRequest, oauth.NewInvalidGrant(err)) + return + } + + authenticated := verified.User // update token after registration if authenticated.GetName() == iamv1alpha2.PreRegistrationUser && authenticated.GetExtra() != nil && @@ -304,28 +455,71 @@ func (h *handler) refreshTokenGrant(req *restful.Request, response *restful.Resp iamv1alpha2.OriginUIDLabel: uid}).String() result, err := h.im.ListUsers(queryParam) if err != nil { - api.HandleInternalError(response, req, apierrors.NewInternalError(err)) + response.WriteHeaderAndEntity(http.StatusInternalServerError, oauth.NewServerError(err)) return } if len(result.Items) != 1 { - err := apierrors.NewUnauthorized("authenticated user does not exist") - api.HandleUnauthorized(response, req, apierrors.NewUnauthorized(err.Error())) + response.WriteHeaderAndEntity(http.StatusBadRequest, oauth.NewInvalidGrant(fmt.Errorf("authenticated user does not exist"))) return } + authenticated = &user.DefaultInfo{Name: result.Items[0].(*iamv1alpha2.User).Name} } - result, err := h.tokenOperator.IssueTo(authenticated) + result, err := h.issueTokenTo(authenticated) if err != nil { - err := apierrors.NewUnauthorized(fmt.Sprintf("Unauthorized: %s", err)) - api.HandleUnauthorized(response, req, apierrors.NewUnauthorized(err.Error())) - return + response.WriteHeaderAndEntity(http.StatusInternalServerError, oauth.NewServerError(err)) } response.WriteEntity(result) } -func (h *handler) Logout(req *restful.Request, resp *restful.Response) { +func (h *handler) codeGrant(req *restful.Request, response *restful.Response) { + code, err := req.BodyParameter("code") + if err != nil { + response.WriteHeaderAndEntity(http.StatusBadRequest, oauth.NewInvalidRequest(err)) + return + } + + authorizeContext, err := h.tokenOperator.Verify(code) + if err != nil { + response.WriteHeaderAndEntity(http.StatusBadRequest, oauth.NewInvalidGrant(err)) + return + } + if authorizeContext.TokenType != token.AuthorizationCode { + err = fmt.Errorf("ivalid token type %v want %v", authorizeContext.TokenType, token.AuthorizationCode) + response.WriteHeaderAndEntity(http.StatusBadRequest, oauth.NewInvalidGrant(err)) + return + } + + // The client MUST NOT use the authorization code more than once. + err = h.tokenOperator.Revoke(code) + if err != nil { + klog.Warningf("grant: failed to revoke authorization code: %v", err) + } + + authenticated := authorizeContext.User + result, err := h.issueTokenTo(authenticated) + if err != nil { + response.WriteHeaderAndEntity(http.StatusInternalServerError, oauth.NewServerError(err)) + } + + idToken, err := h.tokenOperator.IssueTo(&token.IssueRequest{ + User: authenticated, + Claims: token.Claims{ + StandardClaims: jwt.StandardClaims{ + Audience: authorizeContext.Audience, + }, + Nonce: authorizeContext.Nonce, + TokenType: token.IDToken, + }, + ExpiresIn: h.options.OAuthOptions.AccessTokenMaxAge + h.options.OAuthOptions.AccessTokenInactivityTimeout, + }) + result.IDToken = idToken + response.WriteEntity(result) +} + +func (h *handler) logout(req *restful.Request, resp *restful.Response) { authenticated, ok := request.UserFrom(req.Request.Context()) if ok { if err := h.tokenOperator.RevokeAllUserTokens(authenticated.GetName()); err != nil { diff --git a/pkg/kapis/oauth/register.go b/pkg/kapis/oauth/register.go index 9a49cf949..2ba4ec5ef 100644 --- a/pkg/kapis/oauth/register.go +++ b/pkg/kapis/oauth/register.go @@ -19,18 +19,21 @@ package oauth import ( "net/http" + "kubesphere.io/kubesphere/pkg/apiserver/authentication" + "github.com/emicklei/go-restful" restfulspec "github.com/emicklei/go-restful-openapi" "kubesphere.io/kubesphere/pkg/api" "kubesphere.io/kubesphere/pkg/apiserver/authentication/oauth" - authoptions "kubesphere.io/kubesphere/pkg/apiserver/authentication/options" "kubesphere.io/kubesphere/pkg/constants" "kubesphere.io/kubesphere/pkg/models/auth" "kubesphere.io/kubesphere/pkg/models/iam/im" ) -// ks-apiserver includes a built-in OAuth server. Users obtain OAuth access tokens to authenticate themselves to the API. +const contentTypeFormData = "application/x-www-form-urlencoded" + +// AddToContainer ks-apiserver includes a built-in OAuth server. Users obtain OAuth access tokens to authenticate themselves to the API. // The OAuth server supports standard authorization code grant and the implicit grant OAuth authorization flows. // All requests for OAuth tokens involve a request to /oauth/authorize. // Most authentication integrations place an authenticating proxy in front of this endpoint, or configure ks-apiserver @@ -41,7 +44,7 @@ func AddToContainer(c *restful.Container, im im.IdentityManagementInterface, passwordAuthenticator auth.PasswordAuthenticator, oauth2Authenticator auth.OAuthAuthenticator, loginRecorder auth.LoginRecorder, - options *authoptions.AuthenticationOptions) error { + options *authentication.Options) error { ws := &restful.WebService{} ws.Path("/oauth"). @@ -56,36 +59,52 @@ func AddToContainer(c *restful.Container, im im.IdentityManagementInterface, Doc("TokenReview attempts to authenticate a token to a known user. Note: TokenReview requests may be "+ "cached by the webhook token authenticator plugin in the kube-apiserver."). Reads(TokenReview{}). - To(handler.TokenReview). + To(handler.tokenReview). Returns(http.StatusOK, api.StatusOK, TokenReview{}). Metadata(restfulspec.KeyOpenAPITags, []string{constants.AuthenticationTag})) - // Only support implicit grant flow - // https://tools.ietf.org/html/rfc6749#section-4.2 + // https://datatracker.ietf.org/doc/html/rfc6749#section-3.1 ws.Route(ws.GET("/authorize"). - Doc("All requests for OAuth tokens involve a request to /oauth/authorize."). + Doc("The authorization endpoint is used to interact with the resource owner and obtain an authorization grant."). Param(ws.QueryParameter("response_type", "The value MUST be one of \"code\" for requesting an "+ "authorization code as described by [RFC6749] Section 4.1.1, \"token\" for requesting an access token (implicit grant)"+ " as described by [RFC6749] Section 4.2.2.").Required(true)). - Param(ws.QueryParameter("client_id", "The client identifier issued to the client during the "+ - "registration process described by [RFC6749] Section 2.2.").Required(true)). - Param(ws.QueryParameter("redirect_uri", "After completing its interaction with the resource owner, "+ - "the authorization server directs the resource owner's user-agent back to the client.The redirection endpoint "+ - "URI MUST be an absolute URI as defined by [RFC3986] Section 4.3.").Required(false)). - To(handler.Authorize). - Returns(http.StatusFound, http.StatusText(http.StatusFound), ""). + Param(ws.QueryParameter("client_id", "OAuth 2.0 Client Identifier valid at the Authorization Server.").Required(true)). + Param(ws.QueryParameter("redirect_uri", "Redirection URI to which the response will be sent. "+ + "This URI MUST exactly match one of the Redirection URI values for the Client pre-registered at the OpenID Provider.").Required(true)). + Param(ws.QueryParameter("scope", "OpenID Connect requests MUST contain the openid scope value. "+ + "If the openid scope value is not present, the behavior is entirely unspecified.").Required(false)). + Param(ws.QueryParameter("state", "Opaque value used to maintain state between the request and the callback.").Required(false)). + To(handler.authorize). Metadata(restfulspec.KeyOpenAPITags, []string{constants.AuthenticationTag})) - // Resource Owner Password Credentials Grant + + // Authorization Servers MUST support the use of the HTTP GET and POST methods + // defined in RFC 2616 [RFC2616] at the Authorization Endpoint. + ws.Route(ws.POST("/authorize"). + Consumes(contentTypeFormData). + Doc("The authorization endpoint is used to interact with the resource owner and obtain an authorization grant."). + Param(ws.BodyParameter("response_type", "The value MUST be one of \"code\" for requesting an "+ + "authorization code as described by [RFC6749] Section 4.1.1, \"token\" for requesting an access token (implicit grant)"+ + " as described by [RFC6749] Section 4.2.2.").Required(true)). + Param(ws.BodyParameter("client_id", "OAuth 2.0 Client Identifier valid at the Authorization Server.").Required(true)). + Param(ws.BodyParameter("redirect_uri", "Redirection URI to which the response will be sent. "+ + "This URI MUST exactly match one of the Redirection URI values for the Client pre-registered at the OpenID Provider.").Required(true)). + Param(ws.BodyParameter("scope", "OpenID Connect requests MUST contain the openid scope value. "+ + "If the openid scope value is not present, the behavior is entirely unspecified.").Required(false)). + Param(ws.BodyParameter("state", "Opaque value used to maintain state between the request and the callback.").Required(false)). + To(handler.authorize). + Metadata(restfulspec.KeyOpenAPITags, []string{constants.AuthenticationTag})) + // https://tools.ietf.org/html/rfc6749#section-4.3 ws.Route(ws.POST("/token"). - Consumes("application/x-www-form-urlencoded"). + Consumes(contentTypeFormData). Doc("The resource owner password credentials grant type is suitable in\n"+ "cases where the resource owner has a trust relationship with the\n"+ "client, such as the device operating system or a highly privileged application."). Param(ws.FormParameter("grant_type", "Value MUST be set to \"password\".").Required(true)). Param(ws.FormParameter("username", "The resource owner username.").Required(true)). Param(ws.FormParameter("password", "The resource owner password.").Required(true)). - To(handler.Token). + To(handler.token). Returns(http.StatusOK, http.StatusText(http.StatusOK), &oauth.Token{}). Metadata(restfulspec.KeyOpenAPITags, []string{constants.AuthenticationTag})) @@ -123,7 +142,7 @@ func AddToContainer(c *restful.Container, im im.IdentityManagementInterface, Param(ws.QueryParameter("state", "Opaque value used by the RP to maintain state between "+ "the logout request and the callback to the endpoint specified by the post_logout_redirect_uri parameter."). Required(false)). - To(handler.Logout). + To(handler.logout). Returns(http.StatusOK, http.StatusText(http.StatusOK), ""). Metadata(restfulspec.KeyOpenAPITags, []string{constants.AuthenticationTag})) @@ -135,7 +154,7 @@ func AddToContainer(c *restful.Container, im im.IdentityManagementInterface, Consumes(restful.MIME_JSON). Produces(restful.MIME_JSON) legacy.Route(legacy.POST(""). - To(handler.Login). + To(handler.login). Deprecate(). Doc("KubeSphere APIs support token-based authentication via the Authtoken request header. The POST Login API is used to retrieve the authentication token. After the authentication token is obtained, it must be inserted into the Authtoken header for all requests."). Reads(LoginRequest{}). diff --git a/pkg/models/auth/authenticator.go b/pkg/models/auth/authenticator.go index bbdd38224..01a532674 100644 --- a/pkg/models/auth/authenticator.go +++ b/pkg/models/auth/authenticator.go @@ -21,27 +21,18 @@ package auth import ( "context" "fmt" + "net/http" "net/mail" - "strings" - - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - - "golang.org/x/crypto/bcrypt" - - "kubesphere.io/kubesphere/pkg/apiserver/authentication/identityprovider" - informers "kubesphere.io/kubesphere/pkg/client/informers/externalversions" - "kubesphere.io/kubesphere/pkg/constants" "k8s.io/apimachinery/pkg/api/errors" "k8s.io/apimachinery/pkg/labels" authuser "k8s.io/apiserver/pkg/authentication/user" "k8s.io/klog" + "kubesphere.io/kubesphere/pkg/apiserver/authentication/identityprovider" + iamv1alpha2 "kubesphere.io/api/iam/v1alpha2" - "kubesphere.io/kubesphere/pkg/apiserver/authentication/oauth" - authoptions "kubesphere.io/kubesphere/pkg/apiserver/authentication/options" - kubesphere "kubesphere.io/kubesphere/pkg/client/clientset/versioned" iamv1alpha2listers "kubesphere.io/kubesphere/pkg/client/listers/iam/v1alpha2" ) @@ -51,139 +42,20 @@ var ( AccountIsNotActiveError = fmt.Errorf("account is not active") ) +// PasswordAuthenticator is an interface implemented by authenticator which take a +// username and password. type PasswordAuthenticator interface { - Authenticate(username, password string) (authuser.Info, string, error) + Authenticate(ctx context.Context, username, password string) (authuser.Info, string, error) } type OAuthAuthenticator interface { - Authenticate(provider, code string) (authuser.Info, string, error) -} - -type passwordAuthenticator struct { - ksClient kubesphere.Interface - userGetter *userGetter - authOptions *authoptions.AuthenticationOptions -} - -type oauth2Authenticator struct { - ksClient kubesphere.Interface - userGetter *userGetter - authOptions *authoptions.AuthenticationOptions + Authenticate(ctx context.Context, provider string, req *http.Request) (authuser.Info, string, error) } type userGetter struct { userLister iamv1alpha2listers.UserLister } -func NewPasswordAuthenticator(ksClient kubesphere.Interface, - userLister iamv1alpha2listers.UserLister, - options *authoptions.AuthenticationOptions) PasswordAuthenticator { - passwordAuthenticator := &passwordAuthenticator{ - ksClient: ksClient, - userGetter: &userGetter{userLister: userLister}, - authOptions: options, - } - return passwordAuthenticator -} - -func NewOAuthAuthenticator(ksClient kubesphere.Interface, - ksInformer informers.SharedInformerFactory, - options *authoptions.AuthenticationOptions) OAuthAuthenticator { - oauth2Authenticator := &oauth2Authenticator{ - ksClient: ksClient, - userGetter: &userGetter{userLister: ksInformer.Iam().V1alpha2().Users().Lister()}, - authOptions: options, - } - return oauth2Authenticator -} - -func (p *passwordAuthenticator) Authenticate(username, password string) (authuser.Info, string, error) { - // empty username or password are not allowed - if username == "" || password == "" { - return nil, "", IncorrectPasswordError - } - // generic identity provider has higher priority - for _, providerOptions := range p.authOptions.OAuthOptions.IdentityProviders { - // the admin account in kubesphere has the highest priority - if username == constants.AdminUserName { - break - } - if genericProvider, _ := identityprovider.GetGenericProvider(providerOptions.Name); genericProvider != nil { - authenticated, err := genericProvider.Authenticate(username, password) - if err != nil { - if errors.IsUnauthorized(err) { - continue - } - return nil, providerOptions.Name, err - } - linkedAccount, err := p.userGetter.findLinkedAccount(providerOptions.Name, authenticated.GetUserID()) - if err != nil { - return nil, providerOptions.Name, err - } - // using this method requires you to manually provision users. - if providerOptions.MappingMethod == oauth.MappingMethodLookup && linkedAccount == nil { - continue - } - // the user will automatically create and mapping when login successful. - if linkedAccount == nil && providerOptions.MappingMethod == oauth.MappingMethodAuto { - if !providerOptions.DisableLoginConfirmation { - return preRegistrationUser(providerOptions.Name, authenticated), providerOptions.Name, nil - } - - linkedAccount, err = p.ksClient.IamV1alpha2().Users().Create(context.Background(), mappedUser(providerOptions.Name, authenticated), metav1.CreateOptions{}) - if err != nil { - return nil, providerOptions.Name, err - } - } - if linkedAccount != nil { - return &authuser.DefaultInfo{Name: linkedAccount.GetName()}, providerOptions.Name, nil - } - } - } - - // kubesphere account - user, err := p.userGetter.findUser(username) - if err != nil { - // ignore not found error - if !errors.IsNotFound(err) { - klog.Error(err) - return nil, "", err - } - } - - // check user status - if user != nil && (user.Status.State == nil || *user.Status.State != iamv1alpha2.UserActive) { - if user.Status.State != nil && *user.Status.State == iamv1alpha2.UserAuthLimitExceeded { - klog.Errorf("%s, username: %s", RateLimitExceededError, username) - return nil, "", RateLimitExceededError - } else { - // state not active - klog.Errorf("%s, username: %s", AccountIsNotActiveError, username) - return nil, "", AccountIsNotActiveError - } - } - - // if the password is not empty, means that the password has been reset, even if the user was mapping from IDP - if user != nil && user.Spec.EncryptedPassword != "" { - if err = PasswordVerify(user.Spec.EncryptedPassword, password); err != nil { - klog.Error(err) - return nil, "", err - } - u := &authuser.DefaultInfo{ - Name: user.Name, - } - // check if the password is initialized - if uninitialized := user.Annotations[iamv1alpha2.UninitializedAnnotation]; uninitialized != "" { - u.Extra = map[string][]string{ - iamv1alpha2.ExtraUninitialized: {uninitialized}, - } - } - return u, "", nil - } - - return nil, "", IncorrectPasswordError -} - func preRegistrationUser(idp string, identity identityprovider.Identity) authuser.Info { return &authuser.DefaultInfo{ Name: iamv1alpha2.PreRegistrationUser, @@ -193,95 +65,32 @@ func preRegistrationUser(idp string, identity identityprovider.Identity) authuse iamv1alpha2.ExtraUsername: {identity.GetUsername()}, iamv1alpha2.ExtraEmail: {identity.GetEmail()}, }, - Groups: []string{iamv1alpha2.PreRegistrationUserGroup}, } } -func mappedUser(idp string, identity identityprovider.Identity) *iamv1alpha2.User { - // username convert - username := strings.ToLower(identity.GetUsername()) - return &iamv1alpha2.User{ - ObjectMeta: metav1.ObjectMeta{ - Name: username, - Labels: map[string]string{ - iamv1alpha2.IdentifyProviderLabel: idp, - iamv1alpha2.OriginUIDLabel: identity.GetUserID(), - }, - }, - Spec: iamv1alpha2.UserSpec{Email: identity.GetEmail()}, - } -} - -func (o *oauth2Authenticator) Authenticate(provider, code string) (authuser.Info, string, error) { - providerOptions, err := o.authOptions.OAuthOptions.IdentityProviderOptions(provider) - // identity provider not registered - if err != nil { - klog.Error(err) - return nil, "", err - } - oauthIdentityProvider, err := identityprovider.GetOAuthProvider(providerOptions.Name) - if err != nil { - klog.Error(err) - return nil, "", err - } - authenticated, err := oauthIdentityProvider.IdentityExchange(code) - if err != nil { - klog.Error(err) - return nil, "", err - } - - user, err := o.userGetter.findLinkedAccount(providerOptions.Name, authenticated.GetUserID()) - if user == nil && providerOptions.MappingMethod == oauth.MappingMethodLookup { - klog.Error(err) - return nil, "", err - } - - // the user will automatically create and mapping when login successful. - if user == nil && providerOptions.MappingMethod == oauth.MappingMethodAuto { - if !providerOptions.DisableLoginConfirmation { - return preRegistrationUser(providerOptions.Name, authenticated), providerOptions.Name, nil - } - user, err = o.ksClient.IamV1alpha2().Users().Create(context.Background(), mappedUser(providerOptions.Name, authenticated), metav1.CreateOptions{}) - if err != nil { - return nil, providerOptions.Name, err - } - } - - if user != nil { - return &authuser.DefaultInfo{Name: user.GetName()}, providerOptions.Name, nil - } - - return nil, "", errors.NewNotFound(iamv1alpha2.Resource("user"), authenticated.GetUsername()) -} - -func PasswordVerify(encryptedPassword, password string) error { - if err := bcrypt.CompareHashAndPassword([]byte(encryptedPassword), []byte(password)); err != nil { - return IncorrectPasswordError - } - return nil -} - -// findUser +// findUser returns the user associated with the username or email func (u *userGetter) findUser(username string) (*iamv1alpha2.User, error) { if _, err := mail.ParseAddress(username); err != nil { return u.userLister.Get(username) - } else { - users, err := u.userLister.List(labels.Everything()) - if err != nil { - klog.Error(err) - return nil, err - } - for _, find := range users { - if find.Spec.Email == username { - return find, nil - } + } + + users, err := u.userLister.List(labels.Everything()) + if err != nil { + klog.Error(err) + return nil, err + } + + for _, user := range users { + if user.Spec.Email == username { + return user, nil } } return nil, errors.NewNotFound(iamv1alpha2.Resource("user"), username) } -func (u *userGetter) findLinkedAccount(idp, uid string) (*iamv1alpha2.User, error) { +// findMappedUser returns the user which mapped to the identity +func (u *userGetter) findMappedUser(idp, uid string) (*iamv1alpha2.User, error) { selector := labels.SelectorFromSet(labels.Set{ iamv1alpha2.IdentifyProviderLabel: idp, iamv1alpha2.OriginUIDLabel: uid, diff --git a/pkg/models/auth/oauth.go b/pkg/models/auth/oauth.go new file mode 100644 index 000000000..430c603a6 --- /dev/null +++ b/pkg/models/auth/oauth.go @@ -0,0 +1,83 @@ +/* + + Copyright 2021 The KubeSphere 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 auth + +import ( + "context" + "net/http" + + "kubesphere.io/kubesphere/pkg/apiserver/authentication" + + "k8s.io/apimachinery/pkg/api/errors" + authuser "k8s.io/apiserver/pkg/authentication/user" + "k8s.io/klog" + iamv1alpha2 "kubesphere.io/api/iam/v1alpha2" + + "kubesphere.io/kubesphere/pkg/apiserver/authentication/identityprovider" + "kubesphere.io/kubesphere/pkg/apiserver/authentication/oauth" + iamv1alpha2listers "kubesphere.io/kubesphere/pkg/client/listers/iam/v1alpha2" +) + +type oauthAuthenticator struct { + *userGetter + options *authentication.Options +} + +func NewOAuthAuthenticator(userLister iamv1alpha2listers.UserLister, + options *authentication.Options) OAuthAuthenticator { + authenticator := &oauthAuthenticator{ + userGetter: &userGetter{userLister: userLister}, + options: options, + } + return authenticator +} + +func (o oauthAuthenticator) Authenticate(ctx context.Context, provider string, req *http.Request) (authuser.Info, string, error) { + options, err := o.options.OAuthOptions.IdentityProviderOptions(provider) + // identity provider not registered + if err != nil { + klog.Error(err) + return nil, "", err + } + identityProvider, err := identityprovider.GetOAuthProvider(options.Name) + if err != nil { + klog.Error(err) + return nil, "", err + } + identity, err := identityProvider.IdentityExchangeCallback(req) + if err != nil { + klog.Error(err) + return nil, "", err + } + + mappedUser, err := o.findMappedUser(options.Name, identity.GetUserID()) + if mappedUser == nil && options.MappingMethod == oauth.MappingMethodLookup { + klog.Error(err) + return nil, "", err + } + // the user will automatically create and mapping when login successful. + if mappedUser == nil && options.MappingMethod == oauth.MappingMethodAuto { + return preRegistrationUser(options.Name, identity), options.Name, nil + } + if mappedUser != nil { + return &authuser.DefaultInfo{Name: mappedUser.GetName()}, options.Name, nil + } + + return nil, "", errors.NewNotFound(iamv1alpha2.Resource(iamv1alpha2.ResourcesSingularUser), identity.GetUsername()) +} diff --git a/pkg/models/auth/oauth_test.go b/pkg/models/auth/oauth_test.go new file mode 100644 index 000000000..b2f3d9607 --- /dev/null +++ b/pkg/models/auth/oauth_test.go @@ -0,0 +1,203 @@ +/* + + Copyright 2021 The KubeSphere 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 auth + +import ( + "context" + "fmt" + "net/http" + "reflect" + "testing" + + "kubesphere.io/kubesphere/pkg/apiserver/authentication" + + "github.com/mitchellh/mapstructure" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apiserver/pkg/authentication/user" + iamv1alpha2 "kubesphere.io/api/iam/v1alpha2" + + "kubesphere.io/kubesphere/pkg/apiserver/authentication/identityprovider" + "kubesphere.io/kubesphere/pkg/apiserver/authentication/oauth" + fakeks "kubesphere.io/kubesphere/pkg/client/clientset/versioned/fake" + ksinformers "kubesphere.io/kubesphere/pkg/client/informers/externalversions" +) + +func Test_oauthAuthenticator_Authenticate(t *testing.T) { + + oauthOptions := &authentication.Options{ + OAuthOptions: &oauth.Options{ + IdentityProviders: []oauth.IdentityProviderOptions{ + { + Name: "fake", + MappingMethod: "auto", + Type: "FakeIdentityProvider", + Provider: oauth.DynamicOptions{ + "identities": map[string]interface{}{ + "code1": map[string]string{ + "uid": "100001", + "email": "user1@kubesphere.io", + "username": "user1", + }, + }, + }, + }, + }, + }, + } + + identityprovider.RegisterOAuthProvider(&fakeProviderFactory{}) + if err := identityprovider.SetupWithOptions(oauthOptions.OAuthOptions.IdentityProviders); err != nil { + t.Fatal(err) + } + + ksClient := fakeks.NewSimpleClientset() + ksInformerFactory := ksinformers.NewSharedInformerFactory(ksClient, 0) + err := ksInformerFactory.Iam().V1alpha2().Users().Informer().GetIndexer().Add(newUser("user1", "100001", "fake")) + if err != nil { + t.Fatal(err) + } + + type args struct { + ctx context.Context + provider string + req *http.Request + } + tests := []struct { + name string + oauthAuthenticator OAuthAuthenticator + args args + userInfo user.Info + provider string + wantErr bool + }{ + { + name: "Should successfully", + oauthAuthenticator: NewOAuthAuthenticator( + ksInformerFactory.Iam().V1alpha2().Users().Lister(), + oauthOptions, + ), + args: args{ + ctx: context.Background(), + provider: "fake", + req: must(http.NewRequest(http.MethodGet, "https://ks-console.kubesphere.io/oauth/callback/test?code=code1&state=100001", nil)), + }, + userInfo: &user.DefaultInfo{ + Name: "user1", + }, + provider: "fake", + wantErr: false, + }, + { + name: "Should successfully", + oauthAuthenticator: NewOAuthAuthenticator( + ksInformerFactory.Iam().V1alpha2().Users().Lister(), + oauthOptions, + ), + args: args{ + ctx: context.Background(), + provider: "fake1", + req: must(http.NewRequest(http.MethodGet, "https://ks-console.kubesphere.io/oauth/callback/test?code=code1&state=100001", nil)), + }, + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + + userInfo, provider, err := tt.oauthAuthenticator.Authenticate(tt.args.ctx, tt.args.provider, tt.args.req) + if (err != nil) != tt.wantErr { + t.Errorf("Authenticate() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !reflect.DeepEqual(userInfo, tt.userInfo) { + t.Errorf("Authenticate() got = %v, want %v", userInfo, tt.userInfo) + } + if provider != tt.provider { + t.Errorf("Authenticate() got = %v, want %v", provider, tt.provider) + } + }) + } +} + +func must(r *http.Request, err error) *http.Request { + if err != nil { + panic(err) + } + return r +} + +func newUser(username string, uid string, idp string) *iamv1alpha2.User { + return &iamv1alpha2.User{ + TypeMeta: metav1.TypeMeta{ + Kind: iamv1alpha2.ResourceKindUser, + APIVersion: iamv1alpha2.SchemeGroupVersion.String(), + }, + ObjectMeta: metav1.ObjectMeta{ + Name: username, + Labels: map[string]string{ + iamv1alpha2.IdentifyProviderLabel: idp, + iamv1alpha2.OriginUIDLabel: uid, + }, + }, + } +} + +type fakeProviderFactory struct { +} + +type fakeProvider struct { + Identities map[string]fakeIdentity `json:"identities"` +} + +type fakeIdentity struct { + UID string `json:"uid"` + Username string `json:"username"` + Email string `json:"email"` +} + +func (f fakeIdentity) GetUserID() string { + return f.UID +} + +func (f fakeIdentity) GetUsername() string { + return f.Username +} + +func (f fakeIdentity) GetEmail() string { + return f.Email +} + +func (fakeProviderFactory) Type() string { + return "FakeIdentityProvider" +} + +func (fakeProviderFactory) Create(options oauth.DynamicOptions) (identityprovider.OAuthProvider, error) { + var fakeProvider fakeProvider + if err := mapstructure.Decode(options, &fakeProvider); err != nil { + return nil, err + } + return &fakeProvider, nil +} + +func (f fakeProvider) IdentityExchangeCallback(req *http.Request) (identityprovider.Identity, error) { + code := req.URL.Query().Get("code") + if identity, ok := f.Identities[code]; ok { + return identity, nil + } + return nil, fmt.Errorf("authorization failed") +} diff --git a/pkg/models/auth/password.go b/pkg/models/auth/password.go new file mode 100644 index 000000000..a4ed80e57 --- /dev/null +++ b/pkg/models/auth/password.go @@ -0,0 +1,134 @@ +/* + + Copyright 2021 The KubeSphere 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 auth + +import ( + "context" + + "kubesphere.io/kubesphere/pkg/apiserver/authentication" + + "golang.org/x/crypto/bcrypt" + "k8s.io/apimachinery/pkg/api/errors" + authuser "k8s.io/apiserver/pkg/authentication/user" + "k8s.io/klog" + iamv1alpha2 "kubesphere.io/api/iam/v1alpha2" + + "kubesphere.io/kubesphere/pkg/apiserver/authentication/identityprovider" + "kubesphere.io/kubesphere/pkg/apiserver/authentication/oauth" + iamv1alpha2listers "kubesphere.io/kubesphere/pkg/client/listers/iam/v1alpha2" + "kubesphere.io/kubesphere/pkg/constants" +) + +type passwordAuthenticator struct { + userGetter *userGetter + authOptions *authentication.Options +} + +func NewPasswordAuthenticator(userLister iamv1alpha2listers.UserLister, + options *authentication.Options) PasswordAuthenticator { + passwordAuthenticator := &passwordAuthenticator{ + userGetter: &userGetter{userLister: userLister}, + authOptions: options, + } + return passwordAuthenticator +} + +func (p *passwordAuthenticator) Authenticate(ctx context.Context, username, password string) (authuser.Info, string, error) { + // empty username or password are not allowed + if username == "" || password == "" { + return nil, "", IncorrectPasswordError + } + // generic identity provider has higher priority + for _, providerOptions := range p.authOptions.OAuthOptions.IdentityProviders { + // the admin account in kubesphere has the highest priority + if username == constants.AdminUserName { + break + } + if genericProvider, _ := identityprovider.GetGenericProvider(providerOptions.Name); genericProvider != nil { + authenticated, err := genericProvider.Authenticate(username, password) + if err != nil { + if errors.IsUnauthorized(err) { + continue + } + return nil, providerOptions.Name, err + } + linkedAccount, err := p.userGetter.findMappedUser(providerOptions.Name, authenticated.GetUserID()) + // using this method requires you to manually provision users. + if providerOptions.MappingMethod == oauth.MappingMethodLookup && linkedAccount == nil { + continue + } + if linkedAccount != nil { + return &authuser.DefaultInfo{Name: linkedAccount.GetName()}, providerOptions.Name, nil + } + // the user will automatically create and mapping when login successful. + if providerOptions.MappingMethod == oauth.MappingMethodAuto { + return preRegistrationUser(providerOptions.Name, authenticated), providerOptions.Name, nil + } + } + } + + // kubesphere account + user, err := p.userGetter.findUser(username) + if err != nil { + // ignore not found error + if !errors.IsNotFound(err) { + klog.Error(err) + return nil, "", err + } + } + + // check user status + if user != nil && (user.Status.State == nil || *user.Status.State != iamv1alpha2.UserActive) { + if user.Status.State != nil && *user.Status.State == iamv1alpha2.UserAuthLimitExceeded { + klog.Errorf("%s, username: %s", RateLimitExceededError, username) + return nil, "", RateLimitExceededError + } else { + // state not active + klog.Errorf("%s, username: %s", AccountIsNotActiveError, username) + return nil, "", AccountIsNotActiveError + } + } + + // if the password is not empty, means that the password has been reset, even if the user was mapping from IDP + if user != nil && user.Spec.EncryptedPassword != "" { + if err = PasswordVerify(user.Spec.EncryptedPassword, password); err != nil { + klog.Error(err) + return nil, "", err + } + u := &authuser.DefaultInfo{ + Name: user.Name, + } + // check if the password is initialized + if uninitialized := user.Annotations[iamv1alpha2.UninitializedAnnotation]; uninitialized != "" { + u.Extra = map[string][]string{ + iamv1alpha2.ExtraUninitialized: {uninitialized}, + } + } + return u, "", nil + } + + return nil, "", IncorrectPasswordError +} + +func PasswordVerify(encryptedPassword, password string) error { + if err := bcrypt.CompareHashAndPassword([]byte(encryptedPassword), []byte(password)); err != nil { + return IncorrectPasswordError + } + return nil +} diff --git a/pkg/models/auth/authenticator_test.go b/pkg/models/auth/password_test.go similarity index 100% rename from pkg/models/auth/authenticator_test.go rename to pkg/models/auth/password_test.go diff --git a/pkg/models/auth/token.go b/pkg/models/auth/token.go index ec7c8b462..0b441dbbf 100644 --- a/pkg/models/auth/token.go +++ b/pkg/models/auth/token.go @@ -23,99 +23,87 @@ import ( "fmt" "time" - "k8s.io/apiserver/pkg/authentication/user" + "kubesphere.io/kubesphere/pkg/apiserver/authentication" + "k8s.io/klog" - "kubesphere.io/kubesphere/pkg/apiserver/authentication/oauth" - authoptions "kubesphere.io/kubesphere/pkg/apiserver/authentication/options" "kubesphere.io/kubesphere/pkg/apiserver/authentication/token" "kubesphere.io/kubesphere/pkg/simple/client/cache" ) +// TokenManagementInterface Cache issued token, support revocation of tokens after issuance type TokenManagementInterface interface { - // Verify verifies a token, and return a User if it's a valid token, otherwise return error - Verify(token string) (user.Info, error) - // IssueTo issues a token a User, return error if issuing process failed - IssueTo(user user.Info) (*oauth.Token, error) + // Verify the given token and returns token.VerifiedResponse + Verify(token string) (*token.VerifiedResponse, error) + // IssueTo issue a token for the specified user + IssueTo(request *token.IssueRequest) (string, error) + // Revoke revoke the specified token + Revoke(token string) error // RevokeAllUserTokens revoke all user tokens RevokeAllUserTokens(username string) error } type tokenOperator struct { issuer token.Issuer - options *authoptions.AuthenticationOptions + options *authentication.Options cache cache.Interface } -func NewTokenOperator(cache cache.Interface, options *authoptions.AuthenticationOptions) TokenManagementInterface { +func (t tokenOperator) Revoke(token string) error { + pattern := fmt.Sprintf("kubesphere:user:*:token:%s", token) + if keys, err := t.cache.Keys(pattern); err != nil { + klog.Error(err) + return err + } else if len(keys) > 0 { + if err := t.cache.Del(keys...); err != nil { + klog.Error(err) + return err + } + } + return nil +} + +func NewTokenOperator(cache cache.Interface, issuer token.Issuer, options *authentication.Options) TokenManagementInterface { operator := &tokenOperator{ - issuer: token.NewTokenIssuer(options.JwtSecret, options.MaximumClockSkew), + issuer: issuer, options: options, cache: cache, } return operator } -func (t tokenOperator) Verify(tokenStr string) (user.Info, error) { - authenticated, tokenType, err := t.issuer.Verify(tokenStr) +func (t *tokenOperator) Verify(tokenStr string) (*token.VerifiedResponse, error) { + response, err := t.issuer.Verify(tokenStr) if err != nil { return nil, err } if t.options.OAuthOptions.AccessTokenMaxAge == 0 || - tokenType == token.StaticToken { - return authenticated, nil + response.TokenType == token.StaticToken { + return response, nil } - if err := t.tokenCacheValidate(authenticated.GetName(), tokenStr); err != nil { + if err := t.tokenCacheValidate(response.User.GetName(), tokenStr); err != nil { return nil, err } - return authenticated, nil + return response, nil } -func (t tokenOperator) IssueTo(user user.Info) (*oauth.Token, error) { - accessTokenExpiresIn := t.options.OAuthOptions.AccessTokenMaxAge - refreshTokenExpiresIn := accessTokenExpiresIn + t.options.OAuthOptions.AccessTokenInactivityTimeout - - accessToken, err := t.issuer.IssueTo(user, token.AccessToken, accessTokenExpiresIn) +func (t *tokenOperator) IssueTo(request *token.IssueRequest) (string, error) { + tokenStr, err := t.issuer.IssueTo(request) if err != nil { klog.Error(err) - return nil, err + return "", err } - - refreshToken, err := t.issuer.IssueTo(user, token.RefreshToken, refreshTokenExpiresIn) - if err != nil { - klog.Error(err) - return nil, err - } - - result := &oauth.Token{ - AccessToken: accessToken, - TokenType: "Bearer", - RefreshToken: refreshToken, - ExpiresIn: int(accessTokenExpiresIn.Seconds()), - } - - if !t.options.MultipleLogin { - if err = t.RevokeAllUserTokens(user.GetName()); err != nil { + if request.ExpiresIn > 0 { + if err = t.cacheToken(request.User.GetName(), tokenStr, request.ExpiresIn); err != nil { klog.Error(err) - return nil, err + return "", err } } - - if accessTokenExpiresIn > 0 { - if err = t.cacheToken(user.GetName(), accessToken, accessTokenExpiresIn); err != nil { - klog.Error(err) - return nil, err - } - if err = t.cacheToken(user.GetName(), refreshToken, refreshTokenExpiresIn); err != nil { - klog.Error(err) - return nil, err - } - } - - return result, nil + return tokenStr, nil } -func (t tokenOperator) RevokeAllUserTokens(username string) error { +// RevokeAllUserTokens revoke all user tokens in the cache +func (t *tokenOperator) RevokeAllUserTokens(username string) error { pattern := fmt.Sprintf("kubesphere:user:%s:token:*", username) if keys, err := t.cache.Keys(pattern); err != nil { klog.Error(err) @@ -129,7 +117,8 @@ func (t tokenOperator) RevokeAllUserTokens(username string) error { return nil } -func (t tokenOperator) tokenCacheValidate(username, token string) error { +// tokenCacheValidate verify that the token is in the cache +func (t *tokenOperator) tokenCacheValidate(username, token string) error { key := fmt.Sprintf("kubesphere:user:%s:token:%s", username, token) if exist, err := t.cache.Exists(key); err != nil { return err @@ -141,7 +130,8 @@ func (t tokenOperator) tokenCacheValidate(username, token string) error { return nil } -func (t tokenOperator) cacheToken(username, token string, duration time.Duration) error { +// cacheToken cache the token for a period of time +func (t *tokenOperator) cacheToken(username, token string, duration time.Duration) error { key := fmt.Sprintf("kubesphere:user:%s:token:%s", username, token) if err := t.cache.Set(key, token, duration); err != nil { klog.Error(err) diff --git a/pkg/models/iam/im/im.go b/pkg/models/iam/im/im.go index 43874520e..6145d3bad 100644 --- a/pkg/models/iam/im/im.go +++ b/pkg/models/iam/im/im.go @@ -19,13 +19,14 @@ import ( "context" "fmt" + "kubesphere.io/kubesphere/pkg/apiserver/authentication" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/klog" iamv1alpha2 "kubesphere.io/api/iam/v1alpha2" "kubesphere.io/kubesphere/pkg/api" - authoptions "kubesphere.io/kubesphere/pkg/apiserver/authentication/options" "kubesphere.io/kubesphere/pkg/apiserver/query" kubesphere "kubesphere.io/kubesphere/pkg/client/clientset/versioned" "kubesphere.io/kubesphere/pkg/models/auth" @@ -43,7 +44,7 @@ type IdentityManagementInterface interface { PasswordVerify(username string, password string) error } -func NewOperator(ksClient kubesphere.Interface, userGetter resources.Interface, loginRecordGetter resources.Interface, options *authoptions.AuthenticationOptions) IdentityManagementInterface { +func NewOperator(ksClient kubesphere.Interface, userGetter resources.Interface, loginRecordGetter resources.Interface, options *authentication.Options) IdentityManagementInterface { im := &imOperator{ ksClient: ksClient, userGetter: userGetter, @@ -57,7 +58,7 @@ type imOperator struct { ksClient kubesphere.Interface userGetter resources.Interface loginRecordGetter resources.Interface - options *authoptions.AuthenticationOptions + options *authentication.Options } // UpdateUser returns user information after update.