补齐安全码与已验证邮箱二选一授权回归覆盖
Some checks are pending
Security Checks / security (push) Waiting to run

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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