From 3d74bb05897e211aa0f2799940cfa34c1ee9104d Mon Sep 17 00:00:00 2001 From: zryfish Date: Thu, 23 Jul 2020 22:10:39 +0800 Subject: [PATCH] login record CRD (#2565) * Signed-off-by: hongming support ldap identity provider Signed-off-by: hongming * add login record Signed-off-by: Jeff Co-authored-by: hongming --- cmd/controller-manager/app/controllers.go | 15 +- cmd/controller-manager/app/server.go | 16 +- .../crds/iam.kubesphere.io_loginrecords.yaml | 72 ++++++ config/crds/iam.kubesphere.io_users.yaml | 28 +-- pkg/apis/iam/v1alpha2/register.go | 2 + pkg/apis/iam/v1alpha2/types.go | 73 +++--- .../iam/v1alpha2/zz_generated.deepcopy.go | 98 ++++++-- pkg/apiserver/apiserver.go | 43 ++-- .../authenticators/basic/basic.go | 10 +- .../authenticators/jwttoken/jwt_token.go | 12 +- .../identityprovider/github/github.go | 2 +- .../identityprovider/identity_provider.go | 26 -- .../identityprovider/ldap_provider.go | 117 +++++++++ .../identityprovider/oauth_provider.go | 46 ++++ .../authentication/oauth/oauth_options.go | 14 +- .../oauth/oauth_options_test.go | 1 + .../options/authenticate_options.go | 11 +- pkg/apiserver/authentication/token/issuer.go | 22 +- pkg/apiserver/authentication/token/jwt.go | 102 +++----- .../authentication/token/jwt_test.go | 69 +----- pkg/apiserver/authentication/token/user.go | 25 -- pkg/apiserver/filters/authentication.go | 15 +- .../iam/v1alpha2/fake/fake_iam_client.go | 4 + .../iam/v1alpha2/fake/fake_loginrecord.go | 120 +++++++++ .../typed/iam/v1alpha2/generated_expansion.go | 2 + .../typed/iam/v1alpha2/iam_client.go | 5 + .../typed/iam/v1alpha2/loginrecord.go | 164 +++++++++++++ .../informers/externalversions/generic.go | 2 + .../iam/v1alpha2/interface.go | 7 + .../iam/v1alpha2/loginrecord.go | 88 +++++++ .../iam/v1alpha2/expansion_generated.go | 4 + .../listers/iam/v1alpha2/loginrecord.go | 65 +++++ pkg/controller/user/loginrecord_controller.go | 230 ++++++++++++++++++ pkg/controller/user/user_controller.go | 213 +++++++++++----- pkg/controller/user/user_controller_test.go | 13 +- pkg/controller/user/user_webhook.go | 3 +- pkg/controller/workspacerole/workspacerole.go | 2 +- .../workspacetemplate_controller.go | 2 +- pkg/kapis/iam/v1alpha2/handler.go | 29 ++- pkg/kapis/iam/v1alpha2/register.go | 5 + pkg/kapis/oauth/handler.go | 177 ++++++++++---- pkg/kapis/oauth/register.go | 20 +- pkg/models/iam/im/authenticator.go | 173 +++++++++++++ pkg/models/iam/im/im.go | 112 ++------- pkg/models/iam/im/login_recoder.go | 72 ++++++ pkg/models/iam/im/token.go | 146 +++++++++++ .../v1alpha3/loginrecord/loginrecords.go | 87 +++++++ .../v1alpha3/loginrecord/loginrecords_test.go | 110 +++++++++ .../resources/v1alpha3/resource/resource.go | 3 +- pkg/simple/client/cache/simple_cache.go | 9 +- pkg/utils/net/net.go | 25 ++ 51 files changed, 2163 insertions(+), 548 deletions(-) create mode 100644 config/crds/iam.kubesphere.io_loginrecords.yaml create mode 100644 pkg/apiserver/authentication/identityprovider/ldap_provider.go create mode 100644 pkg/apiserver/authentication/identityprovider/oauth_provider.go delete mode 100644 pkg/apiserver/authentication/token/user.go create mode 100644 pkg/client/clientset/versioned/typed/iam/v1alpha2/fake/fake_loginrecord.go create mode 100644 pkg/client/clientset/versioned/typed/iam/v1alpha2/loginrecord.go create mode 100644 pkg/client/informers/externalversions/iam/v1alpha2/loginrecord.go create mode 100644 pkg/client/listers/iam/v1alpha2/loginrecord.go create mode 100644 pkg/controller/user/loginrecord_controller.go create mode 100644 pkg/models/iam/im/authenticator.go create mode 100644 pkg/models/iam/im/login_recoder.go create mode 100644 pkg/models/iam/im/token.go create mode 100644 pkg/models/resources/v1alpha3/loginrecord/loginrecords.go create mode 100644 pkg/models/resources/v1alpha3/loginrecord/loginrecords_test.go diff --git a/cmd/controller-manager/app/controllers.go b/cmd/controller-manager/app/controllers.go index 10ce8498f..d3150f5ee 100644 --- a/cmd/controller-manager/app/controllers.go +++ b/cmd/controller-manager/app/controllers.go @@ -22,6 +22,7 @@ import ( "k8s.io/klog" iamv1alpha2 "kubesphere.io/kubesphere/pkg/apis/iam/v1alpha2" tenantv1alpha2 "kubesphere.io/kubesphere/pkg/apis/tenant/v1alpha2" + authoptions "kubesphere.io/kubesphere/pkg/apiserver/authentication/options" "kubesphere.io/kubesphere/pkg/controller/application" "kubesphere.io/kubesphere/pkg/controller/certificatesigningrequest" "kubesphere.io/kubesphere/pkg/controller/cluster" @@ -61,6 +62,7 @@ func addControllers( devopsClient devops.Interface, s3Client s3.Interface, ldapClient ldapclient.Interface, + authenticationOptions *authoptions.AuthenticationOptions, openpitrixClient openpitrix.Client, multiClusterEnabled bool, networkPolicyEnabled bool, @@ -207,10 +209,18 @@ func addControllers( go fedWorkspaceRoleBindingCacheController.Run(stopCh) } - userController := user.NewController(client.Kubernetes(), client.KubeSphere(), client.Config(), + userController := user.NewUserController(client.Kubernetes(), client.KubeSphere(), client.Config(), kubesphereInformer.Iam().V1alpha2().Users(), fedUserCache, fedUserCacheController, - kubernetesInformer.Core().V1().ConfigMaps(), ldapClient, multiClusterEnabled) + kubesphereInformer.Iam().V1alpha2().LoginRecords(), + kubernetesInformer.Core().V1().ConfigMaps(), + ldapClient, authenticationOptions, multiClusterEnabled) + + loginRecordController := user.NewLoginRecordController( + client.Kubernetes(), + client.KubeSphere(), + kubesphereInformer.Iam().V1alpha2().LoginRecords(), + authenticationOptions) csrController := certificatesigningrequest.NewController(client.Kubernetes(), kubernetesInformer.Certificates().V1beta1().CertificateSigningRequests(), @@ -282,6 +292,7 @@ func addControllers( "storagecapability-controller": storageCapabilityController, "volumeexpansion-controller": volumeExpansionController, "user-controller": userController, + "loginrecord-controller": loginRecordController, "cluster-controller": clusterController, "nsnp-controller": nsnpController, "csr-controller": csrController, diff --git a/cmd/controller-manager/app/server.go b/cmd/controller-manager/app/server.go index e21046502..ae992dac2 100644 --- a/cmd/controller-manager/app/server.go +++ b/cmd/controller-manager/app/server.go @@ -183,10 +183,18 @@ func Run(s *options.KubeSphereControllerManagerOptions, stopCh <-chan struct{}) // TODO(jeff): refactor config with CRD servicemeshEnabled := s.ServiceMeshOptions != nil && len(s.ServiceMeshOptions.IstioPilotHost) != 0 - if err = addControllers(mgr, kubernetesClient, informerFactory, - devopsClient, s3Client, ldapClient, openpitrixClient, - s.MultiClusterOptions.Enable, s.NetworkOptions.EnableNetworkPolicy, - servicemeshEnabled, s.AuthenticationOptions.KubectlImage, stopCh); err != nil { + if err = addControllers(mgr, + kubernetesClient, + informerFactory, + devopsClient, + s3Client, + ldapClient, + s.AuthenticationOptions, + openpitrixClient, + s.MultiClusterOptions.Enable, + s.NetworkOptions.EnableNetworkPolicy, + servicemeshEnabled, + s.AuthenticationOptions.KubectlImage, stopCh); err != nil { klog.Fatalf("unable to register controllers to the manager: %v", err) } diff --git a/config/crds/iam.kubesphere.io_loginrecords.yaml b/config/crds/iam.kubesphere.io_loginrecords.yaml new file mode 100644 index 000000000..318908927 --- /dev/null +++ b/config/crds/iam.kubesphere.io_loginrecords.yaml @@ -0,0 +1,72 @@ + +--- +apiVersion: apiextensions.k8s.io/v1beta1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: (devel) + creationTimestamp: null + name: loginrecords.iam.kubesphere.io +spec: + additionalPrinterColumns: + - JSONPath: .spec.type + name: Type + type: string + - JSONPath: .status.reason + name: Reason + type: string + - JSONPath: .metadata.creationTimestamp + name: Age + type: date + group: iam.kubesphere.io + names: + categories: + - iam + kind: LoginRecord + listKind: LoginRecordList + plural: loginrecords + singular: loginrecord + scope: Cluster + subresources: {} + validation: + openAPIV3Schema: + properties: + apiVersion: + description: 'APIVersion defines the versioned schema of this representation + of an object. Servers should convert recognized schemas to the latest + internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources' + type: string + kind: + description: 'Kind is a string value representing the REST resource this + object represents. Servers may infer this from the endpoint the client + submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds' + type: string + metadata: + type: object + spec: + properties: + reason: + type: string + sourceIP: + type: string + type: + type: string + required: + - reason + - sourceIP + - type + type: object + required: + - spec + type: object + version: v1alpha2 + versions: + - name: v1alpha2 + served: true + storage: true +status: + acceptedNames: + kind: "" + plural: "" + conditions: [] + storedVersions: [] diff --git a/config/crds/iam.kubesphere.io_users.yaml b/config/crds/iam.kubesphere.io_users.yaml index 2bf0b6787..423ffaee6 100644 --- a/config/crds/iam.kubesphere.io_users.yaml +++ b/config/crds/iam.kubesphere.io_users.yaml @@ -69,29 +69,11 @@ spec: status: description: UserStatus defines the observed state of User properties: - conditions: - description: Represents the latest available observations of a user's - current state. - items: - properties: - lastTransitionTime: - format: date-time - type: string - message: - type: string - reason: - type: string - status: - description: Status of the condition, one of True, False, Unknown. - type: string - type: - description: Type of user controller condition. - type: string - required: - - status - - type - type: object - type: array + lastTransitionTime: + format: date-time + type: string + reason: + type: string state: description: The user status type: string diff --git a/pkg/apis/iam/v1alpha2/register.go b/pkg/apis/iam/v1alpha2/register.go index 203799b24..9843c679f 100644 --- a/pkg/apis/iam/v1alpha2/register.go +++ b/pkg/apis/iam/v1alpha2/register.go @@ -51,6 +51,8 @@ func addKnownTypes(scheme *runtime.Scheme) error { scheme.AddKnownTypes(SchemeGroupVersion, &User{}, &UserList{}, + &LoginRecord{}, + &LoginRecordList{}, &GlobalRole{}, &GlobalRoleList{}, &GlobalRoleBinding{}, diff --git a/pkg/apis/iam/v1alpha2/types.go b/pkg/apis/iam/v1alpha2/types.go index 87941fdc5..4df0cece2 100644 --- a/pkg/apis/iam/v1alpha2/types.go +++ b/pkg/apis/iam/v1alpha2/types.go @@ -26,6 +26,9 @@ const ( ResourceKindUser = "User" ResourcesSingularUser = "user" ResourcesPluralUser = "users" + ResourceKindLoginRecord = "LoginRecord" + ResourcesSingularLoginRecord = "loginrecord" + ResourcesPluralLoginRecord = "loginrecords" ResourceKindGlobalRoleBinding = "GlobalRoleBinding" ResourcesSingularGlobalRoleBinding = "globalrolebinding" ResourcesPluralGlobalRoleBinding = "globalrolebindings" @@ -119,6 +122,13 @@ const ( UserActive UserState = "Active" // UserDisabled means the user is disabled. UserDisabled UserState = "Disabled" + // UserDisabled means the user is disabled. + UserAuthLimitExceeded UserState = "AuthLimitExceeded" + + LoginFailure LoginRecordType = "LoginFailure" + LoginSuccess LoginRecordType = "LoginSuccess" + + AuthenticatedSuccessfully = "authenticated successfully" ) // UserStatus defines the observed state of User @@ -126,43 +136,12 @@ type UserStatus struct { // The user status // +optional State UserState `json:"state,omitempty"` - // Represents the latest available observations of a user's current state. - // +optional - Conditions []UserCondition `json:"conditions,omitempty"` -} - -type UserCondition struct { - // Type of user controller condition. - Type UserConditionType `json:"type"` - // Status of the condition, one of True, False, Unknown. - Status ConditionStatus `json:"status"` - // +optional - LastTransitionTime metav1.Time `json:"lastTransitionTime,omitempty"` // +optional Reason string `json:"reason,omitempty"` // +optional - Message string `json:"message,omitempty"` + LastTransitionTime *metav1.Time `json:"lastTransitionTime,omitempty"` } -type UserConditionType string - -// These are valid conditions of a user. -const ( - // UserLoginFailure contains information about user login. - LoginFailure UserConditionType = "LoginFailure" -) - -type ConditionStatus string - -// These are valid condition statuses. "ConditionTrue" means a resource is in the condition. -// "ConditionFalse" means a resource is not in the condition. "ConditionUnknown" means kubernetes -// can't decide if a resource is in the condition or not. In the future, we could add other -// intermediate conditions, e.g. ConditionDegraded. -const ( - ConditionTrue ConditionStatus = "True" - ConditionFalse ConditionStatus = "False" -) - // +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object // UserList contains a list of User @@ -306,3 +285,33 @@ type RoleBaseList struct { metav1.ListMeta `json:"metadata,omitempty"` Items []RoleBase `json:"items"` } + +// +genclient +// +genclient:nonNamespaced +// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object +// +kubebuilder:printcolumn:name="Type",type="string",JSONPath=".spec.type" +// +kubebuilder:printcolumn:name="Reason",type="string",JSONPath=".status.reason" +// +kubebuilder:printcolumn:name="Age",type="date",JSONPath=".metadata.creationTimestamp" +// +kubebuilder:resource:categories="iam",scope="Cluster" +type LoginRecord struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + Spec LoginRecordSpec `json:"spec"` +} + +type LoginRecordSpec struct { + SourceIP string `json:"sourceIP"` + Type LoginRecordType `json:"type"` + Reason string `json:"reason"` +} + +type LoginRecordType string + +// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object + +// LoginRecordList contains a list of LoginRecord +type LoginRecordList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + Items []LoginRecord `json:"items"` +} diff --git a/pkg/apis/iam/v1alpha2/zz_generated.deepcopy.go b/pkg/apis/iam/v1alpha2/zz_generated.deepcopy.go index c95916cd4..eb59d4985 100644 --- a/pkg/apis/iam/v1alpha2/zz_generated.deepcopy.go +++ b/pkg/apis/iam/v1alpha2/zz_generated.deepcopy.go @@ -294,6 +294,79 @@ func (in *GlobalRoleList) DeepCopyObject() runtime.Object { return nil } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *LoginRecord) DeepCopyInto(out *LoginRecord) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + out.Spec = in.Spec +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new LoginRecord. +func (in *LoginRecord) DeepCopy() *LoginRecord { + if in == nil { + return nil + } + out := new(LoginRecord) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *LoginRecord) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *LoginRecordList) DeepCopyInto(out *LoginRecordList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]LoginRecord, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new LoginRecordList. +func (in *LoginRecordList) DeepCopy() *LoginRecordList { + if in == nil { + return nil + } + out := new(LoginRecordList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *LoginRecordList) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *LoginRecordSpec) DeepCopyInto(out *LoginRecordSpec) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new LoginRecordSpec. +func (in *LoginRecordSpec) DeepCopy() *LoginRecordSpec { + if in == nil { + return nil + } + out := new(LoginRecordSpec) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *Placement) DeepCopyInto(out *Placement) { *out = *in @@ -445,22 +518,6 @@ func (in *User) DeepCopyObject() runtime.Object { return nil } -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *UserCondition) DeepCopyInto(out *UserCondition) { - *out = *in - in.LastTransitionTime.DeepCopyInto(&out.LastTransitionTime) -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new UserCondition. -func (in *UserCondition) DeepCopy() *UserCondition { - if in == nil { - return nil - } - out := new(UserCondition) - in.DeepCopyInto(out) - return out -} - // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *UserList) DeepCopyInto(out *UserList) { *out = *in @@ -516,12 +573,9 @@ func (in *UserSpec) DeepCopy() *UserSpec { // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *UserStatus) DeepCopyInto(out *UserStatus) { *out = *in - if in.Conditions != nil { - in, out := &in.Conditions, &out.Conditions - *out = make([]UserCondition, len(*in)) - for i := range *in { - (*in)[i].DeepCopyInto(&(*out)[i]) - } + if in.LastTransitionTime != nil { + in, out := &in.LastTransitionTime, &out.LastTransitionTime + *out = (*in).DeepCopy() } } diff --git a/pkg/apiserver/apiserver.go b/pkg/apiserver/apiserver.go index 9547fe5f3..12b8ce8c6 100644 --- a/pkg/apiserver/apiserver.go +++ b/pkg/apiserver/apiserver.go @@ -37,7 +37,6 @@ import ( "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/authentication/token" "kubesphere.io/kubesphere/pkg/apiserver/authorization/authorizer" "kubesphere.io/kubesphere/pkg/apiserver/authorization/authorizerfactory" authorizationoptions "kubesphere.io/kubesphere/pkg/apiserver/authorization/options" @@ -78,10 +77,9 @@ import ( "kubesphere.io/kubesphere/pkg/simple/client/openpitrix" "kubesphere.io/kubesphere/pkg/simple/client/s3" "kubesphere.io/kubesphere/pkg/simple/client/sonarqube" - "net" + utilnet "kubesphere.io/kubesphere/pkg/utils/net" "net/http" rt "runtime" - "strings" "time" ) @@ -187,8 +185,16 @@ func (s *APIServer) installKubeSphereAPIs() { urlruntime.Must(iamapi.AddToContainer(s.container, imOperator, am.NewOperator(s.InformerFactory, s.KubernetesClient.KubeSphere(), s.KubernetesClient.Kubernetes()), s.Config.AuthenticationOptions)) + urlruntime.Must(oauth.AddToContainer(s.container, imOperator, - token.NewJwtTokenIssuer(token.DefaultIssuerName, s.Config.AuthenticationOptions, s.CacheClient), + im.NewTokenOperator( + s.CacheClient, + s.Config.AuthenticationOptions), + im.NewPasswordAuthenticator( + s.KubernetesClient.KubeSphere(), + s.InformerFactory.KubeSphereSharedInformerFactory().Iam().V1alpha2().Users().Lister(), + s.Config.AuthenticationOptions), + im.NewLoginRecorder(s.KubernetesClient.KubeSphere()), s.Config.AuthenticationOptions)) urlruntime.Must(servicemeshv1alpha2.AddToContainer(s.container)) urlruntime.Must(devopsv1alpha2.AddToContainer(s.container, @@ -283,11 +289,12 @@ func (s *APIServer) buildHandlerChain(stopCh <-chan struct{}) { handler = filters.WithAuthorization(handler, authorizers) + loginRecorder := im.NewLoginRecorder(s.KubernetesClient.KubeSphere()) // authenticators are unordered authn := unionauth.New(anonymous.NewAuthenticator(), - basictoken.New(basic.NewBasicAuthenticator(im.NewOperator(s.KubernetesClient.KubeSphere(), s.InformerFactory, s.Config.AuthenticationOptions))), - bearertoken.New(jwttoken.NewTokenAuthenticator(token.NewJwtTokenIssuer(token.DefaultIssuerName, s.Config.AuthenticationOptions, s.CacheClient)))) - handler = filters.WithAuthentication(handler, authn) + basictoken.New(basic.NewBasicAuthenticator(im.NewPasswordAuthenticator(s.KubernetesClient.KubeSphere(), s.InformerFactory.KubeSphereSharedInformerFactory().Iam().V1alpha2().Users().Lister(), s.Config.AuthenticationOptions))), + bearertoken.New(jwttoken.NewTokenAuthenticator(im.NewTokenOperator(s.CacheClient, s.Config.AuthenticationOptions)))) + handler = filters.WithAuthentication(handler, authn, loginRecorder) handler = filters.WithRequestInfo(handler, requestInfoResolver) s.Server.Handler = handler } @@ -373,6 +380,7 @@ func (s *APIServer) waitForResourceSync(stopCh <-chan struct{}) error { {Group: "iam.kubesphere.io", Version: "v1alpha2", Resource: "globalrolebindings"}, {Group: "iam.kubesphere.io", Version: "v1alpha2", Resource: "workspaceroles"}, {Group: "iam.kubesphere.io", Version: "v1alpha2", Resource: "workspacerolebindings"}, + {Group: "iam.kubesphere.io", Version: "v1alpha2", Resource: "loginrecords"}, {Group: "cluster.kubesphere.io", Version: "v1alpha1", Resource: "clusters"}, {Group: "devops.kubesphere.io", Version: "v1alpha3", Resource: "devopsprojects"}, } @@ -514,7 +522,7 @@ func logRequestAndResponse(req *restful.Request, resp *restful.Response, chain * } logWithVerbose.Infof("%s - \"%s %s %s\" %d %d %dms", - getRequestIP(req), + utilnet.GetRequestIP(req.Request), req.Request.Method, req.Request.URL, req.Request.Proto, @@ -524,25 +532,6 @@ func logRequestAndResponse(req *restful.Request, resp *restful.Response, chain * ) } -func getRequestIP(req *restful.Request) string { - address := strings.Trim(req.Request.Header.Get("X-Real-Ip"), " ") - if address != "" { - return address - } - - address = strings.Trim(req.Request.Header.Get("X-Forwarded-For"), " ") - if address != "" { - return address - } - - address, _, err := net.SplitHostPort(req.Request.RemoteAddr) - if err != nil { - return req.Request.RemoteAddr - } - - return address -} - type errorResponder struct{} func (e *errorResponder) Error(w http.ResponseWriter, req *http.Request, err error) { diff --git a/pkg/apiserver/authentication/authenticators/basic/basic.go b/pkg/apiserver/authentication/authenticators/basic/basic.go index 37fae5e0a..2ad09dc43 100644 --- a/pkg/apiserver/authentication/authenticators/basic/basic.go +++ b/pkg/apiserver/authentication/authenticators/basic/basic.go @@ -29,18 +29,18 @@ import ( // and group from user.AllUnauthenticated. This helps requests be passed along the handler chain, // because some resources are public accessible. type basicAuthenticator struct { - im im.IdentityManagementInterface + authenticator im.PasswordAuthenticator } -func NewBasicAuthenticator(im im.IdentityManagementInterface) authenticator.Password { +func NewBasicAuthenticator(authenticator im.PasswordAuthenticator) authenticator.Password { return &basicAuthenticator{ - im: im, + authenticator: authenticator, } } func (t *basicAuthenticator) AuthenticatePassword(ctx context.Context, username, password string) (*authenticator.Response, bool, error) { - providedUser, err := t.im.Authenticate(username, password) + providedUser, err := t.authenticator.Authenticate(username, password) if err != nil { return nil, false, err @@ -49,7 +49,7 @@ func (t *basicAuthenticator) AuthenticatePassword(ctx context.Context, username, return &authenticator.Response{ User: &user.DefaultInfo{ Name: providedUser.GetName(), - UID: string(providedUser.GetUID()), + UID: providedUser.GetUID(), Groups: []string{user.AllAuthenticated}, }, }, true, nil diff --git a/pkg/apiserver/authentication/authenticators/jwttoken/jwt_token.go b/pkg/apiserver/authentication/authenticators/jwttoken/jwt_token.go index e694e3f83..fdc827bdf 100644 --- a/pkg/apiserver/authentication/authenticators/jwttoken/jwt_token.go +++ b/pkg/apiserver/authentication/authenticators/jwttoken/jwt_token.go @@ -20,7 +20,8 @@ import ( "context" "k8s.io/apiserver/pkg/authentication/authenticator" "k8s.io/apiserver/pkg/authentication/user" - token2 "kubesphere.io/kubesphere/pkg/apiserver/authentication/token" + "k8s.io/klog" + "kubesphere.io/kubesphere/pkg/models/iam/im" ) // TokenAuthenticator implements kubernetes token authenticate interface with our custom logic. @@ -29,18 +30,19 @@ import ( // and group from user.AllUnauthenticated. This helps requests be passed along the handler chain, // because some resources are public accessible. type tokenAuthenticator struct { - jwtTokenIssuer token2.Issuer + tokenOperator im.TokenManagementInterface } -func NewTokenAuthenticator(issuer token2.Issuer) authenticator.Token { +func NewTokenAuthenticator(tokenOperator im.TokenManagementInterface) authenticator.Token { return &tokenAuthenticator{ - jwtTokenIssuer: issuer, + tokenOperator: tokenOperator, } } func (t *tokenAuthenticator) AuthenticateToken(ctx context.Context, token string) (*authenticator.Response, bool, error) { - providedUser, err := t.jwtTokenIssuer.Verify(token) + providedUser, err := t.tokenOperator.Verify(token) if err != nil { + klog.Error(err) return nil, false, err } diff --git a/pkg/apiserver/authentication/identityprovider/github/github.go b/pkg/apiserver/authentication/identityprovider/github/github.go index a8a493883..f052cf1a3 100644 --- a/pkg/apiserver/authentication/identityprovider/github/github.go +++ b/pkg/apiserver/authentication/identityprovider/github/github.go @@ -99,7 +99,7 @@ type GithubIdentity struct { } func init() { - identityprovider.RegisterOAuthProviderCodec(&Github{}) + identityprovider.RegisterOAuthProvider(&Github{}) } func (g *Github) Type() string { diff --git a/pkg/apiserver/authentication/identityprovider/identity_provider.go b/pkg/apiserver/authentication/identityprovider/identity_provider.go index 680f06e35..b0b13a36f 100644 --- a/pkg/apiserver/authentication/identityprovider/identity_provider.go +++ b/pkg/apiserver/authentication/identityprovider/identity_provider.go @@ -16,33 +16,7 @@ limitations under the License. package identityprovider -import ( - "errors" - "kubesphere.io/kubesphere/pkg/apiserver/authentication/oauth" -) - -var ( - ErrorIdentityProviderNotFound = errors.New("the identity provider was not found") - oauthProviders = make(map[string]OAuthProvider, 0) -) - -type OAuthProvider interface { - Type() string - Setup(options *oauth.DynamicOptions) (OAuthProvider, error) - IdentityExchange(code string) (Identity, error) -} type Identity interface { GetName() string GetEmail() string } - -func GetOAuthProvider(providerType string, options *oauth.DynamicOptions) (OAuthProvider, error) { - if provider, ok := oauthProviders[providerType]; ok { - return provider.Setup(options) - } - return nil, ErrorIdentityProviderNotFound -} - -func RegisterOAuthProviderCodec(provider OAuthProvider) { - oauthProviders[provider.Type()] = provider -} diff --git a/pkg/apiserver/authentication/identityprovider/ldap_provider.go b/pkg/apiserver/authentication/identityprovider/ldap_provider.go new file mode 100644 index 000000000..3085a8fdf --- /dev/null +++ b/pkg/apiserver/authentication/identityprovider/ldap_provider.go @@ -0,0 +1,117 @@ +/* + + 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 identityprovider + +import ( + "fmt" + "github.com/go-ldap/ldap" + "gopkg.in/yaml.v3" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/klog" + iamv1alpha2 "kubesphere.io/kubesphere/pkg/apis/iam/v1alpha2" + "kubesphere.io/kubesphere/pkg/apiserver/authentication/oauth" +) + +const LdapIdentityProvider = "LDAPIdentityProvider" + +type LdapProvider interface { + Authenticate(username string, password string) (*iamv1alpha2.User, error) +} + +type ldapOptions struct { + Host string `json:"host" yaml:"host"` + ManagerDN string `json:"managerDN" yaml:"managerDN"` + ManagerPassword string `json:"-" yaml:"managerPassword"` + UserSearchBase string `json:"userSearchBase" yaml:"userSearchBase"` + //This is typically uid + LoginAttribute string `json:"loginAttribute" yaml:"loginAttribute"` + MailAttribute string `json:"mailAttribute" yaml:"mailAttribute"` + DisplayNameAttribute string `json:"displayNameAttribute" yaml:"displayNameAttribute"` +} + +type ldapProvider struct { + options ldapOptions +} + +func NewLdapProvider(options *oauth.DynamicOptions) (LdapProvider, error) { + data, err := yaml.Marshal(options) + if err != nil { + return nil, err + } + var ldapOptions ldapOptions + err = yaml.Unmarshal(data, &ldapOptions) + if err != nil { + return nil, err + } + return &ldapProvider{options: ldapOptions}, nil +} + +func (l ldapProvider) Authenticate(username string, password string) (*iamv1alpha2.User, error) { + conn, err := ldap.Dial("tcp", l.options.Host) + if err != nil { + klog.Error(err) + return nil, err + } + defer conn.Close() + err = conn.Bind(l.options.ManagerDN, l.options.ManagerPassword) + + if err != nil { + klog.Error(err) + return nil, err + } + + filter := fmt.Sprintf("(&(%s=%s))", l.options.LoginAttribute, username) + + result, err := conn.Search(&ldap.SearchRequest{ + BaseDN: l.options.UserSearchBase, + Scope: ldap.ScopeWholeSubtree, + DerefAliases: ldap.NeverDerefAliases, + SizeLimit: 1, + TimeLimit: 0, + TypesOnly: false, + Filter: filter, + Attributes: []string{l.options.LoginAttribute, l.options.MailAttribute, l.options.DisplayNameAttribute}, + }) + + if err != nil { + klog.Error(err) + return nil, err + } + + if len(result.Entries) == 1 { + entry := result.Entries[0] + err = conn.Bind(entry.DN, password) + if err != nil { + klog.Error(err) + return nil, err + } + + return &iamv1alpha2.User{ + ObjectMeta: metav1.ObjectMeta{ + Name: username, + }, + Spec: iamv1alpha2.UserSpec{ + Email: entry.GetAttributeValue(l.options.MailAttribute), + DisplayName: entry.GetAttributeValue(l.options.DisplayNameAttribute), + }, + }, nil + } + + return nil, ldap.NewError(ldap.LDAPResultNoSuchObject, fmt.Errorf(" could not find user %s in LDAP directory", username)) +} diff --git a/pkg/apiserver/authentication/identityprovider/oauth_provider.go b/pkg/apiserver/authentication/identityprovider/oauth_provider.go new file mode 100644 index 000000000..f6384f332 --- /dev/null +++ b/pkg/apiserver/authentication/identityprovider/oauth_provider.go @@ -0,0 +1,46 @@ +/* + + 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 identityprovider + +import ( + "errors" + "kubesphere.io/kubesphere/pkg/apiserver/authentication/oauth" +) + +var ( + oauthProviders = make(map[string]OAuthProvider, 0) + ErrorIdentityProviderNotFound = errors.New("the identity provider was not found") +) + +type OAuthProvider interface { + Type() string + Setup(options *oauth.DynamicOptions) (OAuthProvider, error) + IdentityExchange(code string) (Identity, error) +} + +func GetOAuthProvider(providerType string, options *oauth.DynamicOptions) (OAuthProvider, error) { + if provider, ok := oauthProviders[providerType]; ok { + return provider.Setup(options) + } + return nil, ErrorIdentityProviderNotFound +} + +func RegisterOAuthProvider(provider OAuthProvider) { + oauthProviders[provider.Type()] = provider +} diff --git a/pkg/apiserver/authentication/oauth/oauth_options.go b/pkg/apiserver/authentication/oauth/oauth_options.go index f9c141dc5..9a64978cf 100644 --- a/pkg/apiserver/authentication/oauth/oauth_options.go +++ b/pkg/apiserver/authentication/oauth/oauth_options.go @@ -46,6 +46,7 @@ const ( var ( ErrorClientNotFound = errors.New("the OAuth client was not found") + ErrorProviderNotFound = errors.New("the identity provider was not found") ErrorRedirectURLNotAllowed = errors.New("redirect URL is not allowed") ) @@ -92,7 +93,7 @@ type IdentityProviderOptions struct { Type string `json:"type" yaml:"type"` // The options of identify provider - Provider *DynamicOptions `json:"provider,omitempty" yaml:"provider"` + Provider *DynamicOptions `json:"-" yaml:"provider"` } type Token struct { @@ -155,6 +156,7 @@ var ( DefaultAccessTokenInactivityTimeout = time.Duration(0) DefaultClients = []Client{{ Name: "default", + Secret: "kubesphere", RespondWithChallenges: true, RedirectURIs: []string{AllowAllRedirectURI}, GrantMethod: GrantHandlerAuto, @@ -177,13 +179,13 @@ func (o *Options) OAuthClient(name string) (Client, error) { } return Client{}, ErrorClientNotFound } -func (o *Options) IdentityProviderOptions(name string) (IdentityProviderOptions, error) { +func (o *Options) IdentityProviderOptions(name string) (*IdentityProviderOptions, error) { for _, found := range o.IdentityProviders { if found.Name == name { - return found, nil + return &found, nil } } - return IdentityProviderOptions{}, ErrorClientNotFound + return nil, ErrorProviderNotFound } func (c Client) anyRedirectAbleURI() []string { @@ -224,7 +226,7 @@ func NewOptions() *Options { return &Options{ IdentityProviders: make([]IdentityProviderOptions, 0), Clients: make([]Client, 0), - AccessTokenMaxAge: time.Hour * 24, - AccessTokenInactivityTimeout: 0, + AccessTokenMaxAge: time.Hour * 2, + AccessTokenInactivityTimeout: time.Hour * 2, } } diff --git a/pkg/apiserver/authentication/oauth/oauth_options_test.go b/pkg/apiserver/authentication/oauth/oauth_options_test.go index 3f79fa752..df23586c6 100644 --- a/pkg/apiserver/authentication/oauth/oauth_options_test.go +++ b/pkg/apiserver/authentication/oauth/oauth_options_test.go @@ -28,6 +28,7 @@ func TestDefaultAuthOptions(t *testing.T) { expect := Client{ Name: "default", RespondWithChallenges: true, + Secret: "kubesphere", RedirectURIs: []string{AllowAllRedirectURI}, GrantMethod: GrantHandlerAuto, ScopeRestrictions: []string{"full"}, diff --git a/pkg/apiserver/authentication/options/authenticate_options.go b/pkg/apiserver/authentication/options/authenticate_options.go index 7c156fb27..008d1f6d6 100644 --- a/pkg/apiserver/authentication/options/authenticate_options.go +++ b/pkg/apiserver/authentication/options/authenticate_options.go @@ -25,9 +25,13 @@ import ( ) type AuthenticationOptions struct { - // authenticate rate limit will + // authenticate rate limit AuthenticateRateLimiterMaxTries int `json:"authenticateRateLimiterMaxTries" yaml:"authenticateRateLimiterMaxTries"` - AuthenticateRateLimiterDuration time.Duration `json:"authenticationRateLimiterDuration" yaml:"authenticationRateLimiterDuration"` + AuthenticateRateLimiterDuration time.Duration `json:"authenticateRateLimiterDuration" yaml:"authenticateRateLimiterDuration"` + // Token verification maximum time difference + MaximumClockSkew time.Duration `json:"maximumClockSkew" yaml:"maximumClockSkew"` + // retention login records + RecordRetentionPeriod time.Duration `json:"recordRetentionPeriod" yaml:"recordRetentionPeriod"` // allow multiple users login at the same time MultipleLogin bool `json:"multipleLogin" yaml:"multipleLogin"` // secret to signed jwt token @@ -41,6 +45,8 @@ func NewAuthenticateOptions() *AuthenticationOptions { return &AuthenticationOptions{ AuthenticateRateLimiterMaxTries: 5, AuthenticateRateLimiterDuration: time.Minute * 30, + MaximumClockSkew: 10 * time.Second, + RecordRetentionPeriod: time.Hour * 24 * 7, OAuthOptions: oauth.NewOptions(), MultipleLogin: false, JwtSecret: "", @@ -64,4 +70,5 @@ func (options *AuthenticationOptions) AddFlags(fs *pflag.FlagSet, s *Authenticat fs.StringVar(&options.JwtSecret, "jwt-secret", s.JwtSecret, "Secret to sign jwt token, must not be empty.") fs.DurationVar(&options.OAuthOptions.AccessTokenMaxAge, "access-token-max-age", s.OAuthOptions.AccessTokenMaxAge, "AccessTokenMaxAgeSeconds control the lifetime of access tokens, 0 means no expiration.") fs.StringVar(&s.KubectlImage, "kubectl-image", s.KubectlImage, "Setup the image used by kubectl terminal pod") + fs.DurationVar(&options.MaximumClockSkew, "maximum-clock-skew", s.MaximumClockSkew, "The maximum time difference between the system clocks of the ks-apiserver that issued a JWT and the ks-apiserver that verified the JWT.") } diff --git a/pkg/apiserver/authentication/token/issuer.go b/pkg/apiserver/authentication/token/issuer.go index c1daaee2d..3be7956f8 100644 --- a/pkg/apiserver/authentication/token/issuer.go +++ b/pkg/apiserver/authentication/token/issuer.go @@ -16,16 +16,24 @@ limitations under the License. package token -import "time" +import ( + "k8s.io/apiserver/pkg/authentication/user" + "time" +) + +const ( + AccessToken TokenType = "access_token" + RefreshToken TokenType = "refresh_token" + StaticToken TokenType = "static_token" +) + +type TokenType string // 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, expiresIn time.Duration) (string, error) + IssueTo(user user.Info, tokenType TokenType, expiresIn time.Duration) (string, error) - // Verify verifies a token, and return a User if it's a valid token, otherwise return error - Verify(string) (User, error) - - // Revoke a token, - Revoke(token 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) } diff --git a/pkg/apiserver/authentication/token/jwt.go b/pkg/apiserver/authentication/token/jwt.go index 2ae85f7c2..8251f5c0b 100644 --- a/pkg/apiserver/authentication/token/jwt.go +++ b/pkg/apiserver/authentication/token/jwt.go @@ -21,68 +21,50 @@ import ( "github.com/dgrijalva/jwt-go" "k8s.io/apiserver/pkg/authentication/user" "k8s.io/klog" - authoptions "kubesphere.io/kubesphere/pkg/apiserver/authentication/options" - "kubesphere.io/kubesphere/pkg/server/errors" - "kubesphere.io/kubesphere/pkg/simple/client/cache" "time" ) -const DefaultIssuerName = "kubesphere" - -var ( - errInvalidToken = errors.New("invalid token") - errTokenExpired = errors.New("expired token") +const ( + DefaultIssuerName = "kubesphere" ) type Claims struct { - Username string `json:"username"` - UID string `json:"uid"` + Username string `json:"username"` + UID string `json:"uid"` + TokenType TokenType `json:"token_type"` // Currently, we are not using any field in jwt.StandardClaims jwt.StandardClaims } type jwtTokenIssuer struct { - name string - options *authoptions.AuthenticationOptions - cache cache.Interface - keyFunc jwt.Keyfunc + name string + secret []byte + // Maximum time difference + maximumClockSkew time.Duration } -func (s *jwtTokenIssuer) Verify(tokenString string) (User, error) { - if len(tokenString) == 0 { - return nil, errInvalidToken - } - +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 { - return nil, err + klog.Error(err) + return nil, "", err } - - // accessTokenMaxAge = 0 or token without expiration time means that the token will not expire - // do not validate token cache - if s.options.OAuthOptions.AccessTokenMaxAge > 0 && clm.ExpiresAt > 0 { - _, err = s.cache.Get(tokenCacheKey(tokenString)) - - if err != nil { - if err == cache.ErrNoSuchKey { - return nil, errTokenExpired - } - return nil, err - } - } - - return &user.DefaultInfo{Name: clm.Username, UID: clm.UID}, nil + return &user.DefaultInfo{Name: clm.Username, UID: clm.UID}, clm.TokenType, nil } -func (s *jwtTokenIssuer) IssueTo(user User, expiresIn time.Duration) (string, error) { +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(), - UID: user.GetUID(), + Username: user.GetName(), + UID: user.GetUID(), + TokenType: tokenType, StandardClaims: jwt.StandardClaims{ - IssuedAt: time.Now().Unix(), + IssuedAt: issueAt, Issuer: s.name, - NotBefore: time.Now().Unix(), + NotBefore: notBefore, }, } @@ -92,48 +74,28 @@ func (s *jwtTokenIssuer) IssueTo(user User, expiresIn time.Duration) (string, er token := jwt.NewWithClaims(jwt.SigningMethodHS256, clm) - tokenString, err := token.SignedString([]byte(s.options.JwtSecret)) + tokenString, err := token.SignedString(s.secret) if err != nil { klog.Error(err) return "", err } - // 0 means no expiration. - // validate token cache - if s.options.OAuthOptions.AccessTokenMaxAge > 0 { - err = s.cache.Set(tokenCacheKey(tokenString), tokenString, s.options.OAuthOptions.AccessTokenMaxAge) - if err != nil { - klog.Error(err) - return "", err - } - } - return tokenString, nil } -func (s *jwtTokenIssuer) Revoke(token string) error { - if s.options.OAuthOptions.AccessTokenMaxAge > 0 { - return s.cache.Del(tokenCacheKey(token)) +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"]) } - return nil } -func NewJwtTokenIssuer(issuerName string, options *authoptions.AuthenticationOptions, cache cache.Interface) Issuer { +func NewTokenIssuer(secret string, maximumClockSkew time.Duration) Issuer { return &jwtTokenIssuer{ - name: issuerName, - options: options, - cache: cache, - keyFunc: func(token *jwt.Token) (i interface{}, err error) { - if _, ok := token.Method.(*jwt.SigningMethodHMAC); ok { - return []byte(options.JwtSecret), nil - } else { - return nil, fmt.Errorf("expect token signed with HMAC but got %v", token.Header["alg"]) - } - }, + name: DefaultIssuerName, + secret: []byte(secret), + maximumClockSkew: maximumClockSkew, } } - -func tokenCacheKey(token string) string { - return fmt.Sprintf("kubesphere:tokens:%s", token) -} diff --git a/pkg/apiserver/authentication/token/jwt_test.go b/pkg/apiserver/authentication/token/jwt_test.go index 86c53c52c..d3355a347 100644 --- a/pkg/apiserver/authentication/token/jwt_test.go +++ b/pkg/apiserver/authentication/token/jwt_test.go @@ -19,89 +19,30 @@ package token import ( "github.com/google/go-cmp/cmp" "k8s.io/apiserver/pkg/authentication/user" - "kubesphere.io/kubesphere/pkg/apiserver/authentication/oauth" - authoptions "kubesphere.io/kubesphere/pkg/apiserver/authentication/options" - "kubesphere.io/kubesphere/pkg/simple/client/cache" "testing" ) -func TestJwtTokenIssuer(t *testing.T) { - options := authoptions.NewAuthenticateOptions() - options.JwtSecret = "kubesphere" - issuer := NewJwtTokenIssuer(DefaultIssuerName, options, cache.NewSimpleCache()) - - testCases := []struct { - description string - name string - uid string - email string - }{ - { - name: "admin", - uid: "b8be6edd-2c92-4535-9b2a-df6326474458", - }, - { - name: "bar", - uid: "b8be6edd-2c92-4535-9b2a-df6326474452", - }, - } - - for _, testCase := range testCases { - user := &user.DefaultInfo{ - Name: testCase.name, - UID: testCase.uid, - } - - t.Run(testCase.description, func(t *testing.T) { - token, err := issuer.IssueTo(user, 0) - if err != nil { - t.Fatal(err) - } - - got, err := issuer.Verify(token) - if err != nil { - t.Fatal(err) - } - - if diff := cmp.Diff(user, got); len(diff) != 0 { - t.Errorf("%T differ (-got, +expected), %s", user, diff) - } - }) - } -} - func TestTokenVerifyWithoutCacheValidate(t *testing.T) { - options := authoptions.NewAuthenticateOptions() - // do not set token cache and disable token cache validate, - options.OAuthOptions = &oauth.Options{AccessTokenMaxAge: 0} - options.JwtSecret = "kubesphere" - issuer := NewJwtTokenIssuer(DefaultIssuerName, options, nil) + issuer := NewTokenIssuer("kubesphere", 0) - client, err := options.OAuthOptions.OAuthClient("default") - - if err != nil { - t.Fatal(err) - } - - user := &user.DefaultInfo{ + admin := &user.DefaultInfo{ Name: "admin", - UID: "admin", } - tokenString, err := issuer.IssueTo(user, *client.AccessTokenMaxAge) + tokenString, err := issuer.IssueTo(admin, AccessToken, 0) if err != nil { t.Fatal(err) } - got, err := issuer.Verify(tokenString) + got, _, err := issuer.Verify(tokenString) if err != nil { t.Fatal(err) } - if diff := cmp.Diff(got, user); diff != "" { + if diff := cmp.Diff(got, admin); diff != "" { t.Error("token validate failed") } } diff --git a/pkg/apiserver/authentication/token/user.go b/pkg/apiserver/authentication/token/user.go deleted file mode 100644 index 3801596f3..000000000 --- a/pkg/apiserver/authentication/token/user.go +++ /dev/null @@ -1,25 +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 - -type User interface { - // Name - GetName() string - - // UID - GetUID() string -} diff --git a/pkg/apiserver/filters/authentication.go b/pkg/apiserver/filters/authentication.go index 26e7e0ec8..b3e2b1a1a 100644 --- a/pkg/apiserver/filters/authentication.go +++ b/pkg/apiserver/filters/authentication.go @@ -27,24 +27,31 @@ import ( "k8s.io/apiserver/pkg/endpoints/handlers/responsewriters" "k8s.io/klog" "kubesphere.io/kubesphere/pkg/apiserver/request" + "kubesphere.io/kubesphere/pkg/models/iam/im" "net/http" ) // WithAuthentication installs authentication handler to handler chain. -func WithAuthentication(handler http.Handler, auth authenticator.Request) http.Handler { +func WithAuthentication(handler http.Handler, auth authenticator.Request, loginRecorder im.LoginRecorder) http.Handler { if auth == nil { klog.Warningf("Authentication is disabled") return handler } - s := serializer.NewCodecFactory(runtime.NewScheme()).WithoutConversion() return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { - resp, ok, err := auth.AuthenticateRequest(req) if err != nil || !ok { if err != nil { klog.Errorf("Unable to authenticate the request due to error: %v", err) + if err == im.AuthFailedIncorrectPassword { // log failed login attempts + go func() { + if loginRecorder != nil && resp != nil { + err = loginRecorder.RecordLogin(resp.User.GetName(), err, req) + klog.Errorf("Failed to record unsuccessful login attempt for user %s", resp.User.GetName()) + } + }() + } } ctx := req.Context() @@ -55,7 +62,7 @@ func WithAuthentication(handler http.Handler, auth authenticator.Request) http.H } gv := schema.GroupVersion{Group: requestInfo.APIGroup, Version: requestInfo.APIVersion} - responsewriters.ErrorNegotiated(apierrors.NewUnauthorized(fmt.Sprintf("Unauthorized:%s", err)), s, gv, w, req) + responsewriters.ErrorNegotiated(apierrors.NewUnauthorized(fmt.Sprintf("Unauthorized: %s", err)), s, gv, w, req) return } diff --git a/pkg/client/clientset/versioned/typed/iam/v1alpha2/fake/fake_iam_client.go b/pkg/client/clientset/versioned/typed/iam/v1alpha2/fake/fake_iam_client.go index 31f091267..e65b1b75f 100644 --- a/pkg/client/clientset/versioned/typed/iam/v1alpha2/fake/fake_iam_client.go +++ b/pkg/client/clientset/versioned/typed/iam/v1alpha2/fake/fake_iam_client.go @@ -36,6 +36,10 @@ func (c *FakeIamV1alpha2) GlobalRoleBindings() v1alpha2.GlobalRoleBindingInterfa return &FakeGlobalRoleBindings{c} } +func (c *FakeIamV1alpha2) LoginRecords() v1alpha2.LoginRecordInterface { + return &FakeLoginRecords{c} +} + func (c *FakeIamV1alpha2) RoleBases() v1alpha2.RoleBaseInterface { return &FakeRoleBases{c} } diff --git a/pkg/client/clientset/versioned/typed/iam/v1alpha2/fake/fake_loginrecord.go b/pkg/client/clientset/versioned/typed/iam/v1alpha2/fake/fake_loginrecord.go new file mode 100644 index 000000000..411d82d1f --- /dev/null +++ b/pkg/client/clientset/versioned/typed/iam/v1alpha2/fake/fake_loginrecord.go @@ -0,0 +1,120 @@ +/* +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. +*/ + +// Code generated by client-gen. DO NOT EDIT. + +package fake + +import ( + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + labels "k8s.io/apimachinery/pkg/labels" + schema "k8s.io/apimachinery/pkg/runtime/schema" + types "k8s.io/apimachinery/pkg/types" + watch "k8s.io/apimachinery/pkg/watch" + testing "k8s.io/client-go/testing" + v1alpha2 "kubesphere.io/kubesphere/pkg/apis/iam/v1alpha2" +) + +// FakeLoginRecords implements LoginRecordInterface +type FakeLoginRecords struct { + Fake *FakeIamV1alpha2 +} + +var loginrecordsResource = schema.GroupVersionResource{Group: "iam.kubesphere.io", Version: "v1alpha2", Resource: "loginrecords"} + +var loginrecordsKind = schema.GroupVersionKind{Group: "iam.kubesphere.io", Version: "v1alpha2", Kind: "LoginRecord"} + +// Get takes name of the loginRecord, and returns the corresponding loginRecord object, and an error if there is any. +func (c *FakeLoginRecords) Get(name string, options v1.GetOptions) (result *v1alpha2.LoginRecord, err error) { + obj, err := c.Fake. + Invokes(testing.NewRootGetAction(loginrecordsResource, name), &v1alpha2.LoginRecord{}) + if obj == nil { + return nil, err + } + return obj.(*v1alpha2.LoginRecord), err +} + +// List takes label and field selectors, and returns the list of LoginRecords that match those selectors. +func (c *FakeLoginRecords) List(opts v1.ListOptions) (result *v1alpha2.LoginRecordList, err error) { + obj, err := c.Fake. + Invokes(testing.NewRootListAction(loginrecordsResource, loginrecordsKind, opts), &v1alpha2.LoginRecordList{}) + if obj == nil { + return nil, err + } + + label, _, _ := testing.ExtractFromListOptions(opts) + if label == nil { + label = labels.Everything() + } + list := &v1alpha2.LoginRecordList{ListMeta: obj.(*v1alpha2.LoginRecordList).ListMeta} + for _, item := range obj.(*v1alpha2.LoginRecordList).Items { + if label.Matches(labels.Set(item.Labels)) { + list.Items = append(list.Items, item) + } + } + return list, err +} + +// Watch returns a watch.Interface that watches the requested loginRecords. +func (c *FakeLoginRecords) Watch(opts v1.ListOptions) (watch.Interface, error) { + return c.Fake. + InvokesWatch(testing.NewRootWatchAction(loginrecordsResource, opts)) +} + +// Create takes the representation of a loginRecord and creates it. Returns the server's representation of the loginRecord, and an error, if there is any. +func (c *FakeLoginRecords) Create(loginRecord *v1alpha2.LoginRecord) (result *v1alpha2.LoginRecord, err error) { + obj, err := c.Fake. + Invokes(testing.NewRootCreateAction(loginrecordsResource, loginRecord), &v1alpha2.LoginRecord{}) + if obj == nil { + return nil, err + } + return obj.(*v1alpha2.LoginRecord), err +} + +// Update takes the representation of a loginRecord and updates it. Returns the server's representation of the loginRecord, and an error, if there is any. +func (c *FakeLoginRecords) Update(loginRecord *v1alpha2.LoginRecord) (result *v1alpha2.LoginRecord, err error) { + obj, err := c.Fake. + Invokes(testing.NewRootUpdateAction(loginrecordsResource, loginRecord), &v1alpha2.LoginRecord{}) + if obj == nil { + return nil, err + } + return obj.(*v1alpha2.LoginRecord), err +} + +// Delete takes name of the loginRecord and deletes it. Returns an error if one occurs. +func (c *FakeLoginRecords) Delete(name string, options *v1.DeleteOptions) error { + _, err := c.Fake. + Invokes(testing.NewRootDeleteAction(loginrecordsResource, name), &v1alpha2.LoginRecord{}) + return err +} + +// DeleteCollection deletes a collection of objects. +func (c *FakeLoginRecords) DeleteCollection(options *v1.DeleteOptions, listOptions v1.ListOptions) error { + action := testing.NewRootDeleteCollectionAction(loginrecordsResource, listOptions) + + _, err := c.Fake.Invokes(action, &v1alpha2.LoginRecordList{}) + return err +} + +// Patch applies the patch and returns the patched loginRecord. +func (c *FakeLoginRecords) Patch(name string, pt types.PatchType, data []byte, subresources ...string) (result *v1alpha2.LoginRecord, err error) { + obj, err := c.Fake. + Invokes(testing.NewRootPatchSubresourceAction(loginrecordsResource, name, pt, data, subresources...), &v1alpha2.LoginRecord{}) + if obj == nil { + return nil, err + } + return obj.(*v1alpha2.LoginRecord), err +} diff --git a/pkg/client/clientset/versioned/typed/iam/v1alpha2/generated_expansion.go b/pkg/client/clientset/versioned/typed/iam/v1alpha2/generated_expansion.go index 02e63ea85..fe6634c7a 100644 --- a/pkg/client/clientset/versioned/typed/iam/v1alpha2/generated_expansion.go +++ b/pkg/client/clientset/versioned/typed/iam/v1alpha2/generated_expansion.go @@ -22,6 +22,8 @@ type GlobalRoleExpansion interface{} type GlobalRoleBindingExpansion interface{} +type LoginRecordExpansion interface{} + type RoleBaseExpansion interface{} type UserExpansion interface{} diff --git a/pkg/client/clientset/versioned/typed/iam/v1alpha2/iam_client.go b/pkg/client/clientset/versioned/typed/iam/v1alpha2/iam_client.go index 089ed419d..4378bbf76 100644 --- a/pkg/client/clientset/versioned/typed/iam/v1alpha2/iam_client.go +++ b/pkg/client/clientset/versioned/typed/iam/v1alpha2/iam_client.go @@ -28,6 +28,7 @@ type IamV1alpha2Interface interface { RESTClient() rest.Interface GlobalRolesGetter GlobalRoleBindingsGetter + LoginRecordsGetter RoleBasesGetter UsersGetter WorkspaceRolesGetter @@ -47,6 +48,10 @@ func (c *IamV1alpha2Client) GlobalRoleBindings() GlobalRoleBindingInterface { return newGlobalRoleBindings(c) } +func (c *IamV1alpha2Client) LoginRecords() LoginRecordInterface { + return newLoginRecords(c) +} + func (c *IamV1alpha2Client) RoleBases() RoleBaseInterface { return newRoleBases(c) } diff --git a/pkg/client/clientset/versioned/typed/iam/v1alpha2/loginrecord.go b/pkg/client/clientset/versioned/typed/iam/v1alpha2/loginrecord.go new file mode 100644 index 000000000..f685e803c --- /dev/null +++ b/pkg/client/clientset/versioned/typed/iam/v1alpha2/loginrecord.go @@ -0,0 +1,164 @@ +/* +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. +*/ + +// Code generated by client-gen. DO NOT EDIT. + +package v1alpha2 + +import ( + "time" + + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + types "k8s.io/apimachinery/pkg/types" + watch "k8s.io/apimachinery/pkg/watch" + rest "k8s.io/client-go/rest" + v1alpha2 "kubesphere.io/kubesphere/pkg/apis/iam/v1alpha2" + scheme "kubesphere.io/kubesphere/pkg/client/clientset/versioned/scheme" +) + +// LoginRecordsGetter has a method to return a LoginRecordInterface. +// A group's client should implement this interface. +type LoginRecordsGetter interface { + LoginRecords() LoginRecordInterface +} + +// LoginRecordInterface has methods to work with LoginRecord resources. +type LoginRecordInterface interface { + Create(*v1alpha2.LoginRecord) (*v1alpha2.LoginRecord, error) + Update(*v1alpha2.LoginRecord) (*v1alpha2.LoginRecord, error) + Delete(name string, options *v1.DeleteOptions) error + DeleteCollection(options *v1.DeleteOptions, listOptions v1.ListOptions) error + Get(name string, options v1.GetOptions) (*v1alpha2.LoginRecord, error) + List(opts v1.ListOptions) (*v1alpha2.LoginRecordList, error) + Watch(opts v1.ListOptions) (watch.Interface, error) + Patch(name string, pt types.PatchType, data []byte, subresources ...string) (result *v1alpha2.LoginRecord, err error) + LoginRecordExpansion +} + +// loginRecords implements LoginRecordInterface +type loginRecords struct { + client rest.Interface +} + +// newLoginRecords returns a LoginRecords +func newLoginRecords(c *IamV1alpha2Client) *loginRecords { + return &loginRecords{ + client: c.RESTClient(), + } +} + +// Get takes name of the loginRecord, and returns the corresponding loginRecord object, and an error if there is any. +func (c *loginRecords) Get(name string, options v1.GetOptions) (result *v1alpha2.LoginRecord, err error) { + result = &v1alpha2.LoginRecord{} + err = c.client.Get(). + Resource("loginrecords"). + Name(name). + VersionedParams(&options, scheme.ParameterCodec). + Do(). + Into(result) + return +} + +// List takes label and field selectors, and returns the list of LoginRecords that match those selectors. +func (c *loginRecords) List(opts v1.ListOptions) (result *v1alpha2.LoginRecordList, err error) { + var timeout time.Duration + if opts.TimeoutSeconds != nil { + timeout = time.Duration(*opts.TimeoutSeconds) * time.Second + } + result = &v1alpha2.LoginRecordList{} + err = c.client.Get(). + Resource("loginrecords"). + VersionedParams(&opts, scheme.ParameterCodec). + Timeout(timeout). + Do(). + Into(result) + return +} + +// Watch returns a watch.Interface that watches the requested loginRecords. +func (c *loginRecords) Watch(opts v1.ListOptions) (watch.Interface, error) { + var timeout time.Duration + if opts.TimeoutSeconds != nil { + timeout = time.Duration(*opts.TimeoutSeconds) * time.Second + } + opts.Watch = true + return c.client.Get(). + Resource("loginrecords"). + VersionedParams(&opts, scheme.ParameterCodec). + Timeout(timeout). + Watch() +} + +// Create takes the representation of a loginRecord and creates it. Returns the server's representation of the loginRecord, and an error, if there is any. +func (c *loginRecords) Create(loginRecord *v1alpha2.LoginRecord) (result *v1alpha2.LoginRecord, err error) { + result = &v1alpha2.LoginRecord{} + err = c.client.Post(). + Resource("loginrecords"). + Body(loginRecord). + Do(). + Into(result) + return +} + +// Update takes the representation of a loginRecord and updates it. Returns the server's representation of the loginRecord, and an error, if there is any. +func (c *loginRecords) Update(loginRecord *v1alpha2.LoginRecord) (result *v1alpha2.LoginRecord, err error) { + result = &v1alpha2.LoginRecord{} + err = c.client.Put(). + Resource("loginrecords"). + Name(loginRecord.Name). + Body(loginRecord). + Do(). + Into(result) + return +} + +// Delete takes name of the loginRecord and deletes it. Returns an error if one occurs. +func (c *loginRecords) Delete(name string, options *v1.DeleteOptions) error { + return c.client.Delete(). + Resource("loginrecords"). + Name(name). + Body(options). + Do(). + Error() +} + +// DeleteCollection deletes a collection of objects. +func (c *loginRecords) DeleteCollection(options *v1.DeleteOptions, listOptions v1.ListOptions) error { + var timeout time.Duration + if listOptions.TimeoutSeconds != nil { + timeout = time.Duration(*listOptions.TimeoutSeconds) * time.Second + } + return c.client.Delete(). + Resource("loginrecords"). + VersionedParams(&listOptions, scheme.ParameterCodec). + Timeout(timeout). + Body(options). + Do(). + Error() +} + +// Patch applies the patch and returns the patched loginRecord. +func (c *loginRecords) Patch(name string, pt types.PatchType, data []byte, subresources ...string) (result *v1alpha2.LoginRecord, err error) { + result = &v1alpha2.LoginRecord{} + err = c.client.Patch(pt). + Resource("loginrecords"). + SubResource(subresources...). + Name(name). + Body(data). + Do(). + Into(result) + return +} diff --git a/pkg/client/informers/externalversions/generic.go b/pkg/client/informers/externalversions/generic.go index 129124001..8a13457fb 100644 --- a/pkg/client/informers/externalversions/generic.go +++ b/pkg/client/informers/externalversions/generic.go @@ -93,6 +93,8 @@ func (f *sharedInformerFactory) ForResource(resource schema.GroupVersionResource return &genericInformer{resource: resource.GroupResource(), informer: f.Iam().V1alpha2().GlobalRoles().Informer()}, nil case v1alpha2.SchemeGroupVersion.WithResource("globalrolebindings"): return &genericInformer{resource: resource.GroupResource(), informer: f.Iam().V1alpha2().GlobalRoleBindings().Informer()}, nil + case v1alpha2.SchemeGroupVersion.WithResource("loginrecords"): + return &genericInformer{resource: resource.GroupResource(), informer: f.Iam().V1alpha2().LoginRecords().Informer()}, nil case v1alpha2.SchemeGroupVersion.WithResource("rolebases"): return &genericInformer{resource: resource.GroupResource(), informer: f.Iam().V1alpha2().RoleBases().Informer()}, nil case v1alpha2.SchemeGroupVersion.WithResource("users"): diff --git a/pkg/client/informers/externalversions/iam/v1alpha2/interface.go b/pkg/client/informers/externalversions/iam/v1alpha2/interface.go index 05037e41d..7d91aef4e 100644 --- a/pkg/client/informers/externalversions/iam/v1alpha2/interface.go +++ b/pkg/client/informers/externalversions/iam/v1alpha2/interface.go @@ -28,6 +28,8 @@ type Interface interface { GlobalRoles() GlobalRoleInformer // GlobalRoleBindings returns a GlobalRoleBindingInformer. GlobalRoleBindings() GlobalRoleBindingInformer + // LoginRecords returns a LoginRecordInformer. + LoginRecords() LoginRecordInformer // RoleBases returns a RoleBaseInformer. RoleBases() RoleBaseInformer // Users returns a UserInformer. @@ -59,6 +61,11 @@ func (v *version) GlobalRoleBindings() GlobalRoleBindingInformer { return &globalRoleBindingInformer{factory: v.factory, tweakListOptions: v.tweakListOptions} } +// LoginRecords returns a LoginRecordInformer. +func (v *version) LoginRecords() LoginRecordInformer { + return &loginRecordInformer{factory: v.factory, tweakListOptions: v.tweakListOptions} +} + // RoleBases returns a RoleBaseInformer. func (v *version) RoleBases() RoleBaseInformer { return &roleBaseInformer{factory: v.factory, tweakListOptions: v.tweakListOptions} diff --git a/pkg/client/informers/externalversions/iam/v1alpha2/loginrecord.go b/pkg/client/informers/externalversions/iam/v1alpha2/loginrecord.go new file mode 100644 index 000000000..cd376903e --- /dev/null +++ b/pkg/client/informers/externalversions/iam/v1alpha2/loginrecord.go @@ -0,0 +1,88 @@ +/* +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. +*/ + +// Code generated by informer-gen. DO NOT EDIT. + +package v1alpha2 + +import ( + time "time" + + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + runtime "k8s.io/apimachinery/pkg/runtime" + watch "k8s.io/apimachinery/pkg/watch" + cache "k8s.io/client-go/tools/cache" + iamv1alpha2 "kubesphere.io/kubesphere/pkg/apis/iam/v1alpha2" + versioned "kubesphere.io/kubesphere/pkg/client/clientset/versioned" + internalinterfaces "kubesphere.io/kubesphere/pkg/client/informers/externalversions/internalinterfaces" + v1alpha2 "kubesphere.io/kubesphere/pkg/client/listers/iam/v1alpha2" +) + +// LoginRecordInformer provides access to a shared informer and lister for +// LoginRecords. +type LoginRecordInformer interface { + Informer() cache.SharedIndexInformer + Lister() v1alpha2.LoginRecordLister +} + +type loginRecordInformer struct { + factory internalinterfaces.SharedInformerFactory + tweakListOptions internalinterfaces.TweakListOptionsFunc +} + +// NewLoginRecordInformer constructs a new informer for LoginRecord type. +// Always prefer using an informer factory to get a shared informer instead of getting an independent +// one. This reduces memory footprint and number of connections to the server. +func NewLoginRecordInformer(client versioned.Interface, resyncPeriod time.Duration, indexers cache.Indexers) cache.SharedIndexInformer { + return NewFilteredLoginRecordInformer(client, resyncPeriod, indexers, nil) +} + +// NewFilteredLoginRecordInformer constructs a new informer for LoginRecord type. +// Always prefer using an informer factory to get a shared informer instead of getting an independent +// one. This reduces memory footprint and number of connections to the server. +func NewFilteredLoginRecordInformer(client versioned.Interface, resyncPeriod time.Duration, indexers cache.Indexers, tweakListOptions internalinterfaces.TweakListOptionsFunc) cache.SharedIndexInformer { + return cache.NewSharedIndexInformer( + &cache.ListWatch{ + ListFunc: func(options v1.ListOptions) (runtime.Object, error) { + if tweakListOptions != nil { + tweakListOptions(&options) + } + return client.IamV1alpha2().LoginRecords().List(options) + }, + WatchFunc: func(options v1.ListOptions) (watch.Interface, error) { + if tweakListOptions != nil { + tweakListOptions(&options) + } + return client.IamV1alpha2().LoginRecords().Watch(options) + }, + }, + &iamv1alpha2.LoginRecord{}, + resyncPeriod, + indexers, + ) +} + +func (f *loginRecordInformer) defaultInformer(client versioned.Interface, resyncPeriod time.Duration) cache.SharedIndexInformer { + return NewFilteredLoginRecordInformer(client, resyncPeriod, cache.Indexers{cache.NamespaceIndex: cache.MetaNamespaceIndexFunc}, f.tweakListOptions) +} + +func (f *loginRecordInformer) Informer() cache.SharedIndexInformer { + return f.factory.InformerFor(&iamv1alpha2.LoginRecord{}, f.defaultInformer) +} + +func (f *loginRecordInformer) Lister() v1alpha2.LoginRecordLister { + return v1alpha2.NewLoginRecordLister(f.Informer().GetIndexer()) +} diff --git a/pkg/client/listers/iam/v1alpha2/expansion_generated.go b/pkg/client/listers/iam/v1alpha2/expansion_generated.go index b2b4fc679..27cb25919 100644 --- a/pkg/client/listers/iam/v1alpha2/expansion_generated.go +++ b/pkg/client/listers/iam/v1alpha2/expansion_generated.go @@ -26,6 +26,10 @@ type GlobalRoleListerExpansion interface{} // GlobalRoleBindingLister. type GlobalRoleBindingListerExpansion interface{} +// LoginRecordListerExpansion allows custom methods to be added to +// LoginRecordLister. +type LoginRecordListerExpansion interface{} + // RoleBaseListerExpansion allows custom methods to be added to // RoleBaseLister. type RoleBaseListerExpansion interface{} diff --git a/pkg/client/listers/iam/v1alpha2/loginrecord.go b/pkg/client/listers/iam/v1alpha2/loginrecord.go new file mode 100644 index 000000000..8a11c3e16 --- /dev/null +++ b/pkg/client/listers/iam/v1alpha2/loginrecord.go @@ -0,0 +1,65 @@ +/* +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. +*/ + +// Code generated by lister-gen. DO NOT EDIT. + +package v1alpha2 + +import ( + "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/labels" + "k8s.io/client-go/tools/cache" + v1alpha2 "kubesphere.io/kubesphere/pkg/apis/iam/v1alpha2" +) + +// LoginRecordLister helps list LoginRecords. +type LoginRecordLister interface { + // List lists all LoginRecords in the indexer. + List(selector labels.Selector) (ret []*v1alpha2.LoginRecord, err error) + // Get retrieves the LoginRecord from the index for a given name. + Get(name string) (*v1alpha2.LoginRecord, error) + LoginRecordListerExpansion +} + +// loginRecordLister implements the LoginRecordLister interface. +type loginRecordLister struct { + indexer cache.Indexer +} + +// NewLoginRecordLister returns a new LoginRecordLister. +func NewLoginRecordLister(indexer cache.Indexer) LoginRecordLister { + return &loginRecordLister{indexer: indexer} +} + +// List lists all LoginRecords in the indexer. +func (s *loginRecordLister) List(selector labels.Selector) (ret []*v1alpha2.LoginRecord, err error) { + err = cache.ListAll(s.indexer, selector, func(m interface{}) { + ret = append(ret, m.(*v1alpha2.LoginRecord)) + }) + return ret, err +} + +// Get retrieves the LoginRecord from the index for a given name. +func (s *loginRecordLister) Get(name string) (*v1alpha2.LoginRecord, error) { + obj, exists, err := s.indexer.GetByKey(name) + if err != nil { + return nil, err + } + if !exists { + return nil, errors.NewNotFound(v1alpha2.Resource("loginrecord"), name) + } + return obj.(*v1alpha2.LoginRecord), nil +} diff --git a/pkg/controller/user/loginrecord_controller.go b/pkg/controller/user/loginrecord_controller.go new file mode 100644 index 000000000..9f0ab8a9c --- /dev/null +++ b/pkg/controller/user/loginrecord_controller.go @@ -0,0 +1,230 @@ +/* + * + * 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 user + +import ( + "fmt" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/labels" + utilruntime "k8s.io/apimachinery/pkg/util/runtime" + "k8s.io/apimachinery/pkg/util/wait" + "k8s.io/client-go/kubernetes" + "k8s.io/client-go/kubernetes/scheme" + typedcorev1 "k8s.io/client-go/kubernetes/typed/core/v1" + "k8s.io/client-go/tools/cache" + "k8s.io/client-go/tools/record" + "k8s.io/client-go/util/workqueue" + "k8s.io/klog" + authoptions "kubesphere.io/kubesphere/pkg/apiserver/authentication/options" + kubesphere "kubesphere.io/kubesphere/pkg/client/clientset/versioned" + iamv1alpha2informers "kubesphere.io/kubesphere/pkg/client/informers/externalversions/iam/v1alpha2" + iamv1alpha2listers "kubesphere.io/kubesphere/pkg/client/listers/iam/v1alpha2" + "time" +) + +type LoginRecordController struct { + k8sClient kubernetes.Interface + ksClient kubesphere.Interface + loginRecordInformer iamv1alpha2informers.LoginRecordInformer + loginRecordLister iamv1alpha2listers.LoginRecordLister + loginRecordSynced cache.InformerSynced + // workqueue is a rate limited work queue. This is used to queue work to be + // processed instead of performing it as soon as a change happens. This + // means we can ensure we only process a fixed amount of resources at a + // time, and makes it easy to ensure we are never processing the same item + // simultaneously in two different workers. + workqueue workqueue.RateLimitingInterface + // recorder is an event recorder for recording Event resources to the + // Kubernetes API. + recorder record.EventRecorder + authenticationOptions *authoptions.AuthenticationOptions +} + +func NewLoginRecordController(k8sClient kubernetes.Interface, ksClient kubesphere.Interface, + loginRecordInformer iamv1alpha2informers.LoginRecordInformer, + authenticationOptions *authoptions.AuthenticationOptions) *LoginRecordController { + + klog.V(4).Info("Creating event broadcaster") + eventBroadcaster := record.NewBroadcaster() + eventBroadcaster.StartLogging(klog.Infof) + eventBroadcaster.StartRecordingToSink(&typedcorev1.EventSinkImpl{Interface: k8sClient.CoreV1().Events("")}) + recorder := eventBroadcaster.NewRecorder(scheme.Scheme, corev1.EventSource{Component: "loginrecord-controller"}) + ctl := &LoginRecordController{ + k8sClient: k8sClient, + ksClient: ksClient, + loginRecordInformer: loginRecordInformer, + loginRecordLister: loginRecordInformer.Lister(), + loginRecordSynced: loginRecordInformer.Informer().HasSynced, + authenticationOptions: authenticationOptions, + workqueue: workqueue.NewNamedRateLimitingQueue(workqueue.DefaultControllerRateLimiter(), "WorkspaceTemplate"), + recorder: recorder, + } + klog.Error(authenticationOptions.RecordRetentionPeriod) + klog.Info("Setting up event handlers") + return ctl +} + +func (c *LoginRecordController) Run(threadiness int, stopCh <-chan struct{}) error { + defer utilruntime.HandleCrash() + defer c.workqueue.ShutDown() + + // Start the informer factories to begin populating the informer caches + klog.Info("Starting LoginRecord controller") + + // Wait for the caches to be synced before starting workers + klog.Info("Waiting for informer caches to sync") + + if ok := cache.WaitForCacheSync(stopCh, c.loginRecordSynced); !ok { + return fmt.Errorf("failed to wait for caches to sync") + } + + klog.Info("Starting workers") + // Launch two workers to process Foo resources + for i := 0; i < threadiness; i++ { + go wait.Until(c.runWorker, time.Second, stopCh) + } + + go wait.Until(func() { + if err := c.sync(); err != nil { + klog.Errorf("Error periodically sync user status, %v", err) + } + }, time.Hour, stopCh) + + klog.Info("Started workers") + <-stopCh + klog.Info("Shutting down workers") + return nil +} + +func (c *LoginRecordController) enqueueLoginRecord(obj interface{}) { + var key string + var err error + if key, err = cache.MetaNamespaceKeyFunc(obj); err != nil { + utilruntime.HandleError(err) + return + } + c.workqueue.Add(key) +} + +func (c *LoginRecordController) runWorker() { + for c.processNextWorkItem() { + } +} + +func (c *LoginRecordController) processNextWorkItem() bool { + obj, shutdown := c.workqueue.Get() + + if shutdown { + return false + } + + // We wrap this block in a func so we can defer c.workqueue.Done. + err := func(obj interface{}) error { + // We call Done here so the workqueue knows we have finished + // processing this item. We also must remember to call Forget if we + // do not want this work item being re-queued. For example, we do + // not call Forget if a transient error occurs, instead the item is + // put back on the workqueue and attempted again after a back-off + // period. + defer c.workqueue.Done(obj) + var key string + var ok bool + // We expect strings to come off the workqueue. These are of the + // form namespace/name. We do this as the delayed nature of the + // workqueue means the items in the informer cache may actually be + // more up to date that when the item was initially put onto the + // workqueue. + if key, ok = obj.(string); !ok { + // As the item in the workqueue is actually invalid, we call + // Forget here else we'd go into a loop of attempting to + // process a work item that is invalid. + c.workqueue.Forget(obj) + utilruntime.HandleError(fmt.Errorf("expected string in workqueue but got %#v", obj)) + return nil + } + // Run the reconcile, passing it the namespace/name string of the + // Foo resource to be synced. + if err := c.reconcile(key); err != nil { + // Put the item back on the workqueue to handle any transient errors. + c.workqueue.AddRateLimited(key) + return fmt.Errorf("error syncing '%s': %s, requeuing", key, err.Error()) + } + // Finally, if no error occurs we Forget this item so it does not + // get queued again until another change happens. + c.workqueue.Forget(obj) + klog.Infof("Successfully synced %s:%s", "key", key) + return nil + }(obj) + + if err != nil { + utilruntime.HandleError(err) + return true + } + + return true +} + +// syncHandler compares the actual state with the desired, and attempts to +// converge the two. It then updates the Status block of the Foo resource +// with the current status of the resource. +func (c *LoginRecordController) reconcile(key string) error { + + loginRecord, err := c.loginRecordLister.Get(key) + if err != nil { + // The user may no longer exist, in which case we stop + // processing. + if errors.IsNotFound(err) { + utilruntime.HandleError(fmt.Errorf("login record '%s' in work queue no longer exists", key)) + return nil + } + klog.Error(err) + return err + } + now := time.Now() + if now.Sub(loginRecord.CreationTimestamp.Time) > c.authenticationOptions.RecordRetentionPeriod { + if err = c.ksClient.IamV1alpha2().LoginRecords().Delete(loginRecord.Name, metav1.NewDeleteOptions(0)); err != nil { + klog.Error(err) + return err + } + } + c.recorder.Event(loginRecord, corev1.EventTypeNormal, successSynced, messageResourceSynced) + return nil +} + +func (c *LoginRecordController) Start(stopCh <-chan struct{}) error { + return c.Run(4, stopCh) +} + +func (c *LoginRecordController) sync() error { + records, err := c.loginRecordLister.List(labels.Everything()) + if err != nil { + return err + } + + for _, record := range records { + key, err := cache.MetaNamespaceKeyFunc(record) + if err != nil { + return err + } + c.workqueue.AddRateLimited(key) + } + return nil +} diff --git a/pkg/controller/user/user_controller.go b/pkg/controller/user/user_controller.go index 3812f4e2f..532fc454c 100644 --- a/pkg/controller/user/user_controller.go +++ b/pkg/controller/user/user_controller.go @@ -38,6 +38,7 @@ import ( "k8s.io/client-go/util/workqueue" "k8s.io/klog" iamv1alpha2 "kubesphere.io/kubesphere/pkg/apis/iam/v1alpha2" + authoptions "kubesphere.io/kubesphere/pkg/apiserver/authentication/options" kubesphere "kubesphere.io/kubesphere/pkg/client/clientset/versioned" kubespherescheme "kubesphere.io/kubesphere/pkg/client/clientset/versioned/scheme" iamv1alpha2informers "kubesphere.io/kubesphere/pkg/client/informers/externalversions/iam/v1alpha2" @@ -58,19 +59,25 @@ const ( // is synced successfully messageResourceSynced = "User synced successfully" controllerName = "user-controller" + + // user finalizer + finalizer = "finalizers.kubesphere.io/users" ) type Controller struct { - k8sClient kubernetes.Interface - ksClient kubesphere.Interface - kubeconfig kubeconfig.Interface - userInformer iamv1alpha2informers.UserInformer - userLister iamv1alpha2listers.UserLister - userSynced cache.InformerSynced - cmSynced cache.InformerSynced - fedUserCache cache.Store - fedUserController cache.Controller - ldapClient ldapclient.Interface + k8sClient kubernetes.Interface + ksClient kubesphere.Interface + kubeconfig kubeconfig.Interface + userInformer iamv1alpha2informers.UserInformer + userLister iamv1alpha2listers.UserLister + userSynced cache.InformerSynced + loginRecordInformer iamv1alpha2informers.LoginRecordInformer + loginRecordLister iamv1alpha2listers.LoginRecordLister + loginRecordSynced cache.InformerSynced + cmSynced cache.InformerSynced + fedUserCache cache.Store + fedUserController cache.Controller + ldapClient ldapclient.Interface // workqueue is a rate limited work queue. This is used to queue work to be // processed instead of performing it as soon as a change happens. This // means we can ensure we only process a fixed amount of resources at a @@ -79,15 +86,19 @@ type Controller struct { workqueue workqueue.RateLimitingInterface // recorder is an event recorder for recording Event resources to the // Kubernetes API. - recorder record.EventRecorder - multiClusterEnabled bool + recorder record.EventRecorder + authenticationOptions *authoptions.AuthenticationOptions + multiClusterEnabled bool } -func NewController(k8sClient kubernetes.Interface, ksClient kubesphere.Interface, +func NewUserController(k8sClient kubernetes.Interface, ksClient kubesphere.Interface, config *rest.Config, userInformer iamv1alpha2informers.UserInformer, fedUserCache cache.Store, fedUserController cache.Controller, + loginRecordInformer iamv1alpha2informers.LoginRecordInformer, configMapInformer corev1informers.ConfigMapInformer, - ldapClient ldapclient.Interface, multiClusterEnabled bool) *Controller { + ldapClient ldapclient.Interface, + authenticationOptions *authoptions.AuthenticationOptions, + multiClusterEnabled bool) *Controller { // Create event broadcaster // Add sample-controller types to the default Kubernetes Scheme so Events can be // logged for sample-controller types. @@ -103,19 +114,23 @@ func NewController(k8sClient kubernetes.Interface, ksClient kubesphere.Interface kubeconfigOperator = kubeconfig.NewOperator(k8sClient, configMapInformer, config) } ctl := &Controller{ - k8sClient: k8sClient, - ksClient: ksClient, - kubeconfig: kubeconfigOperator, - userInformer: userInformer, - userLister: userInformer.Lister(), - userSynced: userInformer.Informer().HasSynced, - cmSynced: configMapInformer.Informer().HasSynced, - fedUserCache: fedUserCache, - fedUserController: fedUserController, - ldapClient: ldapClient, - workqueue: workqueue.NewNamedRateLimitingQueue(workqueue.DefaultControllerRateLimiter(), "Users"), - recorder: recorder, - multiClusterEnabled: multiClusterEnabled, + k8sClient: k8sClient, + ksClient: ksClient, + kubeconfig: kubeconfigOperator, + userInformer: userInformer, + userLister: userInformer.Lister(), + userSynced: userInformer.Informer().HasSynced, + loginRecordInformer: loginRecordInformer, + loginRecordLister: loginRecordInformer.Lister(), + loginRecordSynced: loginRecordInformer.Informer().HasSynced, + cmSynced: configMapInformer.Informer().HasSynced, + fedUserCache: fedUserCache, + fedUserController: fedUserController, + ldapClient: ldapClient, + workqueue: workqueue.NewNamedRateLimitingQueue(workqueue.DefaultControllerRateLimiter(), "Users"), + recorder: recorder, + multiClusterEnabled: multiClusterEnabled, + authenticationOptions: authenticationOptions, } klog.Info("Setting up event handlers") userInformer.Informer().AddEventHandler(cache.ResourceEventHandlerFuncs{ @@ -125,6 +140,18 @@ func NewController(k8sClient kubernetes.Interface, ksClient kubesphere.Interface }, DeleteFunc: ctl.enqueueUser, }) + loginRecordInformer.Informer().AddEventHandler(cache.ResourceEventHandlerFuncs{ + AddFunc: func(new interface{}) { + if username := new.(*iamv1alpha2.LoginRecord).Labels[iamv1alpha2.UserReferenceLabel]; username != "" { + ctl.workqueue.Add(username) + } + }, + DeleteFunc: func(obj interface{}) { + if username := obj.(*iamv1alpha2.LoginRecord).Labels[iamv1alpha2.UserReferenceLabel]; username != "" { + ctl.workqueue.Add(username) + } + }, + }) return ctl } @@ -139,7 +166,7 @@ func (c *Controller) Run(threadiness int, stopCh <-chan struct{}) error { klog.Info("Waiting for informer caches to sync") synced := make([]cache.InformerSynced, 0) - synced = append(synced, c.userSynced, c.cmSynced) + synced = append(synced, c.userSynced, c.loginRecordSynced, c.cmSynced) if c.multiClusterEnabled { synced = append(synced, c.fedUserController.HasSynced) } @@ -182,39 +209,19 @@ func (c *Controller) processNextWorkItem() bool { return false } - // We wrap this block in a func so we can defer c.workqueue.Done. err := func(obj interface{}) error { - // We call Done here so the workqueue knows we have finished - // processing this item. We also must remember to call Forget if we - // do not want this work item being re-queued. For example, we do - // not call Forget if a transient error occurs, instead the item is - // put back on the workqueue and attempted again after a back-off - // period. defer c.workqueue.Done(obj) var key string var ok bool - // We expect strings to come off the workqueue. These are of the - // form namespace/name. We do this as the delayed nature of the - // workqueue means the items in the informer cache may actually be - // more up to date that when the item was initially put onto the - // workqueue. if key, ok = obj.(string); !ok { - // As the item in the workqueue is actually invalid, we call - // Forget here else we'd go into a loop of attempting to - // process a work item that is invalid. c.workqueue.Forget(obj) utilruntime.HandleError(fmt.Errorf("expected string in workqueue but got %#v", obj)) return nil } - // Run the reconcile, passing it the namespace/name string of the - // Foo resource to be synced. if err := c.reconcile(key); err != nil { - // Put the item back on the workqueue to handle any transient errors. c.workqueue.AddRateLimited(key) return fmt.Errorf("error syncing '%s': %s, requeuing", key, err.Error()) } - // Finally, if no error occurs we Forget this item so it does not - // get queued again until another change happens. c.workqueue.Forget(obj) klog.Infof("Successfully synced %s:%s", "key", key) return nil @@ -246,9 +253,6 @@ func (c *Controller) reconcile(key string) error { return err } - // name of your custom finalizer - finalizer := "finalizers.kubesphere.io/users" - if user.ObjectMeta.DeletionTimestamp.IsZero() { // The object is not being deleted, so if it does not have our finalizer, // then lets add the finalizer and update the object. @@ -274,12 +278,17 @@ func (c *Controller) reconcile(key string) error { return err } + if err = c.deleteLoginRecords(user); err != nil { + klog.Error(err) + return err + } + // remove our finalizer from the list and update it. user.Finalizers = sliceutil.RemoveString(user.ObjectMeta.Finalizers, func(item string) bool { return item == finalizer }) - if _, err := c.ksClient.IamV1alpha2().Users().Update(user); err != nil { + if user, err = c.ksClient.IamV1alpha2().Users().Update(user); err != nil { return err } } @@ -298,6 +307,11 @@ func (c *Controller) reconcile(key string) error { return err } + if user, err = c.syncUserStatus(user); err != nil { + klog.Error(err) + return err + } + if c.kubeconfig != nil { // ensure user kubeconfig configmap is created if err = c.kubeconfig.CreateKubeConfig(user); err != nil { @@ -319,11 +333,11 @@ func (c *Controller) reconcile(key string) error { } func (c *Controller) Start(stopCh <-chan struct{}) error { - return c.Run(4, stopCh) + return c.Run(5, stopCh) } func (c *Controller) ensurePasswordIsEncrypted(user *iamv1alpha2.User) (*iamv1alpha2.User, error) { - encrypted, _ := strconv.ParseBool(user.Annotations[iamv1alpha2.PasswordEncryptedAnnotation]) + encrypted := user.Annotations[iamv1alpha2.PasswordEncryptedAnnotation] == "true" // password is not encrypted if !encrypted { password, err := encrypt(user.Spec.EncryptedPassword) @@ -337,7 +351,10 @@ func (c *Controller) ensurePasswordIsEncrypted(user *iamv1alpha2.User) (*iamv1al user.Annotations = make(map[string]string, 0) } user.Annotations[iamv1alpha2.PasswordEncryptedAnnotation] = "true" - user.Status.State = iamv1alpha2.UserActive + user.Status = iamv1alpha2.UserStatus{ + State: iamv1alpha2.UserActive, + LastTransitionTime: &metav1.Time{Time: time.Now()}, + } return c.ksClient.IamV1alpha2().Users().Update(user) } @@ -382,15 +399,10 @@ func (c *Controller) multiClusterSync(user *iamv1alpha2.User) error { } if !reflect.DeepEqual(federatedUser.Spec.Template.Spec, user.Spec) || - !reflect.DeepEqual(federatedUser.Spec.Template.Status, user.Status) || - !reflect.DeepEqual(federatedUser.Labels, user.Labels) || - !reflect.DeepEqual(federatedUser.Annotations, user.Annotations) { + !reflect.DeepEqual(federatedUser.Spec.Template.Status, user.Status) { - federatedUser.Labels = user.Labels federatedUser.Spec.Template.Spec = user.Spec federatedUser.Spec.Template.Status = user.Status - federatedUser.Spec.Template.Labels = user.Labels - federatedUser.Spec.Template.Annotations = user.Annotations return c.updateFederatedUser(&federatedUser) } @@ -408,10 +420,6 @@ func (c *Controller) createFederatedUser(user *iamv1alpha2.User) error { }, Spec: iamv1alpha2.FederatedUserSpec{ Template: iamv1alpha2.UserTemplate{ - ObjectMeta: metav1.ObjectMeta{ - Labels: user.Labels, - Annotations: user.Annotations, - }, Spec: user.Spec, Status: user.Status, }, @@ -531,6 +539,81 @@ func (c *Controller) deleteRoleBindings(user *iamv1alpha2.User) error { return nil } +func (c *Controller) deleteLoginRecords(user *iamv1alpha2.User) error { + listOptions := metav1.ListOptions{ + LabelSelector: labels.SelectorFromSet(labels.Set{iamv1alpha2.UserReferenceLabel: user.Name}).String(), + } + deleteOptions := metav1.NewDeleteOptions(0) + + if err := c.ksClient.IamV1alpha2().LoginRecords(). + DeleteCollection(deleteOptions, listOptions); err != nil { + klog.Error(err) + return err + } + return nil +} + +// syncUserStatus will reconcile user state based on user login records +func (c *Controller) syncUserStatus(user *iamv1alpha2.User) (*iamv1alpha2.User, error) { + // disabled user, nothing to do + if user == nil || (user.Status.State == iamv1alpha2.UserDisabled) { + return user, nil + } + + // blocked user, check if need to unblock user + if user.Status.State == iamv1alpha2.UserAuthLimitExceeded { + if user.Status.LastTransitionTime != nil && + user.Status.LastTransitionTime.Add(c.authenticationOptions.AuthenticateRateLimiterDuration).After(time.Now()) { + expected := user.DeepCopy() + // unblock user + if user.Annotations[iamv1alpha2.PasswordEncryptedAnnotation] == "true" { + expected.Status = iamv1alpha2.UserStatus{ + State: iamv1alpha2.UserActive, + LastTransitionTime: &metav1.Time{Time: time.Now()}, + } + } + + if !reflect.DeepEqual(expected.Status, user.Status) { + return c.ksClient.IamV1alpha2().Users().Update(expected) + } + } + } + + // normal user, check user's login records see if we need to block + records, err := c.loginRecordLister.List(labels.SelectorFromSet(labels.Set{iamv1alpha2.UserReferenceLabel: user.Name})) + if err != nil { + klog.Error(err) + return nil, err + } + + // count failed login attempts during last AuthenticateRateLimiterDuration + now := time.Now() + failedLoginAttempts := 0 + for _, loginRecord := range records { + if loginRecord.Spec.Type == iamv1alpha2.LoginFailure && + loginRecord.CreationTimestamp.Add(c.authenticationOptions.AuthenticateRateLimiterDuration).After(now) { + failedLoginAttempts++ + } + } + + // block user if failed login attempts exceeds maximum tries setting + if failedLoginAttempts >= c.authenticationOptions.AuthenticateRateLimiterMaxTries { + expect := user.DeepCopy() + expect.Status = iamv1alpha2.UserStatus{ + State: iamv1alpha2.UserAuthLimitExceeded, + Reason: fmt.Sprintf("Failed login attempts exceed %d in last %s", failedLoginAttempts, c.authenticationOptions.AuthenticateRateLimiterDuration), + LastTransitionTime: &metav1.Time{Time: time.Now()}, + } + + // block user for AuthenticateRateLimiterDuration duration, after that put it back to the queue to unblock + c.workqueue.AddAfter(user.Name, c.authenticationOptions.AuthenticateRateLimiterDuration) + + return c.ksClient.IamV1alpha2().Users().Update(expect) + } + + return user, nil +} + func encrypt(password string) (string, error) { // when user is already mapped to another identity, password is empty by default // unable to log in directly until password reset diff --git a/pkg/controller/user/user_controller_test.go b/pkg/controller/user/user_controller_test.go index 5455beed1..cd947ae07 100644 --- a/pkg/controller/user/user_controller_test.go +++ b/pkg/controller/user/user_controller_test.go @@ -27,6 +27,7 @@ import ( "k8s.io/client-go/tools/cache" "k8s.io/client-go/tools/record" iamv1alpha2 "kubesphere.io/kubesphere/pkg/apis/iam/v1alpha2" + "kubesphere.io/kubesphere/pkg/apiserver/authentication/options" "kubesphere.io/kubesphere/pkg/client/clientset/versioned/fake" ksinformers "kubesphere.io/kubesphere/pkg/client/informers/externalversions" ldapclient "kubesphere.io/kubesphere/pkg/simple/client/ldap" @@ -94,11 +95,12 @@ func (f *fixture) newController() (*Controller, ksinformers.SharedInformerFactor } } - c := NewController(f.k8sclient, f.ksclient, nil, + c := NewUserController(f.k8sclient, f.ksclient, nil, ksinformers.Iam().V1alpha2().Users(), nil, nil, + ksinformers.Iam().V1alpha2().LoginRecords(), k8sinformers.Core().V1().ConfigMaps(), - ldapClient, false) + ldapClient, options.NewAuthenticateOptions(), false) c.userSynced = alwaysReady c.recorder = &record.FakeRecorder{} @@ -187,8 +189,11 @@ func checkAction(expected, actual core.Action, t *testing.T) { e, _ := expected.(core.UpdateActionImpl) expObject := e.GetObject() object := a.GetObject() - - if !reflect.DeepEqual(expObject, object) { + expUser := expObject.(*iamv1alpha2.User) + user := object.(*iamv1alpha2.User) + expUser.Status.LastTransitionTime = nil + user.Status.LastTransitionTime = nil + if !reflect.DeepEqual(expUser, user) { t.Errorf("Action %s %s has wrong object\nDiff:\n %s", a.GetVerb(), a.GetResource().Resource, diff.ObjectGoPrintSideBySide(expObject, object)) } diff --git a/pkg/controller/user/user_webhook.go b/pkg/controller/user/user_webhook.go index c53861096..bb5199064 100644 --- a/pkg/controller/user/user_webhook.go +++ b/pkg/controller/user/user_webhook.go @@ -45,7 +45,8 @@ func (a *EmailValidator) Handle(ctx context.Context, req admission.Request) admi if err != nil { return admission.Errored(http.StatusInternalServerError, err) } - if _, err := mail.ParseAddress(user.Spec.Email); err != nil { + + if _, err := mail.ParseAddress(user.Spec.Email); user.Spec.Email != "" && err != nil { return admission.Errored(http.StatusBadRequest, fmt.Errorf("invalid email address:%s", user.Spec.Email)) } diff --git a/pkg/controller/workspacerole/workspacerole.go b/pkg/controller/workspacerole/workspacerole.go index beb3aa94a..f9fd5260f 100644 --- a/pkg/controller/workspacerole/workspacerole.go +++ b/pkg/controller/workspacerole/workspacerole.go @@ -119,7 +119,7 @@ func (c *Controller) Run(threadiness int, stopCh <-chan struct{}) error { defer c.workqueue.ShutDown() // Start the informer factories to begin populating the informer caches - klog.Info("Starting GlobalRole controller") + klog.Info("Starting WorkspaceRole controller") // Wait for the caches to be synced before starting workers klog.Info("Waiting for informer caches to sync") diff --git a/pkg/controller/workspacetemplate/workspacetemplate_controller.go b/pkg/controller/workspacetemplate/workspacetemplate_controller.go index 66d26a465..9f0c2cef2 100644 --- a/pkg/controller/workspacetemplate/workspacetemplate_controller.go +++ b/pkg/controller/workspacetemplate/workspacetemplate_controller.go @@ -138,7 +138,7 @@ func (c *Controller) Run(threadiness int, stopCh <-chan struct{}) error { defer c.workqueue.ShutDown() // Start the informer factories to begin populating the informer caches - klog.Info("Starting GlobalRole controller") + klog.Info("Starting WorkspaceTemplate controller") // Wait for the caches to be synced before starting workers klog.Info("Waiting for informer caches to sync") diff --git a/pkg/kapis/iam/v1alpha2/handler.go b/pkg/kapis/iam/v1alpha2/handler.go index 99897cd0e..40b9b9db2 100644 --- a/pkg/kapis/iam/v1alpha2/handler.go +++ b/pkg/kapis/iam/v1alpha2/handler.go @@ -5,6 +5,8 @@ import ( "github.com/emicklei/go-restful" rbacv1 "k8s.io/api/rbac/v1" "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/labels" + "k8s.io/apimachinery/pkg/selection" "k8s.io/apiserver/pkg/authentication/user" "k8s.io/klog" "kubesphere.io/kubesphere/pkg/api" @@ -530,8 +532,7 @@ func (h *iamHandler) ModifyPassword(request *restful.Request, response *restful. operator, ok := apirequest.UserFrom(request.Request.Context()) // change password by self if ok && operator.GetName() == username { - _, err := h.im.Authenticate(username, passwordReset.CurrentPassword) - if err != nil { + if err = h.im.PasswordVerify(username, passwordReset.CurrentPassword); err != nil { if err == im.AuthFailedIncorrectPassword { err = errors.NewBadRequest("incorrect old password") klog.Warning(err) @@ -1209,6 +1210,30 @@ func (h *iamHandler) updateGlobalRoleBinding(operator user.Info, user *iamv1alph return nil } +func (h *iamHandler) ListUserLoginRecords(request *restful.Request, response *restful.Response) { + username := request.PathParameter("user") + queryParam := query.ParseQueryParameter(request) + selector, _ := labels.Parse(queryParam.LabelSelector) + if selector == nil { + selector = labels.NewSelector() + } + requirement, err := labels.NewRequirement(iamv1alpha2.UserReferenceLabel, selection.Equals, []string{username}) + if err != nil { + klog.Error(err) + handleError(request, response, err) + return + } + selector.Add(*requirement) + queryParam.LabelSelector = selector.String() + result, err := h.im.ListLoginRecords(queryParam) + if err != nil { + klog.Error(err) + handleError(request, response, err) + return + } + response.WriteEntity(result) +} + func handleError(request *restful.Request, response *restful.Response, err error) { if errors.IsBadRequest(err) { api.HandleBadRequest(response, request, err) diff --git a/pkg/kapis/iam/v1alpha2/register.go b/pkg/kapis/iam/v1alpha2/register.go index 2bde28d7a..c4a59462b 100644 --- a/pkg/kapis/iam/v1alpha2/register.go +++ b/pkg/kapis/iam/v1alpha2/register.go @@ -81,6 +81,11 @@ func AddToContainer(container *restful.Container, im im.IdentityManagementInterf Doc("List all users in global scope."). Returns(http.StatusOK, api.StatusOK, api.ListResult{Items: []interface{}{iamv1alpha2.User{}}}). Metadata(restfulspec.KeyOpenAPITags, []string{constants.AccessManagementTag})) + ws.Route(ws.GET("/users/{user}/loginrecords"). + To(handler.ListUserLoginRecords). + Doc("List user's login records."). + Returns(http.StatusOK, api.StatusOK, api.ListResult{Items: []interface{}{iamv1alpha2.LoginRecord{}}}). + Metadata(restfulspec.KeyOpenAPITags, []string{constants.AccessManagementTag})) // clustermembers ws.Route(ws.POST("/clustermembers"). diff --git a/pkg/kapis/oauth/handler.go b/pkg/kapis/oauth/handler.go index abba5c093..90b63753a 100644 --- a/pkg/kapis/oauth/handler.go +++ b/pkg/kapis/oauth/handler.go @@ -21,7 +21,7 @@ import ( "github.com/emicklei/go-restful" apierrors "k8s.io/apimachinery/pkg/api/errors" v1 "k8s.io/apimachinery/pkg/apis/meta/v1" - authuser "k8s.io/apiserver/pkg/authentication/user" + "k8s.io/apiserver/pkg/authentication/user" "k8s.io/klog" "kubesphere.io/kubesphere/pkg/api" "kubesphere.io/kubesphere/pkg/api/auth" @@ -29,25 +29,26 @@ import ( "kubesphere.io/kubesphere/pkg/apiserver/authentication/identityprovider" "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/apiserver/request" "kubesphere.io/kubesphere/pkg/models/iam/im" "net/http" ) -type oauthHandler struct { - issuer token.Issuer - im im.IdentityManagementInterface - options *authoptions.AuthenticationOptions +type handler struct { + im im.IdentityManagementInterface + options *authoptions.AuthenticationOptions + tokenOperator im.TokenManagementInterface + authenticator im.PasswordAuthenticator + loginRecorder im.LoginRecorder } -func newOAUTHHandler(im im.IdentityManagementInterface, issuer token.Issuer, options *authoptions.AuthenticationOptions) *oauthHandler { - return &oauthHandler{im: im, issuer: issuer, options: options} +func newHandler(im im.IdentityManagementInterface, tokenOperator im.TokenManagementInterface, authenticator im.PasswordAuthenticator, loginRecorder im.LoginRecorder, options *authoptions.AuthenticationOptions) *handler { + return &handler{im: im, tokenOperator: tokenOperator, authenticator: authenticator, loginRecorder: loginRecorder, options: options} } // Implement webhook authentication interface // https://kubernetes.io/docs/reference/access-authn-authz/authentication/#webhook-token-authentication -func (h *oauthHandler) TokenReviewHandler(req *restful.Request, resp *restful.Response) { +func (h *handler) TokenReview(req *restful.Request, resp *restful.Response) { var tokenReview auth.TokenReview err := req.ReadEntity(&tokenReview) @@ -64,7 +65,7 @@ func (h *oauthHandler) TokenReviewHandler(req *restful.Request, resp *restful.Re return } - user, err := h.issuer.Verify(tokenReview.Spec.Token) + authenticated, err := h.tokenOperator.Verify(tokenReview.Spec.Token) if err != nil { klog.Errorln(err) @@ -76,15 +77,15 @@ func (h *oauthHandler) TokenReviewHandler(req *restful.Request, resp *restful.Re Kind: auth.KindTokenReview, Status: &auth.Status{ Authenticated: true, - User: map[string]interface{}{"username": user.GetName(), "uid": user.GetUID()}, + User: map[string]interface{}{"username": authenticated.GetName(), "uid": authenticated.GetUID()}, }, } resp.WriteEntity(success) } -func (h *oauthHandler) AuthorizeHandler(req *restful.Request, resp *restful.Response) { - user, ok := request.UserFrom(req.Request.Context()) +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") @@ -97,7 +98,7 @@ func (h *oauthHandler) AuthorizeHandler(req *restful.Request, resp *restful.Resp } if responseType != "token" { - err := apierrors.NewUnauthorized(fmt.Sprintf("Unauthorized: response type %s is not supported", responseType)) + err := apierrors.NewBadRequest(fmt.Sprintf("Response type %s is not supported", responseType)) resp.WriteError(http.StatusUnauthorized, err) return } @@ -108,7 +109,7 @@ func (h *oauthHandler) AuthorizeHandler(req *restful.Request, resp *restful.Resp return } - token, err := h.issueTo(user.GetName()) + token, err := h.tokenOperator.IssueTo(authenticated) if err != nil { err := apierrors.NewUnauthorized(fmt.Sprintf("Unauthorized: %s", err)) resp.WriteError(http.StatusUnauthorized, err) @@ -132,7 +133,7 @@ func (h *oauthHandler) AuthorizeHandler(req *restful.Request, resp *restful.Resp http.Redirect(resp, req.Request, redirectURL, http.StatusFound) } -func (h *oauthHandler) OAuthCallBackHandler(req *restful.Request, resp *restful.Response) { +func (h *handler) OAuthCallBack(req *restful.Request, resp *restful.Response) { code := req.QueryParameter("code") name := req.PathParameter("callback") @@ -157,7 +158,7 @@ func (h *oauthHandler) OAuthCallBackHandler(req *restful.Request, resp *restful. return } - user, err := oauthIdentityProvider.IdentityExchange(code) + identity, err := oauthIdentityProvider.IdentityExchange(code) if err != nil { err := apierrors.NewUnauthorized(fmt.Sprintf("Unauthorized: %s", err)) @@ -165,16 +166,18 @@ func (h *oauthHandler) OAuthCallBackHandler(req *restful.Request, resp *restful. return } - existed, err := h.im.DescribeUser(user.GetName()) + authenticated, err := h.im.DescribeUser(identity.GetName()) if err != nil { // create user if not exist - if apierrors.IsNotFound(err) && oauth.MappingMethodAuto == providerOptions.MappingMethod { + if (oauth.MappingMethodAuto == providerOptions.MappingMethod || + oauth.MappingMethodMixed == providerOptions.MappingMethod) && + apierrors.IsNotFound(err) { create := &iamv1alpha2.User{ - ObjectMeta: v1.ObjectMeta{Name: user.GetName(), + ObjectMeta: v1.ObjectMeta{Name: identity.GetName(), Annotations: map[string]string{iamv1alpha2.IdentifyProviderLabel: providerOptions.Name}}, - Spec: iamv1alpha2.UserSpec{Email: user.GetEmail()}, + Spec: iamv1alpha2.UserSpec{Email: identity.GetEmail()}, } - if existed, err = h.im.CreateUser(create); err != nil { + if authenticated, err = h.im.CreateUser(create); err != nil { klog.Error(err) api.HandleInternalError(resp, req, err) return @@ -186,9 +189,9 @@ func (h *oauthHandler) OAuthCallBackHandler(req *restful.Request, resp *restful. } } - // oauth.MappingMethodLookup - if existed == nil { - err := apierrors.NewUnauthorized(fmt.Sprintf("user %s cannot bound to this identify provider", user.GetName())) + if oauth.MappingMethodLookup == providerOptions.MappingMethod && + authenticated == nil { + err := apierrors.NewUnauthorized(fmt.Sprintf("user %s cannot bound to this identify provider", identity.GetName())) klog.Error(err) resp.WriteError(http.StatusUnauthorized, err) return @@ -196,64 +199,140 @@ func (h *oauthHandler) OAuthCallBackHandler(req *restful.Request, resp *restful. // oauth.MappingMethodAuto // Fails if a user with that user name is already mapped to another identity. - if providerOptions.MappingMethod == oauth.MappingMethodMixed || existed.Annotations[iamv1alpha2.IdentifyProviderLabel] != providerOptions.Name { - err := apierrors.NewUnauthorized(fmt.Sprintf("user %s is already bound to other identify provider", user.GetName())) + if providerOptions.MappingMethod == oauth.MappingMethodAuto && authenticated.Annotations[iamv1alpha2.IdentifyProviderLabel] != providerOptions.Name { + err := apierrors.NewUnauthorized(fmt.Sprintf("user %s is already bound to other identify provider", identity.GetName())) klog.Error(err) resp.WriteError(http.StatusUnauthorized, err) return } - result, err := h.issueTo(user.GetName()) + result, err := h.tokenOperator.IssueTo(&user.DefaultInfo{ + Name: authenticated.Name, + UID: string(authenticated.UID), + }) + if err != nil { err := apierrors.NewUnauthorized(fmt.Sprintf("Unauthorized: %s", err)) resp.WriteError(http.StatusUnauthorized, err) return } + + if err = h.loginRecorder.RecordLogin(authenticated.Name, nil, req.Request); err != nil { + klog.Error(err) + err := apierrors.NewInternalError(err) + resp.WriteError(http.StatusInternalServerError, err) + return + } + resp.WriteEntity(result) } -func (h *oauthHandler) Login(request *restful.Request, response *restful.Response) { +func (h *handler) Login(request *restful.Request, response *restful.Response) { var loginRequest auth.LoginRequest - err := request.ReadEntity(&loginRequest) if err != nil || loginRequest.Username == "" || loginRequest.Password == "" { response.WriteHeaderAndEntity(http.StatusUnauthorized, fmt.Errorf("empty username or password")) return } + h.passwordGrant(loginRequest.Username, loginRequest.Password, request, response) +} - user, err := h.im.Authenticate(loginRequest.Username, loginRequest.Password) +func (h *handler) Token(req *restful.Request, response *restful.Response) { + grantType, err := req.BodyParameter("grant_type") if err != nil { - err := apierrors.NewUnauthorized(fmt.Sprintf("Unauthorized: %s", err)) - response.WriteError(http.StatusUnauthorized, err) + klog.Error(err) + api.HandleBadRequest(response, req, err) + return + } + switch grantType { + case "password": + username, err := req.BodyParameter("username") + if err != nil { + klog.Error(err) + api.HandleBadRequest(response, req, err) + return + } + password, err := req.BodyParameter("password") + if err != nil { + klog.Error(err) + api.HandleBadRequest(response, req, err) + return + } + h.passwordGrant(username, password, req, response) + break + case "refresh_token": + h.refreshTokenGrant(req, response) + break + default: + err := apierrors.NewBadRequest(fmt.Sprintf("Grant type %s is not supported", grantType)) + response.WriteError(http.StatusBadRequest, err) + } +} + +func (h *handler) passwordGrant(username string, password string, req *restful.Request, response *restful.Response) { + authenticated, err := h.authenticator.Authenticate(username, password) + if err != nil { + if err == im.AuthFailedIncorrectPassword { + if err := h.loginRecorder.RecordLogin(username, err, req.Request); err != nil { + klog.Error(err) + err := apierrors.NewInternalError(err) + response.WriteError(http.StatusInternalServerError, err) + return + } + } + if err == im.AuthFailedIncorrectPassword || + err == im.AuthFailedIdentityMappingNotMatch || + err == im.AuthRateLimitExceeded { + klog.V(4).Info(err) + err := apierrors.NewUnauthorized(fmt.Sprintf("Unauthorized: %s", err)) + response.WriteError(http.StatusUnauthorized, err) + return + } + klog.Error(err) + err := apierrors.NewInternalError(err) + response.WriteError(http.StatusInternalServerError, err) return } - result, err := h.issueTo(user.Name) + result, err := h.tokenOperator.IssueTo(authenticated) if err != nil { - err := apierrors.NewUnauthorized(fmt.Sprintf("Unauthorized: %s", err)) - response.WriteError(http.StatusUnauthorized, err) + klog.Error(err) + err := apierrors.NewInternalError(err) + response.WriteError(http.StatusInternalServerError, err) return } + + if err = h.loginRecorder.RecordLogin(authenticated.GetName(), nil, req.Request); err != nil { + klog.Error(err) + err := apierrors.NewInternalError(err) + response.WriteError(http.StatusInternalServerError, err) + return + } + response.WriteEntity(result) } -func (h *oauthHandler) issueTo(username string) (*oauth.Token, error) { - expiresIn := h.options.OAuthOptions.AccessTokenMaxAge - - accessToken, err := h.issuer.IssueTo(&authuser.DefaultInfo{ - Name: username, - }, expiresIn) - +func (h *handler) refreshTokenGrant(req *restful.Request, response *restful.Response) { + refreshToken, err := req.BodyParameter("refresh_token") if err != nil { klog.Error(err) - return nil, err + api.HandleBadRequest(response, req, err) + return } - result := &oauth.Token{ - AccessToken: accessToken, - TokenType: "Bearer", - ExpiresIn: int(expiresIn.Seconds()), + authenticated, err := h.tokenOperator.Verify(refreshToken) + if err != nil { + err := apierrors.NewUnauthorized(fmt.Sprintf("Unauthorized: %s", err)) + response.WriteError(http.StatusUnauthorized, err) + return } - return result, nil + result, err := h.tokenOperator.IssueTo(authenticated) + if err != nil { + err := apierrors.NewUnauthorized(fmt.Sprintf("Unauthorized: %s", err)) + response.WriteError(http.StatusUnauthorized, err) + return + } + + response.WriteEntity(result) } diff --git a/pkg/kapis/oauth/register.go b/pkg/kapis/oauth/register.go index 907611c27..fd8f57fcb 100644 --- a/pkg/kapis/oauth/register.go +++ b/pkg/kapis/oauth/register.go @@ -23,7 +23,6 @@ import ( "kubesphere.io/kubesphere/pkg/api/auth" "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/constants" "kubesphere.io/kubesphere/pkg/models/iam/im" "net/http" @@ -35,13 +34,13 @@ import ( // Most authentication integrations place an authenticating proxy in front of this endpoint, or configure ks-apiserver // to validate credentials against a backing identity provider. // Requests to /oauth/authorize can come from user-agents that cannot display interactive login pages, such as the CLI. -func AddToContainer(c *restful.Container, im im.IdentityManagementInterface, issuer token.Issuer, options *authoptions.AuthenticationOptions) error { +func AddToContainer(c *restful.Container, im im.IdentityManagementInterface, tokenOperator im.TokenManagementInterface, authenticator im.PasswordAuthenticator, loginRecorder im.LoginRecorder, options *authoptions.AuthenticationOptions) error { ws := &restful.WebService{} ws.Path("/oauth"). Consumes(restful.MIME_JSON). Produces(restful.MIME_JSON) - handler := newOAUTHHandler(im, issuer, options) + handler := newHandler(im, tokenOperator, authenticator, loginRecorder, options) // Implement webhook authentication interface // https://kubernetes.io/docs/reference/access-authn-authz/authentication/#webhook-token-authentication @@ -49,7 +48,7 @@ func AddToContainer(c *restful.Container, im im.IdentityManagementInterface, iss 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(auth.TokenReview{}). - To(handler.TokenReviewHandler). + To(handler.TokenReview). Returns(http.StatusOK, api.StatusOK, auth.TokenReview{}). Metadata(restfulspec.KeyOpenAPITags, []string{constants.IdentityManagementTag})) @@ -65,8 +64,15 @@ func AddToContainer(c *restful.Container, im im.IdentityManagementInterface, iss 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.AuthorizeHandler)) - //ws.Route(ws.POST("/token")) + To(handler.Authorize)) + // Resource Owner Password Credentials Grant + // https://tools.ietf.org/html/rfc6749#section-4.3 + ws.Route(ws.POST("/token"). + Consumes("application/x-www-form-urlencoded"). + 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."). + To(handler.Token)) // Authorization callback URL, where the end of the URL contains the identity provider name. // The provider name is also used to build the callback URL. @@ -85,7 +91,7 @@ func AddToContainer(c *restful.Container, im im.IdentityManagementInterface, iss "otherwise, REQUIRED. The scope of the access token as described by [RFC6479] Section 3.3.").Required(false)). Param(ws.QueryParameter("state", "if the \"state\" parameter was present in the client authorization request."+ "The exact value received from the client.").Required(true)). - To(handler.OAuthCallBackHandler). + To(handler.OAuthCallBack). Returns(http.StatusOK, api.StatusOK, oauth.Token{})) c.Add(ws) diff --git a/pkg/models/iam/im/authenticator.go b/pkg/models/iam/im/authenticator.go new file mode 100644 index 000000000..25cfe6b3f --- /dev/null +++ b/pkg/models/iam/im/authenticator.go @@ -0,0 +1,173 @@ +/* + + 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 im + +import ( + "fmt" + "github.com/go-ldap/ldap" + "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/labels" + authuser "k8s.io/apiserver/pkg/authentication/user" + "k8s.io/klog" + iamv1alpha2 "kubesphere.io/kubesphere/pkg/apis/iam/v1alpha2" + "kubesphere.io/kubesphere/pkg/apiserver/authentication/identityprovider" + "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" + "kubesphere.io/kubesphere/pkg/constants" + "net/mail" +) + +var ( + AuthRateLimitExceeded = fmt.Errorf("auth rate limit exceeded") + AuthFailedIncorrectPassword = fmt.Errorf("incorrect password") + AuthFailedAccountIsNotActive = fmt.Errorf("account is not active") + AuthFailedIdentityMappingNotMatch = fmt.Errorf("identity mapping not match") +) + +type PasswordAuthenticator interface { + Authenticate(username, password string) (authuser.Info, error) +} + +type passwordAuthenticator struct { + ksClient kubesphere.Interface + userLister iamv1alpha2listers.UserLister + options *authoptions.AuthenticationOptions +} + +func NewPasswordAuthenticator(ksClient kubesphere.Interface, + userLister iamv1alpha2listers.UserLister, + options *authoptions.AuthenticationOptions) PasswordAuthenticator { + return &passwordAuthenticator{ + ksClient: ksClient, + userLister: userLister, + options: options} +} + +func (im *passwordAuthenticator) Authenticate(username, password string) (authuser.Info, error) { + + user, err := im.searchUser(username) + if err != nil { + // internal error + if !errors.IsNotFound(err) { + klog.Error(err) + return nil, err + } + } + + providerOptions, ldapProvider := im.getLdapProvider() + + // no identity provider + // even auth failed, still return username to record login attempt + if user == nil && (providerOptions == nil || providerOptions.MappingMethod != oauth.MappingMethodAuto) { + return &authuser.DefaultInfo{Name: user.Name}, AuthFailedIncorrectPassword + } + + if user != nil && user.Status.State != iamv1alpha2.UserActive { + if user.Status.State == iamv1alpha2.UserAuthLimitExceeded { + klog.Errorf("%s, username: %s", AuthRateLimitExceeded, username) + return nil, AuthRateLimitExceeded + } else { + klog.Errorf("%s, username: %s", AuthFailedAccountIsNotActive, username) + return nil, AuthFailedAccountIsNotActive + } + } + + // able to login using the locally principal admin account and password in case of a disruption of LDAP services. + if ldapProvider != nil && username != constants.AdminUserName { + if providerOptions.MappingMethod == oauth.MappingMethodLookup && + (user == nil || user.Labels[iamv1alpha2.IdentifyProviderLabel] != providerOptions.Name) { + klog.Error(AuthFailedIdentityMappingNotMatch) + return nil, AuthFailedIdentityMappingNotMatch + } + if providerOptions.MappingMethod == oauth.MappingMethodAuto && + user != nil && user.Labels[iamv1alpha2.IdentifyProviderLabel] != providerOptions.Name { + klog.Error(AuthFailedIdentityMappingNotMatch) + return nil, AuthFailedIdentityMappingNotMatch + } + + authenticated, err := ldapProvider.Authenticate(username, password) + if err != nil { + klog.Error(err) + if ldap.IsErrorWithCode(err, ldap.LDAPResultInvalidCredentials) || ldap.IsErrorWithCode(err, ldap.LDAPResultNoSuchObject) { + return nil, AuthFailedIncorrectPassword + } else { + return nil, err + } + } + + if authenticated != nil && user == nil { + authenticated.Labels = map[string]string{iamv1alpha2.IdentifyProviderLabel: providerOptions.Name} + if authenticated, err = im.ksClient.IamV1alpha2().Users().Create(authenticated); err != nil { + klog.Error(err) + return nil, err + } + } + + if authenticated != nil { + return &authuser.DefaultInfo{ + Name: authenticated.Name, + UID: string(authenticated.UID), + }, nil + } + } + + if checkPasswordHash(password, user.Spec.EncryptedPassword) { + return &authuser.DefaultInfo{ + Name: user.Name, + UID: string(user.UID), + }, nil + } + + return nil, AuthFailedIncorrectPassword +} + +func (im *passwordAuthenticator) searchUser(username string) (*iamv1alpha2.User, error) { + + if _, err := mail.ParseAddress(username); err != nil { + return im.userLister.Get(username) + } else { + users, err := im.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 + } + } + } + + return nil, errors.NewNotFound(iamv1alpha2.Resource("user"), username) +} + +func (im *passwordAuthenticator) getLdapProvider() (*oauth.IdentityProviderOptions, identityprovider.LdapProvider) { + for _, options := range im.options.OAuthOptions.IdentityProviders { + if options.Type == identityprovider.LdapIdentityProvider { + if provider, err := identityprovider.NewLdapProvider(options.Provider); err != nil { + klog.Error(err) + } else { + return &options, provider + } + } + } + return nil, nil +} diff --git a/pkg/models/iam/im/im.go b/pkg/models/iam/im/im.go index 1f6727de7..c6ead9899 100644 --- a/pkg/models/iam/im/im.go +++ b/pkg/models/iam/im/im.go @@ -16,7 +16,6 @@ limitations under the License. package im import ( - "github.com/pkg/errors" "golang.org/x/crypto/bcrypt" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/klog" @@ -24,11 +23,9 @@ import ( iamv1alpha2 "kubesphere.io/kubesphere/pkg/apis/iam/v1alpha2" authoptions "kubesphere.io/kubesphere/pkg/apiserver/authentication/options" "kubesphere.io/kubesphere/pkg/apiserver/query" - kubesphereclient "kubesphere.io/kubesphere/pkg/client/clientset/versioned" + kubesphere "kubesphere.io/kubesphere/pkg/client/clientset/versioned" "kubesphere.io/kubesphere/pkg/informers" resourcev1alpha3 "kubesphere.io/kubesphere/pkg/models/resources/v1alpha3/resource" - "net/mail" - "time" ) type IdentityManagementInterface interface { @@ -37,34 +34,24 @@ type IdentityManagementInterface interface { DeleteUser(username string) error UpdateUser(user *iamv1alpha2.User) (*iamv1alpha2.User, error) DescribeUser(username string) (*iamv1alpha2.User, error) - Authenticate(username, password string) (*iamv1alpha2.User, error) ModifyPassword(username string, password string) error + ListLoginRecords(query *query.Query) (*api.ListResult, error) + PasswordVerify(username string, password string) error } -var ( - AuthRateLimitExceeded = errors.New("user auth rate limit exceeded") - AuthFailedIncorrectPassword = errors.New("incorrect password") - UserAlreadyExists = errors.New("user already exists") - UserNotExists = errors.New("user not exists") -) - -func NewOperator(ksClient kubesphereclient.Interface, factory informers.InformerFactory, options *authoptions.AuthenticationOptions) IdentityManagementInterface { +func NewOperator(ksClient kubesphere.Interface, factory informers.InformerFactory, options *authoptions.AuthenticationOptions) IdentityManagementInterface { im := &defaultIMOperator{ ksClient: ksClient, resourceGetter: resourcev1alpha3.NewResourceGetter(factory), - } - if options != nil { - im.authenticateRateLimiterDuration = options.AuthenticateRateLimiterDuration - im.authenticateRateLimiterMaxTries = options.AuthenticateRateLimiterMaxTries + options: options, } return im } type defaultIMOperator struct { - ksClient kubesphereclient.Interface - resourceGetter *resourcev1alpha3.ResourceGetter - authenticateRateLimiterMaxTries int - authenticateRateLimiterDuration time.Duration + ksClient kubesphere.Interface + resourceGetter *resourcev1alpha3.ResourceGetter + options *authoptions.AuthenticationOptions } func (im *defaultIMOperator) UpdateUser(user *iamv1alpha2.User) (*iamv1alpha2.User, error) { @@ -110,50 +97,6 @@ func (im *defaultIMOperator) ModifyPassword(username string, password string) er return nil } -func (im *defaultIMOperator) Authenticate(username, password string) (*iamv1alpha2.User, error) { - - var user *iamv1alpha2.User - - if _, err := mail.ParseAddress(username); err != nil { - obj, err := im.resourceGetter.Get(iamv1alpha2.ResourcesPluralUser, "", username) - if err != nil { - klog.Error(err) - return nil, err - } - user = obj.(*iamv1alpha2.User) - } else { - objs, err := im.resourceGetter.List(iamv1alpha2.ResourcesPluralUser, "", &query.Query{ - Pagination: query.NoPagination, - Filters: map[query.Field]query.Value{iamv1alpha2.FieldEmail: query.Value(username)}, - }) - if err != nil { - klog.Error(err) - return nil, err - } - if len(objs.Items) != 1 { - if len(objs.Items) == 0 { - klog.Warningf("username or email: %s not exist", username) - } else { - klog.Errorf("duplicate user entries: %+v", objs) - } - return nil, AuthFailedIncorrectPassword - } - user = objs.Items[0].(*iamv1alpha2.User) - } - - if im.authRateLimitExceeded(user) { - im.authFailRecord(user, AuthRateLimitExceeded) - return nil, AuthRateLimitExceeded - } - - if checkPasswordHash(password, user.Spec.EncryptedPassword) { - return user, nil - } - - im.authFailRecord(user, AuthFailedIncorrectPassword) - return nil, AuthFailedIncorrectPassword -} - func (im *defaultIMOperator) ListUsers(query *query.Query) (result *api.ListResult, err error) { result, err = im.resourceGetter.List(iamv1alpha2.ResourcesPluralUser, "", query) if err != nil { @@ -171,6 +114,19 @@ func (im *defaultIMOperator) ListUsers(query *query.Query) (result *api.ListResu return result, nil } +func (im *defaultIMOperator) PasswordVerify(username string, password string) error { + obj, err := im.resourceGetter.Get(iamv1alpha2.ResourcesPluralUser, "", username) + if err != nil { + klog.Error(err) + return err + } + user := obj.(*iamv1alpha2.User) + if checkPasswordHash(password, user.Spec.EncryptedPassword) { + return nil + } + return AuthFailedIncorrectPassword +} + func checkPasswordHash(password, hash string) bool { err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) return err == nil @@ -200,27 +156,13 @@ func (im *defaultIMOperator) CreateUser(user *iamv1alpha2.User) (*iamv1alpha2.Us return user, nil } -func (im *defaultIMOperator) authRateLimitEnabled() bool { - if im.authenticateRateLimiterMaxTries <= 0 || im.authenticateRateLimiterDuration == 0 { - return false +func (im *defaultIMOperator) ListLoginRecords(query *query.Query) (*api.ListResult, error) { + result, err := im.resourceGetter.List(iamv1alpha2.ResourcesPluralLoginRecord, "", query) + if err != nil { + klog.Error(err) + return nil, err } - return true -} - -func (im *defaultIMOperator) authRateLimitExceeded(user *iamv1alpha2.User) bool { - if !im.authRateLimitEnabled() { - return false - } - // TODO record login history using CRD - return false -} - -func (im *defaultIMOperator) authFailRecord(user *iamv1alpha2.User, err error) { - if !im.authRateLimitEnabled() { - return - } - - // TODO record login history using CRD + return result, nil } func ensurePasswordNotOutput(user *iamv1alpha2.User) *iamv1alpha2.User { diff --git a/pkg/models/iam/im/login_recoder.go b/pkg/models/iam/im/login_recoder.go new file mode 100644 index 000000000..255e5b35d --- /dev/null +++ b/pkg/models/iam/im/login_recoder.go @@ -0,0 +1,72 @@ +/* + * + * 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 im + +import ( + "fmt" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/klog" + iamv1alpha2 "kubesphere.io/kubesphere/pkg/apis/iam/v1alpha2" + kubesphere "kubesphere.io/kubesphere/pkg/client/clientset/versioned" + "kubesphere.io/kubesphere/pkg/utils/net" + "net/http" +) + +type LoginRecorder interface { + RecordLogin(username string, authErr error, req *http.Request) error +} + +type loginRecorder struct { + ksClient kubesphere.Interface +} + +func NewLoginRecorder(ksClient kubesphere.Interface) LoginRecorder { + return &loginRecorder{ + ksClient: ksClient, + } +} + +func (l *loginRecorder) RecordLogin(username string, authErr error, req *http.Request) error { + + loginEntry := &iamv1alpha2.LoginRecord{ + ObjectMeta: metav1.ObjectMeta{ + GenerateName: fmt.Sprintf("%s-", username), + Labels: map[string]string{ + iamv1alpha2.UserReferenceLabel: username, + }, + }, + Spec: iamv1alpha2.LoginRecordSpec{ + SourceIP: net.GetRequestIP(req), + Type: iamv1alpha2.LoginSuccess, + Reason: iamv1alpha2.AuthenticatedSuccessfully, + }, + } + + if authErr != nil { + loginEntry.Spec.Type = iamv1alpha2.LoginFailure + loginEntry.Spec.Reason = authErr.Error() + } + + _, err := l.ksClient.IamV1alpha2().LoginRecords().Create(loginEntry) + if err != nil { + klog.Error(err) + return err + } + return nil +} diff --git a/pkg/models/iam/im/token.go b/pkg/models/iam/im/token.go new file mode 100644 index 000000000..15ce97d34 --- /dev/null +++ b/pkg/models/iam/im/token.go @@ -0,0 +1,146 @@ +/* + + 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 im + +import ( + "fmt" + "k8s.io/apiserver/pkg/authentication/user" + "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" + "time" +) + +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) +} + +type tokenOperator struct { + issuer token.Issuer + options *authoptions.AuthenticationOptions + cache cache.Interface +} + +func NewTokenOperator(cache cache.Interface, options *authoptions.AuthenticationOptions) TokenManagementInterface { + operator := &tokenOperator{ + issuer: token.NewTokenIssuer(options.JwtSecret, options.MaximumClockSkew), + options: options, + cache: cache, + } + return operator +} + +func (t tokenOperator) Verify(tokenStr string) (user.Info, error) { + authenticated, tokenType, err := t.issuer.Verify(tokenStr) + if err != nil { + klog.Error(err) + return nil, err + } + if t.options.OAuthOptions.AccessTokenMaxAge == 0 || + tokenType == token.StaticToken { + return authenticated, nil + } + if err := t.tokenCacheValidate(authenticated.GetName(), tokenStr); err != nil { + klog.Error(err) + return nil, err + } + return authenticated, 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) + if err != nil { + klog.Error(err) + return nil, 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 { + klog.Error(err) + return nil, 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 +} + +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) + return err + } else if len(keys) > 0 { + if err := t.cache.Del(keys...); err != nil { + klog.Error(err) + return err + } + } + return nil +} + +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 + } else if !exist { + return fmt.Errorf("token not found in cache") + } + return nil +} + +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) + return err + } + return nil +} diff --git a/pkg/models/resources/v1alpha3/loginrecord/loginrecords.go b/pkg/models/resources/v1alpha3/loginrecord/loginrecords.go new file mode 100644 index 000000000..a42328fdc --- /dev/null +++ b/pkg/models/resources/v1alpha3/loginrecord/loginrecords.go @@ -0,0 +1,87 @@ +/* +Copyright 2019 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 loginrecord + +import ( + "k8s.io/apimachinery/pkg/runtime" + "kubesphere.io/kubesphere/pkg/api" + iamv1alpha2 "kubesphere.io/kubesphere/pkg/apis/iam/v1alpha2" + "kubesphere.io/kubesphere/pkg/apiserver/query" + ksinformers "kubesphere.io/kubesphere/pkg/client/informers/externalversions" + "kubesphere.io/kubesphere/pkg/models/resources/v1alpha3" +) + +const recordType = "type" + +type loginrecordsGetter struct { + ksInformer ksinformers.SharedInformerFactory +} + +func New(ksinformer ksinformers.SharedInformerFactory) v1alpha3.Interface { + return &loginrecordsGetter{ksInformer: ksinformer} +} + +func (d *loginrecordsGetter) Get(_, name string) (runtime.Object, error) { + return d.ksInformer.Iam().V1alpha2().Users().Lister().Get(name) +} + +func (d *loginrecordsGetter) List(_ string, query *query.Query) (*api.ListResult, error) { + + records, err := d.ksInformer.Iam().V1alpha2().LoginRecords().Lister().List(query.Selector()) + + if err != nil { + return nil, err + } + + var result []runtime.Object + for _, user := range records { + result = append(result, user) + } + + return v1alpha3.DefaultList(result, query, d.compare, d.filter), nil +} + +func (d *loginrecordsGetter) compare(left runtime.Object, right runtime.Object, field query.Field) bool { + + leftUser, ok := left.(*iamv1alpha2.LoginRecord) + if !ok { + return false + } + + rightUser, ok := right.(*iamv1alpha2.LoginRecord) + if !ok { + return false + } + + return v1alpha3.DefaultObjectMetaCompare(leftUser.ObjectMeta, rightUser.ObjectMeta, field) +} + +func (d *loginrecordsGetter) filter(object runtime.Object, filter query.Filter) bool { + record, ok := object.(*iamv1alpha2.LoginRecord) + + if !ok { + return false + } + + switch filter.Field { + case recordType: + return string(record.Spec.Type) == string(filter.Value) + default: + return v1alpha3.DefaultObjectMetaFilter(record.ObjectMeta, filter) + } + +} diff --git a/pkg/models/resources/v1alpha3/loginrecord/loginrecords_test.go b/pkg/models/resources/v1alpha3/loginrecord/loginrecords_test.go new file mode 100644 index 000000000..f0cd4893c --- /dev/null +++ b/pkg/models/resources/v1alpha3/loginrecord/loginrecords_test.go @@ -0,0 +1,110 @@ +/* +Copyright 2019 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 loginrecord + +import ( + "github.com/google/go-cmp/cmp" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "kubesphere.io/kubesphere/pkg/api" + iamv1alpha2 "kubesphere.io/kubesphere/pkg/apis/iam/v1alpha2" + "kubesphere.io/kubesphere/pkg/apiserver/query" + "kubesphere.io/kubesphere/pkg/client/clientset/versioned/fake" + informers "kubesphere.io/kubesphere/pkg/client/informers/externalversions" + "kubesphere.io/kubesphere/pkg/models/resources/v1alpha3" + "testing" +) + +func TestListLoginRecords(t *testing.T) { + tests := []struct { + description string + namespace string + query *query.Query + expected *api.ListResult + expectedErr error + }{ + { + "test name filter", + "bar", + &query.Query{ + Pagination: &query.Pagination{ + Limit: 1, + Offset: 0, + }, + SortBy: query.FieldName, + Ascending: false, + Filters: map[query.Field]query.Value{query.FieldName: query.Value("foo2")}, + }, + &api.ListResult{ + Items: []interface{}{ + foo2, + }, + TotalItems: 1, + }, + nil, + }, + } + + getter := prepare() + + for _, test := range tests { + t.Run(test.description, func(t *testing.T) { + + got, err := getter.List(test.namespace, test.query) + + if test.expectedErr != nil && err != test.expectedErr { + t.Errorf("expected error, got nothing") + } else if err != nil { + t.Fatal(err) + } + + if diff := cmp.Diff(got, test.expected); diff != "" { + t.Errorf("%T differ (-got, +want): %s", test.expected, diff) + } + }) + } +} + +var ( + foo1 = &iamv1alpha2.LoginRecord{ + ObjectMeta: metav1.ObjectMeta{ + Name: "foo1", + }, + } + + foo2 = &iamv1alpha2.LoginRecord{ + ObjectMeta: metav1.ObjectMeta{ + Name: "foo2", + }, + } + bar1 = &iamv1alpha2.LoginRecord{ + ObjectMeta: metav1.ObjectMeta{ + Name: "bar1", + }, + } + + records = []interface{}{foo1, foo2, bar1} +) + +func prepare() v1alpha3.Interface { + client := fake.NewSimpleClientset() + informer := informers.NewSharedInformerFactory(client, 0) + + for _, record := range records { + informer.Iam().V1alpha2().LoginRecords().Informer().GetIndexer().Add(record) + } + return New(informer) +} diff --git a/pkg/models/resources/v1alpha3/resource/resource.go b/pkg/models/resources/v1alpha3/resource/resource.go index f2b1ce6da..d043abf16 100644 --- a/pkg/models/resources/v1alpha3/resource/resource.go +++ b/pkg/models/resources/v1alpha3/resource/resource.go @@ -44,6 +44,7 @@ import ( "kubesphere.io/kubesphere/pkg/models/resources/v1alpha3/globalrole" "kubesphere.io/kubesphere/pkg/models/resources/v1alpha3/globalrolebinding" "kubesphere.io/kubesphere/pkg/models/resources/v1alpha3/ingress" + "kubesphere.io/kubesphere/pkg/models/resources/v1alpha3/loginrecord" "kubesphere.io/kubesphere/pkg/models/resources/v1alpha3/namespace" "kubesphere.io/kubesphere/pkg/models/resources/v1alpha3/networkpolicy" "kubesphere.io/kubesphere/pkg/models/resources/v1alpha3/node" @@ -89,6 +90,7 @@ func NewResourceGetter(factory informers.InformerFactory) *ResourceGetter { getters[iamv1alpha2.SchemeGroupVersion.WithResource(iamv1alpha2.ResourcesPluralUser)] = user.New(factory.KubeSphereSharedInformerFactory(), factory.KubernetesSharedInformerFactory()) getters[iamv1alpha2.SchemeGroupVersion.WithResource(iamv1alpha2.ResourcesPluralGlobalRoleBinding)] = globalrolebinding.New(factory.KubeSphereSharedInformerFactory()) getters[iamv1alpha2.SchemeGroupVersion.WithResource(iamv1alpha2.ResourcesPluralWorkspaceRoleBinding)] = workspacerolebinding.New(factory.KubeSphereSharedInformerFactory()) + getters[iamv1alpha2.SchemeGroupVersion.WithResource(iamv1alpha2.ResourcesPluralLoginRecord)] = loginrecord.New(factory.KubeSphereSharedInformerFactory()) getters[rbacv1.SchemeGroupVersion.WithResource(iamv1alpha2.ResourcesPluralRole)] = role.New(factory.KubernetesSharedInformerFactory()) getters[rbacv1.SchemeGroupVersion.WithResource(iamv1alpha2.ResourcesPluralClusterRole)] = clusterrole.New(factory.KubernetesSharedInformerFactory()) getters[rbacv1.SchemeGroupVersion.WithResource(iamv1alpha2.ResourcesPluralRoleBinding)] = rolebinding.New(factory.KubernetesSharedInformerFactory()) @@ -97,7 +99,6 @@ func NewResourceGetter(factory informers.InformerFactory) *ResourceGetter { getters[snapshotv1beta1.SchemeGroupVersion.WithResource("volumesnapshots")] = volumesnapshot.New(factory.SnapshotSharedInformerFactory()) getters[schema.GroupVersionResource{Group: "cluster.kubesphere.io", Version: "v1alpha1", Resource: "clusters"}] = cluster.New(factory.KubeSphereSharedInformerFactory()) getters[schema.GroupVersionResource{Group: "apiextensions.k8s.io", Version: "v1", Resource: "customresourcedefinitions"}] = customresourcedefinition.New(factory.ApiExtensionSharedInformerFactory()) - getters[typesv1beta1.SchemeGroupVersion.WithResource(typesv1beta1.ResourcesPluralFedNamespace)] = federatednamespace.New(factory.KubeSphereSharedInformerFactory()) return &ResourceGetter{ diff --git a/pkg/simple/client/cache/simple_cache.go b/pkg/simple/client/cache/simple_cache.go index bb196c2cf..ab60198ff 100644 --- a/pkg/simple/client/cache/simple_cache.go +++ b/pkg/simple/client/cache/simple_cache.go @@ -76,13 +76,8 @@ func (s *simpleCache) Set(key string, value string, duration time.Duration) erro func (s *simpleCache) Del(keys ...string) error { for _, key := range keys { - if _, ok := s.store[key]; ok { - delete(s.store, key) - } else { - return ErrNoSuchKey - } + delete(s.store, key) } - return nil } @@ -99,7 +94,7 @@ func (s *simpleCache) Get(key string) (string, error) { func (s *simpleCache) Exists(keys ...string) (bool, error) { for _, key := range keys { if _, ok := s.store[key]; !ok { - return false, ErrNoSuchKey + return false, nil } } diff --git a/pkg/utils/net/net.go b/pkg/utils/net/net.go index abe7e5ffd..fb9681a61 100644 --- a/pkg/utils/net/net.go +++ b/pkg/utils/net/net.go @@ -16,7 +16,32 @@ limitations under the License. package net +import ( + "net" + "net/http" + "strings" +) + // 0 is considered as a non valid port func IsValidPort(port int) bool { return port > 0 && port < 65535 } + +func GetRequestIP(req *http.Request) string { + address := strings.Trim(req.Header.Get("X-Real-Ip"), " ") + if address != "" { + return address + } + + address = strings.Trim(req.Header.Get("X-Forwarded-For"), " ") + if address != "" { + return address + } + + address, _, err := net.SplitHostPort(req.RemoteAddr) + if err != nil { + return req.RemoteAddr + } + + return address +}