From 55249e07a3fd960449b328456ed39fa030bdfd04 Mon Sep 17 00:00:00 2001 From: MaysWind Date: Sun, 14 Jul 2024 17:44:37 +0800 Subject: [PATCH] support setting token min refresh interval --- cmd/user_data.go | 1 + conf/ezbookkeeping.ini | 4 ++ pkg/api/tokens.go | 39 ++++++++++++++++-- pkg/errs/setting.go | 15 +++---- pkg/models/token_record.go | 24 +++++------ pkg/services/tokens.go | 40 +++++++++++++++---- pkg/settings/setting.go | 8 ++++ src/stores/token.js | 10 ++--- .../settings/tabs/UserSecuritySettingTab.vue | 4 +- src/views/mobile/users/SessionListPage.vue | 4 +- 10 files changed, 111 insertions(+), 38 deletions(-) diff --git a/cmd/user_data.go b/cmd/user_data.go index bb318f0a..9f4b928a 100644 --- a/cmd/user_data.go +++ b/cmd/user_data.go @@ -680,5 +680,6 @@ func printUserInfo(user *models.User) { func printTokenInfo(token *models.TokenRecord) { fmt.Printf("[CreatedAt] %s (%d)\n", utils.FormatUnixTimeToLongDateTimeInServerTimezone(token.CreatedUnixTime), token.CreatedUnixTime) fmt.Printf("[ExpiredAt] %s (%d)\n", utils.FormatUnixTimeToLongDateTimeInServerTimezone(token.ExpiredUnixTime), token.ExpiredUnixTime) + fmt.Printf("[LastSeen] %s (%d)\n", utils.FormatUnixTimeToLongDateTimeInServerTimezone(token.LastSeenUnixTime), token.LastSeenUnixTime) fmt.Printf("[UserAgent] %s\n", token.UserAgent) } diff --git a/conf/ezbookkeeping.ini b/conf/ezbookkeeping.ini index 4ccf77e9..84dd84da 100644 --- a/conf/ezbookkeeping.ini +++ b/conf/ezbookkeeping.ini @@ -120,6 +120,10 @@ enable_two_factor = true # Token expired seconds (60 - 4294967295), default is 2592000 (30 days) token_expired_time = 2592000 +# Token minimum refresh interval (0 - 4294967295), the value should be less than token expired time +# Set to 0 to refresh the token every time when refreshing the front end, default is 86400 (1 day) +token_min_refresh_interval = 86400 + # Temporary token expired seconds (60 - 4294967295), default is 300 (5 minutes) temporary_token_expired_time = 300 diff --git a/pkg/api/tokens.go b/pkg/api/tokens.go index cc850f31..888d2c4f 100644 --- a/pkg/api/tokens.go +++ b/pkg/api/tokens.go @@ -2,12 +2,14 @@ package api import ( "sort" + "time" "github.com/mayswind/ezbookkeeping/pkg/core" "github.com/mayswind/ezbookkeeping/pkg/errs" "github.com/mayswind/ezbookkeeping/pkg/log" "github.com/mayswind/ezbookkeeping/pkg/models" "github.com/mayswind/ezbookkeeping/pkg/services" + "github.com/mayswind/ezbookkeeping/pkg/settings" "github.com/mayswind/ezbookkeeping/pkg/utils" ) @@ -44,8 +46,7 @@ func (a *TokensApi) TokenListHandler(c *core.Context) (any, *errs.Error) { TokenId: a.tokens.GenerateTokenId(token), TokenType: token.TokenType, UserAgent: token.UserAgent, - CreatedAt: token.CreatedUnixTime, - ExpiredAt: token.ExpiredUnixTime, + LastSeen: token.LastSeenUnixTime, } if token.Uid == claims.Uid && utils.Int64ToString(token.UserTokenId) == claims.UserTokenId && token.CreatedUnixTime == claims.IssuedAt { @@ -176,6 +177,39 @@ func (a *TokensApi) TokenRefreshHandler(c *core.Context) (any, *errs.Error) { return nil, errs.ErrUserNotFound } + now := time.Now().Unix() + oldTokenClaims := c.GetTokenClaims() + + if now-oldTokenClaims.IssuedAt < int64(settings.Container.Current.TokenMinRefreshInterval) { + log.InfofWithRequestId(c, "[token.TokenRefreshHandler] token of user \"uid:%d\" does not need to be refreshed", uid) + + userTokenId, err := utils.StringToInt64(oldTokenClaims.UserTokenId) + + if err != nil { + log.WarnfWithRequestId(c, "[tokens.TokenRefreshHandler] parse user token id failed, because %s", err.Error()) + } else { + tokenRecord := &models.TokenRecord{ + Uid: oldTokenClaims.Uid, + UserTokenId: userTokenId, + CreatedUnixTime: oldTokenClaims.IssuedAt, + } + + tokenId := a.tokens.GenerateTokenId(tokenRecord) + + err = a.tokens.UpdateTokenLastSeen(c, tokenRecord) + + if err != nil { + log.WarnfWithRequestId(c, "[token.TokenRefreshHandler] failed to update last seen of token \"id:%s\" for user \"uid:%d\", because %s", tokenId, uid, err.Error()) + } + } + + refreshResp := &models.TokenRefreshResponse{ + User: user.ToUserBasicInfo(), + } + + return refreshResp, nil + } + token, claims, err := a.tokens.CreateToken(c, user) if err != nil { @@ -183,7 +217,6 @@ func (a *TokensApi) TokenRefreshHandler(c *core.Context) (any, *errs.Error) { return nil, errs.Or(err, errs.ErrTokenGenerating) } - oldTokenClaims := c.GetTokenClaims() oldUserTokenId, _ := utils.StringToInt64(oldTokenClaims.UserTokenId) oldTokenRecord := &models.TokenRecord{ Uid: uid, diff --git a/pkg/errs/setting.go b/pkg/errs/setting.go index 458eba8e..fea362cd 100644 --- a/pkg/errs/setting.go +++ b/pkg/errs/setting.go @@ -13,11 +13,12 @@ var ( ErrInvalidDuplicateCheckerType = NewSystemError(SystemSubcategorySetting, 6, http.StatusInternalServerError, "invalid duplicate checker type") ErrInvalidInMemoryDuplicateCheckerCleanupInterval = NewSystemError(SystemSubcategorySetting, 7, http.StatusInternalServerError, "invalid in-memory duplicate checker cleanup interval") ErrInvalidTokenExpiredTime = NewSystemError(SystemSubcategorySetting, 8, http.StatusInternalServerError, "invalid token expired time") - ErrInvalidTemporaryTokenExpiredTime = NewSystemError(SystemSubcategorySetting, 9, http.StatusInternalServerError, "invalid temporary token expired time") - ErrInvalidEmailVerifyTokenExpiredTime = NewSystemError(SystemSubcategorySetting, 10, http.StatusInternalServerError, "invalid email verify token expired time") - ErrInvalidAvatarProvider = NewSystemError(SystemSubcategorySetting, 11, http.StatusInternalServerError, "invalid avatar provider") - ErrInvalidMapProvider = NewSystemError(SystemSubcategorySetting, 12, http.StatusInternalServerError, "invalid map provider") - ErrInvalidAmapSecurityVerificationMethod = NewSystemError(SystemSubcategorySetting, 13, http.StatusInternalServerError, "invalid amap security verification method") - ErrInvalidPasswordResetTokenExpiredTime = NewSystemError(SystemSubcategorySetting, 14, http.StatusInternalServerError, "invalid password reset token expired time") - ErrInvalidExchangeRatesDataSource = NewSystemError(SystemSubcategorySetting, 15, http.StatusInternalServerError, "invalid exchange rates data source") + ErrInvalidTokenMinRefreshInterval = NewSystemError(SystemSubcategorySetting, 9, http.StatusInternalServerError, "invalid token min refresh interval") + ErrInvalidTemporaryTokenExpiredTime = NewSystemError(SystemSubcategorySetting, 10, http.StatusInternalServerError, "invalid temporary token expired time") + ErrInvalidEmailVerifyTokenExpiredTime = NewSystemError(SystemSubcategorySetting, 11, http.StatusInternalServerError, "invalid email verify token expired time") + ErrInvalidAvatarProvider = NewSystemError(SystemSubcategorySetting, 12, http.StatusInternalServerError, "invalid avatar provider") + ErrInvalidMapProvider = NewSystemError(SystemSubcategorySetting, 13, http.StatusInternalServerError, "invalid map provider") + ErrInvalidAmapSecurityVerificationMethod = NewSystemError(SystemSubcategorySetting, 14, http.StatusInternalServerError, "invalid amap security verification method") + ErrInvalidPasswordResetTokenExpiredTime = NewSystemError(SystemSubcategorySetting, 15, http.StatusInternalServerError, "invalid password reset token expired time") + ErrInvalidExchangeRatesDataSource = NewSystemError(SystemSubcategorySetting, 16, http.StatusInternalServerError, "invalid exchange rates data source") ) diff --git a/pkg/models/token_record.go b/pkg/models/token_record.go index 79112bd9..68d9230b 100644 --- a/pkg/models/token_record.go +++ b/pkg/models/token_record.go @@ -7,13 +7,14 @@ const TokenMaxUserAgentLength = 255 // TokenRecord represents token data stored in database type TokenRecord struct { - Uid int64 `xorm:"PK INDEX(IDX_token_record_uid_type_expired_time)"` - UserTokenId int64 `xorm:"PK"` - TokenType core.TokenType `xorm:"INDEX(IDX_token_record_uid_type_expired_time) TINYINT NOT NULL"` - Secret string `xorm:"VARCHAR(10) NOT NULL"` - UserAgent string `xorm:"VARCHAR(255)"` - CreatedUnixTime int64 `xorm:"PK"` - ExpiredUnixTime int64 `xorm:"INDEX(IDX_token_record_uid_type_expired_time)"` + Uid int64 `xorm:"PK INDEX(IDX_token_record_uid_type_expired_time)"` + UserTokenId int64 `xorm:"PK"` + TokenType core.TokenType `xorm:"INDEX(IDX_token_record_uid_type_expired_time) TINYINT NOT NULL"` + Secret string `xorm:"VARCHAR(10) NOT NULL"` + UserAgent string `xorm:"VARCHAR(255)"` + CreatedUnixTime int64 `xorm:"PK"` + ExpiredUnixTime int64 `xorm:"INDEX(IDX_token_record_uid_type_expired_time)"` + LastSeenUnixTime int64 } // TokenRevokeRequest represents all parameters of token revoking request @@ -23,8 +24,8 @@ type TokenRevokeRequest struct { // TokenRefreshResponse represents all parameters of token refreshing request type TokenRefreshResponse struct { - NewToken string `json:"newToken"` - OldTokenId string `json:"oldTokenId"` + NewToken string `json:"newToken,omitempty"` + OldTokenId string `json:"oldTokenId,omitempty"` User *UserBasicInfo `json:"user"` } @@ -33,8 +34,7 @@ type TokenInfoResponse struct { TokenId string `json:"tokenId"` TokenType core.TokenType `json:"tokenType"` UserAgent string `json:"userAgent"` - CreatedAt int64 `json:"createdAt"` - ExpiredAt int64 `json:"expiredAt"` + LastSeen int64 `json:"lastSeen"` IsCurrent bool `json:"isCurrent"` } @@ -53,5 +53,5 @@ func (a TokenInfoResponseSlice) Swap(i, j int) { // Less reports whether the first item is less than the second one func (a TokenInfoResponseSlice) Less(i, j int) bool { - return a[i].ExpiredAt > a[j].ExpiredAt + return a[i].LastSeen > a[j].LastSeen } diff --git a/pkg/services/tokens.go b/pkg/services/tokens.go index 3b162cb1..3a368554 100644 --- a/pkg/services/tokens.go +++ b/pkg/services/tokens.go @@ -58,7 +58,7 @@ func (s *TokenService) GetAllUnexpiredNormalTokensByUid(c *core.Context, uid int now := time.Now().Unix() var tokenRecords []*models.TokenRecord - err := s.TokenDB(uid).NewSession(c).Cols("uid", "user_token_id", "token_type", "user_agent", "created_unix_time", "expired_unix_time").Where("uid=? AND token_type=? AND expired_unix_time>?", uid, core.USER_TOKEN_TYPE_NORMAL, now).Find(&tokenRecords) + err := s.TokenDB(uid).NewSession(c).Cols("uid", "user_token_id", "token_type", "user_agent", "created_unix_time", "expired_unix_time", "last_seen_unix_time").Where("uid=? AND token_type=? AND expired_unix_time>?", uid, core.USER_TOKEN_TYPE_NORMAL, now).Find(&tokenRecords) return tokenRecords, err } @@ -98,6 +98,31 @@ func (s *TokenService) CreatePasswordResetToken(c *core.Context, user *models.Us return s.createToken(c, user, core.USER_TOKEN_TYPE_PASSWORD_RESET, s.getUserAgent(c), s.CurrentConfig().PasswordResetTokenExpiredTimeDuration) } +// UpdateTokenLastSeen updates the last seen time of specified token +func (s *TokenService) UpdateTokenLastSeen(c *core.Context, tokenRecord *models.TokenRecord) error { + if tokenRecord.Uid <= 0 { + return errs.ErrUserIdInvalid + } + + if tokenRecord.UserTokenId <= 0 { + return errs.ErrInvalidUserTokenId + } + + tokenRecord.LastSeenUnixTime = time.Now().Unix() + + return s.TokenDB(tokenRecord.Uid).DoTransaction(c, func(sess *xorm.Session) error { + updatedRows, err := sess.Cols("last_seen_unix_time").Where("uid=? AND user_token_id=? AND created_unix_time=?", tokenRecord.Uid, tokenRecord.UserTokenId, tokenRecord.CreatedUnixTime).Update(tokenRecord) + + if err != nil { + return err + } else if updatedRows < 1 { + return errs.ErrTokenRecordNotFound + } + + return nil + }) +} + // DeleteToken deletes given token from database func (s *TokenService) DeleteToken(c *core.Context, tokenRecord *models.TokenRecord) error { if tokenRecord.Uid <= 0 { @@ -294,12 +319,13 @@ func (s *TokenService) createToken(c *core.Context, user *models.User, tokenType now := time.Now() tokenRecord := &models.TokenRecord{ - Uid: user.Uid, - UserTokenId: s.getUserTokenId(), - TokenType: tokenType, - UserAgent: userAgent, - CreatedUnixTime: now.Unix(), - ExpiredUnixTime: now.Add(expiryDate).Unix(), + Uid: user.Uid, + UserTokenId: s.getUserTokenId(), + TokenType: tokenType, + UserAgent: userAgent, + CreatedUnixTime: now.Unix(), + ExpiredUnixTime: now.Add(expiryDate).Unix(), + LastSeenUnixTime: now.Unix(), } if tokenRecord.Secret, err = utils.GetRandomString(10); err != nil { diff --git a/pkg/settings/setting.go b/pkg/settings/setting.go index bd3a06b2..8ca4f981 100644 --- a/pkg/settings/setting.go +++ b/pkg/settings/setting.go @@ -124,6 +124,7 @@ const ( defaultSecretKey string = "ezbookkeeping" defaultTokenExpiredTime uint32 = 2592000 // 30 days + defaultTokenMinRefreshInterval uint32 = 86400 // 1 day defaultTemporaryTokenExpiredTime uint32 = 300 // 5 minutes defaultEmailVerifyTokenExpiredTime uint32 = 3600 // 60 minutes defaultPasswordResetTokenExpiredTime uint32 = 3600 // 60 minutes @@ -217,6 +218,7 @@ type Config struct { EnableTwoFactor bool TokenExpiredTime uint32 TokenExpiredTimeDuration time.Duration + TokenMinRefreshInterval uint32 TemporaryTokenExpiredTime uint32 TemporaryTokenExpiredTimeDuration time.Duration EmailVerifyTokenExpiredTime uint32 @@ -572,6 +574,12 @@ func loadSecurityConfiguration(config *Config, configFile *ini.File, sectionName config.TokenExpiredTimeDuration = time.Duration(config.TokenExpiredTime) * time.Second + config.TokenMinRefreshInterval = getConfigItemUint32Value(configFile, sectionName, "token_min_refresh_interval", defaultTokenMinRefreshInterval) + + if config.TokenMinRefreshInterval >= config.TokenExpiredTime { + return errs.ErrInvalidTokenMinRefreshInterval + } + config.TemporaryTokenExpiredTime = getConfigItemUint32Value(configFile, sectionName, "temporary_token_expired_time", defaultTemporaryTokenExpiredTime) if config.TemporaryTokenExpiredTime < 60 { diff --git a/src/stores/token.js b/src/stores/token.js index 1101376b..59786d23 100644 --- a/src/stores/token.js +++ b/src/stores/token.js @@ -40,14 +40,14 @@ export const useTokensStore = defineStore('tokens', { services.refreshToken().then(response => { const data = response.data; + if (data && data.success && data.result && data.result.user && isObject(data.result.user)) { + const userStore = useUserStore(); + userStore.storeUserInfo(data.result.user); + } + if (data && data.success && data.result && data.result.newToken) { userState.updateToken(data.result.newToken); - if (data.result.user && isObject(data.result.user)) { - const userStore = useUserStore(); - userStore.storeUserInfo(data.result.user); - } - if (data.result.oldTokenId) { self.revokeToken({ tokenId: data.result.oldTokenId, diff --git a/src/views/desktop/user/settings/tabs/UserSecuritySettingTab.vue b/src/views/desktop/user/settings/tabs/UserSecuritySettingTab.vue index bbb2b93e..2aa677a8 100644 --- a/src/views/desktop/user/settings/tabs/UserSecuritySettingTab.vue +++ b/src/views/desktop/user/settings/tabs/UserSecuritySettingTab.vue @@ -118,7 +118,7 @@ {{ session.deviceType }} {{ session.deviceInfo }} - {{ session.createdAt }} + {{ session.lastSeen }} @@ -82,7 +82,7 @@ export default { deviceType: this.$t(token.isCurrent ? 'Current' : 'Other Device'), deviceInfo: parseDeviceInfo(token.userAgent), icon: this.getTokenIcon(token), - createdAt: this.$locale.formatUnixTimeToLongDateTime(this.userStore, token.createdAt) + lastSeen: token.lastSeen ? this.$locale.formatUnixTimeToLongDateTime(this.userStore, token.lastSeen) : '-' }); }