diff --git a/backend/controllers/system_notification.go b/backend/controllers/system_notification.go index dcd4154..654d96d 100644 --- a/backend/controllers/system_notification.go +++ b/backend/controllers/system_notification.go @@ -103,7 +103,7 @@ func GetNotificationStatus(ctx *context.Context, w http.ResponseWriter, req *htt "user_id": user.ID, "account": user.Account, "enabled": item.Enabled == 1, "configured": notification.Configured(item, *user), "version": item.CodeVersion, "can_reveal": notification.Configured(item, *user) && item.CodeCiphertext != "" && - access.CanManageNotification(ctx.EffectiveRole(), user.EffectiveRole(), ctx.UserID == user.ID), + (ctx.UserID == user.ID || access.CanManageNotification(ctx.EffectiveRole(), user.EffectiveRole(), false)), "account_available": user.Disabled == 0 && user.MustChangePassword == 0, "can_manage": access.CanManageNotification(ctx.EffectiveRole(), user.EffectiveRole(), ctx.UserID == user.ID), "minute_limit": item.MinuteLimit, "day_limit": item.DayLimit, @@ -216,20 +216,48 @@ func RevealNotificationCredential(ctx *context.Context, w http.ResponseWriter, r writeSecurityError(w, response.ParamsError, "请填写当前登录密码并选择有效账号") return } - user, ok := loadNotificationUser(ctx, w, input.UserID, true) + user, ok := loadNotificationUser(ctx, w, input.UserID, false) if !ok { return } - _, err := auth.AuthenticateUserByID(ctx.UserID, input.Password) + if err := authorizeCapability(ctx, access.CapabilityNotificationReveal, user.EffectiveRole(), ctx.UserID == user.ID); err != nil { + writeSecurityError(w, response.NoAccessPrivileges, "无权查看该账号的通知发信凭据") + return + } + userKey := reauthUserAttempts.accountKey(strconv.Itoa(ctx.UserID)) + ipKey := reauthIPAttempts.ipKey(req) + if reauthUserAttempts.blocked(userKey) || reauthIPAttempts.blocked(ipKey) { + writeReauthError(w, http.StatusTooManyRequests, "REAUTH_RATE_LIMITED", "尝试次数过多,请稍后再试") + return + } + actor, err := auth.AuthenticateUserByID(ctx.UserID, input.Password) input.Password = "" if err != nil { recordNotificationManagement(ctx, user.ID, "查看系统通知凭据", err, nil) if !writePasswordBusy(w, err) { + reauthUserAttempts.recordFailure(userKey) + reauthIPAttempts.recordFailure(ipKey) writeSecurityError(w, response.ParamsError, "密码验证失败") } return } - code, err := notification.Reveal(db.Instance, *user, input.Version, config.ROOT_PATH, accountWriteGuard(ctx, *user)) + if actor.SessionVersion != ctx.SessionVersion || actor.SecurityVersion != ctx.SecurityVersion || actor.EffectiveRole() != ctx.EffectiveRole() { + writeSecurityError(w, response.NeedLogin, "登录状态已变化,请重新登录") + return + } + guard := accountgroup.OwnGuard(ctx.UserID, ctx.EffectiveRole(), ctx.SessionVersion, ctx.SecurityVersion) + if ctx.UserID != user.ID { + verified := *ctx + verified.RecentAuth = true + if _, ok := loadNotificationUser(&verified, w, user.ID, true); !ok { + return + } + guard = accountWriteGuard(ctx, *user) + } + code, err := notification.Reveal(db.Instance, *user, input.Version, config.ROOT_PATH, guard) + if err == nil { + reauthUserAttempts.clear(userKey) + } recordNotificationManagement(ctx, user.ID, "查看系统通知凭据", err, nil) if err != nil { writeNotificationError(w, err) diff --git a/backend/services/access/authorize.go b/backend/services/access/authorize.go index 51fb935..2850209 100644 --- a/backend/services/access/authorize.go +++ b/backend/services/access/authorize.go @@ -63,6 +63,10 @@ func Authorize(request Request) error { func authorizeResource(request Request) error { switch request.Capability { + case CapabilityNotificationReveal: + if !request.OwnResource && !CanManageNotification(request.ActorRole, request.TargetRole, false) { + return deny(CodeAccessDenied) + } case CapabilityNotificationManage: if !CanManageNotification(request.ActorRole, request.TargetRole, request.OwnResource) { return deny(CodeTargetRoleDenied) diff --git a/backend/services/access/capability.go b/backend/services/access/capability.go index 73009a0..7efffae 100644 --- a/backend/services/access/capability.go +++ b/backend/services/access/capability.go @@ -26,6 +26,7 @@ const ( CapabilityAuditRead Capability = "audit.read" CapabilityAuditExport Capability = "audit.export" CapabilityNotificationManage Capability = "notification.manage" + CapabilityNotificationReveal Capability = "notification.reveal" CapabilityAccountGroupManage Capability = "account_group.manage" ) @@ -38,6 +39,7 @@ type Policy struct { } var policies = map[Capability]Policy{ + CapabilityNotificationReveal: {Capability: CapabilityNotificationReveal, HighRisk: true, RequiresTarget: true}, CapabilityAccountGroupManage: {Capability: CapabilityAccountGroupManage, HighRisk: true, RequiresRecentAuth: true}, CapabilityNotificationManage: {Capability: CapabilityNotificationManage, HighRisk: true, RequiresRecentAuth: true, RequiresRecoveryCode: true, RequiresTarget: true}, CapabilityConfigRead: {Capability: CapabilityConfigRead}, @@ -66,6 +68,7 @@ var policies = map[Capability]Policy{ var grants = map[Role]map[Capability]struct{}{ RoleSuperAdmin: grant( + CapabilityNotificationReveal, CapabilityAccountGroupManage, CapabilityNotificationManage, CapabilityPluginReadGlobal, CapabilityUserCreate, CapabilityProfileWriteOwn, @@ -77,6 +80,7 @@ var grants = map[Role]map[Capability]struct{}{ CapabilityAuditRead, CapabilityAuditExport, ), RoleAdmin: grant( + CapabilityNotificationReveal, CapabilityNotificationManage, CapabilityPluginReadGlobal, CapabilityPluginRead, CapabilityPluginWriteGlobal, CapabilityProfileWriteOwn, CapabilityPluginWriteAnyUser, CapabilityPluginWriteOwn, CapabilityUserRead, @@ -84,6 +88,7 @@ var grants = map[Role]map[Capability]struct{}{ CapabilityAuditRead, ), RoleUser: grant( + CapabilityNotificationReveal, CapabilityProfileWriteOwn, CapabilityPluginRead, CapabilityPluginWriteOwn, CapabilityUserRead, CapabilityPasswordChangeOwn, CapabilityAuditRead, diff --git a/backend/services/access/catalog.go b/backend/services/access/catalog.go index 1b6cf91..1d57041 100644 --- a/backend/services/access/catalog.go +++ b/backend/services/access/catalog.go @@ -9,8 +9,8 @@ type Endpoint struct { } var managementEndpoints = []Endpoint{ - {Method: "POST", Path: "/api/system/notification/credential/reveal", Capability: CapabilityNotificationManage}, - {Method: "POST", Path: "/api/v1/system/notification/credential/reveal", Capability: CapabilityNotificationManage}, + {Method: "POST", Path: "/api/system/notification/credential/reveal", Capability: CapabilityNotificationReveal}, + {Method: "POST", Path: "/api/v1/system/notification/credential/reveal", Capability: CapabilityNotificationReveal}, {Method: "POST", Path: "/api/system/notification/authorization", Capability: CapabilityNotificationManage}, {Method: "POST", Path: "/api/system/notification/credential", Capability: CapabilityNotificationManage}, {Method: "POST", Path: "/api/v1/user/groups/save", Capability: CapabilityAccountGroupManage}, diff --git a/backend/services/accountgroup/service.go b/backend/services/accountgroup/service.go index 1d659c9..e9ead17 100644 --- a/backend/services/accountgroup/service.go +++ b/backend/services/accountgroup/service.go @@ -186,6 +186,28 @@ func ReplaceMembership(tx *xorm.Session, userID int, ids []int) error { type WriteGuard func(*xorm.Session) error +// OwnGuard 只锁定并复核当前账号,不授予管理本人或其他账号的能力。 +func OwnGuard(actorID int, role access.Role, sessionVersion, securityVersion int) WriteGuard { + return func(tx *xorm.Session) error { + if tx == nil || actorID <= 0 || !role.Valid() { + return ErrDenied + } + if _, err := tx.ID(actorID).SetExpr("session_version", "session_version").Update(&models.User{}); err != nil { + return err + } + var current models.User + found, err := tx.ID(actorID).Get(¤t) + if err != nil { + return err + } + if !found || current.Disabled != 0 || current.MustChangePassword != 0 || current.EffectiveRole() != role || + current.SessionVersion != sessionVersion || current.SecurityVersion != securityVersion { + return ErrDenied + } + return nil + } +} + func CheckWrite(tx *xorm.Session, guards ...WriteGuard) error { for _, guard := range guards { if guard == nil { diff --git a/backend/services/notification/service.go b/backend/services/notification/service.go index e3439be..5f71a60 100644 --- a/backend/services/notification/service.go +++ b/backend/services/notification/service.go @@ -26,11 +26,11 @@ const ( ) var ( - ErrDenied = errors.New("notification authorization denied") - ErrConflict = errors.New("notification authorization changed") - ErrQuota = errors.New("notification quota exceeded") - ErrUnavailable = errors.New("notification authorization unavailable") - ErrLimits = errors.New("invalid notification limits") + ErrDenied = errors.New("notification authorization denied") + ErrConflict = errors.New("notification authorization changed") + ErrQuota = errors.New("notification quota exceeded") + ErrUnavailable = errors.New("notification authorization unavailable") + ErrLimits = errors.New("invalid notification limits") ErrNoCiphertext = errors.New("notification credential must be regenerated before reveal") ) diff --git a/frontend/src/views/components/SystemNotification/SystemNotification.css b/frontend/src/views/components/SystemNotification/SystemNotification.css index 4cc7975..15aa5f5 100644 --- a/frontend/src/views/components/SystemNotification/SystemNotification.css +++ b/frontend/src/views/components/SystemNotification/SystemNotification.css @@ -60,6 +60,51 @@ margin-top: 16px; } +.notification-credential { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 10px; + padding: 10px 12px; + margin: 12px 0; + border: 1px solid var(--el-border-color-light); + border-radius: 8px; + background: var(--el-fill-color-light); +} + +.notification-credential-label { + font-weight: 500; +} + +.notification-credential-code { + flex: 1 1 320px; + min-width: 0; + font-size: 13px; + overflow-wrap: anywhere; +} + +.notification-credential-mask { + letter-spacing: 2px; + color: var(--el-text-color-secondary); +} + +.notification-credential-actions { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 8px; +} + +.notification-credential-actions :deep(.el-button + .el-button) { + margin-left: 0; +} + +.notification-countdown { + color: var(--el-color-warning-dark-2); + font-size: 12px; + white-space: nowrap; +} + .notification-compact { gap: 10px; padding: 0; diff --git a/frontend/src/views/components/SystemNotification/SystemNotification.js b/frontend/src/views/components/SystemNotification/SystemNotification.js index 34b25c0..a3d3503 100644 --- a/frontend/src/views/components/SystemNotification/SystemNotification.js +++ b/frontend/src/views/components/SystemNotification/SystemNotification.js @@ -20,12 +20,17 @@ export function usePage(props = {}) { const secretAccount = ref('') const secretVisible = ref(false) const seconds = ref(0) + const verifyVisible = ref(false) + const verifyPassword = ref('') + let verifyTarget = null + let secretSequence = 0 let secretTimer = null let requestSequence = 0 let accountsSequence = 0 let disposed = false const canManage = computed(() => manager.value && Boolean(status.value?.can_manage)) + const canReveal = computed(() => Boolean(status.value?.can_reveal)) const stateText = computed(() => { if (!status.value?.enabled) return '未开启' if (!status.value.account_available) return '账号不可用' @@ -44,6 +49,7 @@ export function usePage(props = {}) { ElNotification({ title: '操作失败', message: cause?.message || '请稍后重试', type: 'error' }) } const clearSecret = () => { + secretSequence += 1 if (secretTimer) clearInterval(secretTimer) secretTimer = null secret.value = '' @@ -52,6 +58,55 @@ export function usePage(props = {}) { secretVisible.value = false } + const clearVerification = () => { + verifyPassword.value = '' + verifyTarget = null + verifyVisible.value = false + } + const showSecret = (code, account) => { + clearSecret() + if (!code || document.hidden) return + secret.value = code + secretAccount.value = account + secretVisible.value = true + const expiresAt = Date.now() + 60000 + seconds.value = 60 + secretTimer = setInterval(() => { + seconds.value = Math.max(0, Math.ceil((expiresAt - Date.now()) / 1000)) + if (seconds.value === 0) clearSecret() + }, 250) + } + const openVerify = () => { + if (!canReveal.value || loading.value || submitting.value || dirty.value) return + clearSecret() + verifyPassword.value = '' + verifyTarget = status.value + verifyVisible.value = true + } + const verifyAndReveal = async () => { + if (!verifyTarget || !verifyPassword.value || submitting.value || !canReveal.value) return + const target = verifyTarget + const sequence = ++secretSequence + submitting.value = true + try { + const data = dataOf(await http.post('/api/v1/system/notification/credential/reveal', { + user_id: target.user_id, version: target.version, password: verifyPassword.value + })) + if (disposed || sequence !== secretSequence || target.user_id !== status.value?.user_id) return + clearVerification() + showSecret(data.code, target.account) + } catch (cause) { showError(cause) } + finally { + verifyPassword.value = '' + submitting.value = false + } + } + const hideOnBackground = () => { + if (!document.hidden) return + clearSecret() + clearVerification() + } + const loadStatus = async () => { const sequence = ++requestSequence loading.value = true @@ -103,6 +158,7 @@ export function usePage(props = {}) { } const refresh = async () => { if (!(await beforeLeave())) return + clearSecret() await Promise.all([loadStatus(), loadAccounts()]) } const save = async () => { @@ -135,19 +191,12 @@ export function usePage(props = {}) { confirmButtonText: '生成新凭据', cancelButtonText: '取消', type: 'warning' }) submitting.value = true + const sequence = ++secretSequence const data = dataOf(await requestWithRecentAuthentication(() => http.post('/api/v1/system/notification/credential', { user_id: target.user_id, version: target.version }))) if (disposed) return - clearSecret() - secret.value = data.code - secretAccount.value = target.account - seconds.value = 60 - secretVisible.value = true - secretTimer = setInterval(() => { - seconds.value -= 1 - if (seconds.value <= 0) clearSecret() - }, 1000) + if (sequence === secretSequence) showSecret(data.code, target.account) await Promise.all([loadStatus(), loadAccounts()]) } catch (cause) { showError(cause) } finally { submitting.value = false } @@ -160,12 +209,24 @@ export function usePage(props = {}) { } catch { showError(new Error('复制失败,请手动复制授权码')) } } - onMounted(() => { loadStatus(); loadAccounts() }) - onUnmounted(() => { disposed = true; requestSequence += 1; accountsSequence += 1; clearSecret() }) + onMounted(() => { + loadStatus() + loadAccounts() + document.addEventListener('visibilitychange', hideOnBackground) + }) + onUnmounted(() => { + disposed = true + requestSequence += 1 + accountsSequence += 1 + clearSecret() + clearVerification() + document.removeEventListener('visibilitychange', hideOnBackground) + }) return { manager, loading, submitting, error, status, accounts, page, total, form, secret, secretAccount, secretVisible, seconds, canManage, stateText, dirty, + canReveal, verifyVisible, verifyPassword, openVerify, verifyAndReveal, clearVerification, loadAccounts, selectAccount, refresh, save, rotate, clearSecret, copySecret, beforeLeave } } diff --git a/frontend/src/views/components/SystemNotification/SystemNotification.vue b/frontend/src/views/components/SystemNotification/SystemNotification.vue index 3278433..19eba36 100644 --- a/frontend/src/views/components/SystemNotification/SystemNotification.vue +++ b/frontend/src/views/components/SystemNotification/SystemNotification.vue @@ -11,7 +11,7 @@

账号已禁用或尚未修改临时密码,不能生成或使用通知凭据。

+
+ 通知发信凭据 + + +
+
@@ -99,13 +123,34 @@
- -

账号:{{ secretAccount }}。请安全保存并交付给该账号的使用者。

- - + + + + + +

验证后显示 60 秒,隐藏后可重新验证查看。

+ +
@@ -122,6 +167,7 @@ const props = defineProps({ const { manager, loading, submitting, error, status, accounts, page, total, form, secret, secretAccount, secretVisible, seconds, canManage, stateText, dirty, + canReveal, verifyVisible, verifyPassword, openVerify, verifyAndReveal, clearVerification, loadAccounts, selectAccount, refresh, save, rotate, clearSecret, copySecret, beforeLeave } = usePage(props)