support setting token min refresh interval

This commit is contained in:
MaysWind
2024-07-14 17:44:37 +08:00
parent d4850b4a18
commit 55249e07a3
10 changed files with 111 additions and 38 deletions
+1
View File
@@ -680,5 +680,6 @@ func printUserInfo(user *models.User) {
func printTokenInfo(token *models.TokenRecord) { func printTokenInfo(token *models.TokenRecord) {
fmt.Printf("[CreatedAt] %s (%d)\n", utils.FormatUnixTimeToLongDateTimeInServerTimezone(token.CreatedUnixTime), token.CreatedUnixTime) 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("[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) fmt.Printf("[UserAgent] %s\n", token.UserAgent)
} }
+4
View File
@@ -120,6 +120,10 @@ enable_two_factor = true
# Token expired seconds (60 - 4294967295), default is 2592000 (30 days) # Token expired seconds (60 - 4294967295), default is 2592000 (30 days)
token_expired_time = 2592000 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 seconds (60 - 4294967295), default is 300 (5 minutes)
temporary_token_expired_time = 300 temporary_token_expired_time = 300
+36 -3
View File
@@ -2,12 +2,14 @@ package api
import ( import (
"sort" "sort"
"time"
"github.com/mayswind/ezbookkeeping/pkg/core" "github.com/mayswind/ezbookkeeping/pkg/core"
"github.com/mayswind/ezbookkeeping/pkg/errs" "github.com/mayswind/ezbookkeeping/pkg/errs"
"github.com/mayswind/ezbookkeeping/pkg/log" "github.com/mayswind/ezbookkeeping/pkg/log"
"github.com/mayswind/ezbookkeeping/pkg/models" "github.com/mayswind/ezbookkeeping/pkg/models"
"github.com/mayswind/ezbookkeeping/pkg/services" "github.com/mayswind/ezbookkeeping/pkg/services"
"github.com/mayswind/ezbookkeeping/pkg/settings"
"github.com/mayswind/ezbookkeeping/pkg/utils" "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), TokenId: a.tokens.GenerateTokenId(token),
TokenType: token.TokenType, TokenType: token.TokenType,
UserAgent: token.UserAgent, UserAgent: token.UserAgent,
CreatedAt: token.CreatedUnixTime, LastSeen: token.LastSeenUnixTime,
ExpiredAt: token.ExpiredUnixTime,
} }
if token.Uid == claims.Uid && utils.Int64ToString(token.UserTokenId) == claims.UserTokenId && token.CreatedUnixTime == claims.IssuedAt { 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 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) token, claims, err := a.tokens.CreateToken(c, user)
if err != nil { if err != nil {
@@ -183,7 +217,6 @@ func (a *TokensApi) TokenRefreshHandler(c *core.Context) (any, *errs.Error) {
return nil, errs.Or(err, errs.ErrTokenGenerating) return nil, errs.Or(err, errs.ErrTokenGenerating)
} }
oldTokenClaims := c.GetTokenClaims()
oldUserTokenId, _ := utils.StringToInt64(oldTokenClaims.UserTokenId) oldUserTokenId, _ := utils.StringToInt64(oldTokenClaims.UserTokenId)
oldTokenRecord := &models.TokenRecord{ oldTokenRecord := &models.TokenRecord{
Uid: uid, Uid: uid,
+8 -7
View File
@@ -13,11 +13,12 @@ var (
ErrInvalidDuplicateCheckerType = NewSystemError(SystemSubcategorySetting, 6, http.StatusInternalServerError, "invalid duplicate checker type") ErrInvalidDuplicateCheckerType = NewSystemError(SystemSubcategorySetting, 6, http.StatusInternalServerError, "invalid duplicate checker type")
ErrInvalidInMemoryDuplicateCheckerCleanupInterval = NewSystemError(SystemSubcategorySetting, 7, http.StatusInternalServerError, "invalid in-memory duplicate checker cleanup interval") ErrInvalidInMemoryDuplicateCheckerCleanupInterval = NewSystemError(SystemSubcategorySetting, 7, http.StatusInternalServerError, "invalid in-memory duplicate checker cleanup interval")
ErrInvalidTokenExpiredTime = NewSystemError(SystemSubcategorySetting, 8, http.StatusInternalServerError, "invalid token expired time") ErrInvalidTokenExpiredTime = NewSystemError(SystemSubcategorySetting, 8, http.StatusInternalServerError, "invalid token expired time")
ErrInvalidTemporaryTokenExpiredTime = NewSystemError(SystemSubcategorySetting, 9, http.StatusInternalServerError, "invalid temporary token expired time") ErrInvalidTokenMinRefreshInterval = NewSystemError(SystemSubcategorySetting, 9, http.StatusInternalServerError, "invalid token min refresh interval")
ErrInvalidEmailVerifyTokenExpiredTime = NewSystemError(SystemSubcategorySetting, 10, http.StatusInternalServerError, "invalid email verify token expired time") ErrInvalidTemporaryTokenExpiredTime = NewSystemError(SystemSubcategorySetting, 10, http.StatusInternalServerError, "invalid temporary token expired time")
ErrInvalidAvatarProvider = NewSystemError(SystemSubcategorySetting, 11, http.StatusInternalServerError, "invalid avatar provider") ErrInvalidEmailVerifyTokenExpiredTime = NewSystemError(SystemSubcategorySetting, 11, http.StatusInternalServerError, "invalid email verify token expired time")
ErrInvalidMapProvider = NewSystemError(SystemSubcategorySetting, 12, http.StatusInternalServerError, "invalid map provider") ErrInvalidAvatarProvider = NewSystemError(SystemSubcategorySetting, 12, http.StatusInternalServerError, "invalid avatar provider")
ErrInvalidAmapSecurityVerificationMethod = NewSystemError(SystemSubcategorySetting, 13, http.StatusInternalServerError, "invalid amap security verification method") ErrInvalidMapProvider = NewSystemError(SystemSubcategorySetting, 13, http.StatusInternalServerError, "invalid map provider")
ErrInvalidPasswordResetTokenExpiredTime = NewSystemError(SystemSubcategorySetting, 14, http.StatusInternalServerError, "invalid password reset token expired time") ErrInvalidAmapSecurityVerificationMethod = NewSystemError(SystemSubcategorySetting, 14, http.StatusInternalServerError, "invalid amap security verification method")
ErrInvalidExchangeRatesDataSource = NewSystemError(SystemSubcategorySetting, 15, http.StatusInternalServerError, "invalid exchange rates data source") ErrInvalidPasswordResetTokenExpiredTime = NewSystemError(SystemSubcategorySetting, 15, http.StatusInternalServerError, "invalid password reset token expired time")
ErrInvalidExchangeRatesDataSource = NewSystemError(SystemSubcategorySetting, 16, http.StatusInternalServerError, "invalid exchange rates data source")
) )
+12 -12
View File
@@ -7,13 +7,14 @@ const TokenMaxUserAgentLength = 255
// TokenRecord represents token data stored in database // TokenRecord represents token data stored in database
type TokenRecord struct { type TokenRecord struct {
Uid int64 `xorm:"PK INDEX(IDX_token_record_uid_type_expired_time)"` Uid int64 `xorm:"PK INDEX(IDX_token_record_uid_type_expired_time)"`
UserTokenId int64 `xorm:"PK"` UserTokenId int64 `xorm:"PK"`
TokenType core.TokenType `xorm:"INDEX(IDX_token_record_uid_type_expired_time) TINYINT NOT NULL"` TokenType core.TokenType `xorm:"INDEX(IDX_token_record_uid_type_expired_time) TINYINT NOT NULL"`
Secret string `xorm:"VARCHAR(10) NOT NULL"` Secret string `xorm:"VARCHAR(10) NOT NULL"`
UserAgent string `xorm:"VARCHAR(255)"` UserAgent string `xorm:"VARCHAR(255)"`
CreatedUnixTime int64 `xorm:"PK"` CreatedUnixTime int64 `xorm:"PK"`
ExpiredUnixTime int64 `xorm:"INDEX(IDX_token_record_uid_type_expired_time)"` ExpiredUnixTime int64 `xorm:"INDEX(IDX_token_record_uid_type_expired_time)"`
LastSeenUnixTime int64
} }
// TokenRevokeRequest represents all parameters of token revoking request // TokenRevokeRequest represents all parameters of token revoking request
@@ -23,8 +24,8 @@ type TokenRevokeRequest struct {
// TokenRefreshResponse represents all parameters of token refreshing request // TokenRefreshResponse represents all parameters of token refreshing request
type TokenRefreshResponse struct { type TokenRefreshResponse struct {
NewToken string `json:"newToken"` NewToken string `json:"newToken,omitempty"`
OldTokenId string `json:"oldTokenId"` OldTokenId string `json:"oldTokenId,omitempty"`
User *UserBasicInfo `json:"user"` User *UserBasicInfo `json:"user"`
} }
@@ -33,8 +34,7 @@ type TokenInfoResponse struct {
TokenId string `json:"tokenId"` TokenId string `json:"tokenId"`
TokenType core.TokenType `json:"tokenType"` TokenType core.TokenType `json:"tokenType"`
UserAgent string `json:"userAgent"` UserAgent string `json:"userAgent"`
CreatedAt int64 `json:"createdAt"` LastSeen int64 `json:"lastSeen"`
ExpiredAt int64 `json:"expiredAt"`
IsCurrent bool `json:"isCurrent"` 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 // Less reports whether the first item is less than the second one
func (a TokenInfoResponseSlice) Less(i, j int) bool { func (a TokenInfoResponseSlice) Less(i, j int) bool {
return a[i].ExpiredAt > a[j].ExpiredAt return a[i].LastSeen > a[j].LastSeen
} }
+33 -7
View File
@@ -58,7 +58,7 @@ func (s *TokenService) GetAllUnexpiredNormalTokensByUid(c *core.Context, uid int
now := time.Now().Unix() now := time.Now().Unix()
var tokenRecords []*models.TokenRecord 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 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) 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 // DeleteToken deletes given token from database
func (s *TokenService) DeleteToken(c *core.Context, tokenRecord *models.TokenRecord) error { func (s *TokenService) DeleteToken(c *core.Context, tokenRecord *models.TokenRecord) error {
if tokenRecord.Uid <= 0 { if tokenRecord.Uid <= 0 {
@@ -294,12 +319,13 @@ func (s *TokenService) createToken(c *core.Context, user *models.User, tokenType
now := time.Now() now := time.Now()
tokenRecord := &models.TokenRecord{ tokenRecord := &models.TokenRecord{
Uid: user.Uid, Uid: user.Uid,
UserTokenId: s.getUserTokenId(), UserTokenId: s.getUserTokenId(),
TokenType: tokenType, TokenType: tokenType,
UserAgent: userAgent, UserAgent: userAgent,
CreatedUnixTime: now.Unix(), CreatedUnixTime: now.Unix(),
ExpiredUnixTime: now.Add(expiryDate).Unix(), ExpiredUnixTime: now.Add(expiryDate).Unix(),
LastSeenUnixTime: now.Unix(),
} }
if tokenRecord.Secret, err = utils.GetRandomString(10); err != nil { if tokenRecord.Secret, err = utils.GetRandomString(10); err != nil {
+8
View File
@@ -124,6 +124,7 @@ const (
defaultSecretKey string = "ezbookkeeping" defaultSecretKey string = "ezbookkeeping"
defaultTokenExpiredTime uint32 = 2592000 // 30 days defaultTokenExpiredTime uint32 = 2592000 // 30 days
defaultTokenMinRefreshInterval uint32 = 86400 // 1 day
defaultTemporaryTokenExpiredTime uint32 = 300 // 5 minutes defaultTemporaryTokenExpiredTime uint32 = 300 // 5 minutes
defaultEmailVerifyTokenExpiredTime uint32 = 3600 // 60 minutes defaultEmailVerifyTokenExpiredTime uint32 = 3600 // 60 minutes
defaultPasswordResetTokenExpiredTime uint32 = 3600 // 60 minutes defaultPasswordResetTokenExpiredTime uint32 = 3600 // 60 minutes
@@ -217,6 +218,7 @@ type Config struct {
EnableTwoFactor bool EnableTwoFactor bool
TokenExpiredTime uint32 TokenExpiredTime uint32
TokenExpiredTimeDuration time.Duration TokenExpiredTimeDuration time.Duration
TokenMinRefreshInterval uint32
TemporaryTokenExpiredTime uint32 TemporaryTokenExpiredTime uint32
TemporaryTokenExpiredTimeDuration time.Duration TemporaryTokenExpiredTimeDuration time.Duration
EmailVerifyTokenExpiredTime uint32 EmailVerifyTokenExpiredTime uint32
@@ -572,6 +574,12 @@ func loadSecurityConfiguration(config *Config, configFile *ini.File, sectionName
config.TokenExpiredTimeDuration = time.Duration(config.TokenExpiredTime) * time.Second 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) config.TemporaryTokenExpiredTime = getConfigItemUint32Value(configFile, sectionName, "temporary_token_expired_time", defaultTemporaryTokenExpiredTime)
if config.TemporaryTokenExpiredTime < 60 { if config.TemporaryTokenExpiredTime < 60 {
+5 -5
View File
@@ -40,14 +40,14 @@ export const useTokensStore = defineStore('tokens', {
services.refreshToken().then(response => { services.refreshToken().then(response => {
const data = response.data; 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) { if (data && data.success && data.result && data.result.newToken) {
userState.updateToken(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) { if (data.result.oldTokenId) {
self.revokeToken({ self.revokeToken({
tokenId: data.result.oldTokenId, tokenId: data.result.oldTokenId,
@@ -118,7 +118,7 @@
{{ session.deviceType }} {{ session.deviceType }}
</td> </td>
<td class="text-sm">{{ session.deviceInfo }}</td> <td class="text-sm">{{ session.deviceInfo }}</td>
<td class="text-sm">{{ session.createdAt }}</td> <td class="text-sm">{{ session.lastSeen }}</td>
<td class="text-sm text-right"> <td class="text-sm text-right">
<v-btn density="comfortable" color="error" variant="tonal" <v-btn density="comfortable" color="error" variant="tonal"
:disabled="session.isCurrent || loadingSession" :disabled="session.isCurrent || loadingSession"
@@ -210,7 +210,7 @@ export default {
deviceType: this.$t(token.isCurrent ? 'Current' : 'Other Device'), deviceType: this.$t(token.isCurrent ? 'Current' : 'Other Device'),
deviceInfo: parseDeviceInfo(token.userAgent), deviceInfo: parseDeviceInfo(token.userAgent),
icon: this.getTokenIcon(token), icon: this.getTokenIcon(token),
createdAt: this.$locale.formatUnixTimeToLongDateTime(this.userStore, token.createdAt) lastSeen: token.lastSeen ? this.$locale.formatUnixTimeToLongDateTime(this.userStore, token.lastSeen) : '-'
}); });
} }
+2 -2
View File
@@ -32,7 +32,7 @@
<f7-icon :f7="session.icon"></f7-icon> <f7-icon :f7="session.icon"></f7-icon>
</template> </template>
<template #after> <template #after>
<small>{{ session.createdAt }}</small> <small>{{ session.lastSeen }}</small>
</template> </template>
<f7-swipeout-actions right v-if="!session.isCurrent"> <f7-swipeout-actions right v-if="!session.isCurrent">
<f7-swipeout-button color="red" :text="$t('Log Out')" @click="revoke(session)"></f7-swipeout-button> <f7-swipeout-button color="red" :text="$t('Log Out')" @click="revoke(session)"></f7-swipeout-button>
@@ -82,7 +82,7 @@ export default {
deviceType: this.$t(token.isCurrent ? 'Current' : 'Other Device'), deviceType: this.$t(token.isCurrent ? 'Current' : 'Other Device'),
deviceInfo: parseDeviceInfo(token.userAgent), deviceInfo: parseDeviceInfo(token.userAgent),
icon: this.getTokenIcon(token), icon: this.getTokenIcon(token),
createdAt: this.$locale.formatUnixTimeToLongDateTime(this.userStore, token.createdAt) lastSeen: token.lastSeen ? this.$locale.formatUnixTimeToLongDateTime(this.userStore, token.lastSeen) : '-'
}); });
} }