This commit is contained in:
parent
05b01bbc83
commit
46572da0f5
@ -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)
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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},
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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")
|
||||
)
|
||||
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user