修复管理员分组代管写入的并发权限边界
Some checks are pending
Security Checks / security (push) Waiting to run

This commit is contained in:
smail 2026-09-25 22:53:15 +08:00
parent 951177c841
commit 4d24bf21d5
10 changed files with 224 additions and 50 deletions

View File

@ -12,6 +12,13 @@ import (
"git.pcux.cn/xuans/Smail/utils/context" "git.pcux.cn/xuans/Smail/utils/context"
) )
func accountWriteGuard(ctx *context.Context, target models.User) accountgroup.WriteGuard {
if ctx == nil {
return nil
}
return accountgroup.Guard(ctx.UserID, ctx.EffectiveRole(), ctx.SessionVersion, ctx.SecurityVersion, target)
}
func requireAccountScope(ctx *context.Context, w http.ResponseWriter, targetID int) bool { func requireAccountScope(ctx *context.Context, w http.ResponseWriter, targetID int) bool {
if ctx == nil || !accountgroup.CanAccess(db.Instance, ctx.UserID, ctx.EffectiveRole(), targetID) { if ctx == nil || !accountgroup.CanAccess(db.Instance, ctx.UserID, ctx.EffectiveRole(), targetID) {
writeSecurityError(w, response.NoAccessPrivileges, "无权查看或管理该账号") writeSecurityError(w, response.NoAccessPrivileges, "无权查看或管理该账号")
@ -110,7 +117,7 @@ func SetAccountGroupMembership(ctx *context.Context, w http.ResponseWriter, req
writeAccountGroupError(w, accountgroup.ErrConflict) writeAccountGroupError(w, accountgroup.ErrConflict)
return return
} }
if err := accountgroup.SetMembership(db.Instance, user, input.GroupIDs); err != nil { if err := accountgroup.SetMembership(db.Instance, user, input.GroupIDs, accountWriteGuard(ctx, user)); err != nil {
writeAccountGroupError(w, err) writeAccountGroupError(w, err)
return return
} }

View File

@ -172,7 +172,7 @@ func SetNotificationAuthorization(ctx *context.Context, w http.ResponseWriter, r
if !ok { if !ok {
return return
} }
err := notification.SetAuthorization(db.Instance, *user, input.Version, *input.Enabled, input.MinuteLimit, input.DayLimit) err := notification.SetAuthorization(db.Instance, *user, input.Version, *input.Enabled, input.MinuteLimit, input.DayLimit, accountWriteGuard(ctx, *user))
recordNotificationManagement(ctx, user.ID, "设置系统通知授权", err, map[string]any{"enabled": *input.Enabled, "minute_limit": input.MinuteLimit, "day_limit": input.DayLimit}) recordNotificationManagement(ctx, user.ID, "设置系统通知授权", err, map[string]any{"enabled": *input.Enabled, "minute_limit": input.MinuteLimit, "day_limit": input.DayLimit})
if err != nil { if err != nil {
writeNotificationError(w, err) writeNotificationError(w, err)
@ -191,7 +191,7 @@ func RotateNotificationCredential(ctx *context.Context, w http.ResponseWriter, r
if !ok { if !ok {
return return
} }
code, err := notification.Rotate(db.Instance, *user, input.Version) code, err := notification.Rotate(db.Instance, *user, input.Version, accountWriteGuard(ctx, *user))
recordNotificationManagement(ctx, user.ID, "重置系统通知凭据", err, nil) recordNotificationManagement(ctx, user.ID, "重置系统通知凭据", err, nil)
if err != nil { if err != nil {
writeNotificationError(w, err) writeNotificationError(w, err)
@ -212,6 +212,10 @@ func recordNotificationManagement(ctx *context.Context, userID int, action strin
} }
func writeNotificationError(w http.ResponseWriter, err error) { func writeNotificationError(w http.ResponseWriter, err error) {
if errors.Is(err, accountgroup.ErrDenied) || errors.Is(err, accountgroup.ErrConflict) {
writeAccountGroupError(w, err)
return
}
switch { switch {
case errors.Is(err, notification.ErrConflict): case errors.Is(err, notification.ErrConflict):
writeSecurityError(w, response.ParamsError, "授权状态已变化,请刷新后重试") writeSecurityError(w, response.ParamsError, "授权状态已变化,请刷新后重试")

View File

@ -320,6 +320,7 @@ func EditUser(ctx *context.Context, w http.ResponseWriter, req *http.Request) {
return return
} }
targetRole := user.EffectiveRole() targetRole := user.EffectiveRole()
writeGuard := accountWriteGuard(ctx, user)
if !requireAccountScope(ctx, w, user.ID) { if !requireAccountScope(ctx, w, user.ID) {
return return
} }
@ -421,6 +422,10 @@ func EditUser(ctx *context.Context, w http.ResponseWriter, req *http.Request) {
} }
}() }()
updateColumns := []string{"name", "disabled", "role", "is_admin"} updateColumns := []string{"name", "disabled", "role", "is_admin"}
if err := accountgroup.CheckWrite(tx, writeGuard); err != nil {
writeAccountGroupError(w, err)
return
}
if reqData.Password != "" { if reqData.Password != "" {
updateColumns = append(updateColumns, "password", "must_change_password", "password_changed_at") updateColumns = append(updateColumns, "password", "must_change_password", "password_changed_at")
} }

View File

@ -10,6 +10,7 @@ import (
"git.pcux.cn/xuans/Smail/dto/response" "git.pcux.cn/xuans/Smail/dto/response"
"git.pcux.cn/xuans/Smail/models" "git.pcux.cn/xuans/Smail/models"
"git.pcux.cn/xuans/Smail/services/access" "git.pcux.cn/xuans/Smail/services/access"
"git.pcux.cn/xuans/Smail/services/accountgroup"
"git.pcux.cn/xuans/Smail/services/externalemail" "git.pcux.cn/xuans/Smail/services/externalemail"
"git.pcux.cn/xuans/Smail/services/verification" "git.pcux.cn/xuans/Smail/services/verification"
"git.pcux.cn/xuans/Smail/utils/context" "git.pcux.cn/xuans/Smail/utils/context"
@ -64,6 +65,14 @@ func loadManagedSecurityUser(ctx *context.Context, w http.ResponseWriter, userID
return &target, true return &target, true
} }
func writeManagedScopeError(w http.ResponseWriter, err error) bool {
if errors.Is(err, accountgroup.ErrDenied) || errors.Is(err, accountgroup.ErrConflict) {
writeAccountGroupError(w, err)
return true
}
return false
}
func managedUserIDFromQuery(req *http.Request) int { func managedUserIDFromQuery(req *http.Request) int {
if req == nil || req.URL == nil { if req == nil || req.URL == nil {
return 0 return 0
@ -112,8 +121,11 @@ func GenerateManagedRecoveryCodes(ctx *context.Context, w http.ResponseWriter, r
writeSecurityError(w, response.ServerError, "安全凭据服务不可用") writeSecurityError(w, response.ServerError, "安全凭据服务不可用")
return return
} }
codes, err := service.GenerateRecoveryCodesContext(recoveryRequestContext(req), target.ID, input.Count) codes, err := service.GenerateRecoveryCodesContext(recoveryRequestContext(req), target.ID, input.Count, accountWriteGuard(ctx, *target))
if err != nil { if err != nil {
if writeManagedScopeError(w, err) {
return
}
if writePasswordBusy(w, err) { if writePasswordBusy(w, err) {
return return
} }
@ -145,7 +157,10 @@ func RevokeManagedRecoveryCodes(ctx *context.Context, w http.ResponseWriter, req
writeSecurityError(w, response.ServerError, "安全凭据服务不可用") writeSecurityError(w, response.ServerError, "安全凭据服务不可用")
return return
} }
if err := service.RevokeRecoveryCodes(target.ID); err != nil { if err := service.RevokeRecoveryCodes(target.ID, accountWriteGuard(ctx, *target)); err != nil {
if writeManagedScopeError(w, err) {
return
}
log.WithContext(ctx).WithError(err).Error("revoke managed recovery codes failed") log.WithContext(ctx).WithError(err).Error("revoke managed recovery codes failed")
writeSecurityError(w, response.ServerError, "撤销安全码失败") writeSecurityError(w, response.ServerError, "撤销安全码失败")
return return
@ -230,8 +245,11 @@ func ChangeManagedExternalEmail(ctx *context.Context, w http.ResponseWriter, req
UserID: target.ID, UserID: target.ID,
Email: normalizedEmail, Email: normalizedEmail,
Type: emailType, Type: emailType,
}) }, accountWriteGuard(ctx, *target))
if err != nil { if err != nil {
if writeManagedScopeError(w, err) {
return
}
if errors.Is(err, externalemail.ErrBindingExists) { if errors.Is(err, externalemail.ErrBindingExists) {
writeSecurityError(w, response.ParamsError, "该外部邮箱已经绑定或正在被其他账号使用") writeSecurityError(w, response.ParamsError, "该外部邮箱已经绑定或正在被其他账号使用")
return return
@ -295,7 +313,10 @@ func VerifyManagedExternalEmail(ctx *context.Context, w http.ResponseWriter, req
writeSecurityError(w, response.ParamsError, externalEmailVerificationCheckMessage(err)) writeSecurityError(w, response.ParamsError, externalEmailVerificationCheckMessage(err))
return return
} }
if err := runtime.external.VerifyReplacement(target.ID, input.BindingID); err != nil { if err := runtime.external.VerifyReplacement(target.ID, input.BindingID, accountWriteGuard(ctx, *target)); err != nil {
if writeManagedScopeError(w, err) {
return
}
log.WithContext(ctx).WithError(err).Error("verify managed external email replacement failed") log.WithContext(ctx).WithError(err).Error("verify managed external email replacement failed")
writeSecurityError(w, response.ParamsError, "外部邮箱替换失败") writeSecurityError(w, response.ParamsError, "外部邮箱替换失败")
return return

View File

@ -187,6 +187,8 @@ func contextIterceptor(h controllers.HandlerFunc) http.HandlerFunc {
} }
} else { } else {
ctx.UserID = currentUser.ID ctx.UserID = currentUser.ID
ctx.SessionVersion = currentUser.SessionVersion
ctx.SecurityVersion = currentUser.SecurityVersion
ctx.UserName = currentUser.Name ctx.UserName = currentUser.Name
ctx.UserAccount = currentUser.Account ctx.UserAccount = currentUser.Account
ctx.Role = currentUser.EffectiveRole() ctx.Role = currentUser.EffectiveRole()

View File

@ -184,7 +184,63 @@ func ReplaceMembership(tx *xorm.Session, userID int, ids []int) error {
return err return err
} }
func SetMembership(engine *xorm.Engine, user models.User, input []int) error { type WriteGuard func(*xorm.Session) error
func CheckWrite(tx *xorm.Session, guards ...WriteGuard) error {
for _, guard := range guards {
if guard == nil {
return ErrDenied
}
if err := guard(tx); err != nil {
return err
}
}
return nil
}
func Guard(actorID int, role access.Role, sessionVersion, securityVersion int, target models.User) WriteGuard {
return func(tx *xorm.Session) error {
if tx == nil || actorID <= 0 || target.ID <= 0 || (role != access.RoleAdmin && role != access.RoleSuperAdmin) {
return ErrDenied
}
ids := []int{actorID}
if target.ID != actorID {
ids = append(ids, target.ID)
}
sort.Ints(ids)
for _, id := range ids {
if _, err := tx.ID(id).SetExpr("session_version", "session_version").Update(&models.User{}); err != nil {
return err
}
}
var actor models.User
found, err := tx.ID(actorID).Get(&actor)
if err != nil {
return err
}
if !found || actor.Disabled != 0 || actor.MustChangePassword != 0 || actor.EffectiveRole() != role ||
actor.SessionVersion != sessionVersion || actor.SecurityVersion != securityVersion {
return ErrDenied
}
var current models.User
query := tx.ID(target.ID)
ApplyScope(query, actorID, role)
found, err = query.Get(&current)
if err != nil {
return err
}
if !found {
return ErrDenied
}
if current.SessionVersion != target.SessionVersion || current.SecurityVersion != target.SecurityVersion ||
current.EffectiveRole() != target.EffectiveRole() || current.Disabled != target.Disabled || current.MustChangePassword != target.MustChangePassword {
return ErrConflict
}
return nil
}
}
func SetMembership(engine *xorm.Engine, user models.User, input []int, guards ...WriteGuard) error {
if engine == nil || user.ID <= 0 { if engine == nil || user.ID <= 0 {
return ErrDenied return ErrDenied
} }
@ -194,6 +250,9 @@ func SetMembership(engine *xorm.Engine, user models.User, input []int) error {
return err return err
} }
defer tx.Rollback() defer tx.Rollback()
if err := CheckWrite(tx, guards...); err != nil {
return err
}
updated := user updated := user
updated.SessionVersion++ updated.SessionVersion++
updated.SecurityVersion++ updated.SecurityVersion++

View File

@ -10,6 +10,7 @@ import (
"time" "time"
"git.pcux.cn/xuans/Smail/models" "git.pcux.cn/xuans/Smail/models"
"git.pcux.cn/xuans/Smail/services/accountgroup"
"git.pcux.cn/xuans/Smail/utils/password" "git.pcux.cn/xuans/Smail/utils/password"
log "github.com/sirupsen/logrus" log "github.com/sirupsen/logrus"
"golang.org/x/text/unicode/norm" "golang.org/x/text/unicode/norm"
@ -146,11 +147,11 @@ func (s *Service) GenerateRecoveryCodes(userID int, count int) ([]string, error)
// GenerateRecoveryCodesContext 在请求取消时停止后续计算,并回滚尚未提交的恢复码轮换。 // GenerateRecoveryCodesContext 在请求取消时停止后续计算,并回滚尚未提交的恢复码轮换。
// 单次 Argon2id 计算不可中断,因此每次计算前后检查取消,不降低原有密码强度。 // 单次 Argon2id 计算不可中断,因此每次计算前后检查取消,不降低原有密码强度。
func (s *Service) GenerateRecoveryCodesContext(ctx context.Context, userID int, count int) ([]string, error) { func (s *Service) GenerateRecoveryCodesContext(ctx context.Context, userID int, count int, guards ...accountgroup.WriteGuard) ([]string, error) {
return s.generateRecoveryCodes(ctx, userID, count, password.Hash) return s.generateRecoveryCodes(ctx, userID, count, password.Hash, guards...)
} }
func (s *Service) generateRecoveryCodes(ctx context.Context, userID int, count int, hashSecret func(string) (string, error)) (codes []string, resultErr error) { func (s *Service) generateRecoveryCodes(ctx context.Context, userID int, count int, hashSecret func(string) (string, error), guards ...accountgroup.WriteGuard) (codes []string, resultErr error) {
if s == nil || s.engine == nil { if s == nil || s.engine == nil {
return nil, ErrUnavailable return nil, ErrUnavailable
} }
@ -209,6 +210,9 @@ func (s *Service) generateRecoveryCodes(ctx context.Context, userID int, count i
return nil, fmt.Errorf("begin recovery code transaction: %w", err) return nil, fmt.Errorf("begin recovery code transaction: %w", err)
} }
defer session.Rollback() defer session.Rollback()
if err := accountgroup.CheckWrite(session, guards...); err != nil {
return nil, err
}
now := s.now() now := s.now()
if _, err := session.Where("user_id = ? AND used_at IS NULL", userID).Cols("used_at").Update(&models.RecoveryCode{UsedAt: &now}); err != nil { if _, err := session.Where("user_id = ? AND used_at IS NULL", userID).Cols("used_at").Update(&models.RecoveryCode{UsedAt: &now}); err != nil {
_ = session.Rollback() _ = session.Rollback()
@ -263,8 +267,6 @@ func (s *Service) ConsumeRecoveryCodeWithSession(session *xorm.Session, userID i
if len(code) < 12 || len(code) > 64 { if len(code) < 12 || len(code) > 64 {
return ErrInvalidCode return ErrInvalidCode
} }
s.mu.Lock()
defer s.mu.Unlock()
return s.consumeRecoveryCode(session, userID, code) return s.consumeRecoveryCode(session, userID, code)
} }
@ -315,7 +317,7 @@ func (s *Service) HasValidRecoveryCode(userID int) (bool, error) {
} }
// RevokeRecoveryCodes 撤销用户现有的离线恢复码,但保留密保问题。 // RevokeRecoveryCodes 撤销用户现有的离线恢复码,但保留密保问题。
func (s *Service) RevokeRecoveryCodes(userID int) error { func (s *Service) RevokeRecoveryCodes(userID int, guards ...accountgroup.WriteGuard) error {
if s == nil || s.engine == nil { if s == nil || s.engine == nil {
return ErrUnavailable return ErrUnavailable
} }
@ -325,10 +327,19 @@ func (s *Service) RevokeRecoveryCodes(userID int) error {
s.mu.Lock() s.mu.Lock()
defer s.mu.Unlock() defer s.mu.Unlock()
now := s.now() now := s.now()
if _, err := s.engine.Where("user_id = ? AND used_at IS NULL", userID).Cols("used_at").Update(&models.RecoveryCode{UsedAt: &now}); err != nil { session := s.engine.NewSession()
defer session.Close()
if err := session.Begin(); err != nil {
return err
}
defer session.Rollback()
if err := accountgroup.CheckWrite(session, guards...); err != nil {
return err
}
if _, err := session.Where("user_id = ? AND used_at IS NULL", userID).Cols("used_at").Update(&models.RecoveryCode{UsedAt: &now}); err != nil {
return fmt.Errorf("revoke recovery codes: %w", err) return fmt.Errorf("revoke recovery codes: %w", err)
} }
return nil return session.Commit()
} }
// RevokeAll 撤销用户的恢复码、密保问题、安全密钥及邮件授权码。 // RevokeAll 撤销用户的恢复码、密保问题、安全密钥及邮件授权码。
@ -352,8 +363,6 @@ func (s *Service) RevokeAllWithSession(session *xorm.Session, userID int) error
if userID <= 0 { if userID <= 0 {
return ErrInvalidUser return ErrInvalidUser
} }
s.mu.Lock()
defer s.mu.Unlock()
now := s.now() now := s.now()
if _, err := session.Where("user_id = ? AND used_at IS NULL", userID).Cols("used_at").Update(&models.RecoveryCode{UsedAt: &now}); err != nil { if _, err := session.Where("user_id = ? AND used_at IS NULL", userID).Cols("used_at").Update(&models.RecoveryCode{UsedAt: &now}); err != nil {
return fmt.Errorf("revoke recovery codes: %w", err) return fmt.Errorf("revoke recovery codes: %w", err)

View File

@ -7,6 +7,7 @@ import (
"time" "time"
"git.pcux.cn/xuans/Smail/models" "git.pcux.cn/xuans/Smail/models"
"git.pcux.cn/xuans/Smail/services/accountgroup"
"xorm.io/xorm" "xorm.io/xorm"
) )
@ -32,7 +33,30 @@ type BindRequest struct {
} }
// BeginReplacement 创建或更新待验证邮箱。旧邮箱会一直保留到新邮箱验证成功。 // BeginReplacement 创建或更新待验证邮箱。旧邮箱会一直保留到新邮箱验证成功。
func (r *Repository) BeginReplacement(request BindRequest) (*models.ExternalEmail, error) { func (r *Repository) BeginReplacement(request BindRequest, guards ...accountgroup.WriteGuard) (*models.ExternalEmail, error) {
if r == nil || r.engine == nil {
return nil, ErrInvalidBinding
}
tx := r.engine.NewSession()
defer tx.Close()
if err := tx.Begin(); err != nil {
return nil, err
}
defer tx.Rollback()
if err := accountgroup.CheckWrite(tx, guards...); err != nil {
return nil, err
}
item, err := r.beginReplacement(tx, request)
if err != nil {
return nil, err
}
if err := tx.Commit(); err != nil {
return nil, err
}
return item, nil
}
func (r *Repository) beginReplacement(tx *xorm.Session, request BindRequest) (*models.ExternalEmail, error) {
if r == nil || r.engine == nil || r.vault == nil || request.UserID <= 0 { if r == nil || r.engine == nil || r.vault == nil || request.UserID <= 0 {
return nil, ErrInvalidBinding return nil, ErrInvalidBinding
} }
@ -53,7 +77,7 @@ func (r *Repository) BeginReplacement(request BindRequest) (*models.ExternalEmai
} }
var sameAddress models.ExternalEmail var sameAddress models.ExternalEmail
hasSameAddress, err := r.engine.Where("email_fingerprint = ?", fingerprint).Get(&sameAddress) hasSameAddress, err := tx.Where("email_fingerprint = ?", fingerprint).Get(&sameAddress)
if err != nil { if err != nil {
return nil, fmt.Errorf("check replacement external email: %w", err) return nil, fmt.Errorf("check replacement external email: %w", err)
} }
@ -76,7 +100,7 @@ func (r *Repository) BeginReplacement(request BindRequest) (*models.ExternalEmai
sameAddress.VerifiedAt = nil sameAddress.VerifiedAt = nil
sameAddress.CredentialVersion++ sameAddress.CredentialVersion++
sameAddress.UpdatedAt = now sameAddress.UpdatedAt = now
updated, err := r.engine.ID(sameAddress.ID).Where("status = ?", models.ExternalEmailStatusDisabled). updated, err := tx.ID(sameAddress.ID).Where("status = ?", models.ExternalEmailStatusDisabled).
Cols("email_type", "status", "verified_at", "credential_version", "updated_at").Update(&sameAddress) Cols("email_type", "status", "verified_at", "credential_version", "updated_at").Update(&sameAddress)
if err != nil { if err != nil {
return nil, fmt.Errorf("reuse disabled replacement external email: %w", err) return nil, fmt.Errorf("reuse disabled replacement external email: %w", err)
@ -88,7 +112,7 @@ func (r *Repository) BeginReplacement(request BindRequest) (*models.ExternalEmai
} }
var pending models.ExternalEmail var pending models.ExternalEmail
hasPending, err := r.engine.Where( hasPending, err := tx.Where(
"user_id = ? AND email_type = ? AND status = ?", "user_id = ? AND email_type = ? AND status = ?",
request.UserID, request.UserID,
request.Type, request.Type,
@ -102,7 +126,7 @@ func (r *Repository) BeginReplacement(request BindRequest) (*models.ExternalEmai
pending.EmailFingerprint = fingerprint pending.EmailFingerprint = fingerprint
pending.CredentialVersion++ pending.CredentialVersion++
pending.UpdatedAt = now pending.UpdatedAt = now
if _, err := r.engine.ID(pending.ID).Cols("email_ciphertext", "email_fingerprint", "credential_version", "updated_at").Update(&pending); err != nil { if _, err := tx.ID(pending.ID).Cols("email_ciphertext", "email_fingerprint", "credential_version", "updated_at").Update(&pending); err != nil {
return nil, fmt.Errorf("update pending replacement external email: %w", err) return nil, fmt.Errorf("update pending replacement external email: %w", err)
} }
return &pending, nil return &pending, nil
@ -118,7 +142,7 @@ func (r *Repository) BeginReplacement(request BindRequest) (*models.ExternalEmai
CreatedAt: now, CreatedAt: now,
UpdatedAt: now, UpdatedAt: now,
} }
if _, err := r.engine.Insert(item); err != nil { if _, err := tx.Insert(item); err != nil {
return nil, fmt.Errorf("create replacement external email: %w", err) return nil, fmt.Errorf("create replacement external email: %w", err)
} }
return item, nil return item, nil
@ -311,7 +335,7 @@ func (r *Repository) Verify(userID, id int) error {
} }
// VerifyReplacement 验证新邮箱后原子停用同用途旧邮箱,并启用新邮箱。 // VerifyReplacement 验证新邮箱后原子停用同用途旧邮箱,并启用新邮箱。
func (r *Repository) VerifyReplacement(userID, id int) error { func (r *Repository) VerifyReplacement(userID, id int, guards ...accountgroup.WriteGuard) error {
if r == nil || r.engine == nil || userID <= 0 || id <= 0 { if r == nil || r.engine == nil || userID <= 0 || id <= 0 {
return ErrInvalidBinding return ErrInvalidBinding
} }
@ -330,6 +354,9 @@ func (r *Repository) VerifyReplacement(userID, id int) error {
} }
}() }()
if err := accountgroup.CheckWrite(session, guards...); err != nil {
return err
}
item := &models.ExternalEmail{} item := &models.ExternalEmail{}
has, err := session.Where("id = ? AND user_id = ? AND status = ?", id, userID, models.ExternalEmailStatusPending).Get(item) has, err := session.Where("id = ? AND user_id = ? AND status = ?", id, userID, models.ExternalEmailStatusPending).Get(item)
if err != nil { if err != nil {

View File

@ -11,6 +11,7 @@ import (
"time" "time"
"git.pcux.cn/xuans/Smail/models" "git.pcux.cn/xuans/Smail/models"
"git.pcux.cn/xuans/Smail/services/accountgroup"
"xorm.io/xorm" "xorm.io/xorm"
) )
@ -52,17 +53,33 @@ func Configured(item models.NotificationGrant, user models.User) bool {
} }
// SetAuthorization 使用版本条件避免覆盖其他管理员的修改;关闭时永久清除旧凭据。 // SetAuthorization 使用版本条件避免覆盖其他管理员的修改;关闭时永久清除旧凭据。
func SetAuthorization(engine *xorm.Engine, user models.User, expected int, enabled bool, minuteLimit, dayLimit int) error { func SetAuthorization(engine *xorm.Engine, user models.User, expected int, enabled bool, minuteLimit, dayLimit int, guards ...accountgroup.WriteGuard) error {
if minuteLimit < 1 || minuteLimit > 100 || dayLimit < minuteLimit || dayLimit > 10000 { if minuteLimit < 1 || minuteLimit > 100 || dayLimit < minuteLimit || dayLimit > 10000 {
return ErrLimits return ErrLimits
} }
if enabled && (user.Disabled != 0 || user.MustChangePassword != 0) { if enabled && (user.Disabled != 0 || user.MustChangePassword != 0) {
return ErrDenied return ErrDenied
} }
item, err := Load(engine, user.ID) if engine == nil || user.ID <= 0 {
return ErrUnavailable
}
tx := engine.NewSession()
defer tx.Close()
if err := tx.Begin(); err != nil {
return err
}
defer tx.Rollback()
if err := accountgroup.CheckWrite(tx, guards...); err != nil {
return err
}
var item models.NotificationGrant
found, err := tx.ID(user.ID).Get(&item)
if err != nil { if err != nil {
return err return err
} }
if !found {
item = models.NotificationGrant{UserID: user.ID}
}
if item.CodeVersion != expected { if item.CodeVersion != expected {
return ErrConflict return ErrConflict
} }
@ -75,20 +92,38 @@ func SetAuthorization(engine *xorm.Engine, user models.User, expected int, enabl
item.MinuteLimit, item.DayLimit = minuteLimit, dayLimit item.MinuteLimit, item.DayLimit = minuteLimit, dayLimit
item.CodeVersion++ item.CodeVersion++
if expected == 0 { if expected == 0 {
_, err = engine.Insert(&item) if _, err := tx.Insert(&item); err != nil {
return err
}
return tx.Commit()
}
affected, err := tx.ID(user.ID).Where("code_version = ?", expected).
Cols("enabled", "code_hash", "code_version", "minute_limit", "day_limit").Update(&item)
if err != nil {
return err return err
} }
affected, err := engine.ID(user.ID).Where("code_version = ?", expected). if affected != 1 {
Cols("enabled", "code_hash", "code_version", "minute_limit", "day_limit").Update(&item)
if err == nil && affected != 1 {
return ErrConflict return ErrConflict
} }
return err return tx.Commit()
} }
// Rotate 仅返回一次明文。随机凭据具有 256 位熵,数据库只保存 SHA-256 摘要。 // Rotate 仅返回一次明文。随机凭据具有 256 位熵,数据库只保存 SHA-256 摘要。
func Rotate(engine *xorm.Engine, user models.User, expected int) (string, error) { func Rotate(engine *xorm.Engine, user models.User, expected int, guards ...accountgroup.WriteGuard) (string, error) {
item, err := Load(engine, user.ID) if engine == nil || user.ID <= 0 {
return "", ErrUnavailable
}
tx := engine.NewSession()
defer tx.Close()
if err := tx.Begin(); err != nil {
return "", err
}
defer tx.Rollback()
if err := accountgroup.CheckWrite(tx, guards...); err != nil {
return "", err
}
var item models.NotificationGrant
_, err := tx.ID(user.ID).Get(&item)
if err != nil { if err != nil {
return "", err return "", err
} }
@ -106,7 +141,7 @@ func Rotate(engine *xorm.Engine, user models.User, expected int) (string, error)
item.CodeHash = digest(code) item.CodeHash = digest(code)
item.CodeVersion++ item.CodeVersion++
item.SessionVersion, item.SecurityVersion = user.SessionVersion, user.SecurityVersion item.SessionVersion, item.SecurityVersion = user.SessionVersion, user.SecurityVersion
affected, err := engine.ID(user.ID).Where("code_version = ? AND enabled = 1", expected). affected, err := tx.ID(user.ID).Where("code_version = ? AND enabled = 1", expected).
Cols("code_hash", "code_version", "session_version", "security_version").Update(&item) Cols("code_hash", "code_version", "session_version", "security_version").Update(&item)
if err != nil { if err != nil {
return "", err return "", err
@ -114,6 +149,9 @@ func Rotate(engine *xorm.Engine, user models.User, expected int) (string, error)
if affected != 1 { if affected != 1 {
return "", ErrConflict return "", ErrConflict
} }
if err := tx.Commit(); err != nil {
return "", err
}
return code, nil return code, nil
} }

View File

@ -11,22 +11,24 @@ const (
) )
type Context struct { type Context struct {
context.Context `json:"-"` context.Context `json:"-"`
UserID int UserID int
UserAccount string SessionVersion int
UserName string SecurityVersion int
Role access.Role UserAccount string
Values map[string]any UserName string
Lang string Role access.Role
IsAdmin bool Values map[string]any
MustChangePassword int Lang string
IsAdmin bool
MustChangePassword int
SecuritySetupAcknowledged int SecuritySetupAcknowledged int
RecentAuth bool RecentAuth bool
RecoveryCodeConfigured bool RecoveryCodeConfigured bool
TargetRecoveryConfigured bool TargetRecoveryConfigured bool
PluginInstanceAuthorized bool PluginInstanceAuthorized bool
AuditScopeAuthorized bool AuditScopeAuthorized bool
DeploymentChannel bool DeploymentChannel bool
} }
// HasRole 判断当前请求是否属于指定角色。 // HasRole 判断当前请求是否属于指定角色。