diff --git a/cmd/climc/shell/identity/users.go b/cmd/climc/shell/identity/users.go index 9a0cc97193..3925242267 100644 --- a/cmd/climc/shell/identity/users.go +++ b/cmd/climc/shell/identity/users.go @@ -191,6 +191,8 @@ func init() { IdpEntityId string `help:"Entity id of identity provider to link with"` Lang string `help:"user default language"` + + Expire string `help:"user expired at"` } R(&UserCreateOptions{}, "user-create", "Create a user", func(s *mcclient.ClientSession, args *UserCreateOptions) error { params := jsonutils.NewDict() @@ -245,6 +247,10 @@ func init() { params.Add(jsonutils.NewString(args.Lang), "lang") } + if len(args.Expire) > 0 { + params.Add(jsonutils.NewString(args.Expire), "expired_at") + } + /*if len(args.DefaultProject) > 0 { projId, err := modules.Projects.GetId(s, args.DefaultProject, nil) if err != nil { @@ -288,6 +294,10 @@ func init() { SkipPasswordComplexityCheck bool `help:"skip_password_complexity_check"` Lang string `help:"update user language"` + + Expire string `help:"user expired at"` + + ClearExpire bool `help:"clear user expired at"` } R(&UserUpdateOptions{}, "user-update", "Update a user", func(s *mcclient.ClientSession, args *UserUpdateOptions) error { query := jsonutils.NewDict() @@ -347,6 +357,11 @@ func init() { if len(args.Lang) > 0 { params.Add(jsonutils.NewString(args.Lang), "lang") } + if args.ClearExpire { + params.Add(jsonutils.JSONTrue, "clear_expire") + } else if len(args.Expire) > 0 { + params.Add(jsonutils.NewString(args.Expire), "expired_at") + } // if len(args.DefaultProject) > 0 { // projId, err := modules.Projects.GetId(s, args.DefaultProject, nil) // if err != nil { diff --git a/pkg/apis/identity/input.go b/pkg/apis/identity/input.go index c69d7fef27..4f75a46ce8 100644 --- a/pkg/apis/identity/input.go +++ b/pkg/apis/identity/input.go @@ -15,6 +15,8 @@ package identity import ( + "time" + "yunion.io/x/jsonutils" "yunion.io/x/pkg/errors" "yunion.io/x/pkg/util/rbacscope" @@ -501,6 +503,12 @@ type UserUpdateInput struct { SkipPasswordComplexityCheck *bool `json:"skip_password_complexity_check"` Lang string `json:"lang"` + + // 过期时间 + ExpiredAt *time.Time `json:"expired_at"` + + // 清除过期时间 + ClearExpire *bool `json:"clear_expire"` } type UserCreateInput struct { @@ -527,6 +535,8 @@ type UserCreateInput struct { IdpEntityId string `json:"idp_entity_id"` Lang string `json:"lang"` + + ExpiredAt *time.Time `json:"expired_at"` } type ProjectCreateInput struct { diff --git a/pkg/apis/identity/usrext.go b/pkg/apis/identity/usrext.go index 7e8307c5c1..a86a241a76 100644 --- a/pkg/apis/identity/usrext.go +++ b/pkg/apis/identity/usrext.go @@ -20,6 +20,7 @@ type SUserExtended struct { Id string Name string Enabled bool + ExpiredAt time.Time DefaultProjectId string CreatedAt time.Time LastActiveAt time.Time diff --git a/pkg/httperrors/consts.go b/pkg/httperrors/consts.go index 6546e307c8..ee364e7b43 100644 --- a/pkg/httperrors/consts.go +++ b/pkg/httperrors/consts.go @@ -91,6 +91,7 @@ const ( ErrUserNotFound = errors.Error("UserNotFound") ErrUserLocked = errors.Error("UserLocked") ErrUserDisabled = errors.Error("UserDisabled") + ErrUserExpired = errors.Error("UserExpired") ErrWrongPassword = errors.Error("WrongPassword") ErrIncorrectUsernameOrPassword = errors.Error("IncorrectUsernameOrPassword") diff --git a/pkg/keystone/models/user_login.go b/pkg/keystone/models/user_login.go new file mode 100644 index 0000000000..abcec073fd --- /dev/null +++ b/pkg/keystone/models/user_login.go @@ -0,0 +1,105 @@ +// Copyright 2019 Yunion +// +// 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 models + +import ( + "context" + "database/sql" + "time" + + "yunion.io/x/pkg/errors" + "yunion.io/x/pkg/utils" + + "yunion.io/x/onecloud/pkg/cloudcommon/db" + "yunion.io/x/onecloud/pkg/mcclient" +) + +// +onecloud:swagger-gen-ignore +type SUserLoginManager struct { + db.SModelBaseManager +} + +var UserLoginManager *SUserLoginManager + +func init() { + UserLoginManager = &SUserLoginManager{ + SModelBaseManager: db.NewModelBaseManager( + SUserLogin{}, + "user_login", + "user_login", + "user_logins", + ), + } + UserLoginManager.SetVirtualObject(UserLoginManager) +} + +// +onecloud:swagger-gen-ignore +type SUserLogin struct { + db.SModelBase + + UserId string `width:"64" charset:"ascii" nullable:"false" primary:"true"` + + // 上次登录时间 + LastActiveAt time.Time `nullable:"true" list:"domain"` + // 上次用户登录IP + LastLoginIp string `nullable:"true" list:"domain"` + // 上次用户登录方式,可能值有:web(web控制台),cli(命令行climc),API(api) + LastLoginSource string `nullable:"true" list:"domain"` +} + +func (manager *SUserLoginManager) fetchUserLogin(userId string) (*SUserLogin, error) { + userLogin := &SUserLogin{} + userLogin.SetModelManager(manager, userLogin) + err := manager.Query().Equals("user_id", userId).First(userLogin) + if err != nil { + return nil, errors.Wrap(err, "Query") + } + return userLogin, nil +} + +func (manager *SUserLoginManager) traceLoginEvent(ctx context.Context, userId string, authCtx mcclient.SAuthContext) error { + userLogin, err := manager.fetchUserLogin(userId) + if err != nil { + if errors.Cause(err) == sql.ErrNoRows { + // do insert + userLogin := &SUserLogin{ + UserId: userId, + LastActiveAt: time.Now().UTC(), + LastLoginIp: authCtx.Ip, + LastLoginSource: authCtx.Source, + } + err := manager.TableSpec().Insert(ctx, userLogin) + if err != nil { + return errors.Wrap(err, "Insert") + } + return nil + } else { + return errors.Wrap(err, "fetchUserLogin") + } + } + // only save web console login record + if userLogin.LastActiveAt.IsZero() || utils.IsInArray(authCtx.Source, []string{mcclient.AuthSourceWeb}) { + _, err := db.Update(userLogin, func() error { + userLogin.LastActiveAt = time.Now().UTC() + userLogin.LastLoginIp = authCtx.Ip + userLogin.LastLoginSource = authCtx.Source + return nil + }) + if err != nil { + return errors.Wrap(err, "Update") + } + } + return nil +} diff --git a/pkg/keystone/models/users.go b/pkg/keystone/models/users.go index 974c79267a..6e578c6e1a 100644 --- a/pkg/keystone/models/users.go +++ b/pkg/keystone/models/users.go @@ -17,6 +17,7 @@ package models import ( "context" "database/sql" + "fmt" "time" "yunion.io/x/jsonutils" @@ -24,7 +25,6 @@ import ( "yunion.io/x/pkg/errors" "yunion.io/x/pkg/tristate" "yunion.io/x/pkg/util/rbacscope" - "yunion.io/x/pkg/utils" "yunion.io/x/sqlchemy" "yunion.io/x/onecloud/pkg/apis" @@ -94,10 +94,16 @@ type SUser struct { Displayname string `with:"128" charset:"utf8" nullable:"true" list:"domain" update:"domain" create:"domain_optional"` // 上次登录时间 + // deprecated + // swagger:ignore LastActiveAt time.Time `nullable:"true" list:"domain"` // 上次用户登录IP + // deprecated + // swagger:ignore LastLoginIp string `nullable:"true" list:"domain"` // 上次用户登录方式,可能值有:web(web控制台),cli(命令行climc),API(api) + // deprecated + // swagger:ignore LastLoginSource string `nullable:"true" list:"domain"` // 是否为系统账号,系统账号不会检查密码复杂度,默认不在列表显示 @@ -113,6 +119,9 @@ type SUser struct { // 用户的默认语言设置,默认是zh_CN Lang string `width:"8" charset:"ascii" nullable:"false" list:"domain" update:"domain" create:"domain_optional"` + + // 过期时间 + ExpiredAt time.Time `nullable:"true" list:"domain" update:"domain" create:"domain_optional"` } func (manager *SUserManager) GetContextManagers() [][]db.IModelManager { @@ -178,6 +187,49 @@ func (manager *SUserManager) InitializeData() error { } } + { + err := manager.migrateUserLogin() + if err != nil { + return errors.Wrap(err, "migrateUserLogin") + } + } + + return nil +} + +func (manager *SUserManager) migrateUserLogin() error { + userLoginQ := UserLoginManager.Query("user_id").SubQuery() + q := manager.Query().NotIn("id", userLoginQ) + + rows, err := q.Rows() + if err != nil { + return errors.Wrap(err, "query.Rows") + } + defer rows.Close() + + type SUserLoginExt struct { + SUserLogin + Id string + } + for rows.Next() { + userLogin := SUserLoginExt{} + err := q.Row2Struct(rows, &userLogin) + if err != nil { + return errors.Wrap(err, "row2struct") + } + userLogin.UserId = userLogin.Id + userLogin.SUserLogin.SetModelManager(UserLoginManager, &userLogin.SUserLogin) + err = UserLoginManager.TableSpec().Insert(context.Background(), &userLogin.SUserLogin) + if err != nil { + return errors.Wrap(err, "insert") + } + } + + sql := fmt.Sprintf("UPDATE `%s` SET last_active_at = NULL, last_login_ip = NULL, last_login_source = NULL WHERE last_active_at IS NOT NULL", manager.TableSpec().Name()) + _, err = manager.TableSpec().GetTableSpec().Database().Exec(sql) + if err != nil { + return errors.Wrap(err, "exec batch update") + } return nil } @@ -300,6 +352,7 @@ func (manager *SUserManager) FetchUserExtended(userId, userName, domainId, domai users.Field("last_active_at"), users.Field("domain_id"), users.Field("is_system_account"), + users.Field("expired_at"), localUsers.Field("id", "local_id"), localUsers.Field("name", "local_name"), localUsers.Field("failed_auth_count", "local_failed_auth_count"), @@ -632,6 +685,10 @@ func (user *SUser) ValidateUpdateData(ctx context.Context, userCred mcclient.Tok boolTrue := true input.EnableMfa = &boolTrue } + if input.ClearExpire != nil && *input.ClearExpire { + tmZero := time.Time{} + input.ExpiredAt = &tmZero + } var err error input.EnabledIdentityBaseUpdateInput, err = user.SEnabledIdentityBaseResource.ValidateUpdateData(ctx, userCred, query, input.EnabledIdentityBaseUpdateInput) if err != nil { @@ -799,7 +856,7 @@ func (manager *SUserManager) FetchCustomizeColumns( if !ok { projectMap[p.UserId] = []api.SFetchDomainObjectWithMetadata{} } - p.SFetchDomainObjectWithMetadata.Metadata, _ = metaMap[p.Id] + p.SFetchDomainObjectWithMetadata.Metadata = metaMap[p.Id] projectMap[p.UserId] = append(projectMap[p.UserId], p.SFetchDomainObjectWithMetadata) } @@ -836,11 +893,27 @@ func (manager *SUserManager) FetchCustomizeColumns( groupMap[ug.UserId] = append(groupMap[ug.UserId], ug.SUserGroup) } + userLogins := make(map[string]*SUserLogin) + userLoginRows := []SUserLogin{} + err = UserLoginManager.Query().In("user_id", userIds).All(&userLoginRows) + if err != nil { + log.Errorf("query user logins error: %v", err) + return rows + } + for _, userLogin := range userLoginRows { + userLogins[userLogin.UserId] = &userLogin + } + for i := range rows { - rows[i].ExternalResourceInfo, _ = scopeResources[userIds[i]] - rows[i].UserUsage, _ = usage[userIds[i]] - rows[i].Projects, _ = projectMap[userIds[i]] - rows[i].Groups, _ = groupMap[userIds[i]] + rows[i].ExternalResourceInfo = scopeResources[userIds[i]] + rows[i].UserUsage = usage[userIds[i]] + rows[i].Projects = projectMap[userIds[i]] + rows[i].Groups = groupMap[userIds[i]] + if userLogin, ok := userLogins[userIds[i]]; ok { + rows[i].LastActiveAt = userLogin.LastActiveAt + rows[i].LastLoginIp = userLogin.LastLoginIp + rows[i].LastLoginSource = userLogin.LastLoginSource + } } return rows @@ -1202,14 +1275,11 @@ func (manager *SUserManager) traceLoginEvent(ctx context.Context, token mcclient return } - // only save web console login record - if usr.LastActiveAt.IsZero() || utils.IsInArray(authCtx.Source, []string{mcclient.AuthSourceWeb}) { - db.Update(usr, func() error { - usr.LastActiveAt = time.Now().UTC() - usr.LastLoginIp = authCtx.Ip - usr.LastLoginSource = authCtx.Source - return nil - }) + { + err = UserLoginManager.traceLoginEvent(ctx, usr.Id, authCtx) + if err != nil { + log.Errorf("UserLoginManager.traceLoginEvent fail %s", err) + } } db.OpsLog.LogEvent(usr, "auth", &s, token) diff --git a/pkg/keystone/service/handlers.go b/pkg/keystone/service/handlers.go index 6c5a48982d..37ab549bda 100644 --- a/pkg/keystone/service/handlers.go +++ b/pkg/keystone/service/handlers.go @@ -62,6 +62,7 @@ func InitHandlers(app *appsrv.Application) { models.NonlocalUserManager, models.PasswordManager, models.UsergroupManager, + models.UserLoginManager, models.FederatedUserManager, models.FederationProtocolManager, diff --git a/pkg/keystone/tokens/auth.go b/pkg/keystone/tokens/auth.go index a73106cb10..14bdd3deab 100644 --- a/pkg/keystone/tokens/auth.go +++ b/pkg/keystone/tokens/auth.go @@ -151,7 +151,11 @@ func authUserByIdentityInternal(ctx context.Context, ident *mcclient.SAuthentica return nil, httperrors.ErrUserLocked } // user disabled - return nil, httperrors.ErrUserLocked + return nil, httperrors.ErrUserDisabled + } + // user is enabled, check expired time + if !usrExt.ExpiredAt.IsZero() && usrExt.ExpiredAt.Before(time.Now()) { + return nil, httperrors.ErrUserExpired } // user exists, query user's idp idps, err := models.IdentityProviderManager.FetchIdentityProvidersByUserId(usrExt.Id, api.PASSWORD_PROTECTED_IDPS) @@ -497,6 +501,10 @@ func AuthenticateV3(ctx context.Context, input mcclient.SAuthenticationInputV3) if !user.Enabled { return nil, ErrUserDisabled } + // user is expired + if !user.ExpiredAt.IsZero() && user.ExpiredAt.Before(time.Now()) { + return nil, httperrors.ErrUserExpired + } if !user.DomainEnabled { return nil, ErrDomainDisabled @@ -508,6 +516,9 @@ func AuthenticateV3(ctx context.Context, input mcclient.SAuthenticationInputV3) token.AuditIds = user.AuditIds now := time.Now().UTC() token.ExpiresAt = now.Add(time.Duration(options.Options.TokenExpirationSeconds) * time.Second) + if !user.ExpiredAt.IsZero() && user.ExpiredAt.Before(token.ExpiresAt) { + token.ExpiresAt = user.ExpiredAt + } token.Context = input.Auth.Context if len(input.Auth.Scope.Project.Id) == 0 && len(input.Auth.Scope.Project.Name) == 0 && len(input.Auth.Scope.Domain.Id) == 0 && len(input.Auth.Scope.Domain.Name) == 0 {