检查点:保存安全方式二选一授权调整以补充回归覆盖
Some checks are pending
Security Checks / security (push) Waiting to run

This commit is contained in:
smail 2026-10-02 18:33:48 +08:00
parent 9bba2f0e92
commit 3ab3c21a09
12 changed files with 40 additions and 20 deletions

View File

@ -61,6 +61,7 @@ type AuditActor struct {
SessionID string SessionID string
RecentAuth bool RecentAuth bool
RecoveryCodeConfigured bool RecoveryCodeConfigured bool
ExternalEmailConfigured bool
} }
type StepUpConfirmation struct { type StepUpConfirmation struct {

View File

@ -166,6 +166,7 @@ func DefaultConfigAuthorization(_ context.Context, actor AuditActor, operation C
Capability: capability, Capability: capability,
RecentAuth: actor.RecentAuth, RecentAuth: actor.RecentAuth,
RecoveryCodeConfigured: actor.RecoveryCodeConfigured, RecoveryCodeConfigured: actor.RecoveryCodeConfigured,
ExternalEmailConfigured: actor.ExternalEmailConfigured,
} }
if operation == ConfigOperationRollback && request.ActorRole == access.RoleSuperAdmin { if operation == ConfigOperationRollback && request.ActorRole == access.RoleSuperAdmin {
if !request.RecentAuth { if !request.RecentAuth {

View File

@ -22,7 +22,9 @@ func authorizeCapabilityWithNewRole(ctx *context.Context, capability access.Capa
OwnResource: ownResource, OwnResource: ownResource,
RecentAuth: ctx.RecentAuth, RecentAuth: ctx.RecentAuth,
RecoveryCodeConfigured: ctx.RecoveryCodeConfigured, RecoveryCodeConfigured: ctx.RecoveryCodeConfigured,
ExternalEmailConfigured: ctx.ExternalEmailConfigured,
TargetRecoveryConfigured: ctx.TargetRecoveryConfigured, TargetRecoveryConfigured: ctx.TargetRecoveryConfigured,
TargetExternalEmailConfigured: ctx.TargetExternalEmailConfigured,
NewRole: newRole, NewRole: newRole,
PluginInstanceAuthorized: ctx.PluginInstanceAuthorized, PluginInstanceAuthorized: ctx.PluginInstanceAuthorized,
AuditScopeAuthorized: ctx.AuditScopeAuthorized, AuditScopeAuthorized: ctx.AuditScopeAuthorized,

View File

@ -372,7 +372,7 @@ func configSectionFromPath(path string) (config.Section, bool) {
} }
func configActor(ctx *appcontext.Context, req *http.Request) config.AuditActor { 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 actor.Name = ctx.UserAccount
if sessionManager := session.Instance; sessionManager != nil { if sessionManager := session.Instance; sessionManager != nil {
if cookie, err := req.Cookie(sessionManager.Cookie.Name); err == nil { if cookie, err := req.Cookie(sessionManager.Cookie.Name); err == nil {

View File

@ -164,13 +164,24 @@ func InvalidateSecurityRuntimeUser(userID int, account string) {
} }
func LoadSecurityContext(ctx *context.Context) { 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 return
} }
if ctx.EffectiveRole() == access.RoleSuperAdmin { if ctx.EffectiveRole() == access.RoleSuperAdmin {
ctx.RecoveryCodeConfigured = false
return 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 service := currentSecurityRuntime().credentials
if service == nil && db.Instance != nil { if service == nil && db.Instance != nil {
service, _ = credentials.NewService(db.Instance) service, _ = credentials.NewService(db.Instance)

View File

@ -52,7 +52,7 @@ func loadNotificationUser(ctx *context.Context, w http.ResponseWriter, userID in
if access.IsDenied(err, access.CodeRecentAuthRequired) { if access.IsDenied(err, access.CodeRecentAuthRequired) {
writeSecurityErrorWithCode(w, response.NoAccessPrivileges, "请先确认当前管理员密码", string(access.CodeRecentAuthRequired)) writeSecurityErrorWithCode(w, response.NoAccessPrivileges, "请先确认当前管理员密码", string(access.CodeRecentAuthRequired))
} else if access.IsDenied(err, access.CodeRecoveryCodeRequired) { } else if access.IsDenied(err, access.CodeRecoveryCodeRequired) {
writeSecurityError(w, response.NoAccessPrivileges, "请先为当前管理员配置安全码") writeSecurityError(w, response.NoAccessPrivileges, "请先为当前管理员配置有效安全码或验证外部邮箱")
} else { } else {
writeSecurityError(w, response.NoAccessPrivileges, "无权修改该账号的系统通知授权") writeSecurityError(w, response.NoAccessPrivileges, "无权修改该账号的系统通知授权")
} }

View File

@ -421,7 +421,7 @@ func EditUser(ctx *context.Context, w http.ResponseWriter, req *http.Request) {
} }
if reqData.Password != "" { if reqData.Password != "" {
if err := authorizeCapability(ctx, access.CapabilityPasswordReset, targetRole, false); err != nil { if err := authorizeCapability(ctx, access.CapabilityPasswordReset, targetRole, false); err != nil {
writeUserAuthorizationError(w, err, "密码重置需要为当前管理员配置有效安全码") writeUserAuthorizationError(w, err, "请先为当前管理员配置有效安全码或验证外部邮箱")
return return
} }
} }
@ -436,17 +436,17 @@ func EditUser(ctx *context.Context, w http.ResponseWriter, req *http.Request) {
return return
} }
ctx.TargetRecoveryConfigured = false ctx.TargetRecoveryConfigured = false
if credentialService := credentialRuntime(); credentialService != nil { ctx.TargetExternalEmailConfigured = false
configured, checkErr := credentialService.HasValidRecoveryCode(user.ID) recoveryStatus, emailStatus, checkErr := loadUserSecurityStatus([]models.User{user})
if checkErr != nil { if checkErr != nil {
log.WithContext(ctx).WithError(checkErr).Error("load target recovery code status failed") log.WithContext(ctx).WithError(checkErr).Error("load target recovery options failed")
response.NewErrorResponse(response.ServerError, "读取目标用户恢复码状态失败", "").FPrint(w) response.NewErrorResponse(response.ServerError, "读取目标用户安全方式失败", "").FPrint(w)
return return
}
ctx.TargetRecoveryConfigured = configured
} }
ctx.TargetRecoveryConfigured = recoveryStatus[user.ID]
ctx.TargetExternalEmailConfigured = emailStatus[user.ID]
if err := authorizeCapabilityWithNewRole(ctx, access.CapabilityRoleManage, targetRole, newRole, false); err != nil { if err := authorizeCapabilityWithNewRole(ctx, access.CapabilityRoleManage, targetRole, newRole, false); err != nil {
writeUserAuthorizationError(w, err, "更改权限级别前,请先为该用户配置有效安全码") writeUserAuthorizationError(w, err, "更改权限级别前,请先为该用户配置有效安全码或验证外部邮箱")
return return
} }
user.ApplyRole(newRole) user.ApplyRole(newRole)

View File

@ -57,7 +57,7 @@ func loadManagedSecurityUser(ctx *context.Context, w http.ResponseWriter, userID
return nil, false return nil, false
} }
if access.IsDenied(err, access.CodeRecoveryCodeRequired) { if access.IsDenied(err, access.CodeRecoveryCodeRequired) {
writeSecurityErrorWithCode(w, response.NoAccessPrivileges, "请先为当前管理员配置有效安全码", string(access.CodeRecoveryCodeRequired)) writeSecurityErrorWithCode(w, response.NoAccessPrivileges, "请先为当前管理员配置有效安全码或验证外部邮箱", string(access.CodeRecoveryCodeRequired))
return nil, false return nil, false
} }
writeSecurityError(w, response.NoAccessPrivileges, "无权管理该用户的安全信息") writeSecurityError(w, response.NoAccessPrivileges, "无权管理该用户的安全信息")

View File

@ -32,7 +32,9 @@ type Request struct {
OwnResource bool OwnResource bool
RecentAuth bool RecentAuth bool
RecoveryCodeConfigured bool RecoveryCodeConfigured bool
ExternalEmailConfigured bool
TargetRecoveryConfigured bool TargetRecoveryConfigured bool
TargetExternalEmailConfigured bool
PluginInstanceAuthorized bool PluginInstanceAuthorized bool
AuditScopeAuthorized bool AuditScopeAuthorized bool
DeploymentChannel bool DeploymentChannel bool
@ -55,7 +57,8 @@ func Authorize(request Request) error {
if policy.RequiresRecentAuth && !request.RecentAuth { if policy.RequiresRecentAuth && !request.RecentAuth {
return deny(CodeRecentAuthRequired) 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 deny(CodeRecoveryCodeRequired)
} }
return authorizeResource(request) return authorizeResource(request)
@ -98,7 +101,7 @@ func authorizeResource(request Request) error {
if !canManageAccountTarget(request.ActorRole, request.TargetRole) || !request.NewRole.Valid() || request.NewRole == RoleSuperAdmin { if !canManageAccountTarget(request.ActorRole, request.TargetRole) || !request.NewRole.Valid() || request.NewRole == RoleSuperAdmin {
return deny(CodeTargetRoleDenied) return deny(CodeTargetRoleDenied)
} }
if !request.TargetRecoveryConfigured { if !request.TargetRecoveryConfigured && !request.TargetExternalEmailConfigured {
return deny(CodeRecoveryCodeRequired) return deny(CodeRecoveryCodeRequired)
} }
case CapabilityPluginRestart: case CapabilityPluginRestart:

View File

@ -25,7 +25,9 @@ type Context struct {
SecuritySetupAcknowledged int SecuritySetupAcknowledged int
RecentAuth bool RecentAuth bool
RecoveryCodeConfigured bool RecoveryCodeConfigured bool
ExternalEmailConfigured bool
TargetRecoveryConfigured bool TargetRecoveryConfigured bool
TargetExternalEmailConfigured bool
PluginInstanceAuthorized bool PluginInstanceAuthorized bool
AuditScopeAuthorized bool AuditScopeAuthorized bool
DeploymentChannel bool DeploymentChannel bool

View File

@ -92,7 +92,7 @@
<div class="section-actions"><el-button v-if="recoveryConfigured && !generatedCodes.length" @click="revealRecoveryCodes">验证后查看</el-button><el-button type="primary" @click="generateRecoveryCodes">生成新恢复码</el-button><el-button v-if="recoveryConfigured" type="danger" plain @click="revokeRecoveryCodes">撤销</el-button></div> <div class="section-actions"><el-button v-if="recoveryConfigured && !generatedCodes.length" @click="revealRecoveryCodes">验证后查看</el-button><el-button type="primary" @click="generateRecoveryCodes">生成新恢复码</el-button><el-button v-if="recoveryConfigured" type="danger" plain @click="revokeRecoveryCodes">撤销</el-button></div>
</div> </div>
<el-alert v-if="recoveryConfigured" title="当前已配置有效离线恢复码" type="success" :closable="false" show-icon /> <el-alert v-if="recoveryConfigured" title="当前已配置有效离线恢复码" type="success" :closable="false" show-icon />
<el-alert v-else title="当前未配置有效离线恢复码,高风险操作可能无法进行。" type="warning" :closable="false" show-icon /> <el-alert v-else title="尚未配置安全码;已验证外部邮箱也可满足安全配置要求。" type="warning" :closable="false" show-icon />
<div v-if="generatedCodes.length" class="recovery-code-panel"> <div v-if="generatedCodes.length" class="recovery-code-panel">
<div class="one-time-tip">安全码已通过安全验证显示,仅在当前页面短暂显示,请立即复制并妥善保存。</div> <div class="one-time-tip">安全码已通过安全验证显示,仅在当前页面短暂显示,请立即复制并妥善保存。</div>
<code v-for="code in generatedCodes" :key="code" class="recovery-code">{{ code }}</code> <code v-for="code in generatedCodes" :key="code" class="recovery-code">{{ code }}</code>

View File

@ -219,8 +219,8 @@
<el-tag v-else :type="roleMeta(editUserInfo.role).type"> <el-tag v-else :type="roleMeta(editUserInfo.role).type">
{{ roleMeta(editUserInfo.role).label }} {{ roleMeta(editUserInfo.role).label }}
</el-tag> </el-tag>
<span v-if="roleChanged && !selectedUser?.RecoveryCodeConfigured" class="role-requirement"> <span v-if="roleChanged && !selectedUser?.RecoveryCodeConfigured && !selectedUser?.ExternalEmailConfigured" class="role-requirement">
更改级别前,请先在下方配置该用户的安全码。 更改级别前,请先为该用户配置安全码或验证外部邮箱。
</span> </span>
</el-form-item> </el-form-item>