完善第三方协议授权码登录
Some checks are pending
Security Checks / security (push) Waiting to run

This commit is contained in:
smail 2026-08-31 02:11:45 +08:00
parent e76e802cc3
commit 18f9fd34e5
18 changed files with 633 additions and 17 deletions

View File

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

View File

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

View File

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

View File

@ -1,4 +1,4 @@
<template>
<template>
<div class="settings-card" v-loading="securityLoading">
<section v-if="!isSuperAdmin" class="security-section">
<div class="settings-header">
@ -45,6 +45,36 @@
</section>
<el-divider class="settings-divider" />
<el-divider class="settings-divider" />
<section class="security-section protocol-section">
<div class="settings-header">
<h3>第三方邮件客户端登录</h3>
<p class="settings-desc">开启协议并生成授权码后,才能使用邮件客户端登录。登录名必须填写完整邮箱地址,密码必须填写授权码,不能填写网页登录密码。</p>
</div>
<el-alert title="授权码只在生成成功后显示一次,请立即保存;撤销授权码或关闭协议后,客户端会立即无法登录。" type="warning" :closable="false" show-icon />
<div class="protocol-list" v-loading="protocolLoading">
<div v-for="item in protocolCredentials" :key="item.protocol" class="protocol-item">
<div class="protocol-info">
<div class="protocol-title">
<strong>{{ protocolLabel(item.protocol) }}</strong>
<el-tag size="small" :type="item.enabled ? 'success' : 'info'">{{ item.enabled ? '已开启' : '已关闭' }}</el-tag>
<el-tag size="small" :type="item.configured ? 'success' : 'warning'">{{ item.configured ? '已配置授权码' : '未生成授权码' }}</el-tag>
</div>
<p>登录名:完整邮箱地址;密码:本协议授权码。</p>
</div>
<div class="protocol-actions">
<el-switch v-model="item.enabled" :loading="protocolLoading" active-text="开启" inactive-text="关闭" @change="value => setProtocolEnabled(item, value)" />
<el-button type="primary" size="small" :disabled="!item.enabled" @click="generateProtocolCode(item)">{{ item.configured ? '重新生成授权码' : '生成授权码' }}</el-button>
<el-button v-if="item.configured" type="danger" plain size="small" @click="revokeProtocolCode(item)">撤销授权码</el-button>
</div>
</div>
</div>
<div v-if="generatedProtocolCode" class="protocol-code-panel">
<div class="one-time-tip">{{ protocolLabel(generatedProtocol) }} 授权码仅本次显示,请复制保存:</div>
<code>{{ generatedProtocolCode }}</code>
<el-button type="primary" plain size="small" @click="copyProtocolCode">复制授权码</el-button>
</div>
</section>
<section class="security-section">
<div class="settings-header"><h3>外部安全邮箱</h3><p class="settings-desc">用于登录二次验证和密码恢复,只显示脱敏地址。</p></div>
<div class="external-bind-form">
@ -79,6 +109,8 @@ import { usePage } from './SecuritySettings.js'
const {
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,

View File

@ -314,6 +314,95 @@ func RevokeRecoveryCodes(ctx *context.Context, w http.ResponseWriter, req *http.
}
// RequestVerification 统一处理登录、密码恢复和外部邮箱验证申请。
type protocolCredentialRequest struct {
Protocol string `json:"protocol"`
Enabled *bool `json:"enabled,omitempty"`
}
func requireProtocolCredentialContext(ctx *context.Context, w http.ResponseWriter) (*credentials.Service, bool) {
if ctx == nil || ctx.UserID <= 0 {
writeSecurityError(w, response.NeedLogin, "请先登录")
return nil, false
}
service := credentialRuntime()
if service == nil {
writeSecurityError(w, response.ParamsError, "安全设置暂时不可用")
return nil, false
}
return service, true
}
// ListProtocolCredentials 返回当前用户的协议开关和授权码状态。
func ListProtocolCredentials(ctx *context.Context, w http.ResponseWriter, req *http.Request) {
service, ok := requireProtocolCredentialContext(ctx, w)
if !ok {
return
}
items, err := service.ListProtocolCredentials(ctx.UserID)
if err != nil {
log.WithError(err).Error("load protocol credentials failed")
writeSecurityError(w, response.ParamsError, "读取第三方协议设置失败")
return
}
response.NewSuccessResponse(items).FPrint(w)
}
// SetProtocolCredentialEnabled 设置当前用户的第三方协议开关。
func SetProtocolCredentialEnabled(ctx *context.Context, w http.ResponseWriter, req *http.Request) {
service, ok := requireProtocolCredentialContext(ctx, w)
if !ok {
return
}
var input protocolCredentialRequest
if err := decodeJSONRequest(w, req, &input); err != nil || input.Enabled == nil {
writeSecurityError(w, response.ParamsError, "协议设置请求无效")
return
}
if err := service.SetProtocolEnabled(ctx.UserID, input.Protocol, *input.Enabled); err != nil {
writeSecurityError(w, response.ParamsError, "协议设置失败")
return
}
response.NewSuccessResponse(map[string]any{"protocol": strings.ToLower(strings.TrimSpace(input.Protocol)), "enabled": *input.Enabled}).FPrint(w)
}
// GenerateProtocolCredential 为当前用户生成指定协议授权码,明文只返回一次。
func GenerateProtocolCredential(ctx *context.Context, w http.ResponseWriter, req *http.Request) {
service, ok := requireProtocolCredentialContext(ctx, w)
if !ok {
return
}
var input protocolCredentialRequest
if err := decodeJSONRequest(w, req, &input); err != nil || strings.TrimSpace(input.Protocol) == "" {
writeSecurityError(w, response.ParamsError, "协议请求无效")
return
}
code, err := service.GenerateProtocolCode(ctx.UserID, input.Protocol)
if err != nil {
writeSecurityError(w, response.ParamsError, "请先开启协议后再生成授权码")
return
}
protocol, _ := credentials.NormalizeProtocol(input.Protocol)
response.NewSuccessResponse(map[string]any{"protocol": protocol, "code": code, "shown_once": true}).FPrint(w)
}
// RevokeProtocolCredential 撤销当前用户指定协议的授权码。
func RevokeProtocolCredential(ctx *context.Context, w http.ResponseWriter, req *http.Request) {
service, ok := requireProtocolCredentialContext(ctx, w)
if !ok {
return
}
var input protocolCredentialRequest
if err := decodeJSONRequest(w, req, &input); err != nil || strings.TrimSpace(input.Protocol) == "" {
writeSecurityError(w, response.ParamsError, "协议请求无效")
return
}
if err := service.RevokeProtocolCode(ctx.UserID, input.Protocol); err != nil {
writeSecurityError(w, response.ParamsError, "撤销授权码失败")
return
}
protocol, _ := credentials.NormalizeProtocol(input.Protocol)
response.NewSuccessResponse(map[string]any{"protocol": protocol, "revoked": true}).FPrint(w)
}
func RequestVerification(ctx *context.Context, w http.ResponseWriter, req *http.Request) {
if ctx == nil || ctx.UserID <= 0 {
writeSecurityError(w, response.NeedLogin, "请先登录")

View File

@ -38,7 +38,7 @@ func newPublicSecurityFixture(t *testing.T) (*recovery.Service, *webauthnservice
&models.Sessions{},
&models.SecurityQuestion{},
&models.RecoveryCode{},
&models.WebAuthnCredential{},
&models.WebAuthnCredential{}, &models.ProtocolCredential{},
} {
if err := engine.Sync2(table); err != nil {
t.Fatal(err)

View File

@ -144,6 +144,7 @@ func syncTables() error {
&models.SecurityQuestion{},
&models.RecoveryCode{},
&models.WebAuthnCredential{},
&models.ProtocolCredential{},
&models.Version{},
}
for _, table := range tables {

View File

@ -0,0 +1,19 @@
package migration
func protocolCredentialStatements() map[Dialect][]string {
return map[Dialect][]string{
DialectSQLite: {
`CREATE TABLE IF NOT EXISTS "protocol_credential" ("id" INTEGER PRIMARY KEY AUTOINCREMENT, "user_id" INTEGER NOT NULL, "protocol" TEXT NOT NULL, "enabled" INTEGER NOT NULL DEFAULT 0, "code_hash" TEXT NOT NULL DEFAULT '', "code_version" INTEGER NOT NULL DEFAULT 1, "created_at" DATETIME NOT NULL, "updated_at" DATETIME NOT NULL, "last_used_at" DATETIME NULL, "revoked_at" DATETIME NULL, UNIQUE("user_id", "protocol"))`,
`CREATE INDEX IF NOT EXISTS "idx_protocol_credential_user_id" ON "protocol_credential"("user_id")`,
`CREATE INDEX IF NOT EXISTS "idx_protocol_credential_protocol_enabled" ON "protocol_credential"("protocol", "enabled", "revoked_at")`,
},
DialectMySQL: {
"CREATE TABLE IF NOT EXISTS `protocol_credential` (`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, `user_id` BIGINT NOT NULL, `protocol` VARCHAR(16) NOT NULL, `enabled` TINYINT(1) NOT NULL DEFAULT 0, `code_hash` VARCHAR(255) NOT NULL DEFAULT '', `code_version` INT NOT NULL DEFAULT 1, `created_at` DATETIME(6) NOT NULL, `updated_at` DATETIME(6) NOT NULL, `last_used_at` DATETIME(6) NULL, `revoked_at` DATETIME(6) NULL, UNIQUE KEY `uq_protocol_credential_user_protocol` (`user_id`, `protocol`), KEY `idx_protocol_credential_user_id` (`user_id`), KEY `idx_protocol_credential_protocol_enabled` (`protocol`, `enabled`, `revoked_at`)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4",
},
DialectPostgres: {
`CREATE TABLE IF NOT EXISTS "protocol_credential" ("id" BIGSERIAL PRIMARY KEY, "user_id" BIGINT NOT NULL, "protocol" VARCHAR(16) NOT NULL, "enabled" BOOLEAN NOT NULL DEFAULT FALSE, "code_hash" VARCHAR(255) NOT NULL DEFAULT '', "code_version" INTEGER NOT NULL DEFAULT 1, "created_at" TIMESTAMPTZ NOT NULL, "updated_at" TIMESTAMPTZ NOT NULL, "last_used_at" TIMESTAMPTZ NULL, "revoked_at" TIMESTAMPTZ NULL, UNIQUE("user_id", "protocol"))`,
`CREATE INDEX IF NOT EXISTS "idx_protocol_credential_user_id" ON "protocol_credential"("user_id")`,
`CREATE INDEX IF NOT EXISTS "idx_protocol_credential_protocol_enabled" ON "protocol_credential"("protocol", "enabled", "revoked_at")`,
},
}
}

View File

@ -14,5 +14,6 @@ func DefaultMigrations() []Migration {
DefineCustom(10, "expand_user_lifecycle_security", "expand_user_lifecycle_security_v1", applyUserLifecycleSchema),
DefineForDialects(11, "create_plugin_configuration_tables", pluginSettingsStatements()),
DefineCustom(12, "expand_user_email_login_addresses", "expand_user_email_login_addresses_v1", applyUserEmailAddressSchema),
DefineForDialects(13, "create_protocol_credentials", protocolCredentialStatements()),
}
}

View File

@ -32,6 +32,10 @@ func registerV1Routes(mux *http.ServeMux) {
registerV1MethodRoute(mux, "/api/v1/security/credentials/recovery-codes", "/api/security/credentials/recovery-codes", http.MethodGet, 0, controllers.GetRecoveryCodeStatus)
registerV1MethodRoute(mux, "/api/v1/security/credentials/recovery-codes/generate", "/api/security/credentials/recovery-codes/generate", http.MethodPost, 16<<10, controllers.GenerateRecoveryCodes)
registerV1MethodRoute(mux, "/api/v1/security/credentials/recovery-codes/revoke", "/api/security/credentials/recovery-codes/revoke", http.MethodPost, 4<<10, controllers.RevokeRecoveryCodes)
registerV1MethodRoute(mux, "/api/v1/security/credentials/protocols", "/api/security/credentials/protocols", http.MethodGet, 0, controllers.ListProtocolCredentials)
registerV1MethodRoute(mux, "/api/v1/security/credentials/protocols/enabled", "/api/security/credentials/protocols/enabled", http.MethodPut, 16<<10, controllers.SetProtocolCredentialEnabled)
registerV1MethodRoute(mux, "/api/v1/security/credentials/protocols/generate", "/api/security/credentials/protocols/generate", http.MethodPost, 16<<10, controllers.GenerateProtocolCredential)
registerV1MethodRoute(mux, "/api/v1/security/credentials/protocols/revoke", "/api/security/credentials/protocols/revoke", http.MethodPost, 16<<10, controllers.RevokeProtocolCredential)
registerV1MethodRoute(mux, "/api/v1/security/external-email/list", "/api/security/external-email/list", http.MethodGet, 0, controllers.ListExternalEmails)
registerV1MethodRoute(mux, "/api/v1/security/external-email/bind", "/api/security/external-email/bind", http.MethodPost, 16<<10, controllers.BindExternalEmail)
registerV1MethodRoute(mux, "/api/v1/security/external-email/verify", "/api/security/external-email/verify", http.MethodPost, 16<<10, controllers.VerifyExternalEmail)

View File

@ -65,6 +65,10 @@ func router(mux *http.ServeMux) error {
mux.HandleFunc("/api/security/credentials/recovery-codes", onlyMethod(http.MethodGet, contextIterceptor(controllers.GetRecoveryCodeStatus)))
mux.HandleFunc("/api/security/credentials/recovery-codes/generate", onlyMethod(http.MethodPost, limitBody(16<<10, contextIterceptor(controllers.GenerateRecoveryCodes))))
mux.HandleFunc("/api/security/credentials/recovery-codes/revoke", onlyMethod(http.MethodPost, limitBody(4<<10, contextIterceptor(controllers.RevokeRecoveryCodes))))
mux.HandleFunc("/api/security/credentials/protocols", onlyMethod(http.MethodGet, contextIterceptor(controllers.ListProtocolCredentials)))
mux.HandleFunc("/api/security/credentials/protocols/enabled", onlyMethod(http.MethodPut, limitBody(16<<10, contextIterceptor(controllers.SetProtocolCredentialEnabled))))
mux.HandleFunc("/api/security/credentials/protocols/generate", onlyMethod(http.MethodPost, limitBody(16<<10, contextIterceptor(controllers.GenerateProtocolCredential))))
mux.HandleFunc("/api/security/credentials/protocols/revoke", onlyMethod(http.MethodPost, limitBody(16<<10, contextIterceptor(controllers.RevokeProtocolCredential))))
mux.HandleFunc("/api/security/external-email/list", onlyMethod(http.MethodGet, contextIterceptor(controllers.ListExternalEmails)))
mux.HandleFunc("/api/security/external-email/bind", onlyMethod(http.MethodPost, limitBody(16<<10, contextIterceptor(controllers.BindExternalEmail))))
mux.HandleFunc("/api/security/external-email/verify", onlyMethod(http.MethodPost, limitBody(16<<10, contextIterceptor(controllers.VerifyExternalEmail))))

View File

@ -0,0 +1,19 @@
package models
import "time"
// ProtocolCredential 保存单个用户的第三方邮件协议开关和授权码哈希。
type ProtocolCredential struct {
ID int `xorm:"id bigint unsigned not null pk autoincr" json:"-"`
UserID int `xorm:"user_id bigint not null" json:"-"`
Protocol string `xorm:"protocol varchar(16) notnull" json:"protocol"`
Enabled bool `xorm:"enabled notnull default(0)" json:"enabled"`
CodeHash string `xorm:"code_hash varchar(255) notnull" json:"-"`
CodeVersion int `xorm:"code_version int notnull default(1)" json:"-"`
CreatedAt time.Time `xorm:"created_at datetime created notnull" json:"created_at"`
UpdatedAt time.Time `xorm:"updated_at datetime updated notnull" json:"updated_at"`
LastUsedAt *time.Time `xorm:"last_used_at datetime null" json:"last_used_at,omitempty"`
RevokedAt *time.Time `xorm:"revoked_at datetime null index" json:"revoked_at,omitempty"`
}
func (ProtocolCredential) TableName() string { return "protocol_credential" }

View File

@ -9,6 +9,7 @@ import (
"git.pcux.cn/xuans/Smail/config"
"git.pcux.cn/xuans/Smail/db"
"git.pcux.cn/xuans/Smail/models"
"git.pcux.cn/xuans/Smail/services/credentials"
"git.pcux.cn/xuans/Smail/utils/context"
"git.pcux.cn/xuans/Smail/utils/password"
)
@ -145,16 +146,33 @@ func TestProtocolAuthenticationAcceptsConfiguredFullEmailIdentifier(t *testing.T
t.Fatal(err)
}
service, err := credentials.NewService(db.Instance)
if err != nil {
t.Fatal(err)
}
for _, protocol := range []string{"smtp", "imap", "pop3"} {
got, err := AuthenticateProtocol(protocol, "192.0.2.10:12345", " Alice@Example.com ", "CorrectP12")
if err := service.SetProtocolEnabled(user.ID, protocol, true); err != nil {
t.Fatalf("enable %s: %v", protocol, err)
}
code, err := service.GenerateProtocolCode(user.ID, protocol)
if err != nil {
t.Fatalf("generate %s code: %v", protocol, err)
}
got, err := AuthenticateProtocol(protocol, "192.0.2.10:12345", " Alice@Example.com ", code)
if err != nil {
t.Fatalf("%s full email authentication failed: %v", protocol, err)
}
if got == nil || got.Account != "alice" {
t.Fatalf("%s authenticated user = %+v", protocol, got)
}
if _, err := AuthenticateProtocol(protocol, "192.0.2.10:12345", "alice@example.com", "CorrectP12"); err == nil {
t.Fatalf("%s must reject the web password", protocol)
}
}
if _, err := AuthenticateProtocol("imap", "192.0.2.10:12345", "alice", "CorrectP12"); err == nil {
t.Fatal("account-only protocol login should be rejected")
}
if _, err := AuthenticateProtocol("imap", "192.0.2.10:12345", "alice@other.example", "CorrectP12"); err == nil {
t.Fatal("unconfigured email domain should be rejected")
}

View File

@ -9,7 +9,9 @@ import (
"sync"
"time"
"git.pcux.cn/xuans/Smail/db"
"git.pcux.cn/xuans/Smail/models"
"git.pcux.cn/xuans/Smail/services/credentials"
)
const (
@ -42,28 +44,44 @@ var (
)
func AuthenticateProtocol(protocol string, remoteAddress string, account string, plain string) (*models.User, error) {
normalizedProtocol, protocolErr := credentials.NormalizeProtocol(protocol)
normalizedAccount := strings.ToLower(strings.TrimSpace(account))
if normalizedIdentifier, err := NormalizeIdentifierKey(account); err == nil {
normalizedAccount = normalizedIdentifier
}
normalizedRemote := protocolRemoteHost(remoteAddress)
pairKey := hashedProtocolLoginKey("pair", protocol, normalizedRemote, normalizedAccount)
pairKey := hashedProtocolLoginKey("pair", normalizedProtocol, normalizedRemote, normalizedAccount)
accountKey := hashedProtocolLoginKey("account", normalizedAccount)
remoteKey := hashedProtocolLoginKey("remote", normalizedRemote)
if protocolPairAttempts.blocked(pairKey) || protocolAccountAttempts.blocked(accountKey) || protocolRemoteAttempts.blocked(remoteKey) {
return nil, errors.New("invalid credentials")
}
user, err := AuthenticateIdentifier(account, plain)
if err != nil {
fail := func() (*models.User, error) {
protocolPairAttempts.recordFailure(pairKey)
protocolAccountAttempts.recordFailure(accountKey)
protocolRemoteAttempts.recordFailure(remoteKey)
return nil, err
return nil, errors.New("invalid credentials")
}
if protocolErr != nil || db.Instance == nil {
return fail()
}
identity, err := ResolveIdentifier(account)
if err != nil || identity.Kind != IdentifierEmail || identity.UserID <= 0 {
return fail()
}
service, err := credentials.NewService(db.Instance)
if err != nil || !service.VerifyProtocolCode(identity.UserID, normalizedProtocol, plain) {
return fail()
}
var user models.User
has, err := db.Instance.ID(identity.UserID).Where("disabled = 0").Get(&user)
if err != nil || !has || user.ID <= 0 {
return fail()
}
protocolPairAttempts.clear(pairKey)
protocolAccountAttempts.clear(accountKey)
return user, nil
return &user, nil
}
func newProtocolLoginLimiter(failureLimit int) *protocolLoginLimiter {

View File

@ -0,0 +1,226 @@
package credentials
import (
"crypto/rand"
"encoding/base32"
"errors"
"fmt"
"strings"
"time"
"git.pcux.cn/xuans/Smail/models"
"git.pcux.cn/xuans/Smail/utils/password"
)
const (
ProtocolIMAP = "imap"
ProtocolPOP3 = "pop3"
ProtocolSMTP = "smtp"
)
var (
ErrInvalidProtocol = errors.New("invalid mail protocol")
ErrProtocolDisabled = errors.New("mail protocol is disabled")
ErrProtocolCodeMissing = errors.New("protocol authorization code is missing")
)
var supportedProtocols = []string{ProtocolIMAP, ProtocolPOP3, ProtocolSMTP}
// ProtocolCredentialStatus 返回协议开关和授权码状态,不包含授权码哈希。
type ProtocolCredentialStatus struct {
Protocol string `json:"protocol"`
Enabled bool `json:"enabled"`
Configured bool `json:"configured"`
CreatedAt *time.Time `json:"created_at,omitempty"`
UpdatedAt *time.Time `json:"updated_at,omitempty"`
LastUsedAt *time.Time `json:"last_used_at,omitempty"`
}
func NormalizeProtocol(value string) (string, error) {
protocol := strings.ToLower(strings.TrimSpace(value))
switch protocol {
case ProtocolIMAP, ProtocolPOP3, ProtocolSMTP:
return protocol, nil
default:
return "", ErrInvalidProtocol
}
}
func SupportedProtocols() []string {
return append([]string(nil), supportedProtocols...)
}
// ListProtocolCredentials 返回当前用户三个邮件协议的完整状态。
func (s *Service) ListProtocolCredentials(userID int) ([]ProtocolCredentialStatus, error) {
if s == nil || s.engine == nil {
return nil, ErrUnavailable
}
if userID <= 0 {
return nil, ErrInvalidUser
}
var items []models.ProtocolCredential
if err := s.engine.Where("user_id = ?", userID).Find(&items); err != nil {
return nil, fmt.Errorf("load protocol credentials: %w", err)
}
byProtocol := make(map[string]models.ProtocolCredential, len(items))
for _, item := range items {
byProtocol[item.Protocol] = item
}
result := make([]ProtocolCredentialStatus, 0, len(supportedProtocols))
for _, protocol := range supportedProtocols {
status := ProtocolCredentialStatus{Protocol: protocol}
if item, ok := byProtocol[protocol]; ok {
createdAt := item.CreatedAt
updatedAt := item.UpdatedAt
status.Enabled = item.Enabled
status.Configured = item.CodeHash != "" && item.RevokedAt == nil
status.CreatedAt = &createdAt
status.UpdatedAt = &updatedAt
status.LastUsedAt = item.LastUsedAt
}
result = append(result, status)
}
return result, nil
}
// SetProtocolEnabled 设置用户级协议开关。关闭后授权码会保留,但认证立即失效。
func (s *Service) SetProtocolEnabled(userID int, protocol string, enabled bool) error {
if s == nil || s.engine == nil {
return ErrUnavailable
}
if userID <= 0 {
return ErrInvalidUser
}
protocol, err := NormalizeProtocol(protocol)
if err != nil {
return err
}
s.mu.Lock()
defer s.mu.Unlock()
var item models.ProtocolCredential
has, err := s.engine.Where("user_id = ? AND protocol = ?", userID, protocol).Get(&item)
if err != nil {
return fmt.Errorf("load protocol credential: %w", err)
}
if !has {
item = models.ProtocolCredential{UserID: userID, Protocol: protocol, Enabled: enabled, CodeVersion: 1}
if _, err := s.engine.Insert(&item); err != nil {
return fmt.Errorf("create protocol credential: %w", err)
}
return nil
}
item.Enabled = enabled
if _, err := s.engine.ID(item.ID).Cols("enabled", "updated_at").Update(&item); err != nil {
return fmt.Errorf("update protocol credential: %w", err)
}
return nil
}
// GenerateProtocolCode 生成只展示一次的协议授权码,并撤销同协议旧授权码。
func (s *Service) GenerateProtocolCode(userID int, protocol string) (string, error) {
if s == nil || s.engine == nil {
return "", ErrUnavailable
}
if userID <= 0 {
return "", ErrInvalidUser
}
protocol, err := NormalizeProtocol(protocol)
if err != nil {
return "", err
}
plain, err := newProtocolCode()
if err != nil {
return "", err
}
hash, err := password.Hash(plain)
if err != nil {
return "", fmt.Errorf("hash protocol authorization code: %w", err)
}
s.mu.Lock()
defer s.mu.Unlock()
var item models.ProtocolCredential
has, err := s.engine.Where("user_id = ? AND protocol = ?", userID, protocol).Get(&item)
if err != nil {
return "", fmt.Errorf("load protocol credential: %w", err)
}
if !has || !item.Enabled {
return "", ErrProtocolDisabled
}
version := item.CodeVersion + 1
if version <= 1 {
version = 1
}
item.CodeHash = hash
item.CodeVersion = version
item.RevokedAt = nil
item.LastUsedAt = nil
if _, err := s.engine.ID(item.ID).Cols("code_hash", "code_version", "revoked_at", "last_used_at", "updated_at").Update(&item); err != nil {
return "", fmt.Errorf("update protocol authorization code: %w", err)
}
return plain, nil
}
// RevokeProtocolCode 撤销指定协议的授权码,协议开关状态保持不变。
func (s *Service) RevokeProtocolCode(userID int, protocol string) error {
if s == nil || s.engine == nil {
return ErrUnavailable
}
if userID <= 0 {
return ErrInvalidUser
}
protocol, err := NormalizeProtocol(protocol)
if err != nil {
return err
}
s.mu.Lock()
defer s.mu.Unlock()
now := s.now()
if _, err := s.engine.Where("user_id = ? AND protocol = ? AND revoked_at IS NULL", userID, protocol).
Cols("revoked_at", "updated_at").
Update(&models.ProtocolCredential{RevokedAt: &now}); err != nil {
return fmt.Errorf("revoke protocol authorization code: %w", err)
}
return nil
}
// VerifyProtocolCode 校验协议开关和授权码,不接受网页登录密码。
func (s *Service) VerifyProtocolCode(userID int, protocol string, plain string) bool {
if s == nil || s.engine == nil || userID <= 0 || plain == "" || len([]byte(plain)) > 256 {
password.VerifyDummy(plain)
return false
}
protocol, err := NormalizeProtocol(protocol)
if err != nil {
password.VerifyDummy(plain)
return false
}
var item models.ProtocolCredential
has, err := s.engine.Where("user_id = ? AND protocol = ? AND enabled = ? AND revoked_at IS NULL", userID, protocol, true).Get(&item)
if err != nil || !has || item.CodeHash == "" {
password.VerifyDummy(plain)
return false
}
if !password.Verify(item.CodeHash, strings.TrimSpace(plain)) {
return false
}
now := s.now()
_, _ = s.engine.ID(item.ID).Cols("last_used_at", "updated_at").Update(&models.ProtocolCredential{LastUsedAt: &now})
return true
}
func newProtocolCode() (string, error) {
buf := make([]byte, 15)
if _, err := rand.Read(buf); err != nil {
return "", fmt.Errorf("generate protocol authorization code: %w", err)
}
encoded := base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(buf)
groups := make([]string, 0, 6)
for len(encoded) > 4 {
groups = append(groups, encoded[:4])
encoded = encoded[4:]
}
if encoded != "" {
groups = append(groups, encoded)
}
return strings.Join(groups, "-"), nil
}

View File

@ -281,6 +281,9 @@ func (s *Service) RevokeAllWithSession(session *xorm.Session, userID int) error
if _, err := session.Where("user_id = ? AND revoked_at IS NULL", userID).Cols("revoked_at").Update(&models.WebAuthnCredential{RevokedAt: &now}); err != nil {
return fmt.Errorf("revoke webauthn credentials: %w", err)
}
if _, err := session.Where("user_id = ? AND revoked_at IS NULL", userID).Cols("revoked_at", "updated_at").Update(&models.ProtocolCredential{RevokedAt: &now}); err != nil {
return fmt.Errorf("revoke protocol credentials: %w", err)
}
return nil
}
@ -306,6 +309,10 @@ func revokeAllOnSession(session *xorm.Session, now func() time.Time, userID int)
_ = session.Rollback()
return fmt.Errorf("revoke webauthn credentials: %w", err)
}
if _, err := session.Where("user_id = ? AND revoked_at IS NULL", userID).Cols("revoked_at", "updated_at").Update(&models.ProtocolCredential{RevokedAt: &when}); err != nil {
_ = session.Rollback()
return fmt.Errorf("revoke protocol credentials: %w", err)
}
if err := session.Commit(); err != nil {
return fmt.Errorf("commit credential revocation: %w", err)
}

View File

@ -3,6 +3,7 @@ package credentials
import (
"errors"
"path/filepath"
"strings"
"sync"
"testing"
@ -18,7 +19,7 @@ func newFixture(t *testing.T) *Service {
t.Fatal(err)
}
t.Cleanup(func() { _ = engine.Close() })
if err := engine.Sync2(&models.User{}, &models.SecurityQuestion{}, &models.RecoveryCode{}, &models.WebAuthnCredential{}); err != nil {
if err := engine.Sync2(&models.User{}, &models.SecurityQuestion{}, &models.RecoveryCode{}, &models.WebAuthnCredential{}, &models.ProtocolCredential{}); err != nil {
t.Fatal(err)
}
if _, err := engine.Insert(&models.User{Account: "alice", Name: "Alice", Password: "unused", Role: "user"}); err != nil {
@ -107,3 +108,39 @@ func TestRecoveryCodeConcurrentConsumptionHasOneWinner(t *testing.T) {
t.Fatalf("winners = %d", winners)
}
}
func TestProtocolCredentialLifecycle(t *testing.T) {
service := newFixture(t)
if err := service.SetProtocolEnabled(1, ProtocolSMTP, true); err != nil {
t.Fatal(err)
}
statuses, err := service.ListProtocolCredentials(1)
if err != nil {
t.Fatal(err)
}
if len(statuses) != 3 || !statuses[2].Enabled || statuses[2].Configured {
t.Fatalf("unexpected initial protocol status: %+v", statuses)
}
if _, err := service.GenerateProtocolCode(1, ProtocolIMAP); !errors.Is(err, ErrProtocolDisabled) {
t.Fatalf("generating a disabled protocol code error = %v", err)
}
code, err := service.GenerateProtocolCode(1, ProtocolSMTP)
if err != nil {
t.Fatal(err)
}
if code == "" || strings.Contains(code, " ") {
t.Fatalf("invalid generated protocol code %q", code)
}
if !service.VerifyProtocolCode(1, ProtocolSMTP, code) {
t.Fatal("generated protocol code was rejected")
}
if service.VerifyProtocolCode(1, ProtocolSMTP, "wrong-code") {
t.Fatal("wrong protocol code was accepted")
}
if err := service.RevokeProtocolCode(1, ProtocolSMTP); err != nil {
t.Fatal(err)
}
if service.VerifyProtocolCode(1, ProtocolSMTP, code) {
t.Fatal("revoked protocol code was accepted")
}
}

View File

@ -23,7 +23,7 @@ func newRecoveryFixture(t *testing.T) (*Service, *verification.Service, *xorm.En
t.Fatal(err)
}
t.Cleanup(func() { _ = engine.Close() })
for _, table := range []any{&models.User{}, &models.ExternalEmail{}, &models.Sessions{}, &models.SecurityQuestion{}, &models.RecoveryCode{}, &models.WebAuthnCredential{}} {
for _, table := range []any{&models.User{}, &models.ExternalEmail{}, &models.Sessions{}, &models.SecurityQuestion{}, &models.RecoveryCode{}, &models.WebAuthnCredential{}, &models.ProtocolCredential{}} {
if err := engine.Sync2(table); err != nil {
t.Fatal(err)
}