diff --git a/backend/controllers/account_group.go b/backend/controllers/account_group.go index 34b3057..e80b099 100644 --- a/backend/controllers/account_group.go +++ b/backend/controllers/account_group.go @@ -12,6 +12,13 @@ import ( "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 { if ctx == nil || !accountgroup.CanAccess(db.Instance, ctx.UserID, ctx.EffectiveRole(), targetID) { writeSecurityError(w, response.NoAccessPrivileges, "无权查看或管理该账号") @@ -110,7 +117,7 @@ func SetAccountGroupMembership(ctx *context.Context, w http.ResponseWriter, req writeAccountGroupError(w, accountgroup.ErrConflict) 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) return } diff --git a/backend/controllers/system_notification.go b/backend/controllers/system_notification.go index 591d388..09a3056 100644 --- a/backend/controllers/system_notification.go +++ b/backend/controllers/system_notification.go @@ -172,7 +172,7 @@ func SetNotificationAuthorization(ctx *context.Context, w http.ResponseWriter, r if !ok { 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}) if err != nil { writeNotificationError(w, err) @@ -191,7 +191,7 @@ func RotateNotificationCredential(ctx *context.Context, w http.ResponseWriter, r if !ok { 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) if err != nil { writeNotificationError(w, err) @@ -212,6 +212,10 @@ func recordNotificationManagement(ctx *context.Context, userID int, action strin } func writeNotificationError(w http.ResponseWriter, err error) { + if errors.Is(err, accountgroup.ErrDenied) || errors.Is(err, accountgroup.ErrConflict) { + writeAccountGroupError(w, err) + return + } switch { case errors.Is(err, notification.ErrConflict): writeSecurityError(w, response.ParamsError, "授权状态已变化,请刷新后重试") diff --git a/backend/controllers/user.go b/backend/controllers/user.go index 5ad17d0..ef32b4a 100644 --- a/backend/controllers/user.go +++ b/backend/controllers/user.go @@ -320,6 +320,7 @@ func EditUser(ctx *context.Context, w http.ResponseWriter, req *http.Request) { return } targetRole := user.EffectiveRole() + writeGuard := accountWriteGuard(ctx, user) if !requireAccountScope(ctx, w, user.ID) { return } @@ -421,6 +422,10 @@ func EditUser(ctx *context.Context, w http.ResponseWriter, req *http.Request) { } }() updateColumns := []string{"name", "disabled", "role", "is_admin"} + if err := accountgroup.CheckWrite(tx, writeGuard); err != nil { + writeAccountGroupError(w, err) + return + } if reqData.Password != "" { updateColumns = append(updateColumns, "password", "must_change_password", "password_changed_at") } diff --git a/backend/controllers/user_security.go b/backend/controllers/user_security.go index 5587547..f35bf34 100644 --- a/backend/controllers/user_security.go +++ b/backend/controllers/user_security.go @@ -10,6 +10,7 @@ import ( "git.pcux.cn/xuans/Smail/dto/response" "git.pcux.cn/xuans/Smail/models" "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/verification" "git.pcux.cn/xuans/Smail/utils/context" @@ -64,6 +65,14 @@ func loadManagedSecurityUser(ctx *context.Context, w http.ResponseWriter, userID 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 { if req == nil || req.URL == nil { return 0 @@ -112,8 +121,11 @@ func GenerateManagedRecoveryCodes(ctx *context.Context, w http.ResponseWriter, r writeSecurityError(w, response.ServerError, "安全凭据服务不可用") 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 writeManagedScopeError(w, err) { + return + } if writePasswordBusy(w, err) { return } @@ -145,7 +157,10 @@ func RevokeManagedRecoveryCodes(ctx *context.Context, w http.ResponseWriter, req writeSecurityError(w, response.ServerError, "安全凭据服务不可用") 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") writeSecurityError(w, response.ServerError, "撤销安全码失败") return @@ -230,8 +245,11 @@ func ChangeManagedExternalEmail(ctx *context.Context, w http.ResponseWriter, req UserID: target.ID, Email: normalizedEmail, Type: emailType, - }) + }, accountWriteGuard(ctx, *target)) if err != nil { + if writeManagedScopeError(w, err) { + return + } if errors.Is(err, externalemail.ErrBindingExists) { writeSecurityError(w, response.ParamsError, "该外部邮箱已经绑定或正在被其他账号使用") return @@ -295,7 +313,10 @@ func VerifyManagedExternalEmail(ctx *context.Context, w http.ResponseWriter, req writeSecurityError(w, response.ParamsError, externalEmailVerificationCheckMessage(err)) 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") writeSecurityError(w, response.ParamsError, "外部邮箱替换失败") return diff --git a/backend/listen/http_server/https_server.go b/backend/listen/http_server/https_server.go index 267257e..4231278 100644 --- a/backend/listen/http_server/https_server.go +++ b/backend/listen/http_server/https_server.go @@ -187,6 +187,8 @@ func contextIterceptor(h controllers.HandlerFunc) http.HandlerFunc { } } else { ctx.UserID = currentUser.ID + ctx.SessionVersion = currentUser.SessionVersion + ctx.SecurityVersion = currentUser.SecurityVersion ctx.UserName = currentUser.Name ctx.UserAccount = currentUser.Account ctx.Role = currentUser.EffectiveRole() diff --git a/backend/services/accountgroup/service.go b/backend/services/accountgroup/service.go index 2e11881..1d659c9 100644 --- a/backend/services/accountgroup/service.go +++ b/backend/services/accountgroup/service.go @@ -184,7 +184,63 @@ func ReplaceMembership(tx *xorm.Session, userID int, ids []int) error { 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(¤t) + 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 { return ErrDenied } @@ -194,6 +250,9 @@ func SetMembership(engine *xorm.Engine, user models.User, input []int) error { return err } defer tx.Rollback() + if err := CheckWrite(tx, guards...); err != nil { + return err + } updated := user updated.SessionVersion++ updated.SecurityVersion++ diff --git a/backend/services/credentials/service.go b/backend/services/credentials/service.go index 10d8e58..a8f2106 100644 --- a/backend/services/credentials/service.go +++ b/backend/services/credentials/service.go @@ -10,6 +10,7 @@ import ( "time" "git.pcux.cn/xuans/Smail/models" + "git.pcux.cn/xuans/Smail/services/accountgroup" "git.pcux.cn/xuans/Smail/utils/password" log "github.com/sirupsen/logrus" "golang.org/x/text/unicode/norm" @@ -146,11 +147,11 @@ func (s *Service) GenerateRecoveryCodes(userID int, count int) ([]string, error) // GenerateRecoveryCodesContext 在请求取消时停止后续计算,并回滚尚未提交的恢复码轮换。 // 单次 Argon2id 计算不可中断,因此每次计算前后检查取消,不降低原有密码强度。 -func (s *Service) GenerateRecoveryCodesContext(ctx context.Context, userID int, count int) ([]string, error) { - return s.generateRecoveryCodes(ctx, userID, count, password.Hash) +func (s *Service) GenerateRecoveryCodesContext(ctx context.Context, userID int, count int, guards ...accountgroup.WriteGuard) ([]string, error) { + 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 { 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) } defer session.Rollback() + if err := accountgroup.CheckWrite(session, guards...); err != nil { + return nil, err + } 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 { _ = session.Rollback() @@ -263,8 +267,6 @@ func (s *Service) ConsumeRecoveryCodeWithSession(session *xorm.Session, userID i if len(code) < 12 || len(code) > 64 { return ErrInvalidCode } - s.mu.Lock() - defer s.mu.Unlock() return s.consumeRecoveryCode(session, userID, code) } @@ -315,7 +317,7 @@ func (s *Service) HasValidRecoveryCode(userID int) (bool, error) { } // RevokeRecoveryCodes 撤销用户现有的离线恢复码,但保留密保问题。 -func (s *Service) RevokeRecoveryCodes(userID int) error { +func (s *Service) RevokeRecoveryCodes(userID int, guards ...accountgroup.WriteGuard) error { if s == nil || s.engine == nil { return ErrUnavailable } @@ -325,10 +327,19 @@ func (s *Service) RevokeRecoveryCodes(userID int) error { s.mu.Lock() defer s.mu.Unlock() 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 nil + return session.Commit() } // RevokeAll 撤销用户的恢复码、密保问题、安全密钥及邮件授权码。 @@ -352,8 +363,6 @@ func (s *Service) RevokeAllWithSession(session *xorm.Session, userID int) error if userID <= 0 { return ErrInvalidUser } - s.mu.Lock() - defer s.mu.Unlock() 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 { return fmt.Errorf("revoke recovery codes: %w", err) diff --git a/backend/services/externalemail/repository.go b/backend/services/externalemail/repository.go index 1746313..ad00493 100644 --- a/backend/services/externalemail/repository.go +++ b/backend/services/externalemail/repository.go @@ -7,6 +7,7 @@ import ( "time" "git.pcux.cn/xuans/Smail/models" + "git.pcux.cn/xuans/Smail/services/accountgroup" "xorm.io/xorm" ) @@ -32,7 +33,30 @@ type BindRequest struct { } // 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 { return nil, ErrInvalidBinding } @@ -53,7 +77,7 @@ func (r *Repository) BeginReplacement(request BindRequest) (*models.ExternalEmai } 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 { 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.CredentialVersion++ 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) if err != nil { 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 - hasPending, err := r.engine.Where( + hasPending, err := tx.Where( "user_id = ? AND email_type = ? AND status = ?", request.UserID, request.Type, @@ -102,7 +126,7 @@ func (r *Repository) BeginReplacement(request BindRequest) (*models.ExternalEmai pending.EmailFingerprint = fingerprint pending.CredentialVersion++ 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 &pending, nil @@ -118,7 +142,7 @@ func (r *Repository) BeginReplacement(request BindRequest) (*models.ExternalEmai CreatedAt: 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 item, nil @@ -311,7 +335,7 @@ func (r *Repository) Verify(userID, id int) error { } // 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 { 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{} has, err := session.Where("id = ? AND user_id = ? AND status = ?", id, userID, models.ExternalEmailStatusPending).Get(item) if err != nil { diff --git a/backend/services/notification/service.go b/backend/services/notification/service.go index b46dd0f..d797dae 100644 --- a/backend/services/notification/service.go +++ b/backend/services/notification/service.go @@ -11,6 +11,7 @@ import ( "time" "git.pcux.cn/xuans/Smail/models" + "git.pcux.cn/xuans/Smail/services/accountgroup" "xorm.io/xorm" ) @@ -52,17 +53,33 @@ func Configured(item models.NotificationGrant, user models.User) bool { } // 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 { return ErrLimits } if enabled && (user.Disabled != 0 || user.MustChangePassword != 0) { 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 { return err } + if !found { + item = models.NotificationGrant{UserID: user.ID} + } if item.CodeVersion != expected { return ErrConflict } @@ -75,20 +92,38 @@ func SetAuthorization(engine *xorm.Engine, user models.User, expected int, enabl item.MinuteLimit, item.DayLimit = minuteLimit, dayLimit item.CodeVersion++ 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 } - affected, err := engine.ID(user.ID).Where("code_version = ?", expected). - Cols("enabled", "code_hash", "code_version", "minute_limit", "day_limit").Update(&item) - if err == nil && affected != 1 { + if affected != 1 { return ErrConflict } - return err + return tx.Commit() } // Rotate 仅返回一次明文。随机凭据具有 256 位熵,数据库只保存 SHA-256 摘要。 -func Rotate(engine *xorm.Engine, user models.User, expected int) (string, error) { - item, err := Load(engine, user.ID) +func Rotate(engine *xorm.Engine, user models.User, expected int, guards ...accountgroup.WriteGuard) (string, error) { + 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 { return "", err } @@ -106,7 +141,7 @@ func Rotate(engine *xorm.Engine, user models.User, expected int) (string, error) item.CodeHash = digest(code) item.CodeVersion++ 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) if err != nil { return "", err @@ -114,6 +149,9 @@ func Rotate(engine *xorm.Engine, user models.User, expected int) (string, error) if affected != 1 { return "", ErrConflict } + if err := tx.Commit(); err != nil { + return "", err + } return code, nil } diff --git a/backend/utils/context/context.go b/backend/utils/context/context.go index 5b9592f..0e717a3 100644 --- a/backend/utils/context/context.go +++ b/backend/utils/context/context.go @@ -11,22 +11,24 @@ const ( ) type Context struct { - context.Context `json:"-"` - UserID int - UserAccount string - UserName string - Role access.Role - Values map[string]any - Lang string - IsAdmin bool - MustChangePassword int + context.Context `json:"-"` + UserID int + SessionVersion int + SecurityVersion int + UserAccount string + UserName string + Role access.Role + Values map[string]any + Lang string + IsAdmin bool + MustChangePassword int SecuritySetupAcknowledged int - RecentAuth bool - RecoveryCodeConfigured bool - TargetRecoveryConfigured bool - PluginInstanceAuthorized bool - AuditScopeAuthorized bool - DeploymentChannel bool + RecentAuth bool + RecoveryCodeConfigured bool + TargetRecoveryConfigured bool + PluginInstanceAuthorized bool + AuditScopeAuthorized bool + DeploymentChannel bool } // HasRole 判断当前请求是否属于指定角色。