修复管理员分组代管写入的并发权限边界
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"
)
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
}

View File

@ -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, "授权状态已变化,请刷新后重试")

View File

@ -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")
}

View File

@ -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

View File

@ -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()

View File

@ -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(&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 {
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++

View File

@ -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)

View File

@ -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 {

View File

@ -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
}

View File

@ -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 判断当前请求是否属于指定角色。