From 20a6eea3b30c36070f64d5d05c8a76a7a8155a5c Mon Sep 17 00:00:00 2001 From: smail Date: Sun, 6 Sep 2026 22:28:20 +0800 Subject: [PATCH] =?UTF-8?q?=E8=A1=A5=E9=BD=90=E9=82=AE=E4=BB=B6=E5=8D=8F?= =?UTF-8?q?=E8=AE=AE=E9=9A=94=E7=A6=BB=E5=9B=9E=E5=BD=92=E5=B9=B6=E4=BC=98?= =?UTF-8?q?=E5=8C=96=E5=AE=A2=E6=88=B7=E7=AB=AF=E8=AE=BE=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/controllers/security.go | 4 +- backend/models/protocol_credential.go | 2 +- backend/services/credentials/protocol.go | 17 +- .../ConfigSettings/ConfigForm/ConfigForm.js | 4 +- .../ConfigSettings/ConfigSettings.js | 2 +- .../views/components/HomeHeader/HomeHeader.js | 2 +- .../ThirdPartyAuthorization.js | 10 +- .../ThirdPartyAuthorization.vue | 7 +- test/README.md | 1 + test/backend/mail_listeners_test.go | 4 +- test/backend/protocol_access_test.go | 145 ++++++++++++++++++ test/backend/protocol_credentials_test.go | 117 ++++++++++++++ test/frontend/protocol-client.test.mjs | 42 +++++ test/frontend/settings-menu.test.mjs | 7 +- test/run.mjs | 17 +- 15 files changed, 360 insertions(+), 21 deletions(-) create mode 100644 test/backend/protocol_access_test.go create mode 100644 test/backend/protocol_credentials_test.go create mode 100644 test/frontend/protocol-client.test.mjs diff --git a/backend/controllers/security.go b/backend/controllers/security.go index 2dea560..19e3c01 100644 --- a/backend/controllers/security.go +++ b/backend/controllers/security.go @@ -369,13 +369,13 @@ func ListProtocolCredentials(ctx *context.Context, w http.ResponseWriter, req *h items, err := service.ListProtocolCredentials(ctx.UserID) if err != nil { log.WithError(err).Error("load protocol credentials failed") - writeSecurityError(w, response.ParamsError, "读取第三方协议设置失败") + writeSecurityError(w, response.ParamsError, "读取邮件客户端协议设置失败") return } response.NewSuccessResponse(items).FPrint(w) } -// SetProtocolCredentialEnabled 设置当前用户的第三方协议开关。 +// SetProtocolCredentialEnabled 设置当前用户的邮件客户端协议开关,不改变服务监听状态。 func SetProtocolCredentialEnabled(ctx *context.Context, w http.ResponseWriter, req *http.Request) { service, ok := requireProtocolCredentialContext(ctx, w) if !ok { diff --git a/backend/models/protocol_credential.go b/backend/models/protocol_credential.go index 9f44440..fdbe246 100644 --- a/backend/models/protocol_credential.go +++ b/backend/models/protocol_credential.go @@ -2,7 +2,7 @@ package models import "time" -// ProtocolCredential 保存单个用户的第三方邮件协议开关和授权码哈希。 +// ProtocolCredential 保存单个用户的邮件客户端协议开关和授权码哈希,不控制全局监听服务。 type ProtocolCredential struct { ID int `xorm:"id bigint unsigned not null pk autoincr" json:"-"` UserID int `xorm:"user_id bigint not null" json:"-"` diff --git a/backend/services/credentials/protocol.go b/backend/services/credentials/protocol.go index 6d28188..02029e0 100644 --- a/backend/services/credentials/protocol.go +++ b/backend/services/credentials/protocol.go @@ -204,8 +204,21 @@ func (s *Service) VerifyProtocolCode(userID int, protocol string, plain string) return false } now := s.now() - _, _ = s.engine.ID(item.ID).Cols("last_used_at", "updated_at").Update(&models.ProtocolCredential{LastUsedAt: &now}) - return true + // 哈希计算期间可能发生关闭、撤销或换码,返回成功前必须再次原子确认凭据状态。 + affected, err := s.engine.ID(item.ID). + Where("enabled = ? AND revoked_at IS NULL AND code_version = ? AND code_hash = ?", true, item.CodeVersion, item.CodeHash). + Cols("last_used_at", "updated_at").Update(&models.ProtocolCredential{LastUsedAt: &now}) + if err != nil { + return false + } + if affected == 1 { + return true + } + // MySQL 在时间字段未变化时可能报告零影响行,必须再次查询有效状态而不是误拒绝或直接放行。 + valid, err := s.engine.ID(item.ID). + Where("enabled = ? AND revoked_at IS NULL AND code_version = ? AND code_hash = ?", true, item.CodeVersion, item.CodeHash). + Exist(&models.ProtocolCredential{}) + return err == nil && valid } func newProtocolCode() (string, error) { diff --git a/frontend/src/views/components/ConfigSettings/ConfigForm/ConfigForm.js b/frontend/src/views/components/ConfigSettings/ConfigForm/ConfigForm.js index 2d444af..c3c20bd 100644 --- a/frontend/src/views/components/ConfigSettings/ConfigForm/ConfigForm.js +++ b/frontend/src/views/components/ConfigSettings/ConfigForm/ConfigForm.js @@ -116,12 +116,12 @@ const baseGroups = [ ] const smtpGroups = [ - group('listener', 'SMTP 服务监听', '', [ + group('listener', 'SMTP 服务监听', '本地邮件服务默认启动,不受中继配置或用户协议开关控制。', [ port('listener.port', 'SMTP 收信端口', { min: 1, hint: '默认 25,用于接收其他邮件服务器投递,支持 STARTTLS;不是发信中继端口。' }), port('listener.starttlsPort', 'SMTP 提交端口(STARTTLS)', { min: 1, hint: '默认 587,客户端升级 TLS 后进行认证。' }), port('listener.tlsPort', 'SMTP 隐式 TLS 端口', { min: 1, hint: '默认 465,客户端建立连接时直接使用 TLS。' }) ]), - group('relay', 'SMTP 中继连接', '用于发送验证及通知邮件。未使用中继时可保留空值。', [ + group('relay', 'SMTP 中继连接(可选)', '仅用于验证及通知邮件外发,不负责启动本地邮件服务;未使用时可保留空值。', [ field('relay.relayHost', 'SMTP 服务器', 'text', { placeholder: 'smtp.example.com' }), field('relay.security', '连接加密', 'select', { options: options([ diff --git a/frontend/src/views/components/ConfigSettings/ConfigSettings.js b/frontend/src/views/components/ConfigSettings/ConfigSettings.js index 26603e0..d289d01 100644 --- a/frontend/src/views/components/ConfigSettings/ConfigSettings.js +++ b/frontend/src/views/components/ConfigSettings/ConfigSettings.js @@ -13,7 +13,7 @@ const sections = [ { value: 'ssl', label: 'SSL 配置', description: '管理证书与加密连接,应用后请留意待重启提示。' }, { value: 'plugins', label: '插件配置', description: '管理插件运行配置,敏感设置仅显示配置状态。' }, { value: 'spam_block', label: '垃圾邮件拦截', description: '配置垃圾邮件检测服务、超时和拦截阈值,仅编辑当前插件。' }, - { value: 'telegram_push', label: 'Telegram 推送', description: '配置 Telegram 推送的运行参数与网络权限,个人绑定在第三方授权中管理。' }, + { value: 'telegram_push', label: 'Telegram 推送', description: '配置 Telegram 推送的运行参数与网络权限,个人绑定在扩展功能中管理。' }, { value: 'wechat_push', label: '微信推送', description: '配置微信推送的应用、模板与接收用户,敏感凭据不回填。' } ] diff --git a/frontend/src/views/components/HomeHeader/HomeHeader.js b/frontend/src/views/components/HomeHeader/HomeHeader.js index fd0d98b..1449184 100644 --- a/frontend/src/views/components/HomeHeader/HomeHeader.js +++ b/frontend/src/views/components/HomeHeader/HomeHeader.js @@ -44,7 +44,7 @@ export function usePage() { const settingsMenu = computed(() => [ { key: 'security', label: lang.security, hint: '账号与登录保护', icon: Lock, component: SecuritySettings, capability: 'password.change_own' }, - { key: 'third-party', label: '第三方授权', hint: '邮件客户端接入', icon: Connection, component: ThirdPartyAuthorization, capability: 'plugin.write_own' }, + { key: 'third-party', label: '邮件客户端', hint: '当前账号协议授权', icon: Connection, component: ThirdPartyAuthorization, capability: 'user.read' }, { key: 'config', label: '配置管理', hint: '服务与运行配置', icon: Setting, component: ConfigSettings, capability: 'config.read' }, { key: 'group', label: lang.group_settings, hint: '邮箱分组管理', icon: FolderOpened, component: GroupSettings, capability: 'user.read' }, { key: 'rule', label: lang.rule_setting, hint: '邮件处理规则', icon: Filter, component: RuleSettings, capability: 'user.read' }, diff --git a/frontend/src/views/components/ThirdPartyAuthorization/ThirdPartyAuthorization.js b/frontend/src/views/components/ThirdPartyAuthorization/ThirdPartyAuthorization.js index 8182283..212ef63 100644 --- a/frontend/src/views/components/ThirdPartyAuthorization/ThirdPartyAuthorization.js +++ b/frontend/src/views/components/ThirdPartyAuthorization/ThirdPartyAuthorization.js @@ -29,11 +29,11 @@ export function usePage() { try { const response = await http.get('/api/v1/security/credentials/protocols') if (response.errorNo !== 0) { - throw new Error(response.errorMsg || '读取第三方授权设置失败') + throw new Error(response.errorMsg || '读取邮件客户端协议设置失败') } protocolCredentials.value = Array.isArray(response.data) ? response.data : [] } catch (error) { - notify(error?.message || '读取第三方授权设置失败') + notify(error?.message || '读取邮件客户端协议设置失败') } finally { protocolLoading.value = false } @@ -52,7 +52,11 @@ export function usePage() { if (response.errorNo !== 0) { throw new Error(response.errorMsg || '协议设置失败') } - notify(`${protocolLabel(item.protocol)} 协议已${enabled ? '开启' : '关闭'}`, 'success') + if (!enabled && generatedProtocol.value === item.protocol) { + generatedProtocol.value = '' + generatedProtocolCode.value = '' + } + notify(`${protocolLabel(item.protocol)} 当前账号客户端访问已${enabled ? '开启' : '关闭'}`, 'success') } catch (error) { item.enabled = previous notify(error?.message || '协议设置失败') diff --git a/frontend/src/views/components/ThirdPartyAuthorization/ThirdPartyAuthorization.vue b/frontend/src/views/components/ThirdPartyAuthorization/ThirdPartyAuthorization.vue index d22290d..08d02ea 100644 --- a/frontend/src/views/components/ThirdPartyAuthorization/ThirdPartyAuthorization.vue +++ b/frontend/src/views/components/ThirdPartyAuthorization/ThirdPartyAuthorization.vue @@ -2,14 +2,14 @@
-

第三方邮件客户端登录

+

邮件客户端协议

- 开启协议并生成授权码后,才能使用邮件客户端登录。登录名必须填写完整邮箱地址,密码必须填写授权码,不能填写网页登录密码。 + 本地邮件服务默认启动,不依赖 SMTP 中继。以下开关只控制当前账号的客户端访问权限,不影响其他用户或服务监听。

readFileSync(new URL(`ThirdPartyAuthorization.${extension}`, root), 'utf8') + +for (const succeeded of [false, true]) { + test(`关闭协议时只在保存成功后清除本次明文:成功=${succeeded}`, async () => { + const usePage = compileFunction( + read('js').replace(/^import .*$/gm, '').replace('export function usePage', 'function usePage') + '\nreturn usePage', + ['onMounted', 'ref', 'ElMessageBox', 'ElNotification', 'http'], + )(() => {}, ref, {}, () => {}, { + put: async () => ({ errorNo: succeeded ? 0 : 1 }), + }) + const page = usePage() + const item = { protocol: 'imap', enabled: true, configured: true } + page.generatedProtocol.value = 'imap' + page.generatedProtocolCode.value = 'temporary-test-only' + await page.setProtocolEnabled(item, false) + assert.equal(item.enabled, !succeeded) + assert.equal(page.generatedProtocolCode.value, succeeded ? '' : 'temporary-test-only') + assert.equal(page.generatedProtocol.value, succeeded ? '' : 'imap') + assert.equal(item.configured, true) + assert.equal(page.protocolLoading.value, false) + }) +} + +test('邮件客户端文案明确区分本地服务、账号权限和可恢复的关闭状态', () => { + const template = read('vue') + assert.match(template, /邮件客户端协议/) + assert.match(template, /不依赖 SMTP 中继/) + assert.match(template, /只控制当前账号/) + assert.match(template, /重新开启可继续使用未撤销的授权码/) + assert.match(template, /:aria-label=/) + assert.doesNotMatch(template, /第三方邮件|&#x[0-9a-f]+;/i) +}) diff --git a/test/frontend/settings-menu.test.mjs b/test/frontend/settings-menu.test.mjs index fc69abd..2e53d17 100644 --- a/test/frontend/settings-menu.test.mjs +++ b/test/frontend/settings-menu.test.mjs @@ -44,19 +44,20 @@ test('一级菜单保留能力过滤和当前分类标题联动', () => { .replace(/export \{[\s\S]*?\}\s*$/, '') + '\nreturn usePage()', ['computed', 'ref', 'onMounted', 'onUnmounted', 'window', 'useGlobalStatusStore', 'lang', ...names] ) - const capabilities = reactive(new Set(['password.change_own', 'plugin.write_own'])) + const capabilities = reactive(new Set(['password.change_own', 'user.read'])) const store = reactive({ settingsActiveTab: 'security', hasCapability: value => capabilities.has(value) }) const page = createPage(computed, ref, () => {}, () => {}, { innerWidth: 1440 }, () => store, { security: '安全' }, ...names.map(name => ({ name }))) - assert.deepEqual(page.settingsMenu.value.map(item => item.key), ['security', 'third-party']) + assert.deepEqual(page.settingsMenu.value.map(item => item.key), ['security', 'third-party', 'group', 'rule']) + assert.equal(page.settingsMenu.value.find(item => item.key === 'third-party').label, '邮件客户端') assert.equal(page.activeSettings.value.label, '安全') capabilities.add('config.read') store.settingsActiveTab = 'config' assert.equal(page.activeSettings.value.label, '配置管理') - assert.equal(page.settingsMenu.value.length, 3) + assert.equal(page.settingsMenu.value.length, 5) assert.ok(page.settingsMenu.value.every(item => item.icon && item.hint)) capabilities.delete('config.read') assert.equal(page.activeSettings.value, undefined) diff --git a/test/run.mjs b/test/run.mjs index 8da4931..e252b02 100644 --- a/test/run.mjs +++ b/test/run.mjs @@ -1,6 +1,6 @@ import { spawn } from 'node:child_process' import { createWriteStream } from 'node:fs' -import { mkdir, readdir } from 'node:fs/promises' +import { mkdir, readdir, writeFile } from 'node:fs/promises' import { fileURLToPath } from 'node:url' import path from 'node:path' @@ -40,7 +40,20 @@ if (suite !== 'backend') { } if (suite !== 'frontend' && result === 0) { const args = extra[0] === '--' ? extra.slice(1) : extra - result = await run('backend', 'go', ['test', ...(args.length ? args : ['./...'])], path.join(root, 'backend')) + // 独立回归源码集中放在 test/backend,通过 overlay 保留同包测试语义。 + const replacements = { + 'backend/services/credentials/client_regression_test.go': 'test/backend/protocol_credentials_test.go', + 'backend/controllers/client_regression_test.go': 'test/backend/protocol_access_test.go', + 'backend/res_init/client_listeners_test.go': 'test/backend/mail_listeners_test.go', + } + const overlay = path.join(directory, 'overlay.json') + const Replace = Object.fromEntries(Object.entries(replacements).map(([target, source]) => [ + path.join(root, target), path.join(root, source), + ])) + await writeFile(overlay, JSON.stringify({ Replace }, null, 2) + '\n') + // 显式传入的审查 overlay 保持原有行为,默认入口自动包含本轮回归。 + const overlayArgs = args.some(arg => arg === '-overlay' || arg.startsWith('-overlay=')) ? [] : [`-overlay=${overlay}`] + result = await run('backend', 'go', ['test', ...overlayArgs, ...(args.length ? args : ['./...'])], path.join(root, 'backend')) } console.log(`测试记录:${path.relative(root, directory)}`) process.exitCode = result