refactor(auth): replace dashboard sessions with stateless tokens and session control (#6329)
* refactor(auth): replace dashboard sessions with stateless tokens * feat(auth): harden session issuance and distributed enforcement * fix(proxy): preserve trusted proxy compatibility defaults * refactor: address dashboard auth review feedback * refactor: remove classic frontend and flatten web app
This commit is contained in:
+113
-18
@@ -108,18 +108,22 @@ type User struct {
|
||||
StripeCustomer string `json:"stripe_customer" gorm:"type:varchar(64);column:stripe_customer;index"`
|
||||
CreatedAt int64 `json:"created_at" gorm:"autoCreateTime;column:created_at"`
|
||||
LastLoginAt int64 `json:"last_login_at" gorm:"default:0;column:last_login_at"`
|
||||
AuthVersion int64 `json:"-" gorm:"type:bigint;not null;default:1;column:auth_version"`
|
||||
AdminPermissions map[string]map[string]bool `json:"admin_permissions,omitempty" gorm:"-:all"`
|
||||
}
|
||||
|
||||
func (user *User) ToBaseUser() *UserBase {
|
||||
cache := &UserBase{
|
||||
Id: user.Id,
|
||||
Group: user.Group,
|
||||
Quota: user.Quota,
|
||||
Status: user.Status,
|
||||
Username: user.Username,
|
||||
Setting: user.Setting,
|
||||
Email: user.Email,
|
||||
Id: user.Id,
|
||||
Group: user.Group,
|
||||
Quota: user.Quota,
|
||||
Status: user.Status,
|
||||
Role: user.Role,
|
||||
Username: user.Username,
|
||||
Setting: user.Setting,
|
||||
Email: user.Email,
|
||||
AuthVersion: user.AuthVersion,
|
||||
CacheSchema: userCacheSchemaVersion,
|
||||
}
|
||||
return cache
|
||||
}
|
||||
@@ -699,10 +703,23 @@ func (user *User) FinalizeOAuthUserCreation(inviterId int) {
|
||||
}
|
||||
|
||||
func (user *User) Update(updatePassword bool) error {
|
||||
if err := user.UpdateWithTx(DB, updatePassword); err != nil {
|
||||
var previousAuthVersion int64
|
||||
if err := DB.Model(&User{}).Where("id = ?", user.Id).Select("auth_version").Find(&previousAuthVersion).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return updateUserCache(*user)
|
||||
if err := DB.Transaction(func(tx *gorm.DB) error {
|
||||
return user.UpdateWithTx(tx, updatePassword)
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := updateUserCache(*user); err != nil {
|
||||
return err
|
||||
}
|
||||
if user.AuthVersion > previousAuthVersion {
|
||||
_, err := RevokeAllUserSessions(user.Id, "user_security_changed")
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (user *User) UpdateWithTx(tx *gorm.DB, updatePassword bool) error {
|
||||
@@ -718,17 +735,43 @@ func (user *User) UpdateWithTx(tx *gorm.DB, updatePassword bool) error {
|
||||
if err = tx.First(¤t, user.Id).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err = tx.Model(¤t).Omit("quota", "used_quota", "request_count").Updates(newUser).Error; err != nil {
|
||||
// Updates(struct) ignores zero values. Match that behavior when deciding
|
||||
// whether this request actually changes authentication-sensitive state;
|
||||
// partial self-profile updates intentionally leave role/status/group empty.
|
||||
authChanged := (updatePassword && current.Password != newUser.Password) ||
|
||||
(newUser.Role != 0 && current.Role != newUser.Role) ||
|
||||
(newUser.Status != 0 && current.Status != newUser.Status) ||
|
||||
(newUser.Group != "" && current.Group != newUser.Group)
|
||||
if authChanged {
|
||||
newUser.AuthVersion, err = IncrementUserAuthVersionWithTx(tx, user.Id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err = tx.Model(¤t).Omit("quota", "used_quota", "request_count", "auth_version").Updates(newUser).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.First(user, user.Id).Error
|
||||
}
|
||||
|
||||
func (user *User) Edit(updatePassword bool) error {
|
||||
if err := user.EditWithTx(DB, updatePassword); err != nil {
|
||||
var previousAuthVersion int64
|
||||
if err := DB.Model(&User{}).Where("id = ?", user.Id).Select("auth_version").Find(&previousAuthVersion).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return updateUserCache(*user)
|
||||
if err := DB.Transaction(func(tx *gorm.DB) error {
|
||||
return user.EditWithTx(tx, updatePassword)
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := updateUserCache(*user); err != nil {
|
||||
return err
|
||||
}
|
||||
if user.AuthVersion > previousAuthVersion {
|
||||
_, err := RevokeAllUserSessions(user.Id, "user_security_changed")
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (user *User) EditWithTx(tx *gorm.DB, updatePassword bool) error {
|
||||
@@ -755,6 +798,13 @@ func (user *User) EditWithTx(tx *gorm.DB, updatePassword bool) error {
|
||||
if err = tx.First(¤t, user.Id).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
authChanged := (updatePassword && current.Password != newUser.Password) || current.Group != newUser.Group
|
||||
if authChanged {
|
||||
newUser.AuthVersion, err = IncrementUserAuthVersionWithTx(tx, user.Id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err = tx.Model(¤t).Updates(updates).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -781,7 +831,15 @@ func (user *User) ClearBinding(bindingType string) error {
|
||||
return errors.New("invalid binding type")
|
||||
}
|
||||
|
||||
if err := DB.Model(&User{}).Where("id = ?", user.Id).Update(column, "").Error; err != nil {
|
||||
if err := DB.Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Model(&User{}).Where("id = ?", user.Id).Update(column, "").Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if bindingType == ExternalIdentityProviderTelegram {
|
||||
return ReleaseExternalIdentityWithTx(tx, ExternalIdentityProviderTelegram, user.Id)
|
||||
}
|
||||
return nil
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -796,11 +854,23 @@ func (user *User) Delete() error {
|
||||
if user.Id == 0 {
|
||||
return errors.New("id 为空!")
|
||||
}
|
||||
if err := DB.Delete(user).Error; err != nil {
|
||||
var nextAuthVersion int64
|
||||
if err := DB.Transaction(func(tx *gorm.DB) error {
|
||||
var err error
|
||||
nextAuthVersion, err = IncrementUserAuthVersionWithTx(tx, user.Id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Delete(user).Error
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := publishCommittedUserAuthVersion(user.Id, nextAuthVersion); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := RevokeAllUserSessions(user.Id, "user_deleted"); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 清除缓存
|
||||
return invalidateUserCache(user.Id)
|
||||
}
|
||||
|
||||
@@ -809,7 +879,13 @@ func (user *User) HardDelete() error {
|
||||
return errors.New("id 为空!")
|
||||
}
|
||||
var tokens []Token
|
||||
var deletedAuthVersion int64
|
||||
err := DB.Transaction(func(tx *gorm.DB) error {
|
||||
var err error
|
||||
deletedAuthVersion, err = IncrementUserAuthVersionWithTx(tx, user.Id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if common.RedisEnabled {
|
||||
if err := tx.Unscoped().Select("id", commonKeyCol).Where("user_id = ?", user.Id).Find(&tokens).Error; err != nil {
|
||||
return err
|
||||
@@ -823,6 +899,9 @@ func (user *User) HardDelete() error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := publishCommittedUserAuthVersion(user.Id, deletedAuthVersion); err != nil {
|
||||
common.SysError(fmt.Sprintf("failed to publish auth tombstone after hard deleting user %d: %v", user.Id, err))
|
||||
}
|
||||
if err := invalidateTokensCache(tokens); err != nil {
|
||||
common.SysError(fmt.Sprintf("failed to invalidate token cache after hard deleting user %d: %v", user.Id, err))
|
||||
}
|
||||
@@ -833,9 +912,14 @@ func (user *User) HardDelete() error {
|
||||
}
|
||||
|
||||
func deleteUserAuthenticationData(tx *gorm.DB, userId int) error {
|
||||
if err := releaseAllExternalIdentitiesWithTx(tx, userId); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, authenticationData := range []any{
|
||||
&TwoFABackupCode{},
|
||||
&TwoFA{},
|
||||
&UserSession{},
|
||||
&AuthFlow{},
|
||||
&PasskeyCredential{},
|
||||
&Token{},
|
||||
} {
|
||||
@@ -997,7 +1081,18 @@ func ResetUserPasswordByEmail(email string, password string) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = DB.Model(&User{}).Where("id = ?", user.Id).Update("password", hashedPassword).Error
|
||||
if err = DB.Transaction(func(tx *gorm.DB) error {
|
||||
if _, err := IncrementUserAuthVersionWithTx(tx, user.Id); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Model(&User{}).Where("id = ?", user.Id).Update("password", hashedPassword).Error
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := PublishUserAuthCache(user.Id); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = RevokeAllUserSessions(user.Id, "password_reset")
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -1074,7 +1169,7 @@ func GetUserGroup(id int, fromDB bool) (group string, err error) {
|
||||
// Update Redis cache asynchronously on successful DB read
|
||||
if shouldUpdateRedis(fromDB, err) {
|
||||
gopool.Go(func() {
|
||||
if err := updateUserGroupCache(id, group); err != nil {
|
||||
if err := RefreshUserGroupCache(id); err != nil {
|
||||
common.SysLog("failed to update user group cache: " + err.Error())
|
||||
}
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user