This commit is contained in:
parent
110a5f86cc
commit
20a6eea3b3
@ -369,13 +369,13 @@ func ListProtocolCredentials(ctx *context.Context, w http.ResponseWriter, req *h
|
|||||||
items, err := service.ListProtocolCredentials(ctx.UserID)
|
items, err := service.ListProtocolCredentials(ctx.UserID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.WithError(err).Error("load protocol credentials failed")
|
log.WithError(err).Error("load protocol credentials failed")
|
||||||
writeSecurityError(w, response.ParamsError, "读取第三方协议设置失败")
|
writeSecurityError(w, response.ParamsError, "读取邮件客户端协议设置失败")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
response.NewSuccessResponse(items).FPrint(w)
|
response.NewSuccessResponse(items).FPrint(w)
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetProtocolCredentialEnabled 设置当前用户的第三方协议开关。
|
// SetProtocolCredentialEnabled 设置当前用户的邮件客户端协议开关,不改变服务监听状态。
|
||||||
func SetProtocolCredentialEnabled(ctx *context.Context, w http.ResponseWriter, req *http.Request) {
|
func SetProtocolCredentialEnabled(ctx *context.Context, w http.ResponseWriter, req *http.Request) {
|
||||||
service, ok := requireProtocolCredentialContext(ctx, w)
|
service, ok := requireProtocolCredentialContext(ctx, w)
|
||||||
if !ok {
|
if !ok {
|
||||||
|
|||||||
@ -2,7 +2,7 @@ package models
|
|||||||
|
|
||||||
import "time"
|
import "time"
|
||||||
|
|
||||||
// ProtocolCredential 保存单个用户的第三方邮件协议开关和授权码哈希。
|
// ProtocolCredential 保存单个用户的邮件客户端协议开关和授权码哈希,不控制全局监听服务。
|
||||||
type ProtocolCredential struct {
|
type ProtocolCredential struct {
|
||||||
ID int `xorm:"id bigint unsigned not null pk autoincr" json:"-"`
|
ID int `xorm:"id bigint unsigned not null pk autoincr" json:"-"`
|
||||||
UserID int `xorm:"user_id bigint not null" json:"-"`
|
UserID int `xorm:"user_id bigint not null" json:"-"`
|
||||||
|
|||||||
@ -204,8 +204,21 @@ func (s *Service) VerifyProtocolCode(userID int, protocol string, plain string)
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
now := s.now()
|
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) {
|
func newProtocolCode() (string, error) {
|
||||||
|
|||||||
@ -116,12 +116,12 @@ const baseGroups = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
const smtpGroups = [
|
const smtpGroups = [
|
||||||
group('listener', 'SMTP 服务监听', '', [
|
group('listener', 'SMTP 服务监听', '本地邮件服务默认启动,不受中继配置或用户协议开关控制。', [
|
||||||
port('listener.port', 'SMTP 收信端口', { min: 1, hint: '默认 25,用于接收其他邮件服务器投递,支持 STARTTLS;不是发信中继端口。' }),
|
port('listener.port', 'SMTP 收信端口', { min: 1, hint: '默认 25,用于接收其他邮件服务器投递,支持 STARTTLS;不是发信中继端口。' }),
|
||||||
port('listener.starttlsPort', 'SMTP 提交端口(STARTTLS)', { min: 1, hint: '默认 587,客户端升级 TLS 后进行认证。' }),
|
port('listener.starttlsPort', 'SMTP 提交端口(STARTTLS)', { min: 1, hint: '默认 587,客户端升级 TLS 后进行认证。' }),
|
||||||
port('listener.tlsPort', 'SMTP 隐式 TLS 端口', { min: 1, hint: '默认 465,客户端建立连接时直接使用 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.relayHost', 'SMTP 服务器', 'text', { placeholder: 'smtp.example.com' }),
|
||||||
field('relay.security', '连接加密', 'select', {
|
field('relay.security', '连接加密', 'select', {
|
||||||
options: options([
|
options: options([
|
||||||
|
|||||||
@ -13,7 +13,7 @@ const sections = [
|
|||||||
{ value: 'ssl', label: 'SSL 配置', description: '管理证书与加密连接,应用后请留意待重启提示。' },
|
{ value: 'ssl', label: 'SSL 配置', description: '管理证书与加密连接,应用后请留意待重启提示。' },
|
||||||
{ value: 'plugins', label: '插件配置', description: '管理插件运行配置,敏感设置仅显示配置状态。' },
|
{ value: 'plugins', label: '插件配置', description: '管理插件运行配置,敏感设置仅显示配置状态。' },
|
||||||
{ value: 'spam_block', 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: '配置微信推送的应用、模板与接收用户,敏感凭据不回填。' }
|
{ value: 'wechat_push', label: '微信推送', description: '配置微信推送的应用、模板与接收用户,敏感凭据不回填。' }
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@ -44,7 +44,7 @@ export function usePage() {
|
|||||||
|
|
||||||
const settingsMenu = computed(() => [
|
const settingsMenu = computed(() => [
|
||||||
{ key: 'security', label: lang.security, hint: '账号与登录保护', icon: Lock, component: SecuritySettings, capability: 'password.change_own' },
|
{ 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: '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: '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' },
|
{ key: 'rule', label: lang.rule_setting, hint: '邮件处理规则', icon: Filter, component: RuleSettings, capability: 'user.read' },
|
||||||
|
|||||||
@ -29,11 +29,11 @@ export function usePage() {
|
|||||||
try {
|
try {
|
||||||
const response = await http.get('/api/v1/security/credentials/protocols')
|
const response = await http.get('/api/v1/security/credentials/protocols')
|
||||||
if (response.errorNo !== 0) {
|
if (response.errorNo !== 0) {
|
||||||
throw new Error(response.errorMsg || '读取第三方授权设置失败')
|
throw new Error(response.errorMsg || '读取邮件客户端协议设置失败')
|
||||||
}
|
}
|
||||||
protocolCredentials.value = Array.isArray(response.data) ? response.data : []
|
protocolCredentials.value = Array.isArray(response.data) ? response.data : []
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
notify(error?.message || '读取第三方授权设置失败')
|
notify(error?.message || '读取邮件客户端协议设置失败')
|
||||||
} finally {
|
} finally {
|
||||||
protocolLoading.value = false
|
protocolLoading.value = false
|
||||||
}
|
}
|
||||||
@ -52,7 +52,11 @@ export function usePage() {
|
|||||||
if (response.errorNo !== 0) {
|
if (response.errorNo !== 0) {
|
||||||
throw new Error(response.errorMsg || '协议设置失败')
|
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) {
|
} catch (error) {
|
||||||
item.enabled = previous
|
item.enabled = previous
|
||||||
notify(error?.message || '协议设置失败')
|
notify(error?.message || '协议设置失败')
|
||||||
|
|||||||
@ -2,14 +2,14 @@
|
|||||||
<div class="third-party-authorization" v-loading="protocolLoading">
|
<div class="third-party-authorization" v-loading="protocolLoading">
|
||||||
<section class="authorization-section">
|
<section class="authorization-section">
|
||||||
<div class="authorization-header">
|
<div class="authorization-header">
|
||||||
<h3>第三方邮件客户端登录</h3>
|
<h3>邮件客户端协议</h3>
|
||||||
<p class="authorization-desc">
|
<p class="authorization-desc">
|
||||||
开启协议并生成授权码后,才能使用邮件客户端登录。登录名必须填写完整邮箱地址,密码必须填写授权码,不能填写网页登录密码。
|
本地邮件服务默认启动,不依赖 SMTP 中继。以下开关只控制当前账号的客户端访问权限,不影响其他用户或服务监听。
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<el-alert
|
<el-alert
|
||||||
title="授权码只在生成成功后显示一次,请立即保存;撤销授权码或关闭协议后,客户端会立即无法登录。"
|
title="登录名填写完整邮箱地址,密码填写本协议授权码,不能使用网页登录密码。授权码仅显示一次;关闭协议会暂停新登录,重新开启可继续使用未撤销的授权码。"
|
||||||
type="warning"
|
type="warning"
|
||||||
:closable="false"
|
:closable="false"
|
||||||
show-icon
|
show-icon
|
||||||
@ -36,6 +36,7 @@
|
|||||||
:loading="protocolLoading"
|
:loading="protocolLoading"
|
||||||
active-text="开启"
|
active-text="开启"
|
||||||
inactive-text="关闭"
|
inactive-text="关闭"
|
||||||
|
:aria-label="`${protocolLabel(item.protocol)} 当前账号客户端访问`"
|
||||||
@change="value => setProtocolEnabled(item, value)"
|
@change="value => setProtocolEnabled(item, value)"
|
||||||
/>
|
/>
|
||||||
<el-button
|
<el-button
|
||||||
|
|||||||
@ -3,6 +3,7 @@
|
|||||||
## 目录用途
|
## 目录用途
|
||||||
|
|
||||||
- `frontend/`:前端自动测试及其构建路径约束测试,直接复用 `frontend/node_modules`。
|
- `frontend/`:前端自动测试及其构建路径约束测试,直接复用 `frontend/node_modules`。
|
||||||
|
- `backend/`:集中保存邮件监听、账号协议隔离和凭据竞态回归,通过默认入口的 Go overlay 注入相应业务包;不复制测试文件到源码目录。
|
||||||
- `tools/password-check/`:离线密码哈希校验工具,只通过环境变量接收输入,不输出密码和哈希。
|
- `tools/password-check/`:离线密码哈希校验工具,只通过环境变量接收输入,不输出密码和哈希。
|
||||||
- `manual/smtp/`:人工 SMTP 发信脚本,可能向外部发送真实邮件,不包含在自动测试中。运行前需自行检查收发件人和服务器配置。
|
- `manual/smtp/`:人工 SMTP 发信脚本,可能向外部发送真实邮件,不包含在自动测试中。运行前需自行检查收发件人和服务器配置。
|
||||||
- `plugins/spam_block/`:垃圾邮件模型测试,需要额外安装 TensorFlow 并准备相应模型;不包含在默认测试中。
|
- `plugins/spam_block/`:垃圾邮件模型测试,需要额外安装 TensorFlow 并准备相应模型;不包含在默认测试中。
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
package integration_test
|
package res_init_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bufio"
|
"bufio"
|
||||||
@ -33,6 +33,8 @@ func TestConfiguredMailListenerPortsAcceptProtocolConnections(t *testing.T) {
|
|||||||
smtp := config.SMTPSectionConfig{Meta: meta, Listener: config.SMTPListenerConfig{
|
smtp := config.SMTPSectionConfig{Meta: meta, Listener: config.SMTPListenerConfig{
|
||||||
Port: ports[0], STARTTLSPort: ports[1], TLSPort: ports[2],
|
Port: ports[0], STARTTLSPort: ports[1], TLSPort: ports[2],
|
||||||
}}
|
}}
|
||||||
|
// 中继与 IMAP 共用数值端口也不冲突;不配置中继主机仍可启动全部本地监听。
|
||||||
|
smtp.Relay.RelayPort = ports[3]
|
||||||
email := config.EmailSectionConfig{Meta: meta, IMAP: config.IMAPListenerConfig{TLSPort: ports[3]}, POP3: config.POP3ListenerConfig{Port: ports[4], TLSPort: ports[5]}}
|
email := config.EmailSectionConfig{Meta: meta, IMAP: config.IMAPListenerConfig{TLSPort: ports[3]}, POP3: config.POP3ListenerConfig{Port: ports[4], TLSPort: ports[5]}}
|
||||||
ssl := config.SSLSectionConfig{Meta: meta, TLS: config.SSLConfig{Mode: "default", DefaultDirectory: "config/ssl"}}
|
ssl := config.SSLSectionConfig{Meta: meta, TLS: config.SSLConfig{Mode: "default", DefaultDirectory: "config/ssl"}}
|
||||||
rootConfig := config.RootConfig{
|
rootConfig := config.RootConfig{
|
||||||
|
|||||||
145
test/backend/protocol_access_test.go
Normal file
145
test/backend/protocol_access_test.go
Normal file
@ -0,0 +1,145 @@
|
|||||||
|
package controllers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.pcux.cn/xuans/Smail/db"
|
||||||
|
"git.pcux.cn/xuans/Smail/dto/response"
|
||||||
|
"git.pcux.cn/xuans/Smail/models"
|
||||||
|
"git.pcux.cn/xuans/Smail/services/access"
|
||||||
|
"git.pcux.cn/xuans/Smail/services/auth"
|
||||||
|
"git.pcux.cn/xuans/Smail/services/credentials"
|
||||||
|
appcontext "git.pcux.cn/xuans/Smail/utils/context"
|
||||||
|
_ "modernc.org/sqlite"
|
||||||
|
"xorm.io/xorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestProtocolClientHandlersIsolateEveryRole(t *testing.T) {
|
||||||
|
engine, err := xorm.NewEngine("sqlite", filepath.Join(t.TempDir(), "protocol-access.db"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
previousDB, previousRuntime := db.Instance, currentSecurityRuntime()
|
||||||
|
t.Cleanup(func() {
|
||||||
|
ConfigureSecurityRuntime(previousRuntime.verification, previousRuntime.external, previousRuntime.recovery, previousRuntime.credentials)
|
||||||
|
db.Instance = previousDB
|
||||||
|
_ = engine.Close()
|
||||||
|
})
|
||||||
|
if err := engine.Sync2(&models.User{}, &models.UserEmail{}, &models.ProtocolCredential{}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
db.Instance = engine
|
||||||
|
service, err := credentials.NewService(engine)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
ConfigureSecurityRuntime(nil, nil, nil, service)
|
||||||
|
newUser := func(account string, role access.Role) models.User {
|
||||||
|
user := models.User{Account: account, Name: account}
|
||||||
|
user.ApplyRole(role)
|
||||||
|
if err := auth.SetPassword(&user, "WebOnly42"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := engine.Insert(&user); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
now, address := time.Now(), account+"@example.com"
|
||||||
|
if _, err := engine.Insert(&models.UserEmail{UserID: user.ID, Address: address, AddressCanonical: &address, VerifiedAt: &now}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return user
|
||||||
|
}
|
||||||
|
victim := newUser("other", access.RoleUser)
|
||||||
|
victimCodes := map[string]string{}
|
||||||
|
for _, protocol := range credentials.SupportedProtocols() {
|
||||||
|
if err := service.SetProtocolEnabled(victim.ID, protocol, true); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
victimCodes[protocol], err = service.GenerateProtocolCode(victim.ID, protocol)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for index, role := range []access.Role{access.RoleUser, access.RoleAdmin, access.RoleSuperAdmin} {
|
||||||
|
t.Run(string(role), func(t *testing.T) {
|
||||||
|
user := newUser(fmt.Sprintf("actor%d", index), role)
|
||||||
|
ctx := &appcontext.Context{UserID: user.ID, UserAccount: user.Account, Role: role, IsAdmin: user.IsAdmin == 1}
|
||||||
|
call := func(handler func(*appcontext.Context, http.ResponseWriter, *http.Request), method, body string) response.Response {
|
||||||
|
t.Helper()
|
||||||
|
// 即使查询参数伪造目标账号,所有操作也只能作用于会话所属用户。
|
||||||
|
request := httptest.NewRequest(method, fmt.Sprintf("/api/security/credentials/protocols?user_id=%d", victim.ID), strings.NewReader(body))
|
||||||
|
recorder := httptest.NewRecorder()
|
||||||
|
handler(ctx, recorder, request)
|
||||||
|
if strings.Contains(recorder.Body.String(), "code_hash") {
|
||||||
|
t.Fatal("接口泄露授权码哈希")
|
||||||
|
}
|
||||||
|
var result response.Response
|
||||||
|
if err := json.Unmarshal(recorder.Body.Bytes(), &result); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
mustSucceed := func(result response.Response) {
|
||||||
|
t.Helper()
|
||||||
|
if result.ErrorNo != 0 {
|
||||||
|
t.Fatalf("当前角色操作自己的协议设置失败:%d", result.ErrorNo)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, protocol := range credentials.SupportedProtocols() {
|
||||||
|
body := fmt.Sprintf(`{"protocol":%q}`, protocol)
|
||||||
|
malicious := fmt.Sprintf(`{"protocol":%q,"enabled":false,"user_id":%d}`, protocol, victim.ID)
|
||||||
|
if result := call(SetProtocolCredentialEnabled, http.MethodPut, malicious); result.ErrorNo == 0 {
|
||||||
|
t.Fatal("接口接受了未知目标用户字段")
|
||||||
|
}
|
||||||
|
mustSucceed(call(SetProtocolCredentialEnabled, http.MethodPut, fmt.Sprintf(`{"protocol":%q,"enabled":true}`, protocol)))
|
||||||
|
generated := call(GenerateProtocolCredential, http.MethodPost, body)
|
||||||
|
mustSucceed(generated)
|
||||||
|
data, ok := generated.Data.(map[string]any)
|
||||||
|
if !ok || data["shown_once"] != true {
|
||||||
|
t.Fatal("授权码未按一次性响应返回")
|
||||||
|
}
|
||||||
|
code, ok := data["code"].(string)
|
||||||
|
if !ok || code == "" {
|
||||||
|
t.Fatal("生成的授权码为空")
|
||||||
|
}
|
||||||
|
login := func(plain string) bool {
|
||||||
|
got, err := auth.AuthenticateProtocol(protocol, "192.0.2.231:12345", user.Account+"@example.com", plain)
|
||||||
|
return err == nil && got != nil && got.ID == user.ID
|
||||||
|
}
|
||||||
|
if !login(code) || login("WebOnly42") {
|
||||||
|
t.Fatal("协议认证未正确区分授权码与网页登录密码")
|
||||||
|
}
|
||||||
|
listed := call(ListProtocolCredentials, http.MethodGet, "")
|
||||||
|
mustSucceed(listed)
|
||||||
|
encoded, err := json.Marshal(listed.Data)
|
||||||
|
if err != nil || strings.Contains(string(encoded), code) {
|
||||||
|
t.Fatal("状态查询重新返回了授权码明文")
|
||||||
|
}
|
||||||
|
mustSucceed(call(SetProtocolCredentialEnabled, http.MethodPut, fmt.Sprintf(`{"protocol":%q,"enabled":false}`, protocol)))
|
||||||
|
if login(code) {
|
||||||
|
t.Fatal("关闭协议后实际认证入口仍放行")
|
||||||
|
}
|
||||||
|
mustSucceed(call(SetProtocolCredentialEnabled, http.MethodPut, fmt.Sprintf(`{"protocol":%q,"enabled":true}`, protocol)))
|
||||||
|
if !login(code) {
|
||||||
|
t.Fatal("重新开启协议后有效授权码未恢复")
|
||||||
|
}
|
||||||
|
mustSucceed(call(RevokeProtocolCredential, http.MethodPost, body))
|
||||||
|
if login(code) {
|
||||||
|
t.Fatal("撤销授权码后实际认证入口仍放行")
|
||||||
|
}
|
||||||
|
for _, otherProtocol := range credentials.SupportedProtocols() {
|
||||||
|
if !service.VerifyProtocolCode(victim.ID, otherProtocol, victimCodes[otherProtocol]) {
|
||||||
|
t.Fatal("当前角色的操作影响了其他用户")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
117
test/backend/protocol_credentials_test.go
Normal file
117
test/backend/protocol_credentials_test.go
Normal file
@ -0,0 +1,117 @@
|
|||||||
|
package credentials
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.pcux.cn/xuans/Smail/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestProtocolClientSwitchesIsolateUsersAndProtocols(t *testing.T) {
|
||||||
|
service := newFixture(t)
|
||||||
|
other := models.User{Account: "bob", Name: "Bob", Password: "unused", Role: "user"}
|
||||||
|
if _, err := service.engine.Insert(&other); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
codes := map[int]map[string]string{}
|
||||||
|
for _, userID := range []int{1, other.ID} {
|
||||||
|
statuses, err := service.ListProtocolCredentials(userID)
|
||||||
|
if err != nil || len(statuses) != 3 {
|
||||||
|
t.Fatalf("初始协议状态不完整:%v", err)
|
||||||
|
}
|
||||||
|
codes[userID] = map[string]string{}
|
||||||
|
for _, status := range statuses {
|
||||||
|
if status.Enabled || status.Configured {
|
||||||
|
t.Fatal("新账号不应自动获得邮件客户端访问权限")
|
||||||
|
}
|
||||||
|
if err := service.SetProtocolEnabled(userID, status.Protocol, true); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
code, err := service.GenerateProtocolCode(userID, status.Protocol)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
codes[userID][status.Protocol] = code
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, protocol := range SupportedProtocols() {
|
||||||
|
t.Run(protocol, func(t *testing.T) {
|
||||||
|
code := codes[1][protocol]
|
||||||
|
if !service.VerifyProtocolCode(1, protocol, code) {
|
||||||
|
t.Fatal("开启状态下有效授权码被拒绝")
|
||||||
|
}
|
||||||
|
if err := service.SetProtocolEnabled(1, protocol, false); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if service.VerifyProtocolCode(1, protocol, code) {
|
||||||
|
t.Fatal("关闭协议后仍能通过授权码认证")
|
||||||
|
}
|
||||||
|
if _, err := service.GenerateProtocolCode(1, protocol); !errors.Is(err, ErrProtocolDisabled) {
|
||||||
|
t.Fatalf("关闭协议后仍能生成授权码:%v", err)
|
||||||
|
}
|
||||||
|
for _, otherProtocol := range SupportedProtocols() {
|
||||||
|
if otherProtocol != protocol && !service.VerifyProtocolCode(1, otherProtocol, codes[1][otherProtocol]) {
|
||||||
|
t.Fatal("关闭一个协议影响了当前用户的其他协议")
|
||||||
|
}
|
||||||
|
if !service.VerifyProtocolCode(other.ID, otherProtocol, codes[other.ID][otherProtocol]) {
|
||||||
|
t.Fatal("关闭协议影响了其他用户")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := service.SetProtocolEnabled(1, protocol, true); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !service.VerifyProtocolCode(1, protocol, code) {
|
||||||
|
t.Fatal("重新开启后未撤销的授权码应恢复认证")
|
||||||
|
}
|
||||||
|
if service.VerifyProtocolCode(other.ID, protocol, code) {
|
||||||
|
t.Fatal("授权码可被其他账号使用")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProtocolClientRejectsChangesDuringHashVerification(t *testing.T) {
|
||||||
|
for _, change := range []string{"disable", "revoke", "rotate", "database_error"} {
|
||||||
|
t.Run(change, func(t *testing.T) {
|
||||||
|
service := newFixture(t)
|
||||||
|
if err := service.SetProtocolEnabled(1, ProtocolSMTP, true); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
code, err := service.GenerateProtocolCode(1, ProtocolSMTP)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
writer, err := NewService(service.engine)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// 在哈希匹配后、认证提交前模拟另一个请求写入,稳定覆盖竞态而不依赖休眠。
|
||||||
|
changed := false
|
||||||
|
service.now = func() time.Time {
|
||||||
|
changed = true
|
||||||
|
var err error
|
||||||
|
switch change {
|
||||||
|
case "disable":
|
||||||
|
err = writer.SetProtocolEnabled(1, ProtocolSMTP, false)
|
||||||
|
case "revoke":
|
||||||
|
err = writer.RevokeProtocolCode(1, ProtocolSMTP)
|
||||||
|
case "rotate":
|
||||||
|
_, err = writer.GenerateProtocolCode(1, ProtocolSMTP)
|
||||||
|
case "database_error":
|
||||||
|
err = service.engine.Close()
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return time.Now()
|
||||||
|
}
|
||||||
|
if service.VerifyProtocolCode(1, ProtocolSMTP, code) {
|
||||||
|
t.Fatal("凭据状态变化或数据库写入失败后,过期认证仍返回成功")
|
||||||
|
}
|
||||||
|
if !changed {
|
||||||
|
t.Fatal("未进入哈希计算后的竞态验证位置")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
42
test/frontend/protocol-client.test.mjs
Normal file
42
test/frontend/protocol-client.test.mjs
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import test from 'node:test'
|
||||||
|
import { readFileSync } from 'node:fs'
|
||||||
|
import { compileFunction } from 'node:vm'
|
||||||
|
import { ref } from './vue.mjs'
|
||||||
|
|
||||||
|
// 将已有真实 Vue 开关事件回归纳入默认前端测试,避免只覆盖调用函数的模拟。
|
||||||
|
import '../review/protocol-frontend.test.mjs'
|
||||||
|
|
||||||
|
const root = new URL('../../frontend/src/views/components/ThirdPartyAuthorization/', import.meta.url)
|
||||||
|
const read = extension => 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)
|
||||||
|
})
|
||||||
@ -44,19 +44,20 @@ test('一级菜单保留能力过滤和当前分类标题联动', () => {
|
|||||||
.replace(/export \{[\s\S]*?\}\s*$/, '') + '\nreturn usePage()',
|
.replace(/export \{[\s\S]*?\}\s*$/, '') + '\nreturn usePage()',
|
||||||
['computed', 'ref', 'onMounted', 'onUnmounted', 'window', 'useGlobalStatusStore', 'lang', ...names]
|
['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({
|
const store = reactive({
|
||||||
settingsActiveTab: 'security',
|
settingsActiveTab: 'security',
|
||||||
hasCapability: value => capabilities.has(value)
|
hasCapability: value => capabilities.has(value)
|
||||||
})
|
})
|
||||||
const page = createPage(computed, ref, () => {}, () => {}, { innerWidth: 1440 }, () => store,
|
const page = createPage(computed, ref, () => {}, () => {}, { innerWidth: 1440 }, () => store,
|
||||||
{ security: '安全' }, ...names.map(name => ({ name })))
|
{ 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, '安全')
|
assert.equal(page.activeSettings.value.label, '安全')
|
||||||
capabilities.add('config.read')
|
capabilities.add('config.read')
|
||||||
store.settingsActiveTab = 'config'
|
store.settingsActiveTab = 'config'
|
||||||
assert.equal(page.activeSettings.value.label, '配置管理')
|
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))
|
assert.ok(page.settingsMenu.value.every(item => item.icon && item.hint))
|
||||||
capabilities.delete('config.read')
|
capabilities.delete('config.read')
|
||||||
assert.equal(page.activeSettings.value, undefined)
|
assert.equal(page.activeSettings.value, undefined)
|
||||||
|
|||||||
17
test/run.mjs
17
test/run.mjs
@ -1,6 +1,6 @@
|
|||||||
import { spawn } from 'node:child_process'
|
import { spawn } from 'node:child_process'
|
||||||
import { createWriteStream } from 'node:fs'
|
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 { fileURLToPath } from 'node:url'
|
||||||
import path from 'node:path'
|
import path from 'node:path'
|
||||||
|
|
||||||
@ -40,7 +40,20 @@ if (suite !== 'backend') {
|
|||||||
}
|
}
|
||||||
if (suite !== 'frontend' && result === 0) {
|
if (suite !== 'frontend' && result === 0) {
|
||||||
const args = extra[0] === '--' ? extra.slice(1) : extra
|
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)}`)
|
console.log(`测试记录:${path.relative(root, directory)}`)
|
||||||
process.exitCode = result
|
process.exitCode = result
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user