支持通知凭据验证后查看并收紧账号权限
Some checks are pending
Security Checks / security (push) Waiting to run

This commit is contained in:
smail 2026-09-26 22:16:47 +08:00
parent 05b01bbc83
commit 46572da0f5
9 changed files with 240 additions and 29 deletions

View File

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

View File

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

View File

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

View File

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

View File

@ -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(&current)
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 {

View File

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

View File

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

View File

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

View File

@ -11,7 +11,7 @@
<el-alert
v-if="!compact"
title="默认关闭,逐账号授权"
:description="manager ? '只有管理范围内的账号可以授权。管理员修改授权前需要身份确认;关闭时永久撤销旧凭据。' : '普通用户只能查看自己的状态和连接参数。需要开启或重置凭据时,请联系管理员。'"
:description="manager ? '只有管理范围内的账号可以授权。管理员修改授权前需要身份确认;关闭时永久撤销旧凭据。' : '可查看自己的状态,并验证本人密码查看通知凭据。开启或重置凭据请联系管理员。'"
type="info"
show-icon
:closable="false"
@ -54,6 +54,30 @@
</div>
<p v-if="!status.account_available" class="notification-warning">账号已禁用或尚未修改临时密码,不能生成或使用通知凭据。</p>
<div v-if="status.configured" class="notification-credential">
<span class="notification-credential-label">通知发信凭据</span>
<template v-if="secretVisible && secret">
<code class="notification-credential-code">{{ secret }}</code>
<div class="notification-credential-actions">
<el-button size="small" type="primary" @click="copySecret">复制凭据</el-button>
<el-button size="small" @click="clearSecret">隐藏</el-button>
<span class="notification-countdown" role="status">{{ seconds }} 秒后自动隐藏</span>
</div>
</template>
<template v-else>
<span class="notification-credential-mask" aria-label="凭据已隐藏">••••••••••••••••</span>
<el-button
v-if="canReveal"
size="small"
type="primary"
plain
:disabled="submitting || loading || dirty"
@click="openVerify"
>安全验证查看明文</el-button>
<span v-else class="notification-hint">旧凭据未保留密文,请联系管理员重新生成。</span>
</template>
</div>
<div class="notification-columns">
<el-form v-if="canManage" label-position="top" :disabled="submitting || loading" @submit.prevent="save">
<el-form-item label="允许该账号通过 SMTP 发送系统通知">
@ -99,13 +123,34 @@
<el-empty v-else-if="!loading && !error" description="尚未读取账号状态" />
</div>
<el-dialog v-model="secretVisible" title="通知发信凭据(仅显示一次)" width="min(560px, 92vw)" destroy-on-close @closed="clearSecret">
<p>账号:{{ secretAccount }}。请安全保存并交付给该账号的使用者。</p>
<el-alert :title="`${seconds} 秒后自动隐藏;关闭后无法再次查看,只能重新生成。`" type="warning" :closable="false" show-icon />
<el-input :model-value="secret" readonly aria-label="新通知发信凭据" class="notification-secret" />
<el-dialog
v-model="verifyVisible"
title="安全验证"
width="min(420px, 92vw)"
append-to-body
destroy-on-close
:close-on-click-modal="false"
:close-on-press-escape="!submitting"
:show-close="!submitting"
@closed="clearVerification"
>
<el-form label-position="top" @submit.prevent="verifyAndReveal">
<el-form-item label="当前登录密码">
<el-input
v-model="verifyPassword"
type="password"
autocomplete="current-password"
placeholder="请输入本人登录密码"
:disabled="submitting"
aria-label="当前登录密码"
/>
</el-form-item>
<p class="notification-hint">验证后显示 60 秒,隐藏后可重新验证查看。</p>
<button type="submit" hidden></button>
</el-form>
<template #footer>
<el-button @click="copySecret">复制凭据</el-button>
<el-button type="primary" @click="clearSecret">已保存,关闭</el-button>
<el-button :disabled="submitting" @click="clearVerification">取消</el-button>
<el-button type="primary" :loading="submitting" :disabled="!verifyPassword" @click="verifyAndReveal">验证并查看</el-button>
</template>
</el-dialog>
</section>
@ -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)