From 10a2733605d57dc1a6c6837109f008daf43cb011 Mon Sep 17 00:00:00 2001 From: smail Date: Fri, 2 Oct 2026 18:36:39 +0800 Subject: [PATCH] =?UTF-8?q?=E8=A1=A5=E9=BD=90=E5=AE=89=E5=85=A8=E7=A0=81?= =?UTF-8?q?=E4=B8=8E=E5=B7=B2=E9=AA=8C=E8=AF=81=E9=82=AE=E7=AE=B1=E4=BA=8C?= =?UTF-8?q?=E9=80=89=E4=B8=80=E6=8E=88=E6=9D=83=E5=9B=9E=E5=BD=92=E8=A6=86?= =?UTF-8?q?=E7=9B=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/config/config_security_test.go | 24 ++++++++ backend/config/manager_v2.go | 12 ++-- backend/config/security.go | 8 +-- backend/controllers/authorization.go | 24 ++++---- backend/controllers/security_setup_test.go | 69 ++++++++++++++++++++++ backend/services/access/access_test.go | 57 ++++++++++++++++++ backend/services/access/authorize.go | 24 ++++---- backend/utils/context/context.go | 38 ++++++------ 8 files changed, 203 insertions(+), 53 deletions(-) diff --git a/backend/config/config_security_test.go b/backend/config/config_security_test.go index 15f706b..d5716b8 100644 --- a/backend/config/config_security_test.go +++ b/backend/config/config_security_test.go @@ -8,10 +8,34 @@ import ( "path/filepath" "testing" + "git.pcux.cn/xuans/Smail/services/access" appcontext "git.pcux.cn/xuans/Smail/utils/context" log "github.com/sirupsen/logrus" ) +func TestConfigAuthorizationAcceptsEitherRecoveryOption(t *testing.T) { + actor := AuditActor{UserID: "2", Role: string(access.RoleAdmin), RecentAuth: true, ExternalEmailConfigured: true} + if err := DefaultConfigAuthorization(stdcontext.Background(), actor, ConfigOperationUpdate, SectionPlugins); err != nil { + t.Fatalf("email-only admin rejected: %v", err) + } + actor.RecentAuth = false + if err := DefaultConfigAuthorization(stdcontext.Background(), actor, ConfigOperationUpdate, SectionPlugins); !access.IsDenied(err, access.CodeRecentAuthRequired) { + t.Fatalf("email bypassed recent auth: %v", err) + } + actor.RecentAuth = true + if err := DefaultConfigAuthorization(stdcontext.Background(), actor, ConfigOperationRollback, SectionRoot); !access.IsDenied(err, access.CodeAccessDenied) { + t.Fatalf("email granted rollback: %v", err) + } + actor.ExternalEmailConfigured = false + if err := DefaultConfigAuthorization(stdcontext.Background(), actor, ConfigOperationUpdate, SectionPlugins); !access.IsDenied(err, access.CodeRecoveryCodeRequired) { + t.Fatalf("missing recovery option accepted: %v", err) + } + actor.RecoveryCodeConfigured = true + if err := DefaultConfigAuthorization(stdcontext.Background(), actor, ConfigOperationUpdate, SectionPlugins); err != nil { + t.Fatalf("code-only admin rejected: %v", err) + } +} + func TestLogFormatterHandlesMissingLogID(t *testing.T) { entry := &log.Entry{ Context: &appcontext.Context{Context: stdcontext.Background()}, diff --git a/backend/config/manager_v2.go b/backend/config/manager_v2.go index 180e797..af97a96 100644 --- a/backend/config/manager_v2.go +++ b/backend/config/manager_v2.go @@ -55,12 +55,12 @@ type UpdateRequest struct { } type AuditActor struct { - UserID string - Name string - Role string - SessionID string - RecentAuth bool - RecoveryCodeConfigured bool + UserID string + Name string + Role string + SessionID string + RecentAuth bool + RecoveryCodeConfigured bool ExternalEmailConfigured bool } diff --git a/backend/config/security.go b/backend/config/security.go index bf4ec5a..dad2c28 100644 --- a/backend/config/security.go +++ b/backend/config/security.go @@ -162,10 +162,10 @@ func DefaultConfigAuthorization(_ context.Context, actor AuditActor, operation C capability = access.CapabilityDatabaseMigrate } request := access.Request{ - ActorRole: access.Role(actor.Role), - Capability: capability, - RecentAuth: actor.RecentAuth, - RecoveryCodeConfigured: actor.RecoveryCodeConfigured, + ActorRole: access.Role(actor.Role), + Capability: capability, + RecentAuth: actor.RecentAuth, + RecoveryCodeConfigured: actor.RecoveryCodeConfigured, ExternalEmailConfigured: actor.ExternalEmailConfigured, } if operation == ConfigOperationRollback && request.ActorRole == access.RoleSuperAdmin { diff --git a/backend/controllers/authorization.go b/backend/controllers/authorization.go index d899e36..e11bdef 100644 --- a/backend/controllers/authorization.go +++ b/backend/controllers/authorization.go @@ -16,18 +16,18 @@ func authorizeCapabilityWithNewRole(ctx *context.Context, capability access.Capa return &access.Denial{Code: access.CodeAccessDenied} } return access.Authorize(access.Request{ - ActorRole: ctx.EffectiveRole(), - Capability: capability, - TargetRole: targetRole, - OwnResource: ownResource, - RecentAuth: ctx.RecentAuth, - RecoveryCodeConfigured: ctx.RecoveryCodeConfigured, - ExternalEmailConfigured: ctx.ExternalEmailConfigured, - TargetRecoveryConfigured: ctx.TargetRecoveryConfigured, + ActorRole: ctx.EffectiveRole(), + Capability: capability, + TargetRole: targetRole, + OwnResource: ownResource, + RecentAuth: ctx.RecentAuth, + RecoveryCodeConfigured: ctx.RecoveryCodeConfigured, + ExternalEmailConfigured: ctx.ExternalEmailConfigured, + TargetRecoveryConfigured: ctx.TargetRecoveryConfigured, TargetExternalEmailConfigured: ctx.TargetExternalEmailConfigured, - NewRole: newRole, - PluginInstanceAuthorized: ctx.PluginInstanceAuthorized, - AuditScopeAuthorized: ctx.AuditScopeAuthorized, - DeploymentChannel: ctx.DeploymentChannel, + NewRole: newRole, + PluginInstanceAuthorized: ctx.PluginInstanceAuthorized, + AuditScopeAuthorized: ctx.AuditScopeAuthorized, + DeploymentChannel: ctx.DeploymentChannel, }) } diff --git a/backend/controllers/security_setup_test.go b/backend/controllers/security_setup_test.go index fb22a2f..9ddb3db 100644 --- a/backend/controllers/security_setup_test.go +++ b/backend/controllers/security_setup_test.go @@ -71,6 +71,75 @@ func setupSecurityPromptTest(t *testing.T) *xorm.Engine { return engine } +func TestSecurityContextRecoveryOptionsRefresh(t *testing.T) { + for _, role := range []access.Role{access.RoleAdmin, access.RoleUser} { + t.Run(string(role), func(t *testing.T) { + engine := setupSecurityPromptTest(t) + user := models.User{Account: "option-user", Role: string(role)} + if _, err := engine.Insert(&user); err != nil { + t.Fatal(err) + } + ctx := &appcontext.Context{UserID: user.ID, Role: role, RecentAuth: true, RecoveryCodeConfigured: true, ExternalEmailConfigured: true} + assertState := func(code, email bool) { + t.Helper() + LoadSecurityContext(ctx) + if ctx.RecoveryCodeConfigured != code || ctx.ExternalEmailConfigured != email { + t.Fatalf("unexpected context: code=%v email=%v", ctx.RecoveryCodeConfigured, ctx.ExternalEmailConfigured) + } + } + assertState(false, false) + email := models.ExternalEmail{UserID: user.ID, Status: models.ExternalEmailStatusPending, EmailFingerprint: "fixture"} + if _, err := engine.Insert(&email); err != nil { + t.Fatal(err) + } + assertState(false, false) + if _, err := engine.ID(email.ID).Cols("status").Update(&models.ExternalEmail{Status: models.ExternalEmailStatusVerified}); err != nil { + t.Fatal(err) + } + assertState(false, true) + if role == access.RoleAdmin { + if err := authorizeCapability(ctx, access.CapabilityPasswordReset, access.RoleUser, false); err != nil { + t.Fatalf("email state not forwarded: %v", err) + } + } + if _, err := engine.ID(email.ID).Cols("status").Update(&models.ExternalEmail{Status: models.ExternalEmailStatusDisabled}); err != nil { + t.Fatal(err) + } + assertState(false, false) + code := models.RecoveryCode{UserID: user.ID, CodeHash: "fixture"} + if _, err := engine.Insert(&code); err != nil { + t.Fatal(err) + } + assertState(true, false) + now := time.Now() + if _, err := engine.ID(code.ID).Cols("used_at").Update(&models.RecoveryCode{UsedAt: &now}); err != nil { + t.Fatal(err) + } + assertState(false, false) + ctx.UserID = 0 + ctx.ExternalEmailConfigured, ctx.RecoveryCodeConfigured = true, true + assertState(false, false) + ctx.UserID = user.ID + db.Instance = nil + ctx.ExternalEmailConfigured, ctx.RecoveryCodeConfigured = true, true + assertState(false, false) + }) + } + LoadSecurityContext(nil) +} + +func TestRoleAuthorizationForwardsTargetEmail(t *testing.T) { + ctx := &appcontext.Context{UserID: 1, Role: access.RoleSuperAdmin, RecentAuth: true, TargetExternalEmailConfigured: true} + if err := authorizeCapabilityWithNewRole(ctx, access.CapabilityRoleManage, access.RoleUser, access.RoleAdmin, false); err != nil { + t.Fatalf("target email was not forwarded: %v", err) + } + ctx.TargetExternalEmailConfigured = false + ctx.ExternalEmailConfigured = true + if err := authorizeCapabilityWithNewRole(ctx, access.CapabilityRoleManage, access.RoleUser, access.RoleAdmin, false); !access.IsDenied(err, access.CodeRecoveryCodeRequired) { + t.Fatalf("actor state substituted for target: %v", err) + } +} + func TestSecuritySetupAcknowledgementIsSessionScoped(t *testing.T) { engine := setupSecurityPromptTest(t) previousSession, previousConfig, previousManager := session.Instance, config.Instance, config.CurrentManager() diff --git a/backend/services/access/access_test.go b/backend/services/access/access_test.go index 1f94ed6..86d7328 100644 --- a/backend/services/access/access_test.go +++ b/backend/services/access/access_test.go @@ -2,6 +2,63 @@ package access import "testing" +func TestRecoveryOptionAuthorization(t *testing.T) { + for _, capability := range []Capability{CapabilityPasswordReset, CapabilityUserSecurityManage, CapabilityNotificationManage} { + for _, ready := range []struct { + name string + code bool + email bool + }{{"neither", false, false}, {"code", true, false}, {"email", false, true}, {"both", true, true}} { + t.Run(string(capability)+"/"+ready.name, func(t *testing.T) { + req := Request{ActorRole: RoleAdmin, TargetRole: RoleUser, Capability: capability, RecentAuth: true, + RecoveryCodeConfigured: ready.code, ExternalEmailConfigured: ready.email} + err := Authorize(req) + if ready.code || ready.email { + if err != nil { + t.Fatalf("expected allow: %v", err) + } + } else if !IsDenied(err, CodeRecoveryCodeRequired) { + t.Fatalf("expected recovery requirement: %v", err) + } + req.RecentAuth = false + if err := Authorize(req); !IsDenied(err, CodeRecentAuthRequired) { + t.Fatalf("missing recent auth accepted: %v", err) + } + }) + } + } + for _, target := range []Role{RoleAdmin, RoleSuperAdmin} { + req := Request{ActorRole: RoleAdmin, TargetRole: target, Capability: CapabilityPasswordReset, RecentAuth: true, ExternalEmailConfigured: true} + if err := Authorize(req); !IsDenied(err, CodeTargetRoleDenied) { + t.Fatalf("email bypassed target boundary: %v", err) + } + } + req := Request{ActorRole: RoleUser, TargetRole: RoleUser, Capability: CapabilityNotificationManage, RecentAuth: true, ExternalEmailConfigured: true, RecoveryCodeConfigured: true} + if err := Authorize(req); !IsDenied(err, CodeAccessDenied) { + t.Fatalf("user received management permission: %v", err) + } +} + +func TestRoleChangeAcceptsTargetEmailWithoutBorrowingActorState(t *testing.T) { + req := Request{ActorRole: RoleSuperAdmin, Capability: CapabilityRoleManage, TargetRole: RoleUser, NewRole: RoleAdmin, RecentAuth: true, ExternalEmailConfigured: true} + if err := Authorize(req); !IsDenied(err, CodeRecoveryCodeRequired) { + t.Fatalf("actor email substituted for target: %v", err) + } + req.TargetExternalEmailConfigured = true + if err := Authorize(req); err != nil { + t.Fatalf("target email did not qualify: %v", err) + } + req.NewRole = RoleUser + req.TargetRole = RoleAdmin + if err := Authorize(req); err != nil { + t.Fatalf("email-only downgrade rejected: %v", err) + } + req.RecentAuth = false + if err := Authorize(req); !IsDenied(err, CodeRecentAuthRequired) { + t.Fatalf("role change bypassed recent auth: %v", err) + } +} + func TestDefaultDeny(t *testing.T) { tests := []struct { name string diff --git a/backend/services/access/authorize.go b/backend/services/access/authorize.go index 42983d7..9e06c78 100644 --- a/backend/services/access/authorize.go +++ b/backend/services/access/authorize.go @@ -25,19 +25,19 @@ func IsDenied(err error, code DenialCode) bool { } type Request struct { - ActorRole Role - Capability Capability - TargetRole Role - NewRole Role - OwnResource bool - RecentAuth bool - RecoveryCodeConfigured bool - ExternalEmailConfigured bool - TargetRecoveryConfigured bool + ActorRole Role + Capability Capability + TargetRole Role + NewRole Role + OwnResource bool + RecentAuth bool + RecoveryCodeConfigured bool + ExternalEmailConfigured bool + TargetRecoveryConfigured bool TargetExternalEmailConfigured bool - PluginInstanceAuthorized bool - AuditScopeAuthorized bool - DeploymentChannel bool + PluginInstanceAuthorized bool + AuditScopeAuthorized bool + DeploymentChannel bool } func Authorize(request Request) error { diff --git a/backend/utils/context/context.go b/backend/utils/context/context.go index b70dd8f..ba03640 100644 --- a/backend/utils/context/context.go +++ b/backend/utils/context/context.go @@ -11,26 +11,26 @@ const ( ) type Context struct { - 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 - ExternalEmailConfigured bool - TargetRecoveryConfigured bool + 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 + ExternalEmailConfigured bool + TargetRecoveryConfigured bool TargetExternalEmailConfigured bool - PluginInstanceAuthorized bool - AuditScopeAuthorized bool - DeploymentChannel bool + PluginInstanceAuthorized bool + AuditScopeAuthorized bool + DeploymentChannel bool } // HasRole 判断当前请求是否属于指定角色。