This commit is contained in:
parent
e76e802cc3
commit
18f9fd34e5
57
README_CN.md
57
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)
|
||||
|
||||
@ -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; }
|
||||
}
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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, "请先登录")
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -144,6 +144,7 @@ func syncTables() error {
|
||||
&models.SecurityQuestion{},
|
||||
&models.RecoveryCode{},
|
||||
&models.WebAuthnCredential{},
|
||||
&models.ProtocolCredential{},
|
||||
&models.Version{},
|
||||
}
|
||||
for _, table := range tables {
|
||||
|
||||
19
server/db/migration/protocol_credentials.go
Normal file
19
server/db/migration/protocol_credentials.go
Normal 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")`,
|
||||
},
|
||||
}
|
||||
}
|
||||
@ -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()),
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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))))
|
||||
|
||||
19
server/models/protocol_credential.go
Normal file
19
server/models/protocol_credential.go
Normal 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" }
|
||||
@ -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")
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
226
server/services/credentials/protocol.go
Normal file
226
server/services/credentials/protocol.go
Normal 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
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user