补齐安全码与已验证邮箱二选一授权回归覆盖

This commit is contained in:
smail 2026-10-02 18:36:39 +08:00
parent 96656d9b21
commit f965affcaa
8 changed files with 203 additions and 53 deletions

View File

@ -8,10 +8,34 @@ import (
"path/filepath" "path/filepath"
"testing" "testing"
"git.pcux.cn/xuans/Smail/services/access"
appcontext "git.pcux.cn/xuans/Smail/utils/context" appcontext "git.pcux.cn/xuans/Smail/utils/context"
log "github.com/sirupsen/logrus" 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) { func TestLogFormatterHandlesMissingLogID(t *testing.T) {
entry := &log.Entry{ entry := &log.Entry{
Context: &appcontext.Context{Context: stdcontext.Background()}, Context: &appcontext.Context{Context: stdcontext.Background()},

View File

@ -55,12 +55,12 @@ type UpdateRequest struct {
} }
type AuditActor struct { type AuditActor struct {
UserID string UserID string
Name string Name string
Role string Role string
SessionID string SessionID string
RecentAuth bool RecentAuth bool
RecoveryCodeConfigured bool RecoveryCodeConfigured bool
ExternalEmailConfigured bool ExternalEmailConfigured bool
} }

View File

@ -162,10 +162,10 @@ func DefaultConfigAuthorization(_ context.Context, actor AuditActor, operation C
capability = access.CapabilityDatabaseMigrate capability = access.CapabilityDatabaseMigrate
} }
request := access.Request{ request := access.Request{
ActorRole: access.Role(actor.Role), ActorRole: access.Role(actor.Role),
Capability: capability, Capability: capability,
RecentAuth: actor.RecentAuth, RecentAuth: actor.RecentAuth,
RecoveryCodeConfigured: actor.RecoveryCodeConfigured, RecoveryCodeConfigured: actor.RecoveryCodeConfigured,
ExternalEmailConfigured: actor.ExternalEmailConfigured, ExternalEmailConfigured: actor.ExternalEmailConfigured,
} }
if operation == ConfigOperationRollback && request.ActorRole == access.RoleSuperAdmin { if operation == ConfigOperationRollback && request.ActorRole == access.RoleSuperAdmin {

View File

@ -16,18 +16,18 @@ func authorizeCapabilityWithNewRole(ctx *context.Context, capability access.Capa
return &access.Denial{Code: access.CodeAccessDenied} return &access.Denial{Code: access.CodeAccessDenied}
} }
return access.Authorize(access.Request{ return access.Authorize(access.Request{
ActorRole: ctx.EffectiveRole(), ActorRole: ctx.EffectiveRole(),
Capability: capability, Capability: capability,
TargetRole: targetRole, TargetRole: targetRole,
OwnResource: ownResource, OwnResource: ownResource,
RecentAuth: ctx.RecentAuth, RecentAuth: ctx.RecentAuth,
RecoveryCodeConfigured: ctx.RecoveryCodeConfigured, RecoveryCodeConfigured: ctx.RecoveryCodeConfigured,
ExternalEmailConfigured: ctx.ExternalEmailConfigured, ExternalEmailConfigured: ctx.ExternalEmailConfigured,
TargetRecoveryConfigured: ctx.TargetRecoveryConfigured, TargetRecoveryConfigured: ctx.TargetRecoveryConfigured,
TargetExternalEmailConfigured: ctx.TargetExternalEmailConfigured, TargetExternalEmailConfigured: ctx.TargetExternalEmailConfigured,
NewRole: newRole, NewRole: newRole,
PluginInstanceAuthorized: ctx.PluginInstanceAuthorized, PluginInstanceAuthorized: ctx.PluginInstanceAuthorized,
AuditScopeAuthorized: ctx.AuditScopeAuthorized, AuditScopeAuthorized: ctx.AuditScopeAuthorized,
DeploymentChannel: ctx.DeploymentChannel, DeploymentChannel: ctx.DeploymentChannel,
}) })
} }

View File

@ -71,6 +71,75 @@ func setupSecurityPromptTest(t *testing.T) *xorm.Engine {
return 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) { func TestSecuritySetupAcknowledgementIsSessionScoped(t *testing.T) {
engine := setupSecurityPromptTest(t) engine := setupSecurityPromptTest(t)
previousSession, previousConfig, previousManager := session.Instance, config.Instance, config.CurrentManager() previousSession, previousConfig, previousManager := session.Instance, config.Instance, config.CurrentManager()

View File

@ -2,6 +2,63 @@ package access
import "testing" 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) { func TestDefaultDeny(t *testing.T) {
tests := []struct { tests := []struct {
name string name string

View File

@ -25,19 +25,19 @@ func IsDenied(err error, code DenialCode) bool {
} }
type Request struct { type Request struct {
ActorRole Role ActorRole Role
Capability Capability Capability Capability
TargetRole Role TargetRole Role
NewRole Role NewRole Role
OwnResource bool OwnResource bool
RecentAuth bool RecentAuth bool
RecoveryCodeConfigured bool RecoveryCodeConfigured bool
ExternalEmailConfigured bool ExternalEmailConfigured bool
TargetRecoveryConfigured bool TargetRecoveryConfigured bool
TargetExternalEmailConfigured bool TargetExternalEmailConfigured bool
PluginInstanceAuthorized bool PluginInstanceAuthorized bool
AuditScopeAuthorized bool AuditScopeAuthorized bool
DeploymentChannel bool DeploymentChannel bool
} }
func Authorize(request Request) error { func Authorize(request Request) error {

View File

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