feature: user expired_at attribute support (#23727)

Co-authored-by: Qiu Jian <qiujian@yunionyun.com>
This commit is contained in:
Jian Qiu
2025-11-14 10:53:39 +08:00
committed by GitHub
co-authored by Qiu Jian
parent c33dc74019
commit 65e0f4414e
8 changed files with 229 additions and 15 deletions
+15
View File
@@ -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 {
+10
View File
@@ -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 {
+1
View File
@@ -20,6 +20,7 @@ type SUserExtended struct {
Id string
Name string
Enabled bool
ExpiredAt time.Time
DefaultProjectId string
CreatedAt time.Time
LastActiveAt time.Time
+1
View File
@@ -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")
+105
View File
@@ -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),APIapi
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
}
+84 -14
View File
@@ -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),APIapi
// 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)
+1
View File
@@ -62,6 +62,7 @@ func InitHandlers(app *appsrv.Application) {
models.NonlocalUserManager,
models.PasswordManager,
models.UsergroupManager,
models.UserLoginManager,
models.FederatedUserManager,
models.FederationProtocolManager,
+12 -1
View File
@@ -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 {