补齐安全码与已验证邮箱二选一授权回归覆盖
This commit is contained in:
parent
96656d9b21
commit
f965affcaa
@ -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()},
|
||||||
|
|||||||
@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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()
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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 判断当前请求是否属于指定角色。
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user