From 18f9fd34e510f493d3b18b89c0fc00041305d1ab Mon Sep 17 00:00:00 2001 From: smail Date: Mon, 31 Aug 2026 02:11:45 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=8C=E5=96=84=E7=AC=AC=E4=B8=89=E6=96=B9?= =?UTF-8?q?=E5=8D=8F=E8=AE=AE=E6=8E=88=E6=9D=83=E7=A0=81=E7=99=BB=E5=BD=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README_CN.md | 57 ++++- .../SecuritySettings/SecuritySettings.css | 16 ++ .../SecuritySettings/SecuritySettings.js | 82 ++++++- .../SecuritySettings/SecuritySettings.vue | 34 ++- server/controllers/security.go | 89 +++++++ .../security_public_response_test.go | 2 +- server/db/init.go | 1 + server/db/migration/protocol_credentials.go | 19 ++ server/db/migration/registry.go | 1 + server/listen/http_server/api_v1_compat.go | 4 + server/listen/http_server/http_server.go | 4 + server/models/protocol_credential.go | 19 ++ server/services/auth/auth_test.go | 20 +- server/services/auth/protocol_limiter.go | 28 ++- server/services/credentials/protocol.go | 226 ++++++++++++++++++ server/services/credentials/service.go | 7 + server/services/credentials/service_test.go | 39 ++- server/services/recovery/service_test.go | 2 +- 18 files changed, 633 insertions(+), 17 deletions(-) create mode 100644 server/db/migration/protocol_credentials.go create mode 100644 server/models/protocol_credential.go create mode 100644 server/services/credentials/protocol.go diff --git a/README_CN.md b/README_CN.md index a71227d6..ff7e064d 100644 --- a/README_CN.md +++ b/README_CN.md @@ -101,18 +101,63 @@ Smail是一个追求极简部署流程、极致资源占用的个人域名邮箱 # 第三方邮件客户端配置 -POP3地址: pop.[你的域名] +Smail 的 IMAP、POP3、SMTP 第三方客户端登录默认受用户级安全设置保护。每个协议都必须分别完成以下步骤: -POP3端口: 110/995(SSL) +1. 登录 Web 管理页面,打开“设置 → 账户安全 → 第三方邮件客户端登录”。 +2. 开启需要使用的协议开关。 +3. 为该协议生成授权码,并在生成后立即复制保存。授权码只在生成成功时显示一次,重新生成会使同一协议的旧授权码失效。 +4. 在邮件客户端中,登录名必须填写完整邮箱地址,例如 `alice@example.com`,不能只填写 `alice`。 +5. 客户端密码必须填写对应协议的授权码,不能填写网页登录密码、离线恢复码或邮箱验证码。 -SMTP地址: smtp.[你的域名] +## 服务地址和端口 -SMTP端口: 25/465、587(SSL) +服务地址通常使用已解析到 Smail 服务器的邮件子域名。请根据实际 DNS 配置填写,例如 `imap.example.com`、`pop.example.com` 和 `smtp.example.com`。如果所有协议共用同一个邮件域名,也可以使用实际部署的统一主机名;不要直接照抄示例域名。 -IMAP地址: imap.[Your Domain] +| 协议 | 服务地址示例 | 推荐端口 | 加密方式 | 登录名 | 密码 | +| --- | --- | --- | --- | --- | --- | +| IMAP | `imap.example.com` | `993` | SSL/TLS | 完整邮箱地址 | IMAP 授权码 | +| POP3 | `pop.example.com` | `995` | SSL/TLS | 完整邮箱地址 | POP3 授权码 | +| SMTP | `smtp.example.com` | `465` | SSL/TLS | 完整邮箱地址 | SMTP 授权码 | +| SMTP | `smtp.example.com` | `587` | STARTTLS | 完整邮箱地址 | SMTP 授权码 | +| SMTP | `smtp.example.com` | `25` | 明文或按服务器策略启用 TLS | 完整邮箱地址 | SMTP 授权码 | -IMAP端口: 993(SSL) +端口说明: +- `993` 和 `995` 分别用于 IMAP over SSL/TLS 和 POP3 over SSL/TLS,生产环境优先使用这两个端口。 +- SMTP `465` 使用连接即加密的 SSL/TLS;SMTP `587` 使用 STARTTLS,客户端必须打开“STARTTLS”或“需要安全连接”选项。 +- SMTP `25` 主要用于服务器间投递,许多云服务商会限制出站连接;普通客户端提交邮件优先使用 `465` 或 `587`。 +- Web 页面由 Nginx 代理 HTTPS 时,不代表 IMAP、POP3、SMTP 端口也由 Nginx 代理。邮件客户端仍应连接邮件服务实际监听的端口,并使用邮件服务端配置的证书和 TLS 设置。 + +## 协议开关和授权码规则 + +- 关闭协议后,该用户对应协议的第三方登录立即失效,网页登录不受影响。 +- 撤销授权码后,该协议已有客户端会在下一次认证时失败;需要重新生成授权码并更新客户端配置。 +- 开启协议但尚未生成授权码时,协议仍不能登录。 +- 授权码按协议分别管理,IMAP、POP3、SMTP 可以使用不同授权码;不要把一个协议的授权码填到另一个协议。 +- 授权码只保存哈希,不会在设置页面再次读取,也不会写入日志。请将授权码视为长期密码,不要提交到工单、截图或 Git。 +- Smail 第三方协议认证不会接受网页登录密码。这样可以在不暴露网页登录密码的情况下单独撤销邮件客户端访问。 + +## 常见登录失败排查 + +1. 确认协议开关已开启,并且该协议已经生成授权码。 +2. 确认登录名包含完整域名,例如 `alice@example.com`,不要填写 `alice`、显示名称或别名。 +3. 确认密码是当前协议的授权码,不是网页登录密码、恢复码或邮箱验证码。 +4. 确认客户端加密模式和端口匹配:`465` 使用 SSL/TLS,`587` 使用 STARTTLS,`993/995` 使用 SSL/TLS。 +5. 确认邮件协议主机名的 DNS 记录指向实际 Smail 服务器,防火墙已放行对应端口,证书的域名与客户端连接的主机名匹配。 +6. 如果刚刚关闭协议、撤销或重新生成授权码,请删除客户端保存的旧密码后重新输入新授权码。 +7. 仅检查 Web HTTPS 是否正常不能证明邮件协议可用;应分别使用 SMTP、IMAP 或 POP3 客户端进行认证测试。 + +## PMail 原版兼容性说明 + +PMail 原版的协议登录流程与 Smail 不兼容,不能直接作为 Smail 的第三方客户端兼容层: + +- PMail 原版会把包含 `@` 的登录名截断为账号部分,不能可靠识别 Smail 要求的完整邮箱地址。 +- PMail 原版没有按用户、按协议的开关和授权码机制。 +- PMail 原版把客户端密码当作网页登录密码校验;Smail 要求客户端使用协议授权码,因此把 Smail 授权码填入 PMail 原版会被当成错误的网页登录密码。 +- Smail 使用 Argon2id 保存用户密码,PMail 原版的旧密码校验逻辑不能直接验证 Smail 的 Argon2id 哈希。 +- 多域名环境下,PMail 截断域名还可能造成账号冲突或错误用户解析。 + +因此,使用 Smail 邮件服务时,请让客户端直接连接 Smail 的 SMTP、IMAP、POP3 服务,并按本节的开关、完整邮箱地址和协议授权码规则配置;不要把 PMail 原版的账号截断或旧密码逻辑复制到 Smail。 # 插件 [微信推送](server/hooks/wechat_push/README.md) diff --git a/fe/src/views/components/SecuritySettings/SecuritySettings.css b/fe/src/views/components/SecuritySettings/SecuritySettings.css index 9cf70584..ac8b8d78 100644 --- a/fe/src/views/components/SecuritySettings/SecuritySettings.css +++ b/fe/src/views/components/SecuritySettings/SecuritySettings.css @@ -24,3 +24,19 @@ .credential-actions { width: 100%; } .credential-actions .el-button { flex: 1; } } + +.protocol-section > .el-alert { margin-bottom: 18px; } +.protocol-list { display: grid; gap: 12px; } +.protocol-item { display: flex; align-items: center; justify-content: space-between; gap: 18px; padding: 16px; border: 1px solid var(--pm-border-color); border-radius: var(--pm-radius-sm); background: var(--pm-bg-secondary); } +.protocol-info { min-width: 0; } +.protocol-title { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; } +.protocol-title strong { color: var(--pm-text-primary); font-size: 16px; } +.protocol-info p { margin: 8px 0 0; color: var(--pm-text-secondary); font-size: 13px; } +.protocol-actions { display: flex; align-items: center; justify-content: flex-end; flex-wrap: wrap; gap: 8px; flex: none; } +.protocol-code-panel { display: flex; align-items: center; flex-wrap: wrap; gap: 12px; margin-top: 16px; padding: 16px; border: 1px solid var(--pm-primary-color); border-radius: var(--pm-radius-sm); background: color-mix(in srgb, var(--pm-primary-color) 7%, var(--pm-bg-primary)); } +.protocol-code-panel .one-time-tip { width: 100%; color: var(--pm-text-secondary); font-size: 13px; } +.protocol-code-panel code { padding: 8px 12px; border-radius: var(--pm-radius-sm); background: var(--pm-bg-secondary); color: var(--pm-text-primary); font-size: 16px; font-weight: 700; letter-spacing: 1px; word-break: break-all; } +@media (max-width: 720px) { + .protocol-item { align-items: stretch; flex-direction: column; } + .protocol-actions { justify-content: flex-start; } +} \ No newline at end of file diff --git a/fe/src/views/components/SecuritySettings/SecuritySettings.js b/fe/src/views/components/SecuritySettings/SecuritySettings.js index 492bc031..01b23a24 100644 --- a/fe/src/views/components/SecuritySettings/SecuritySettings.js +++ b/fe/src/views/components/SecuritySettings/SecuritySettings.js @@ -22,6 +22,10 @@ export function usePage() { const generatedCodes = ref([]) const externalEmails = ref([]) const externalLoading = ref(false) + const protocolLoading = ref(false) + const protocolCredentials = ref([]) + const generatedProtocolCode = ref('') + const generatedProtocol = ref('') const bindForm = reactive({ email: '', type: 'backup', challengeId: '', bindingId: 0, code: '' }) const ruleForm = reactive({ current_pwd: '', new_pwd: '', new_pwd2: '' }) @@ -53,13 +57,15 @@ export function usePage() { securityLoading.value = true try { const requests = [ + http.get('/api/v1/security/credentials/protocols'), http.get('/api/v1/security/credentials/question'), http.get('/api/v1/security/external-email/list'), ] if (!isSuperAdmin.value) { requests.push(http.get('/api/v1/security/credentials/recovery-codes')) } - const [question, external, recovery] = await Promise.all(requests) + const [protocol, question, external, recovery] = await Promise.all(requests) + if (protocol.errorNo === 0) protocolCredentials.value = Array.isArray(protocol.data) ? protocol.data : [] if (question.errorNo === 0 && question.data) { securityQuestion.configured = Boolean(question.data.configured) securityQuestion.question = question.data.question || '' @@ -74,6 +80,78 @@ export function usePage() { } } + const protocolLabel = protocol => ({ imap: 'IMAP', pop3: 'POP3', smtp: 'SMTP' }[protocol] || protocol.toUpperCase()) + + const loadProtocolCredentials = async () => { + const res = await http.get('/api/v1/security/credentials/protocols') + if (res.errorNo !== 0) throw new Error(res.errorMsg || '读取第三方协议设置失败') + protocolCredentials.value = Array.isArray(res.data) ? res.data : [] + } + + const setProtocolEnabled = async (item, enabled) => { + const previous = item.enabled + item.enabled = enabled + protocolLoading.value = true + try { + const res = await http.put('/api/v1/security/credentials/protocols/enabled', { protocol: item.protocol, enabled }) + if (res.errorNo !== 0) throw new Error(res.errorMsg || '协议设置失败') + notify(`${protocolLabel(item.protocol)} 协议已${enabled ? '开启' : '关闭'}`, 'success') + } catch (error) { + item.enabled = previous + notify(error?.message || '协议设置失败') + } finally { + protocolLoading.value = false + } + } + + const generateProtocolCode = async item => { + if (!item.enabled) { + notify(`请先开启 ${protocolLabel(item.protocol)} 协议`) + return + } + protocolLoading.value = true + try { + const res = await http.post('/api/v1/security/credentials/protocols/generate', { protocol: item.protocol }) + if (res.errorNo !== 0) throw new Error(res.errorMsg || '生成授权码失败') + generatedProtocol.value = item.protocol + generatedProtocolCode.value = res.data?.code || '' + item.configured = true + notify(`${protocolLabel(item.protocol)} 授权码已生成,请立即保存`, 'success') + } catch (error) { + notify(error?.message || '生成授权码失败') + } finally { + protocolLoading.value = false + } + } + + const copyProtocolCode = async () => { + if (!generatedProtocolCode.value) return + try { + await navigator.clipboard.writeText(generatedProtocolCode.value) + notify('授权码已复制', 'success') + } catch { + notify('复制失败,请手动复制授权码') + } + } + + const revokeProtocolCode = async item => { + try { + await ElMessageBox.confirm(`撤销后 ${protocolLabel(item.protocol)} 授权码将立即失效,是否继续?`, '撤销协议授权码', { type: 'warning' }) + protocolLoading.value = true + const res = await http.post('/api/v1/security/credentials/protocols/revoke', { protocol: item.protocol }) + if (res.errorNo !== 0) throw new Error(res.errorMsg || '撤销授权码失败') + item.configured = false + if (generatedProtocol.value === item.protocol) { + generatedProtocol.value = '' + generatedProtocolCode.value = '' + } + notify(`${protocolLabel(item.protocol)} 授权码已撤销`, 'success') + } catch (error) { + if (error !== 'cancel' && error !== 'close') notify(error?.message || '撤销授权码失败') + } finally { + protocolLoading.value = false + } + } const registerCredential = async () => { if (!supported || registering.value) return registering.value = true @@ -235,6 +313,8 @@ export function usePage() { return { lang, isSuperAdmin, supported, registering, credentialsLoading, credentials, securityLoading, securityQuestion, questionForm, recoveryConfigured, generatedCodes, externalEmails, externalLoading, + protocolLoading, protocolCredentials, generatedProtocolCode, generatedProtocol, protocolLabel, + setProtocolEnabled, generateProtocolCode, copyProtocolCode, revokeProtocolCode, bindForm, ruleForm, rules, submit, registerCredential, renameCredential, revokeCredential, saveSecurityQuestion, generateRecoveryCodes, revokeRecoveryCodes, bindExternalEmail, disableExternalEmail, formatTime, logout, diff --git a/fe/src/views/components/SecuritySettings/SecuritySettings.vue b/fe/src/views/components/SecuritySettings/SecuritySettings.vue index 4a6cdedb..15233131 100644 --- a/fe/src/views/components/SecuritySettings/SecuritySettings.vue +++ b/fe/src/views/components/SecuritySettings/SecuritySettings.vue @@ -1,4 +1,4 @@ -