diff --git a/backend/config/manager_v2.go b/backend/config/manager_v2.go index 870ac04..180e797 100644 --- a/backend/config/manager_v2.go +++ b/backend/config/manager_v2.go @@ -61,6 +61,7 @@ type AuditActor struct { SessionID string RecentAuth bool RecoveryCodeConfigured bool + ExternalEmailConfigured bool } type StepUpConfirmation struct { diff --git a/backend/config/security.go b/backend/config/security.go index b3ff3ab..bf4ec5a 100644 --- a/backend/config/security.go +++ b/backend/config/security.go @@ -166,6 +166,7 @@ func DefaultConfigAuthorization(_ context.Context, actor AuditActor, operation C Capability: capability, RecentAuth: actor.RecentAuth, RecoveryCodeConfigured: actor.RecoveryCodeConfigured, + ExternalEmailConfigured: actor.ExternalEmailConfigured, } if operation == ConfigOperationRollback && request.ActorRole == access.RoleSuperAdmin { if !request.RecentAuth { diff --git a/backend/controllers/authorization.go b/backend/controllers/authorization.go index 00fded8..d899e36 100644 --- a/backend/controllers/authorization.go +++ b/backend/controllers/authorization.go @@ -22,7 +22,9 @@ func authorizeCapabilityWithNewRole(ctx *context.Context, capability access.Capa 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, diff --git a/backend/controllers/config.go b/backend/controllers/config.go index dcfa712..a545fb6 100644 --- a/backend/controllers/config.go +++ b/backend/controllers/config.go @@ -372,7 +372,7 @@ func configSectionFromPath(path string) (config.Section, bool) { } func configActor(ctx *appcontext.Context, req *http.Request) config.AuditActor { - actor := config.AuditActor{UserID: strconv.Itoa(ctx.UserID), Role: string(ctx.EffectiveRole()), RecentAuth: ctx.RecentAuth, RecoveryCodeConfigured: ctx.RecoveryCodeConfigured} + actor := config.AuditActor{UserID: strconv.Itoa(ctx.UserID), Role: string(ctx.EffectiveRole()), RecentAuth: ctx.RecentAuth, RecoveryCodeConfigured: ctx.RecoveryCodeConfigured, ExternalEmailConfigured: ctx.ExternalEmailConfigured} actor.Name = ctx.UserAccount if sessionManager := session.Instance; sessionManager != nil { if cookie, err := req.Cookie(sessionManager.Cookie.Name); err == nil { diff --git a/backend/controllers/security.go b/backend/controllers/security.go index 3737f4b..bb4e1fa 100644 --- a/backend/controllers/security.go +++ b/backend/controllers/security.go @@ -164,13 +164,24 @@ func InvalidateSecurityRuntimeUser(userID int, account string) { } func LoadSecurityContext(ctx *context.Context) { - if ctx == nil || ctx.UserID <= 0 { + if ctx == nil { + return + } + // 每次请求重新读取,停用邮箱或用完安全码后不能沿用旧的授权状态。 + ctx.RecoveryCodeConfigured = false + ctx.ExternalEmailConfigured = false + if ctx.UserID <= 0 { return } if ctx.EffectiveRole() == access.RoleSuperAdmin { - ctx.RecoveryCodeConfigured = false return } + if db.Instance != nil { + configured, err := db.Instance.Where("user_id = ? AND status = ?", ctx.UserID, models.ExternalEmailStatusVerified).Exist(&models.ExternalEmail{}) + if err == nil { + ctx.ExternalEmailConfigured = configured + } + } service := currentSecurityRuntime().credentials if service == nil && db.Instance != nil { service, _ = credentials.NewService(db.Instance) diff --git a/backend/controllers/system_notification.go b/backend/controllers/system_notification.go index 654d96d..6344fc5 100644 --- a/backend/controllers/system_notification.go +++ b/backend/controllers/system_notification.go @@ -52,7 +52,7 @@ func loadNotificationUser(ctx *context.Context, w http.ResponseWriter, userID in if access.IsDenied(err, access.CodeRecentAuthRequired) { writeSecurityErrorWithCode(w, response.NoAccessPrivileges, "请先确认当前管理员密码", string(access.CodeRecentAuthRequired)) } else if access.IsDenied(err, access.CodeRecoveryCodeRequired) { - writeSecurityError(w, response.NoAccessPrivileges, "请先为当前管理员配置安全码") + writeSecurityError(w, response.NoAccessPrivileges, "请先为当前管理员配置有效安全码或验证外部邮箱") } else { writeSecurityError(w, response.NoAccessPrivileges, "无权修改该账号的系统通知授权") } diff --git a/backend/controllers/user.go b/backend/controllers/user.go index 75f5fd7..25eaecd 100644 --- a/backend/controllers/user.go +++ b/backend/controllers/user.go @@ -421,7 +421,7 @@ func EditUser(ctx *context.Context, w http.ResponseWriter, req *http.Request) { } if reqData.Password != "" { if err := authorizeCapability(ctx, access.CapabilityPasswordReset, targetRole, false); err != nil { - writeUserAuthorizationError(w, err, "密码重置需要为当前管理员配置有效安全码") + writeUserAuthorizationError(w, err, "请先为当前管理员配置有效安全码或验证外部邮箱") return } } @@ -436,17 +436,17 @@ func EditUser(ctx *context.Context, w http.ResponseWriter, req *http.Request) { return } ctx.TargetRecoveryConfigured = false - if credentialService := credentialRuntime(); credentialService != nil { - configured, checkErr := credentialService.HasValidRecoveryCode(user.ID) - if checkErr != nil { - log.WithContext(ctx).WithError(checkErr).Error("load target recovery code status failed") - response.NewErrorResponse(response.ServerError, "读取目标用户恢复码状态失败", "").FPrint(w) - return - } - ctx.TargetRecoveryConfigured = configured + ctx.TargetExternalEmailConfigured = false + recoveryStatus, emailStatus, checkErr := loadUserSecurityStatus([]models.User{user}) + if checkErr != nil { + log.WithContext(ctx).WithError(checkErr).Error("load target recovery options failed") + response.NewErrorResponse(response.ServerError, "读取目标用户安全方式失败", "").FPrint(w) + return } + ctx.TargetRecoveryConfigured = recoveryStatus[user.ID] + ctx.TargetExternalEmailConfigured = emailStatus[user.ID] if err := authorizeCapabilityWithNewRole(ctx, access.CapabilityRoleManage, targetRole, newRole, false); err != nil { - writeUserAuthorizationError(w, err, "更改权限级别前,请先为该用户配置有效安全码") + writeUserAuthorizationError(w, err, "更改权限级别前,请先为该用户配置有效安全码或验证外部邮箱") return } user.ApplyRole(newRole) diff --git a/backend/controllers/user_security.go b/backend/controllers/user_security.go index 1575cc3..6f16a34 100644 --- a/backend/controllers/user_security.go +++ b/backend/controllers/user_security.go @@ -57,7 +57,7 @@ func loadManagedSecurityUser(ctx *context.Context, w http.ResponseWriter, userID return nil, false } if access.IsDenied(err, access.CodeRecoveryCodeRequired) { - writeSecurityErrorWithCode(w, response.NoAccessPrivileges, "请先为当前管理员配置有效安全码", string(access.CodeRecoveryCodeRequired)) + writeSecurityErrorWithCode(w, response.NoAccessPrivileges, "请先为当前管理员配置有效安全码或验证外部邮箱", string(access.CodeRecoveryCodeRequired)) return nil, false } writeSecurityError(w, response.NoAccessPrivileges, "无权管理该用户的安全信息") diff --git a/backend/services/access/authorize.go b/backend/services/access/authorize.go index 2850209..42983d7 100644 --- a/backend/services/access/authorize.go +++ b/backend/services/access/authorize.go @@ -32,7 +32,9 @@ type Request struct { OwnResource bool RecentAuth bool RecoveryCodeConfigured bool + ExternalEmailConfigured bool TargetRecoveryConfigured bool + TargetExternalEmailConfigured bool PluginInstanceAuthorized bool AuditScopeAuthorized bool DeploymentChannel bool @@ -55,7 +57,8 @@ func Authorize(request Request) error { if policy.RequiresRecentAuth && !request.RecentAuth { return deny(CodeRecentAuthRequired) } - if policy.RequiresRecoveryCode && request.ActorRole != RoleSuperAdmin && !request.RecoveryCodeConfigured && !request.DeploymentChannel { + // 保留原策略与错误码名称;普通角色的恢复保障允许安全码或已验证外部邮箱任选其一。 + if policy.RequiresRecoveryCode && request.ActorRole != RoleSuperAdmin && !request.RecoveryCodeConfigured && !request.ExternalEmailConfigured && !request.DeploymentChannel { return deny(CodeRecoveryCodeRequired) } return authorizeResource(request) @@ -98,7 +101,7 @@ func authorizeResource(request Request) error { if !canManageAccountTarget(request.ActorRole, request.TargetRole) || !request.NewRole.Valid() || request.NewRole == RoleSuperAdmin { return deny(CodeTargetRoleDenied) } - if !request.TargetRecoveryConfigured { + if !request.TargetRecoveryConfigured && !request.TargetExternalEmailConfigured { return deny(CodeRecoveryCodeRequired) } case CapabilityPluginRestart: diff --git a/backend/utils/context/context.go b/backend/utils/context/context.go index 0e717a3..b70dd8f 100644 --- a/backend/utils/context/context.go +++ b/backend/utils/context/context.go @@ -25,7 +25,9 @@ type Context struct { SecuritySetupAcknowledged int RecentAuth bool RecoveryCodeConfigured bool + ExternalEmailConfigured bool TargetRecoveryConfigured bool + TargetExternalEmailConfigured bool PluginInstanceAuthorized bool AuditScopeAuthorized bool DeploymentChannel bool diff --git a/frontend/src/views/components/SecuritySettings/SecuritySettings.vue b/frontend/src/views/components/SecuritySettings/SecuritySettings.vue index 1d97d23..22ff3de 100644 --- a/frontend/src/views/components/SecuritySettings/SecuritySettings.vue +++ b/frontend/src/views/components/SecuritySettings/SecuritySettings.vue @@ -92,7 +92,7 @@
{{ code }}
diff --git a/frontend/src/views/components/UserManagement/UserManagement.vue b/frontend/src/views/components/UserManagement/UserManagement.vue
index c4fbfeb..7e738b5 100644
--- a/frontend/src/views/components/UserManagement/UserManagement.vue
+++ b/frontend/src/views/components/UserManagement/UserManagement.vue
@@ -219,8 +219,8 @@