100 KiB
Smail 配置系统重构技术设计
1. 文档信息
| 项目 | 内容 |
|---|---|
| 文档名称 | Smail 配置系统重构技术设计 |
| 文档状态 | 技术复审通过,待实施 |
| 当前版本 | 0.5 |
| 编写日期 | 2026-08-25 |
| 上游文档 | docs/配置系统重构需求文档.md 0.7、docs/超级管理员重置与邮箱验证码需求文档.md 0.3 |
| 适用项目 | Smail |
| 适用目录 | server、frontend、plugins、docs |
本文档将已经确认的产品和安全需求转换为可实施的后端、前端、数据库、插件和测试设计。本文档不代表功能已经完成,所有实现状态以实施任务清单、代码审查和测试结果为准。
2. 设计范围
本轮设计包含:
- 以
config/config.json为唯一主入口的分区配置系统; - 配置加载、严格解析、验证、加密、保存、应用、备份和回滚;
new、bootstrap、ready、degraded四态初始化状态机;super_admin、admin、user三级持久化角色与能力授权;- 用户名或完整用户邮箱的统一登录标识解析;
- Web、SMTP、IMAP、POP3 共用的密码验证边界;
- 密保问答、离线恢复码和 WebAuthn 安全密钥组成的密码恢复流程;
- 插件全局配置、用户级配置、运行时配置下发和进程隔离;
- 配置管理、账户安全和插件设置页面;
- 数据库迁移、审计、测试、部署和回退设计;
- 完整邮箱验证码服务、SMTP 投递、登录二次验证、外部邮箱密码恢复、外部邮箱管理页面、运营数据和定期清理;
smail-admin短时服务、受保护凭据交互和跨平台本机 IPC。
本轮不实现任意插件上传、插件直接访问主数据库、跨数据库类型或跨服务器在线切换、任意绝对路径配置、短信验证码、TOTP、第三方社交账号登录和旧弱加密算法兼容解密。仅实现同一数据库类型、服务器、端口和账户内的数据库名称迁移。
3. 设计原则
3.1 单一可信入口
config/config.json 是配置清单、当前配置代次和初始化状态的唯一可信入口。SMTP、邮箱策略、SSL 和插件文件是被主入口引用的受控分区,不得被业务模块或插件自行读取。
3.2 默认拒绝
权限、插件网络访问、敏感配置读取、维护接口和未知配置字段全部采用默认拒绝。前端隐藏仅改善交互,不能代替后端授权。
3.3 一套验证边界
登录密码、密码修改、管理员重置、忘记密码、SMTP、IMAP 和 POP3 必须复用同一套账户解析、密码策略和密码哈希服务。数据库、SMTP、第三方插件凭据必须复用同一套敏感值输入模型、加密封装和脱敏输出规则。
3.4 不使用旧算法回退
新实现不得引入旧弱算法、可逆密码存储、随机选择算法或“验证失败后尝试旧算法”的隐式回退。算法升级通过显式版本字段完成;旧数据必须先迁移,无法安全迁移时要求重新设置凭据。
3.5 内存快照优先
正常业务请求只读取不可变运行时快照,不在请求路径直接读取磁盘配置。配置写入串行化,配置读取无锁或使用只读快照。
3.6 可恢复而非带错运行
核心配置无效时进入安全初始化或降级模式。可选插件失败只隔离该插件,不允许影响数据库、管理后台和核心邮件服务。
3.7 平滑迁移
优先保留现有 backend/config 导入路径,在包内部逐步替换 Config 和 Instance。兼容字段只能用于短期响应兼容,不得继续作为授权或配置决策依据。
4. 当前实现和差距
4.1 当前后端
当前主要实现位于:
backend/config/config.go
backend/config/storage.go
backend/services/auth/auth.go
backend/controllers/login.go
backend/controllers/plugin.go
backend/hooks/
backend/db/init.go
backend/models/User.go
主要差距:
config.Config将数据库、域名、端口、SSL、推送凭据和插件许可混在一个结构中;config.Instance是全局可修改指针,无法保证读取方只读;config.IsInit和Config.IsInit不能表达初始化失败、引导和降级状态;User.IsAdmin只能表达两级身份;NormalizeAccount遇到@会直接截断域名,无法安全识别真实绑定邮箱;- 登录请求仍使用
account,协议服务没有明确的统一标识解析结果; - 插件列表和设置接口仅使用布尔管理员判断;
- 当前插件设置页面允许插件返回 HTML,需要收敛为主程序控制的配置模型;
- 数据库表主要依赖
Sync2,缺少明确、可审计、可回退的迁移版本。
4.2 当前可复用安全基础
以下现有能力应保留并纳入统一设计:
- 配置目录和文件类型检查;
- 配置文件原子写入;
backend/utils/credential凭据加密能力;- Argon2id 密码哈希;
- 登录限流和虚假密码验证;
- Session 版本撤销;
- CSRF 和同源检查;
- 插件文件权限、进程隔离和本地 Socket RPC;
- 请求体、插件响应和文件大小限制。
5. 目标架构
5.1 总体调用关系
管理页面 / 初始化页面 / 本地维护命令
│
▼
HTTP Controller
│
鉴权 + 能力授权 + 二次确认
│
▼
ConfigManager
┌────────────┼────────────┐
│ │ │
▼ ▼ ▼
严格解析验证 CredentialVault 配置事务与审计
│ │ │
└────────────┼────────────┘
▼
config.json + smtp.json + email.json + ssl.json + plugins.json
│
▼
不可变 RuntimeConfig 快照
┌────────────┼────────────┐
▼ ▼ ▼
Web/认证服务 邮件协议服务 PluginManager
│
▼
插件专属 DTO + 本地 RPC
5.2 后端模块边界
建议在现有 backend/config 包内平滑增加:
backend/config/
├── manager.go
├── model.go
├── root.go
├── smtp.go
├── email.go
├── ssl.go
├── plugins.go
├── loader.go
├── parser.go
├── validator.go
├── snapshot.go
├── transaction.go
├── backup.go
├── migrate.go
├── secret.go
├── audit.go
└── storage.go
认证和授权建议增加:
backend/services/auth/
├── authenticator.go
├── identifier.go
├── password.go
├── recovery.go
├── recovery_code.go
├── security_question.go
└── webauthn.go
backend/services/access/
├── role.go
├── capability.go
├── authorizer.go
└── policy.go
插件管理建议增加:
backend/services/plugin/
├── manager.go
├── catalog.go
├── config.go
├── user_setting.go
├── rpc.go
├── health.go
└── policy.go
5.3 依赖规则
- Controller 只能调用服务接口,不直接读写配置文件;
- 邮件协议服务只能使用认证服务和配置快照;
- 插件管理器只能从配置管理器获取单插件运行时配置;
- 插件进程不得导入或调用主程序
config、db包; - 数据库模型不得依赖 HTTP Controller;
- 审计服务只接收脱敏事件,不接收明文 Secret;
- 前端不得自行推导角色等级,只消费后端返回的能力列表。
6. 配置文件和数据模型
6.1 受控目录
config/
├── config.json
├── smtp.json
├── email.json
├── ssl.json
├── plugins.json
├── credential.key
├── dkim/
├── ssl/
├── backup/
└── transaction/
限制:
- 配置分区文件名由程序常量决定,不接受 API 传入路径;
- 路径解析后必须位于受控配置根目录;
- 不接受符号链接、重解析点、非常规文件或硬链接替换风险;
- Linux 目录权限目标为
0700,敏感文件目标为0600; - Windows 必须检查文件位于预期根目录,并避免继承不必要的普通用户写权限;
- 临时文件、事务日志和备份不得存放到插件目录或 Web 静态目录。
6.2 主入口模型
type SetupState string
const (
SetupStateNew SetupState = "new"
SetupStateBootstrap SetupState = "bootstrap"
SetupStateReady SetupState = "ready"
SetupStateDegraded SetupState = "degraded"
)
type SectionRef struct {
File string `json:"file"`
Schema uint32 `json:"schema"`
Generation uint64 `json:"generation"`
SHA256 string `json:"sha256"`
}
type DatabaseType string
const (
DatabaseTypeMySQL DatabaseType = "mysql"
DatabaseTypePostgreSQL DatabaseType = "postgresql"
DatabaseTypeSQLite DatabaseType = "sqlite"
)
type DatabaseConfig struct {
Type DatabaseType `json:"type"`
Host string `json:"host,omitempty"`
Port uint16 `json:"port,omitempty"`
Username string `json:"username,omitempty"`
Password EncryptedSecret `json:"password,omitempty"`
Name string `json:"name,omitempty"`
LocalRef string `json:"localRef,omitempty"`
}
type RootConfig struct {
ConfigVersion uint32 `json:"configVersion"`
Generation uint64 `json:"generation"`
SetupState SetupState `json:"setupState"`
LogLevel string `json:"logLevel"`
Database DatabaseConfig `json:"database"`
Domains DomainConfig `json:"domains"`
HTTP HTTPConfig `json:"http"`
Sections map[Section]SectionRef `json:"sections"`
}
主入口示例:
{
"configVersion": 1,
"generation": 12,
"setupState": "ready",
"logLevel": "info",
"database": {
"type": "mysql",
"host": "127.0.0.1",
"port": 3306,
"username": "smail",
"password": "enc:v1:encrypted-value",
"name": "smail"
},
"domains": {
"primary": "mail.example.com",
"accepted": ["example.com"],
"web": "mail.example.com"
},
"http": {
"port": 80,
"httpsPort": 443,
"httpsEnabled": true
},
"sections": {
"smtp": {
"file": "smtp.json",
"schema": 1,
"generation": 12,
"sha256": "omitted"
},
"email": {
"file": "email.json",
"schema": 1,
"generation": 12,
"sha256": "omitted"
},
"ssl": {
"file": "ssl.json",
"schema": 1,
"generation": 12,
"sha256": "omitted"
},
"plugins": {
"file": "plugins.json",
"schema": 1,
"generation": 12,
"sha256": "omitted"
}
}
}
file 字段只允许匹配程序内置的分区到文件名映射。即使文件内容被人为修改,也不能借此引用任意路径。
数据库模型规则:
- MySQL 和 PostgreSQL 必须填写
host、port、username、password和name; - SQLite 只使用后端生成的
localRef,禁止 API 接收任意文件路径; - 初始化 DTO 不接收
name,由后端按部署规则创建数据库和表结构; - MySQL 默认端口为
3306,PostgreSQL 默认端口为5432,前端只在高级设置中允许修改; Password使用CredentialVault加密,主加密密钥必须位于数据库之外的受保护介质;- DSN 仅在连接工厂内存中临时构造,不进入配置模型、API、日志和审计。
6.3 运行时模型
type RuntimeConfig struct {
Generation uint64
SetupState SetupState
Root RootConfig
SMTP SMTPConfig
Email EmailConfig
SSL SSLConfig
Plugins PluginConfig
LoadedAt time.Time
}
运行时结构要求:
- 对外只返回只读快照;
- 发布前深复制所有切片、映射和字节数组;
- Secret 在内存中使用专用类型,禁止默认 JSON 序列化和日志格式化;
- 快照中不得包含配置加密主密钥;
- 插件管理器获取的是裁剪后的
PluginRuntimeConfig,不是完整快照。
6.4 分区公共元数据
type SectionMeta struct {
Schema uint32 `json:"schema"`
Generation uint64 `json:"generation"`
}
每个分区必须包含 schema 和 generation。加载时要求:
schema是当前版本或存在显式迁移器;generation与主入口对应分区引用一致;- 文件摘要与主入口一致;
- 不存在未知顶级字段;
- 所有必填字段通过语义校验。
7. ConfigManager 设计
7.1 核心接口
type Section string
const (
SectionRoot Section = "root"
SectionSMTP Section = "smtp"
SectionEmail Section = "email"
SectionSSL Section = "ssl"
SectionPlugins Section = "plugins"
)
type Manager interface {
Snapshot() *RuntimeConfig
Status() ConfigStatus
Validate(ctx context.Context, req ValidateRequest) (*ValidationResult, error)
Update(ctx context.Context, req UpdateRequest) (*UpdateResult, error)
Apply(ctx context.Context, generation uint64) (*ApplyResult, error)
Rollback(ctx context.Context, generation uint64) (*ApplyResult, error)
}
type UpdateRequest struct {
Section Section
ExpectedGeneration uint64
Payload json.RawMessage
SecretChanges map[string]SecretChange
Actor AuditActor
Confirmation StepUpConfirmation
}
7.2 初始化方式
manager, err := config.NewManager(config.Options{
RootPath: config.ROOT_PATH,
CredentialVault: vault,
AuditWriter: auditWriter,
Clock: time.Now,
})
NewManager 只创建依赖,不发布配置。主程序必须调用 Load 完成预检后再启动相应服务。
7.3 状态模型
type ConfigStatus struct {
SetupState SetupState
ActiveGeneration uint64
LastValidAt *time.Time
PendingRestart []ServiceName
DegradedReasons []PublicIssue
}
公开状态只能包含可展示的问题分类,不包含 DSN、文件绝对路径、Secret、内部堆栈或第三方原始响应。
8. 加载、解析和验证
8.1 加载顺序
确定受控配置根目录
→ 检查目录权限和文件类型
→ 恢复或清理未完成事务
→ 读取 config.json
→ 严格解析主入口
→ 校验主入口版本和固定分区映射
→ 读取各分区
→ 校验摘要、schema 和 generation
→ 解密敏感字段
→ 执行跨分区语义校验
→ 生成不可变快照
→ 原子发布快照
→ 根据 setupState 启动允许的服务
8.2 严格 JSON 解析
解析器必须:
- 使用
json.Decoder.DisallowUnknownFields(); - 限制文件大小和 JSON 嵌套深度;
- 拒绝同名重复字段,不能由后出现的字段静默覆盖;
- 拒绝一个 JSON 值之后的额外内容;
- 拒绝 UTF-8 BOM、无效 UTF-8 和控制字符污染;
- 对数值执行范围校验,不接受隐式截断;
- 对枚举执行白名单校验;
- 对域名、端口、URL、文件名和插件 ID 使用专用验证器;
- 错误只返回配置区和字段路径,不回显字段原值。
标准库默认不拒绝重复键,因此需要在反序列化前进行 Token 级重复键检测,或采用经过测试的内部严格解析器。
8.3 语义验证
语义验证分为:
| 层级 | 内容 | 是否允许外部连接 |
|---|---|---|
| 结构验证 | 类型、必填、长度、枚举、未知字段 | 否 |
| 本地语义验证 | 端口冲突、路径范围、配置依赖 | 否 |
| 凭据解密验证 | 密文格式、密钥版本、认证标签 | 否 |
| 连接测试 | 数据库、SMTP、第三方服务可达性 | 是,必须显式触发 |
| 应用验证 | 服务能否接受候选配置 | 仅访问受控服务 |
保存配置不能默认向任意用户输入地址发起网络连接。连接测试端点必须有独立权限、目标白名单、超时、DNS 重绑定防护和审计。
8.4 跨分区校验
至少校验:
- SMTP、HTTP、HTTPS、IMAP、POP3 监听端口不得冲突;
- SSL 启用时证书域名覆盖管理域名;
- 插件逻辑 ID 唯一且可执行文件映射合法;
- 启用插件所需能力必须在策略中明确授权;
- 主域名必须存在于接收域名集合;
ready状态必须满足数据库、超级管理员和必要服务条件;degraded状态不得自动开放不安全的 HTTP 远程管理。
9. 不可变快照和并发
9.1 发布模型
type SnapshotStore struct {
current atomic.Pointer[RuntimeConfig]
}
快照发布要求:
- 候选配置全部验证成功后才调用
Store; - 发布后的对象不得再修改;
- 更新路径通过单个写互斥锁串行执行;
- 业务读取不持有写锁;
- 旧快照在已有请求结束前仍然有效;
- 插件配置使用按插件生成的独立快照。
9.2 并发覆盖控制
所有更新请求必须携带 expectedGeneration。服务端发现当前代次不同,返回 CONFIG_CONFLICT,并要求前端重新加载,不允许“最后写入者静默覆盖”。
9.3 敏感对象生命周期
Go 无法可靠保证普通字符串立即清零,因此:
- API 层尽量以短生命周期字节数组接收 Secret;
- Secret 不写入结构化日志、错误对象或审计差异;
- 完成加密或连接测试后尽快释放引用;
- 不将明文 Secret 放入全局快照,运行服务确需使用时由受控凭据句柄解密;
- 插件 Secret 只在生成单插件 DTO 时短暂解密。
10. 配置事务、备份和回滚
10.1 单分区更新
鉴权和二次确认
→ 校验 expectedGeneration
→ 解析并验证候选配置
→ 合并 SecretChange
→ 加密新 Secret
→ 写入 transaction/<id>/candidate
→ fsync 候选文件和目录
→ 写事务清单
→ 备份当前有效文件
→ 原子替换分区文件
→ 最后原子替换 config.json
→ 重新加载并发布快照
→ 应用可热更新服务
→ 写脱敏审计结果
主入口最后提交,使其成为当前代次的提交标记。由于多个文件无法依靠普通重命名形成真正的跨文件原子事务,必须使用事务清单和启动恢复逻辑处理进程崩溃。
10.2 事务清单
type TransactionManifest struct {
ID string
FromGeneration uint64
ToGeneration uint64
Sections []Section
Phase TransactionPhase
CreatedAt time.Time
}
事务阶段只允许:
prepared
sections_committed
root_committed
verified
rolled_back
启动时根据主入口代次、分区代次和事务阶段决定完成提交或恢复备份,禁止猜测性合并。
10.3 SecretChange 模型
敏感字段不得使用空字符串表达所有语义:
type SecretOperation string
const (
SecretKeep SecretOperation = "keep"
SecretReplace SecretOperation = "replace"
SecretClear SecretOperation = "clear"
)
type SecretChange struct {
Operation SecretOperation `json:"operation"`
Value SecretInput `json:"value,omitempty"`
}
规则:
- 查询接口只返回
configured、updatedAt和脱敏提示; keep不需要传明文;replace必须通过长度、格式和权限验证;clear只允许可选字段,并需要二次确认;- 数据库、SMTP、微信、Telegram 等所有第三方凭据统一使用该模型。
10.4 备份
配置备份每 7 天创建一次,最长保留 30 天,最多保留 4 份,并始终保留至少一个已经验证可恢复的有效代次。备份目录必须与配置目录采用相同安全权限,并包含:
- 主入口和涉及的分区文件;
- 代次、摘要和创建时间;
- 配置 schema 版本;
- 不包含解密后的 Secret;
- 不包含数据库业务数据或 SSL 私钥副本之外的额外敏感数据。
备份清理按“新备份成功并验证 → 清理超过 30 天 → 超过 4 份时删除最旧”的顺序执行。新备份失败时不得删除任何当前有效备份,也不得删除当前运行代次或唯一有效备份。该保留策略只适用于系统生成的配置备份,不授权删除用户文件、旧配置原件或旧数据库。
10.5 回滚
回滚必须:
- 仅允许超级管理员;
- 要求最近重新认证或 WebAuthn 二次确认;
- 先验证目标备份的摘要、schema 和可解密性;
- 以新代次重新提交,不将 generation 倒退;
- 撤销当前候选应用并重新加载服务;
- 完整记录目标历史代次和新生成代次;
- 回滚失败时进入
degraded,不带着混合代次继续运行。
11. 配置迁移设计
11.1 迁移入口
配置迁移只能由 ConfigManager 在持有写锁时执行。启动流程先识别旧单文件结构,再生成迁移计划,禁止业务模块在读取配置时顺便修改文件。
type Migrator interface {
Detect(ctx context.Context) (*MigrationPlan, error)
Validate(ctx context.Context, plan *MigrationPlan) error
Execute(ctx context.Context, plan *MigrationPlan) (*MigrationResult, error)
}
11.2 旧字段映射
| 旧字段 | 新位置 | 处理方式 |
|---|---|---|
dbType、dbDSN |
config.json.database |
仅在迁移器内解析已知 DSN,拆分为结构化字段,数据库密码使用当前凭据机制重新加密;解析失败则要求重新配置 |
domain、domains、webDomain |
config.json.domains |
规范化并检查重复 |
httpPort、httpsPort、httpsEnabled |
config.json.http |
转换为明确布尔和端口字段 |
dkimPrivateKeyPath |
smtp.json |
只允许受控 DKIM 目录 |
spamFilterLevel |
smtp.json |
转换为枚举策略 |
| SSL 类型和证书路径 | ssl.json |
导入默认目录或自定义上传托管目录,生成内部引用,不保留客户端可控绝对路径 |
| 微信和 Telegram 全局配置 | plugins.json 和 plugin_secret |
按逻辑插件 ID 拆分,非敏感设置进入分区文件,Secret 加密迁移到数据库 |
pluginAllowList |
plugins.json.plugins.*.enabled |
可执行文件名映射为固定逻辑 ID |
isInit |
setupState |
根据完整预检结果决定状态,不能机械映射 |
11.3 算法迁移规则
- 已由当前
CredentialVault正确保护的凭据可解密后重新封装为版本化密文; - 不实现旧弱算法长期兼容读取;
- 无法确认来源或认证标签的密文视为无效,不尝试猜测算法;
- 无法安全迁移的第三方凭据标记为“需要重新配置”;
- 用户密码只接受当前受支持的 Argon2id 格式;如发现其他格式,通过一次性迁移任务或强制重置处理,不在每次登录中长期保留旧验证器;
- 密保答案、离线恢复码和重置票据从新表开始,不从未知旧字段推导。
11.4 数据库迁移框架
新增显式迁移表:
schema_migration
├── version
├── name
├── checksum
├── applied_at
└── execution_ms
数据库初始化顺序:
连接数据库
→ 获取数据库级迁移锁
→ 检查已应用版本和校验和
→ 执行待应用迁移事务
→ 验证约束和关键索引
→ 释放迁移锁
→ 启动业务服务
Sync2 在迁移完成后只能用于测试环境的结构对照,不能继续作为生产环境唯一变更机制。
11.5 迁移安全边界
- 迁移前创建只读备份,不覆盖原配置;
- 迁移器必须可重复执行;
- 新配置全部通过验证后才提交主入口;
- 迁移失败保留旧文件和失败原因;
- 日志只记录字段名和结果,不记录字段值;
- 旧文件是否清理必须另行获得用户明确同意。
11.6 SSL 双模式托管
SSL 存储只接受两种模式:
managed_default → 使用 Smail 默认受控目录
managed_upload → 用户上传后由 Smail 导入受控目录
两种模式都只在 ssl.json 保存内部引用,不保存客户端提交的服务器绝对路径。上传流程必须限制大小和 PEM 类型,使用候选目录校验证书链、私钥匹配、域名和有效期,拒绝符号链接、重解析点、硬链接、路径穿越和异常文件,再原子切换 current 引用。Linux 私钥目标权限为 0600;Windows 只允许服务账户和受信任本机管理员读取。
11.7 数据库名称在线迁移
数据库名称迁移只允许超级管理员操作,并限定同一数据库类型、主机、端口和账户。SQLite 不进入在线迁移流程。
近期重新认证
→ 创建迁移预览和操作编号
→ 获取全局维护锁并进入维护模式
→ 创建目标数据库和 Schema
→ 分批复制业务数据并记录断点
→ 校验记录数、约束、索引和业务摘要
→ 写入候选结构化配置
→ 原子切换数据库名称
→ 通过受控服务管理器重启
→ 健康检查成功后结束维护模式
任一步失败都不得切断旧数据库,运行配置继续指向旧名称。目标数据库和旧数据库均不自动删除,失败现场、复制进度、校验结果、源目标脱敏标识和配置代次写入审计。迁移 API 不接受主机、端口、用户名、密码和 SQLite 文件位置变更。
11.8 受控服务重启
需要重启的配置保存后进入 pendingRestart 状态,由超级管理员在页面确认目标配置代次并点击“重启 Smail”。后端只调用部署时注册的受控服务管理器:
type ServiceManager interface {
Status(ctx context.Context) (ServiceStatus, error)
RestartSmail(ctx context.Context, expectedGeneration uint64) error
}
Windows 实现固定控制安装时登记的 Windows Service,Ubuntu 实现固定控制 Smail 的 systemd unit。接口、配置和请求体均不接受任意命令、服务名、参数、工作目录或脚本路径;Web 进程不得拼接 Shell 命令。重启请求写入持久化操作记录后再交给独立服务管理器,进程退出后由服务管理器完成拉起;启动成功、失败、超时和配置代次不匹配均写审计并在下次页面连接时可查询。
12. 初始化状态机
12.1 状态定义
| 状态 | 含义 | 允许启动的主要能力 |
|---|---|---|
new |
尚无有效主配置或数据库 | 本地/受控初始化页面 |
bootstrap |
数据库和首个超级管理员已建立,完整服务未就绪 | 登录、配置管理、健康检查 |
ready |
所有必需配置通过预检 | 完整管理和邮件服务 |
degraded |
已初始化但关键配置或服务异常 | 安全管理入口、诊断和回滚 |
12.2 允许的转换
new ──初始化数据库和首个超级管理员──> bootstrap
bootstrap ──完整预检通过──> ready
bootstrap ──关键配置失败──> degraded
ready ──关键运行检查失败──> degraded
degraded ──修复并完整预检通过──> ready
默认不允许从 ready 回到 new。重置系统属于独立高风险运维操作,不在本轮在线 API 范围内。
12.3 状态转换服务
type SetupService interface {
Status(ctx context.Context) SetupStatus
BootstrapDatabase(ctx context.Context, req DatabaseBootstrapRequest) error
CreateSuperAdmin(ctx context.Context, req SuperAdminBootstrapRequest) error
Preflight(ctx context.Context) (*PreflightResult, error)
Transition(ctx context.Context, target SetupState) error
}
MySQL 和 PostgreSQL 的初始化请求只接收数据库类型、主机、用户名、密码,以及高级设置中的可选端口;不接收数据库名称、表名、DSN 或 SQL。后端先使用受限管理连接验证权限,再按固定命名规则创建数据库、应用全部显式迁移并创建表结构,最后才保存加密后的结构化配置。SQLite 只接收数据库类型,由后端在受控数据目录创建独立文件和 localRef。任一步失败都不得写入 ready 配置,也不得在响应或日志中输出密码或完整连接信息。
初始化流程在数据库和首个超级管理员建立后继续要求主域名及 SSL。SSL 使用 managed_default 或 managed_upload,证书校验成功前 HTTP 只开放初始化路由;校验成功并提交 HTTPS 配置后,生产环境管理后台关闭普通 HTTP 管理入口并进入 HTTPS 访问边界。
状态转换必须满足:
- 转换操作幂等;
- 每一步有短期初始化授权,不使用普通 Session 替代;
- 初始化授权绑定浏览器、来源和有效期;
- 创建首个超级管理员后立即撤销匿名初始化权限;
- 数据库中必须至少存在一个未禁用超级管理员才能进入
ready; - 预检失败只返回脱敏问题代码;
- 降级状态禁止自动回退到不安全的公网 HTTP 管理;
- 初始化继续完成主域名和 SSL;SSL 成功前 HTTP 只允许用于受控初始化,SSL 成功后生产环境强制 HTTPS。
12.4 服务启动矩阵
| 服务 | new |
bootstrap |
ready |
degraded |
|---|---|---|---|---|
| 初始化 API | 是 | 受限 | 否 | 否 |
| 登录 API | 否 | 是 | 是 | 是 |
| 配置管理 API | 否 | 超级管理员 | 按能力 | 超级管理员 |
| SMTP 接收 | 否 | 否 | 是 | 视故障隔离结果 |
| SMTP Submission | 否 | 否 | 是 | 视故障隔离结果 |
| IMAP/POP3 | 否 | 否 | 是 | 视故障隔离结果 |
| 插件 | 否 | 否 | 是 | 默认停用 |
13. 角色和能力授权
13.1 角色字段
type Role string
const (
RoleSuperAdmin Role = "super_admin"
RoleAdmin Role = "admin"
RoleUser Role = "user"
)
user.role 是唯一持久化角色依据。迁移期的 is_admin 只能作为旧前端兼容输出,由 role 计算,不得写回或参与授权。
13.2 能力模型
角色不通过大小比较授权,而映射到明确能力:
type Capability string
const (
CapabilityConfigRead Capability = "config.read"
CapabilityConfigWriteGlobal Capability = "config.write_global"
CapabilityConfigRollback Capability = "config.rollback"
CapabilityPluginControl Capability = "plugin.control"
CapabilityPluginRestart Capability = "plugin.restart"
CapabilityPluginWriteGlobal Capability = "plugin.write_global"
CapabilityPluginWriteAnyUser Capability = "plugin.write_any_user"
CapabilityPluginWriteOwn Capability = "plugin.write_own"
CapabilityUserManage Capability = "user.manage"
CapabilityRoleManage Capability = "role.manage"
CapabilityPasswordReset Capability = "password.reset"
CapabilityAuditRead Capability = "audit.read"
)
13.3 默认能力矩阵
| 能力 | 超级管理员 | 管理员 | 普通用户 |
|---|---|---|---|
| 查看脱敏系统状态 | 是 | 是 | 否 |
| 修改全局配置 | 是 | 否 | 否 |
| 回滚配置 | 是 | 否 | 否 |
| 启用或禁用插件 | 是 | 否 | 否 |
| 重启插件实例 | 是 | 仅明确授权的实例 | 否 |
| 修改插件全局配置 | 是 | 否 | 否 |
| 修改任意普通用户插件配置 | 是 | 是 | 否 |
| 修改自己的插件配置 | 是 | 是 | 是 |
| 管理普通用户 | 是 | 是 | 否 |
| 管理管理员角色 | 是 | 否 | 否 |
| 重置管理员密码 | 是 | 否 | 否 |
| 重置普通用户密码 | 是 | 是 | 否 |
| 查看审计日志 | 全部 | 授权范围 | 自己的安全事件 |
管理员修改用户级插件配置时,目标用户必须是 user;不能借此修改管理员或超级管理员配置。超级管理员可以修改所有用户配置,但不能绕过字段验证和审计。plugin.control 只控制启用和禁用,plugin.restart 单独授权;管理员只能重启后端策略明确授权且不涉及全局权限变更的插件实例。
13.4 资源级授权
type AuthorizationRequest struct {
ActorID int64
Capability Capability
Resource ResourceRef
TargetUser *UserSecurityView
}
授权器必须同时检查:
- 当前会话仍有效且角色未变化;
- Actor 是否拥有能力;
- 目标资源是否属于允许范围;
- 目标用户角色是否允许被操作;
- 是否要求近期重新认证或 WebAuthn 二次确认;
- 系统是否处于允许该操作的状态;
- 能力策略是否要求管理员或普通用户已经配置有效离线恢复码;
- 角色变更的目标用户为管理员或普通用户时,目标用户是否已经配置有效离线恢复码。
超级管理员明确豁免离线恢复码前置条件。数据库名称迁移、配置回滚等仅限超级管理员的高风险操作,继续使用近期重新认证或 WebAuthn 二次确认、能力授权、审计和事务保护,不得因为豁免恢复码而降低其他安全要求。
13.5 超级管理员保护
- 不能禁用、删除或降级最后一个未禁用超级管理员;
- 变更超级管理员角色必须在数据库事务中重新计数并锁定相关记录;
- 超级管理员不能通过普通用户更新接口修改角色;
- 高风险配置变更、回滚和管理员授权要求近期重新认证;
- Web API 不提供超级管理员“忘记密码”流程。
14. 用户和安全数据模型
14.1 User 扩展
type User struct {
ID int64
Account string
Name string
Password string
Role Role
Disabled bool
MustChangePassword bool
SessionVersion uint64
SecurityVersion uint64
PasswordChangedAt time.Time
CreatedAt time.Time
UpdatedAt time.Time
}
数据库约束:
account必须唯一;role只允许三个枚举值;session_version、security_version不小于 1;- 禁止空密码哈希;
- 用户禁用后不得进行 Web 或邮件协议认证。
14.2 用户邮箱绑定
现有 user_email 表需要增加或确认以下字段:
id
user_id
address
address_canonical
is_primary
verified_at
created_at
updated_at
必须建立 address_canonical 唯一索引。登录只认可已经绑定且允许登录的邮箱,不根据用户名和域名临时拼接。
14.3 安全表
新增:
security_question
recovery_code
webauthn_credential
password_reset
security_audit
详细字段见本文档第 17、18、19、24 章。
14.4 插件用户设置表
plugin_user_setting
├── id
├── plugin_id
├── user_id
├── schema_version
├── settings_ciphertext
├── settings_hash
├── enabled
├── created_at
└── updated_at
唯一约束为 (plugin_id, user_id)。即使普通字段也统一通过主程序验证;含敏感信息的用户设置使用凭据机制加密。
15. 登录标识解析和统一认证
15.1 请求模型
新 Web 登录请求:
{
"identifier": "用户名或完整用户邮箱",
"password": "用户输入的密码"
}
迁移期可短期接受 account,但服务端必须在 DTO 层转换为 identifier,记录兼容使用次数,并在前端升级后移除。两个字段同时存在或值不一致时拒绝请求。
15.2 标识解析器
type IdentifierKind string
const (
IdentifierAccount IdentifierKind = "account"
IdentifierEmail IdentifierKind = "email"
)
type ResolvedIdentity struct {
UserID int64
Account string
LoginAddress string
Kind IdentifierKind
}
type IdentifierResolver interface {
Resolve(ctx context.Context, identifier string) (*ResolvedIdentity, error)
}
解析规则:
- 去除首尾空白后进行长度和字符校验;
- 不包含
@时按规范化用户名精确查询; - 包含
@时必须解析为完整邮箱,并按user_email.address_canonical精确查询; - 绝不删除
@后的域名; - 域名部分统一进行小写和规范化;
- 本地部分按系统邮箱地址创建规则规范化,不能擅自应用第三方邮箱的点号或加号规则;
- 多条记录、冲突映射或未验证绑定必须拒绝登录并触发安全告警;
- 对外统一返回“用户名、邮箱或密码错误”,不泄露账户是否存在。
15.3 统一认证器
type Authenticator interface {
Authenticate(ctx context.Context, req AuthenticationRequest) (*Principal, error)
}
type AuthenticationRequest struct {
Identifier string
Password SecretInput
Channel AuthChannel
Source AuthSource
}
AuthChannel 至少包含:
web
smtp
imap
pop3
所有通道复用:
- 标识解析;
- 用户启用状态检查;
- 当前 Argon2id 验证;
- 统一的虚假哈希耗时保护;
- 账户、来源 IP、账户与 IP 组合限流;
- 密码升级判断;
- 安全审计事件模型。
各协议只负责解析协议命令,不得复制账户截断或密码验证逻辑。
15.4 登录结果
Web 登录成功后返回:
{
"user": {
"id": 1,
"account": "smail",
"name": "示例用户",
"role": "super_admin"
},
"capabilities": ["config.read", "config.write_global"],
"security": {
"mustChangePassword": false,
"recoveryCodesConfigured": false,
"webauthnConfigured": false,
"showRecoverySetupPrompt": false
},
"domains": ["example.com"]
}
is_admin 如需兼容,只能由 role != user 计算,并标记为待移除字段。
15.5 Session 处理
- 登录成功前更新 Session ID,防止固定会话;
- Session 绑定
user_id、role、session_version和security_version; - 角色、密码、关键安全凭据变化时递增相应版本并撤销旧 Session;
must_change_password=true时,仅允许访问修改密码、退出和必要安全引导接口;- Cookie 使用
HttpOnly、SameSite和生产环境Secure; - 写操作继续要求 CSRF 和同源验证。
16. 密码策略和修改边界
16.1 密码服务
type PasswordService interface {
ValidateNewPassword(ctx context.Context, user *User, password SecretInput) error
Verify(encoded string, password SecretInput) bool
Hash(password SecretInput) (string, error)
NeedsRehash(encoded string) bool
}
密码哈希固定使用经过评审的 Argon2id 参数和随机盐,参数写入编码字符串。算法或参数更新通过 NeedsRehash 显式升级,不随机选择算法,也不保留无限期旧算法回退。
16.2 用户主动修改密码
本节 Web 修改密码接口只适用于 admin 和 user。super_admin 无论是否已经登录,都必须使用第 16.4 节的部署主机本地维护通道修改密码。
- 需要当前密码或有效的近期重新认证票据;
- 新密码通过统一策略和泄露风险检查;
- 新旧密码不能相同;
- 更新密码、密码时间和 Session 版本必须在同一数据库事务内完成;
- 密码修改成功后递增 Session 版本,撤销包括当前会话在内的全部旧会话;响应成功后清除当前 Session Cookie,要求用户使用新密码重新登录;
- 写安全审计,不记录密码内容;
- 后端在进入密码服务前必须拒绝
super_admin,不能只依赖前端隐藏修改入口。
16.3 分角色跨用户重置密码
超级管理员可以重置管理员和普通用户密码,管理员只能重置普通用户密码。两种跨用户重置必须复用同一个密码事务服务、目标角色校验和凭据撤销流程:
- 需要独立能力授权、近期重新认证或 WebAuthn 二次确认;
- 生成临时密码时必须使用密码学安全随机源;
- API 只允许一次返回临时密码,不写日志和审计详情;
- 设置
must_change_password=true; - 递增目标用户 Session 和 Security 版本;
- 递增目标用户 Credential 版本;
- 撤销目标用户全部登录会话、全部 WebAuthn 凭据、全部旧离线恢复码、未完成恢复流程和未消费密码重置票据;
- 设置下次登录必须生成新离线恢复码,并展示重新绑定 WebAuthn 提示;
- 向已经验证的外部安全邮箱发送安全通知;通知失败写入审计和告警,但不得恢复已撤销凭据;
- 不能通过该接口重置超级管理员密码,管理员也不能重置管理员;
- 服务端不得生成 WebAuthn 私钥,只能由目标用户重新注册认证器。
16.4 超级管理员部署端专用重置通道
超级管理员可以不配置离线恢复码,密码恢复不暴露为公网或普通 Web API。Linux 部署人员必须通过标准 systemd 入口启动一次短时重置事务,由 smail-admin 通过受保护的本机控制接口请求开启短时专用重置通道:
systemctl start smail-admin
│
▼
Windows Named Pipe / Unix Domain Socket
│
▼
短时一次性专用重置通道
│
▼
systemd-ask-password 或等效受保护本机交互输入并确认新密码
本轮正式实现以下内部接口,具体传输由 Windows Named Pipe 和 Unix Domain Socket 平台适配器实现,不注册到普通 HTTP Router:
type SuperAdminResetChannelService interface {
Open(ctx context.Context, account string) (ResetChannel, error)
ResetPassword(ctx context.Context, channelID string, credential SecretInput, password SecretInput) error
Close(ctx context.Context, channelID string) error
}
通道状态固定为 closed -> open -> consumed 或 closed -> open -> expired,不得从终态恢复为可用状态。smail-admin 完成一次事务后必须退出,不得成为常驻网络服务;该服务、systemd 单元、平台适配器和测试完成前不得通过最终验收。
Linux systemd 单元必须使用 Type=oneshot、RemainAfterExit=no 和 Restart=no,不得包含 [Install]、WantedBy、RequiredBy、Alias、ExecReload、启动依赖、重启传播或自动重启策略。安装和升级流程不得执行 enable、preset、start 或 restart smail-admin,并应清理旧版本遗留的启用关系。主服务未运行时使用 Requisite=smail.service 安全拒绝,不得为了重置自动拉起主服务。
正式管理程序启动时必须同时验证有效 UID、systemd 进程上下文、INVOCATION_ID、可用时的 SYSTEMD_EXEC_PID,以及当前 smail-admin.service Job 类型。systemd 会把 restart 的启动阶段标为 start,因此 Job 类型不能单独证明原始操作;还须检查唯一的、本机 root 前台 systemctl start smail-admin 进程,校验真实可执行文件、命令参数、进程启动时间及终端设备。并发启动、重启动作、通配符、多服务和非交互启动均拒绝。交互直接绑定此终端,密码保持无回显,不向系统共享密码代理发布请求。启动进程退出、终端断开、Ctrl+C、超时或 stop 均取消输入并关闭本机连接;KillMode=mixed 允许主进程先恢复终端。status、show、is-active 和日志查询仍可使用。该检查约束部署命令使用方式,不构成对主机 root 的安全隔离,受保护 IPC 身份校验和业务安全验证必须独立保留。
安全要求:
- 默认关闭且禁止开机自启动,Linux 只能由部署主机上的固定
systemctl start smail-admin请求开启,systemctl stop smail-admin只能终止事务; - 不监听 TCP,不注册到普通 HTTP Router;
- Unix Socket 权限为
0600,Windows Named Pipe 仅服务账户和本机管理员可访问; - 普通 systemd 服务不继承调用终端,新密码必须通过
systemd-ask-password或等效短时受保护本机凭据交互获取;禁止使用命令行参数、URL、环境变量、临时明文文件、日志、标准输入或标准输出传递; - 操作前检查主机权限和目标角色;
- 每次启用必须生成密码学安全、短时、一次性的维护凭据,并绑定本次进程实例、维护用途、签发时间和过期时间;
- 维护凭据只能通过受保护的本机交互通道传递,服务端只保存不可逆校验值,禁止写入命令行参数、普通日志、环境变量或长期配置;
- 维护凭据验证成功后必须先原子标记已消费,再允许进入密码更新事务,失败或超时后也不得恢复;
- 修改后递增 Session 与 Security 版本并撤销全部会话;
- 记录不含密码和维护凭据的本地安全审计;
- 操作完成、验证失败达到阈值或超时后自动关闭维护通道;
- 开发模式不得为了方便而自动开放该接口;
- 普通配置项、环境变量、Web 请求和插件均不得开启通道;
- 无可用密码代理或安全交互环境时必须安全拒绝,不得降级读取环境变量或文件;
- Windows 和 Ubuntu 平台都必须完成权限、重放、超时、进程退出和网络暴露测试,任一平台未通过都视为验收阻塞。
17. 密保问答
17.1 定位
密保问答是密码恢复中的知识凭据,只能与离线恢复码或 WebAuthn 安全密钥组合使用,不能单独重置密码,也不能作为登录第二因素。
17.2 数据模型
security_question
├── id
├── user_id
├── question_ciphertext
├── answer_hash
├── algorithm_version
├── failed_attempts
├── locked_until
├── created_at
└── updated_at
规则:
- 每个用户当前只保留一组有效问题和答案;
- 问题由用户自行设置,但必须限制长度、控制字符和危险 HTML;
- 问题属于隐私数据,建议加密落盘,公开恢复接口只在达到流程阶段后展示;
- 答案不可逆存储,使用随机盐、固定版本 Argon2id 和服务端 Pepper;
- 不使用“随机选择哈希算法”,算法版本由统一安全策略决定;
- 答案规范化规则必须在设置和验证时完全一致;
- 修改密保问题要求当前密码或 WebAuthn 二次确认,并撤销所有未完成恢复流程。
17.3 答案规范化
首版固定采用大小写不敏感且可解释的处理:
- 去除首尾空白;
- Unicode 采用固定规范化形式;
- 英文字符统一转换为小写;
- 合并连续空白,但不删除标点或特殊字符;
- 规范化后的字节长度必须在安全范围内。
不得采用模糊匹配、拼音转换、相似答案或人工客服绕过。
17.4 防枚举和限流
- 获取问题接口不直接说明账户是否存在;
- 只有成功创建恢复会话后才返回对应问题;
- 按账户、IP、恢复会话和设备指纹限流;
- 答案错误达到阈值后锁定恢复流程;
- 不因正确或错误答案产生明显响应时差;
- 失败日志不记录答案和问题原文。
18. 离线恢复码
18.1 生成规则
- 使用操作系统密码学安全随机源;
- 每组建议生成 10 个独立恢复码;
- 每个恢复码应具有足够随机强度,并使用便于人工录入的无歧义字符集;
- 显示时可按分组增加连字符,验证前只移除格式字符;
- 服务端只保存带用户域隔离的不可逆校验值;
- 每组包含独立的
set_id,重新生成后旧组全部失效; - 明文只在生成成功后的当前响应中展示一次。
18.2 数据模型
recovery_code
├── id
├── user_id
├── set_id
├── code_hash
├── algorithm_version
├── code_index
├── used_at
├── revoked_at
└── created_at
唯一约束建议为 (user_id, set_id, code_index)。验证时先按用户和当前 set_id 读取未使用记录,再执行固定时间比较或受控哈希验证。
18.3 首次登录提示
管理员和普通用户的登录响应由后端计算;超级管理员的 showRecoverySetupPrompt 必须为 false,避免产生可通过 Web 恢复超级管理员密码的错误预期:
{
"recoveryCodesConfigured": false,
"showRecoverySetupPrompt": true
}
前端规则:
- 管理员和普通用户登录成功进入主页面后立即打开中文安全提示弹窗;
- 弹窗提供“立即生成恢复码”和“稍后处理”;
- 用户选择稍后处理后,每次新登录仍提示;
- 未完成配置时,账户安全页持续显示明显风险状态;
- 管理员和普通用户执行被标记为需要恢复保障的高风险操作前,后端能力策略必须检查有效恢复码状态,不能只靠前端判断;超级管理员明确豁免该检查;
- 管理员和普通用户的设置页面始终提供“生成”“重新生成”“查看剩余数量”和“撤销”入口;
- 已生成的明文不能再次查看,只能重新生成。
18.4 一次性展示
生成接口必须:
- 要求当前密码或 WebAuthn 二次确认;
- 在数据库事务中撤销旧组并写入新组哈希;
- 事务提交成功后才返回明文;
- 响应使用
Cache-Control: no-store; - 不允许代理缓存,不写入前端持久化存储;
- 提供复制和下载纯文本文件功能,但由浏览器本地生成文件;
- 用户确认已经保存后关闭一次性展示页面。
18.5 单次消费
恢复码消费必须使用数据库条件更新:
UPDATE recovery_code
SET used_at = CURRENT_TIMESTAMP
WHERE id = ? AND used_at IS NULL AND revoked_at IS NULL;
只有受影响行数为 1 才表示消费成功,防止两个并发请求重复使用同一恢复码。消费与创建密码重置票据必须位于同一事务内。
18.6 重新生成和撤销
- 重新生成会撤销当前组全部未使用代码;
- 撤销操作要求近期重新认证;
- 使用、重新生成和撤销都递增
security_version或撤销现有恢复会话; - 审计只记录使用了第几个代码,不记录代码内容或哈希;
- 若用户丢失全部代码且未启用 WebAuthn,只能由超级管理员执行账户密码重置。
19. WebAuthn 安全密钥
19.1 启用原则
WebAuthn 由用户自行选择是否启用。系统不得把未启用 WebAuthn 的用户阻断在正常登录之外,但必须保留离线恢复码提示和设置入口。
19.2 数据模型
webauthn_credential
├── id
├── user_id
├── credential_id
├── public_key
├── sign_count
├── transports
├── aaguid
├── user_verified_required
├── name
├── last_used_at
├── created_at
└── revoked_at
credential_id 必须全局唯一。公钥不是密码,但仍需限制读取范围并避免在普通列表接口返回完整内容。
19.3 注册流程
用户近期重新认证
→ 服务端生成随机 Challenge
→ Challenge 绑定 user_id、Session、RP ID、Origin 和有效期
→ 浏览器调用 WebAuthn 创建凭据
→ 服务端校验 ClientData、AuthenticatorData、RP ID Hash、Origin 和用户验证
→ 检查 credential_id 未被其他用户绑定
→ 保存公钥和元数据
→ 撤销 Challenge
→ 写安全审计
要求:
- Challenge 至少具有足够随机强度并且只能使用一次;
- Challenge 短期有效,不能放入可篡改 Cookie;
- 生产环境只允许配置的 HTTPS Origin;
- RP ID 由受控配置生成,不接受请求体覆盖;
- 注册要求用户验证能力时,服务端必须实际检查验证标志;
- 不允许把同一凭据绑定给多个账户;
- 用户可设置便于识别的中文名称,但名称需要长度和 HTML 安全处理。
19.4 恢复验证流程
忘记密码场景中,WebAuthn 作为强恢复凭据:
- 先通过受限恢复会话完成标识和密保问答阶段;
- 服务端只下发该用户允许的凭据描述符;
- 校验 Challenge、Origin、RP ID、签名、用户归属和用户验证;
- 检查签名计数异常并产生安全事件;
- 验证成功后签发一次性密码重置票据;
- WebAuthn 断言本身不能直接成为长期 Session。
19.5 管理操作
- 用户可新增、重命名和撤销自己的安全密钥;
- 删除最后一把安全密钥前提示确认离线恢复码状态;
- 超级管理员不能读取私钥,也不能替用户注册安全密钥;
- 超级管理员重置用户密码时,必须撤销目标用户全部 WebAuthn 凭据;
- 管理员不能操作其他用户的 WebAuthn 凭据;
- 所有变更均要求近期重新认证并撤销未完成 Challenge。
20. 忘记密码流程
20.1 适用角色
| 角色 | Web 忘记密码 | 超级管理员重置 | 本地维护接口 |
|---|---|---|---|
| 超级管理员 | 否 | 否 | 是 |
| 管理员 | 是 | 是 | 否 |
| 普通用户 | 是 | 是 | 否 |
20.2 恢复条件
路径 A 由管理员和普通用户使用,必须同时满足:
正确的密保答案
+
一个未使用的离线恢复码或有效的 WebAuthn 安全密钥断言
路径 B 在本轮正式实现,使用已经验证的外部安全邮箱验证码,不要求密保问答。普通用户可以单独使用路径 B;管理员使用路径 B 时还必须验证离线恢复码。功能关闭、尚未通过安全验收、配置错误或投递失败时,路径 B 不可用,不能降级为仅知道邮箱、仅回答密保问题、管理员人工放行、旧 Token 或浏览器本地标记。
20.3 流程状态
started
question_verified
strong_credential_verified
external_email_verified
ticket_issued
completed
expired
locked
恢复会话只能向前推进,不能跳过阶段或重复使用已完成阶段的请求。
20.4 PasswordReset 数据模型
password_reset
├── id
├── user_id
├── flow_id_hash
├── stage
├── strong_method
├── ticket_hash
├── expires_at
├── attempt_count
├── source_ip_hash
├── user_agent_hash
├── consumed_at
└── created_at
流程 ID 和重置票据都只保存哈希。原始票据采用高熵随机值,短期有效,只能使用一次。
外部安全邮箱验证码任务必须绑定不可变用户 ID、外部安全邮箱、用途、恢复会话、签发实例、有效期、错误次数和单次消费状态。验证码原文、校验值和完整任务只保存在签发验证码的认证进程内存,使用进程临时 HMAC 密钥;不得进入数据库、缓存、文件、日志、审计或消息队列。过期、消费、进程退出或服务重启后立即销毁。数据库只持久化不包含验证码秘密的限流、投递、脱敏统计和安全审计数据,并每 14 天执行一次分批清理。
20.5 完整流程
提交 identifier
→ 统一防枚举响应
→ 创建短期恢复会话
→ 展示密保问题
→ 验证答案并限流
→ 用户选择离线恢复码或 WebAuthn
→ 原子消费恢复码,或验证 WebAuthn 断言
→ 签发一次性重置票据
→ 提交新密码
→ 统一密码策略校验和 Argon2id 哈希
→ 更新密码并递增 Session/Security 版本
→ 消费重置票据并撤销其他恢复会话
→ 撤销全部旧登录会话
→ 返回成功并要求重新登录
路径 B 从有效恢复会话进入 external_email_verified,再签发与路径 A 相同类型的一次性重置票据。两条路径必须复用同一密码策略、Session/Security 版本更新、恢复流程撤销和审计服务。功能开关默认关闭,只有验证码、SMTP、限流、清理、安全测试和管理页面全部验收后,才允许超级管理员显式启用。
20.6 错误和防枚举
- 第一阶段始终返回相同状态和近似耗时;
- 错误响应不区分用户不存在、答案错误、恢复码错误或 WebAuthn 未配置;
- 前端只在已建立有效恢复会话后显示可用强凭据类型;
- 所有阶段有独立重试次数和总有效期;
- 同一账户并发恢复会话数量受限;
- 成功修改密码后全部旧恢复会话立即失效。
21. 插件配置模型
21.1 逻辑标识
首批固定逻辑 ID:
spam_block
wechat_push
telegram_push
逻辑 ID 与可执行文件名分离:
| 逻辑 ID | Windows 可执行文件 | Linux 可执行文件 |
|---|---|---|
spam_block |
spam_block.exe |
spam_block |
wechat_push |
wechat_push.exe |
wechat_push |
telegram_push |
pmail_telegram_push.exe |
pmail_telegram_push |
可执行文件名只能来自主程序内置目录或签名清单,管理 API 不接受任意路径。
21.2 全局配置
type PluginConfig struct {
Meta SectionMeta `json:"meta"`
Plugins map[string]PluginInstance `json:"plugins"`
}
type PluginInstance struct {
Enabled bool `json:"enabled"`
Executable string `json:"executable"`
Permissions PluginPermissions `json:"permissions"`
Settings json.RawMessage `json:"settings"`
}
每个插件拥有主程序内置的 JSON Schema 或等价 Go 验证器。Settings 只能保存非敏感设置,先按插件 ID 解析到专用 DTO,再进入运行时。Token、Secret、API Key、Webhook 签名密钥等关键信息不得写入 plugins.json,统一写入数据库 plugin_secret 表,并由数据库之外的主密钥加密。
21.3 全局和用户配置边界
| 配置示例 | 存储 | 修改者 |
|---|---|---|
| 插件启用状态 | plugins.json |
超级管理员 |
| Bot Token、微信 Secret、API Key | plugin_secret 数据库密文 |
超级管理员 |
| 网络权限 | plugins.json |
超级管理员 |
| 普通用户 Chat ID | plugin_user_setting |
用户本人、管理员、超级管理员 |
| 管理员自己的 Chat ID | plugin_user_setting |
管理员本人、超级管理员 |
| 超级管理员自己的 Chat ID | plugin_user_setting |
超级管理员本人或其他获授权超级管理员 |
管理员不能修改管理员或超级管理员的用户设置。所有用户级读写都必须同时绑定 plugin_id 和 user_id,禁止只按客户端传入用户名更新。
plugin_secret 至少绑定 plugin_id、固定字段名、密文、密钥版本、记录版本和更新时间。查询接口只返回“未设置、已设置”状态;更新统一使用 keep、replace、clear,数据库备份、审计、错误和插件健康结果均不得包含明文。Telegram 的用户级 Chat ID 等设置继续保存在 plugin_user_setting,不得回写插件文件。
本人设置、跨用户设置和批量设置必须使用三个独立的服务入口:
- 本人设置只从 Session 获取
user_id,请求体和路径均不接受目标用户 ID; - 跨用户设置必须显式传入不可变
user_id,并执行操作者能力和目标角色双重检查; - 批量设置只允许超级管理员使用,先生成只读预览,再携带短时一次性预览票据、近期重新认证凭据、目标集合和明确覆盖策略提交;
- 批量预览票据必须绑定插件 ID、目标用户集合摘要、字段摘要、覆盖策略、操作者和过期时间;
- 批量操作默认只补充缺失值,不覆盖个性化配置;需要覆盖时必须逐字段明确声明,并在审计中记录受影响用户数量和字段路径;
- 任一目标在预览后发生版本变化时,该目标必须冲突失败,不得以最后写入者覆盖;
- 批量结果逐用户返回成功、跳过、冲突或失败,不回显 Secret 和用户现有敏感值。
21.4 配置 DTO
主程序向插件下发专属 DTO:
type PluginRuntimeConfig struct {
PluginID string `json:"pluginId"`
Generation uint64 `json:"generation"`
Global json.RawMessage `json:"global"`
Capabilities []string `json:"capabilities"`
Network NetworkPolicy `json:"network"`
}
用户级设置不应随全局配置一次性下发全部用户数据。插件处理某个邮件事件时,主程序只附带该事件目标用户所需、已经裁剪的用户配置。插件 Secret 由主程序在内存中按固定字段白名单解密,只随本地受保护 RPC 下发给对应插件实例;不得进入通用 DTO 序列化、日志、审计、健康响应或其他插件进程。
Web Push 作为插件能力管理。只有清单声明并经超级管理员启用 web_push 能力的插件,才可获取经过裁剪的订阅投递能力;订阅端点、加密材料和用户关联数据仍由主程序受控存储,插件不能直接读取主数据库。
21.5 插件配置统一验证
插件配置保存采用与数据库、SMTP 凭据相同的流程:
主程序 DTO 严格解析
→ 主程序 Schema 和语义验证
→ 主程序统一 SecretChange 处理
→ 插件 /ValidateConfig 二次业务验证
→ 主程序加密保存
→ 配置代次提交
→ 向单插件 /Configure 下发
→ /Health 验证
插件返回的验证结果只是业务校验补充,不能覆盖主程序的路径、权限、网络和敏感字段校验。
22. 插件运行和 RPC
22.1 启动顺序
读取不可变配置快照
→ 检查插件启用状态
→ 解析固定可执行文件映射
→ 校验目录、文件权限和所有者
→ 创建最小权限进程隔离
→ 创建本地通信端点
→ 启动插件
→ 获取 /Capabilities
→ 校验能力不超过授权策略
→ 调用 /ValidateConfig
→ 调用 /Configure
→ 调用 /Health
→ 注册 Hook
任一步失败都停止该插件、注销 Hook 并写脱敏审计,不影响其他插件和核心服务。
22.2 RPC 端点
POST /Capabilities
POST /ValidateConfig
POST /Configure
POST /Health
POST /Hook/ReceiveParseBefore
POST /Hook/ReceiveSaveAfter
RPC 要求:
- 只使用本地 Unix Socket、抽象 Socket 或 Windows Named Pipe;
- 每次插件启动生成随机认证材料并绑定进程;
- 请求和响应均限制大小、并发和超时;
- 序列化 DTO 使用严格字段白名单;
- 插件不能通过 RPC 请求任意文件、数据库查询或主配置;
- 插件退出后立即注销 Hook 和通信端点;
- 主程序对插件错误进行脱敏,不直接返回给普通用户。
22.3 网络策略
- 默认拒绝网络;
network=true仅表示允许进入受控网络能力评审,不等于任意联网;- 首版必须按插件强制限制目标主机、端口和协议,不提供“允许任意外网”的配置;
- 禁止访问回环地址、链路本地地址、云元数据地址和主数据库地址,除非存在明确内置例外;
- DNS 解析结果在连接前后校验,防止 DNS 重绑定;
- 外部请求设置连接、TLS、响应头和总超时;
- 不允许插件自行读取系统代理中的敏感凭据;
- 白名单由主程序内置基线和超级管理员配置共同收紧,协议只允许明确列出的
https、wss等安全协议; - 域名匹配必须使用规范化后的完整主机名,禁止模糊后缀、用户信息、重定向跨白名单和解析后私网地址;
- 每次重定向、DNS 变化和新建连接都重新执行白名单与目标地址检查。
22.4 插件设置页面
目标状态采用主程序渲染的 Schema 驱动表单:
插件清单中的字段定义
→ 后端返回脱敏设置模型
→ Vue 组件渲染中文表单
→ 后端统一验证和保存
现有 SettingsHtml 仅作为迁移期兼容接口:
- 生产环境默认关闭;
- 必须使用独立 Origin 或严格 sandbox iframe;
- 不允许访问父页面 Cookie、LocalStorage 和任意导航;
postMessage必须校验来源、消息类型和数据结构;- 三个首批插件迁移完成后删除兼容路由需要另行获得文件删除许可,但应先停止使用该路由。
23. 管理 API 设计
23.1 通用约定
- API 使用
/api/v1版本前缀; - 写操作要求 Session、CSRF、同源验证和能力授权;
- 高风险写操作要求近期重新认证或 WebAuthn 二次确认;
- 请求体使用严格 JSON 解析和大小限制;
- 响应使用统一中文错误消息和稳定英文错误码;
- 所有 Secret 查询只返回状态,不返回原文;
- 所有更新携带
expectedGeneration或资源版本; - 所有成功和失败的安全操作写审计。
23.2 配置接口
| 方法 | 路径 | 能力 | 用途 |
|---|---|---|---|
| GET | /api/v1/config/status |
config.read |
获取配置状态 |
| GET | /api/v1/config/{section} |
config.read |
获取脱敏分区 |
| POST | /api/v1/config/{section}/validate |
config.write_global |
验证候选配置 |
| PUT | /api/v1/config/{section} |
config.write_global |
保存候选配置 |
| POST | /api/v1/config/{section}/apply |
config.write_global |
应用指定代次 |
| GET | /api/v1/config/backups |
config.rollback |
查看可回滚代次 |
| POST | /api/v1/config/rollback/preview |
config.rollback 和近期重新认证 |
预览可回滚字段、排除项和影响 |
| POST | /api/v1/config/rollback |
config.rollback 和近期重新认证 |
使用一次性预览票据回滚为新代次 |
| POST | /api/v1/config/test/{target} |
专用测试能力 | 执行受控连接测试 |
| POST | /api/v1/database/migrations/preview |
database.migrate_name 和近期重新认证 |
预检数据库名称迁移 |
| POST | /api/v1/database/migrations |
database.migrate_name 和近期重新认证 |
创建迁移任务 |
| GET | /api/v1/database/migrations/{id} |
database.migrate_name |
查询迁移阶段和脱敏结果 |
| POST | /api/v1/system/restart/preview |
system.restart 和近期重新认证 |
检查待应用代次和服务管理器状态 |
| POST | /api/v1/system/restart |
system.restart 和近期重新认证 |
使用固定服务标识受控重启 Smail |
配置 API 的 {section} 只允许 base、smtp、email、ssl、plugins。外部 base 映射到内部 SectionRoot,客户端不能传入文件名、路径或内部枚举值。{target} 使用后端固定测试目标清单,不接受任意 URL、主机或脚本名称。
数据库名称迁移接口只接受目标数据库名称、当前配置代次和一次性预检票据,不接受新数据库类型、主机、端口、用户名、客户端 DSN 或 SQL。受控重启接口不接受命令、参数、脚本路径或服务名,后端只能调用部署时固定注册的 Windows Service 或 systemd 适配器。
超级管理员配置审计接口:
| 方法 | 路径 | 能力 | 用途 |
|---|---|---|---|
| GET | /api/v1/audits/config |
audit.read |
按操作者、分区、结果、代次和时间筛选 |
| GET | /api/v1/audits/config/{id} |
audit.read |
查看脱敏差异摘要、重启和回滚关联 |
| POST | /api/v1/audits/config/export |
audit.export 和近期重新认证 |
导出脱敏 CSV 或 JSON,并记录导出审计 |
普通 Web API 不提供删除单条配置审计记录的接口。归档和到期清理只能由受控后台任务按至少 365 天保留策略执行。
23.3 插件接口
| 方法 | 路径 | 能力 | 用途 |
|---|---|---|---|
| GET | /api/v1/plugins |
登录用户 | 返回按权限裁剪的插件列表 |
| GET | /api/v1/plugins/{id}/global |
plugin.write_global |
获取脱敏全局配置 |
| PUT | /api/v1/plugins/{id}/global |
plugin.write_global |
修改全局配置 |
| POST | /api/v1/plugins/{id}/enable |
plugin.control |
启用插件 |
| POST | /api/v1/plugins/{id}/disable |
plugin.control |
禁用插件 |
| POST | /api/v1/plugins/{id}/restart |
plugin.restart |
重启明确授权的插件实例 |
| GET | /api/v1/plugins/{id}/settings |
plugin.write_own |
获取当前 Session 用户的设置 |
| PUT | /api/v1/plugins/{id}/settings |
plugin.write_own |
修改当前 Session 用户的设置 |
| POST | /api/v1/plugins/{id}/settings/test |
plugin.write_own |
测试当前 Session 用户的设置 |
| GET | /api/v1/plugins/{id}/users/{userId} |
plugin.write_any_user 和资源级授权 |
获取指定用户设置 |
| PUT | /api/v1/plugins/{id}/users/{userId} |
plugin.write_any_user 和资源级授权 |
修改指定用户设置 |
| POST | /api/v1/plugins/{id}/users/{userId}/test |
plugin.write_any_user 和资源级授权 |
测试指定用户设置 |
| POST | /api/v1/plugins/{id}/users/batch/preview |
plugin.write_global 和近期重新认证 |
生成批量修改预览和一次性票据 |
| PUT | /api/v1/plugins/{id}/users/batch |
plugin.write_global 和近期重新认证 |
按预览票据执行批量修改 |
普通用户只能访问不含 userId 的本人设置接口。含 userId 的跨用户接口不向普通用户开放;管理员只允许目标角色为普通用户,超级管理员可以操作全部角色。批量接口只允许超级管理员,并且必须执行第 21.3 节定义的预览、版本冲突和审计规则。
23.4 认证和账户安全接口
| 方法 | 路径 | 用途 |
|---|---|---|
| POST | /api/v1/auth/login |
用户名或完整邮箱登录 |
| POST | /api/v1/auth/logout |
退出登录 |
| POST | /api/v1/auth/reauthenticate |
获取近期重新认证票据 |
| PUT | /api/v1/security/password |
管理员或普通用户主动修改密码,拒绝超级管理员 |
| GET/PUT | /api/v1/security/question |
管理员或普通用户查询状态或设置密保问答 |
| POST | /api/v1/security/recovery-codes |
管理员或普通用户生成或重新生成恢复码 |
| GET | /api/v1/security/recovery-codes/status |
管理员或普通用户获取剩余数量和状态 |
| DELETE | /api/v1/security/recovery-codes |
管理员或普通用户撤销当前恢复码组 |
| POST | /api/v1/security/webauthn/register/options |
生成注册 Challenge |
| POST | /api/v1/security/webauthn/register/verify |
完成注册 |
| GET | /api/v1/security/webauthn/credentials |
列出自己的安全密钥 |
| DELETE | /api/v1/security/webauthn/credentials/{id} |
撤销安全密钥 |
| POST | /api/v1/recovery/start |
开始忘记密码流程 |
| POST | /api/v1/recovery/question |
验证密保答案 |
| POST | /api/v1/recovery/code |
使用离线恢复码 |
| POST | /api/v1/recovery/webauthn/options |
获取恢复 Challenge |
| POST | /api/v1/recovery/webauthn/verify |
验证安全密钥 |
| POST | /api/v1/recovery/email/start |
创建外部安全邮箱恢复验证码任务并执行 SMTP 投递 |
| POST | /api/v1/recovery/email/verify |
验证并原子消费外部安全邮箱恢复验证码 |
| POST | /api/v1/recovery/password |
使用一次性票据设置新密码 |
| GET | /api/v1/security/external-emails |
获取本人外部邮箱脱敏列表和验证状态 |
| POST | /api/v1/security/external-emails |
发起外部邮箱绑定验证 |
| POST | /api/v1/security/external-emails/{id}/verify |
验证并完成外部邮箱绑定或变更 |
| DELETE | /api/v1/security/external-emails/{id} |
删除本人外部邮箱并使关联任务失效 |
| POST | /api/v1/auth/email-challenge/resend |
重发登录邮箱二次验证码 |
| POST | /api/v1/auth/email-challenge/verify |
验证登录邮箱二次验证码并升级受限会话 |
所有 /api/v1/recovery/* 和 /api/v1/security/recovery-codes* 接口在识别到目标角色为 super_admin 时统一返回不可枚举的拒绝结果,不创建恢复会话或恢复码。WebAuthn 注册接口可以供超级管理员用于近期重新认证,但其凭据不得进入超级管理员 Web 忘记密码流程。部署端专用重置通道使用独立本机 IPC 契约,不属于 /api/v1 Web API。
外部安全邮箱验证码接口由独立 external_email_recovery 功能开关控制并默认关闭。功能关闭、模块未通过验收、配置不完整或投递不可用时统一返回稳定且防枚举的失败结果,不得创建可用验证码、恢复会话或密码重置票据,也不得绕过其他恢复路径。功能启用后,验证码仍不得单独批准数据库迁移、配置回滚、角色变更等高风险操作。
23.5 用户管理接口
跨用户重置密码使用:
POST /api/v1/users/{userId}/password-reset
服务端必须执行操作者与目标角色矩阵:超级管理员可重置管理员和普通用户,管理员仅可重置普通用户,普通用户不得调用跨用户接口。接口必须设置 mustChangePassword,并同时撤销目标用户全部 WebAuthn 凭据、全部旧离线恢复码、全部登录 Session、未完成恢复流程和密码重置票据,递增 Session、Security 和 Credential 版本。下次登录必须要求生成新恢复码并提示重新绑定 WebAuthn;若已存在经过验证的外部安全邮箱,则发送安全通知。任何撤销或审计步骤失败时,密码重置不得部分成功。角色变更使用独立接口和数据库事务,不能复用通用用户更新 DTO。
24. 数据库表和迁移设计
24.1 迁移批次
建议按以下顺序执行显式迁移:
| 版本 | 内容 | 回退重点 |
|---|---|---|
001 |
创建 schema_migration |
仅移除空迁移表 |
002 |
为 user 增加角色和安全字段 |
保留 is_admin 兼容列 |
003 |
完善 user_email 登录地址和唯一索引 |
先检测冲突再建索引 |
004 |
创建密保、恢复码、WebAuthn、重置流程、外部安全邮箱及验证码非敏感运营数据正式表 | 新表为空时可回退 |
005 |
创建插件用户设置和插件 Secret 表 | 不影响旧插件配置,Secret 迁移后不回写文件 |
006 |
创建配置审计、安全审计和数据库名称迁移操作表 | 审计表只追加,迁移表保留阶段记录 |
007 |
切换授权读路径到 role |
提供兼容开关回退 |
008 |
停止写入 is_admin |
删除列需另行审批 |
24.2 角色迁移
映射规则:
初始化创建或最早的有效管理员 → super_admin
其他 is_admin = 1 的用户 → admin
is_admin = 0 的用户 → user
如果无法唯一确定首个超级管理员,迁移必须停止并要求部署人员明确选择,不能静默把多个账户全部设为超级管理员。
24.3 关键索引和约束
至少建立:
UNIQUE user(account)
INDEX user(role, disabled)
UNIQUE user_email(address_canonical)
INDEX user_email(user_id, is_primary)
UNIQUE recovery_code(user_id, set_id, code_index)
UNIQUE webauthn_credential(credential_id)
UNIQUE plugin_user_setting(plugin_id, user_id)
UNIQUE plugin_secret(plugin_id, field_name)
UNIQUE external_recovery_email(user_id)
INDEX password_reset(user_id, stage, expires_at)
INDEX email_verification_event(user_id, purpose, result, created_at)
INDEX email_delivery_event(user_id, purpose, result, created_at)
INDEX config_audit(created_at, actor_user_id)
INDEX security_audit(created_at, user_id, event_type)
INDEX database_name_migration(status, created_at)
外键行为必须显式定义。用户停用不能级联删除邮件、安全凭据或审计;用户删除是否支持以及保留策略需要单独评审。
plugin_secret 只能保存带版本的密文、密钥版本和完整性校验元数据,主加密密钥不得进入该表或同一数据库。external_recovery_email、email_verification_event 和 email_delivery_event 属于本轮正式实现的数据结构;事件表只能保存不具备验证码验证能力的限流、投递结果、验证结果、脱敏统计和审计关联数据,禁止保存验证码、验证码校验值、内存任务 challenge_id、进程临时密钥或邮件正文。验证码功能默认关闭仅用于完成安全验收后的受控启用,不得缩减服务、SMTP、登录二次验证、密码恢复、管理页面、运营数据和定期清理的实现范围。database_name_migration 记录源目标名称、阶段、校验摘要、失败原因码和关联审计 ID,不记录数据库密码、完整连接串或复制数据正文。
24.4 跨数据库兼容
迁移 SQL 需要同时覆盖 MySQL、PostgreSQL 和 SQLite:
- 布尔字段使用统一服务层语义,不依赖数据库隐式转换;
- 时间统一存储 UTC;
- JSON 字段优先存储为受验证文本,避免三种数据库行为差异影响安全;
- 条件唯一索引无法统一时,由迁移器生成数据库专属语句;
- SQLite 迁移需要考虑重建表和单连接事务;
- 每种数据库都必须验证并发恢复码消费和最后一个超级管理员保护。
25. 前端页面和状态管理
25.1 页面结构
在当前 frontend/src/components/HomeHeader.vue 设置抽屉基础上,逐步拆分为路由或独立组件:
系统设置
├── 配置状态
├── 基础设置
├── 数据库名称迁移
├── SMTP 设置
├── 邮箱策略
├── SSL 设置
├── 插件管理
├── 用户管理
├── 配置审计
└── 账户安全
├── 登录密码
├── 密保问题
├── 离线恢复码
├── WebAuthn 安全密钥
├── 外部安全邮箱
└── 登录会话
账户安全页面必须按角色裁剪:管理员和普通用户可以使用 Web 密码修改、密保问答和离线恢复码;超级管理员的“登录密码”仅显示“请使用部署主机本地维护通道”的说明,不发送 Web 改密请求。WebAuthn 可由三种角色自行管理,但超级管理员凭据只用于登录后二次确认,不进入 Web 忘记密码流程。
数据库名称迁移、配置审计、配置回滚和“重启 Smail”按钮只向超级管理员展示,并仍由后端能力校验。外部安全邮箱板块必须提供脱敏列表、绑定、验证、变更、删除、登录二次验证策略和安全通知状态;功能未启用时明确显示关闭原因,不得伪造发送成功。
25.2 前端状态
建议增加:
frontend/src/stores/useAuthStore.js
frontend/src/stores/useConfigStore.js
frontend/src/stores/useSecurityStore.js
frontend/src/stores/usePluginStore.js
状态原则:
useAuthStore保存后端返回的角色和能力,不自行推导权限;- Secret 明文不进入 Pinia 持久化、LocalStorage 或 URL;
- 配置编辑表单保存
generation,冲突时提示重新加载; - 离线恢复码明文只存在一次性页面内存中,页面离开后清理;
- WebAuthn Challenge 不持久化;
- 配置审计列表只保存当前筛选条件和脱敏摘要,不在前端缓存完整导出文件;
- 数据库迁移和重启页面只保存任务 ID、配置代次和脱敏状态,不保存数据库凭据;
- 全局错误拦截不得把请求体或 Secret 输出到控制台。
25.3 登录页面
- 输入框文案为“用户名或邮箱”;
- 请求字段使用
identifier; - 错误统一显示“用户名、邮箱或密码错误”;
- 提供“忘记密码”入口,并静态说明超级管理员需使用部署主机维护方式;后端不得通过差异响应暴露输入标识是否属于超级管理员;
- 登录后根据
mustChangePassword和showRecoverySetupPrompt决定安全引导; - 不在前端根据是否包含
@截断或拼接账号。
25.4 离线恢复码弹窗
该弹窗只适用于管理员和普通用户。超级管理员登录后不显示密码恢复码弹窗。
弹窗状态至少包括:
需要设置
正在生成
一次性展示
已确认保存
生成失败
一次性展示页提供:
- 中文安全提示;
- 复制全部恢复码;
- 浏览器本地下载;
- 已保存确认框;
- 剩余恢复码数量入口;
- 重新生成会使旧代码失效的明显警告。
25.5 插件页面
- 超级管理员看到启用状态、全局设置、健康状态和用户设置管理;
- 管理员只看到允许使用的插件和普通用户设置管理;
- 普通用户只看到自己可用的插件和个人设置;
- Secret 输入框显示“已配置”,不回填原值;
- 保存使用
keep、replace、clear明确操作; - 插件测试结果必须脱敏并使用中文说明;
- 超级管理员批量修改前必须查看受影响人数、字段和覆盖策略预览,并完成近期重新认证;
- 批量结果逐用户显示成功、跳过、冲突或失败,不在前端自行重试冲突目标。
25.6 可访问性和浏览器验证
- 弹窗和表单支持键盘操作与焦点管理;
- 密码和恢复码操作有屏幕阅读器标签;
- 错误与输入项关联,不只依赖颜色;
- 在桌面、低高度和移动视口验证无遮挡、无横向溢出;
- WebAuthn 不可用时给出可理解的兼容提示,不自动降级到弱恢复方式。
26. 审计设计
26.1 配置审计表
config_audit
├── id
├── actor_user_id
├── actor_account_snapshot
├── actor_role
├── source_ip_masked
├── source_ip_hash
├── session_ref
├── action
├── section
├── target_type
├── target_id
├── changed_fields
├── diff_summary
├── from_generation
├── to_generation
├── validation_result
├── apply_result
├── restart_required
├── restart_status
├── rollback_of_audit_id
├── request_id
└── created_at
id、actor_user_id 和 request_id 使用不可变标识。页面显示操作者账号快照、角色、脱敏 IP、截断后的 Session 引用、配置分区、目标、结果、配置代次、重启状态和脱敏差异摘要。账号后续改名不能改变历史审计中的操作者快照。
差异摘要规则:
- 普通非敏感字段可以展示规范化后的旧值和新值;
- 密码、Token、Secret、API Key、私钥和密保材料只展示“已设置、已修改、已清除”;
- 数据库字段可以展示类型、脱敏主机、端口和数据库名称,不展示密码或完整连接串;
- 批量插件设置只展示字段路径、目标角色范围、成功数、跳过数、冲突数和失败数;
- 回滚记录必须关联原审计 ID,同时明确本次回滚生成的新配置代次;
- 导出内容执行与页面相同或更严格的脱敏规则。
26.2 安全审计表
security_audit
├── id
├── user_id
├── actor_user_id
├── event_type
├── result
├── source_ip_hash
├── user_agent_hash
├── request_id
├── metadata
└── created_at
事件至少包括:
login_succeeded
login_failed
password_changed
password_reset_by_super_admin
recovery_question_changed
recovery_code_generated
recovery_code_consumed
recovery_code_revoked
webauthn_registered
webauthn_used
webauthn_revoked
role_changed
plugin_config_changed
plugin_user_setting_changed
plugin_user_setting_batch_changed
local_super_admin_password_reset
database_name_migration_started
database_name_migration_succeeded
database_name_migration_failed
config_rollback_previewed
config_rolled_back
config_audit_exported
system_restart_requested
system_restart_succeeded
system_restart_failed
external_email_recovery_blocked
26.3 脱敏规则
不得记录:
- 密码、密保答案和恢复码;
- Session、CSRF、恢复流程和重置票据原文;
- 数据库完整 DSN;
- SMTP 密码、Token、Secret;
- SSL 私钥、WebAuthn 私有材料;
- 插件完整配置正文;
- 未经脱敏的第三方错误响应。
changed_fields 只记录字段路径,例如 plugins.telegram_push.secrets.botToken。允许展示的旧值和新值只能进入经过字段分类器处理的 diff_summary,不得由 Controller 或前端直接拼装。
26.4 审计可靠性
- 高风险写操作的业务事务与审计写入应保持一致;
- 审计写入失败时,高风险操作默认失败;
- 登录失败等高频事件可异步写入,但必须有容量限制和丢弃告警;
- 审计记录只追加,普通 Web API 不允许修改或删除单条记录;
- 配置审计至少保留 365 天,到期归档或清理由独立受控后台任务执行并记录管理事件;
- CSV 和 JSON 导出只允许超级管理员在近期重新认证后执行,导出行为本身必须写审计;
- 回滚前必须展示脱敏预览并要求近期重新认证,提交时校验一次性预览票据、当前代次和目标代次;
- 回滚通过正常配置事务生成新代次,不能覆盖或删除历史代次;
- 密码、角色、已消费恢复码、已撤销 WebAuthn、已执行数据库写入和数据库名称迁移结果不能一键回滚;
- 审计存储不可用时,数据库迁移、角色变更、密码重置、配置回滚、插件全局配置和受控重启默认拒绝执行。
27. 错误模型
27.1 统一响应
统一错误对象包含:
success: false
error.code: 稳定英文错误码
error.message: 中文公开提示
error.requestId: 可公开请求标识
error.fields: 可选字段错误列表
字段错误只返回字段路径和中文说明,不回显输入值。
27.2 错误码
AUTH_INVALID_CREDENTIALS
AUTH_RATE_LIMITED
AUTH_REAUTH_REQUIRED
ACCESS_DENIED
RECOVERY_CODE_REQUIRED
CONFIG_INVALID
CONFIG_CONFLICT
CONFIG_APPLY_FAILED
CONFIG_ROLLBACK_FAILED
PLUGIN_UNAVAILABLE
RECOVERY_INVALID
RECOVERY_EXPIRED
RECOVERY_LOCKED
WEBAUTHN_INVALID
MAINTENANCE_DISABLED
公开消息使用中文。日志可以记录内部错误链,但必须先移除 Secret、绝对路径和第三方响应正文。
27.3 HTTP 状态
| 场景 | 状态码 |
|---|---|
| 请求格式错误 | 400 |
| 未登录 | 401 |
| 权限不足 | 403 |
| 资源不存在或为防枚举隐藏 | 404 或统一流程响应 |
| 代次冲突 | 409 |
| 请求过大 | 413 |
| 限流 | 429 |
| 语义验证失败 | 422 |
| 服务暂不可用 | 503 |
28. 安全控制清单
28.1 配置和凭据
- 严格 JSON 和重复键拒绝;
- 固定分区文件名和受控路径;
- 原子写入、事务日志、摘要和代次校验;
- Secret 统一加密、统一脱敏、统一更新语义;
- 不使用旧算法回退;
- 配置查询不返回 Secret 原文;
- 连接测试具有 SSRF 防护和超时;
- 配置变更需要能力授权、CSRF 和审计;
- 数据库使用结构化字段,DSN 只允许在连接工厂内存中临时构造;
- 数据库名称迁移仅限超级管理员、同类型同服务器同账户,并保留旧数据库;
- 配置审计追加写入、至少保留 365 天,导出和回滚同样审计;
- 受控重启不接受任意命令、参数或服务名。
28.2 账户和恢复
- 用户名和邮箱唯一、无歧义解析;
- Web、SMTP、IMAP、POP3 统一认证;
- Argon2id 和虚假哈希耗时保护;
- 多维限流和统一错误;
- 密保答案不可逆存储;
- 恢复必须为密保问答加离线恢复码或 WebAuthn;
- 恢复码单次展示、单次消费、并发安全;
- WebAuthn 校验 Challenge、Origin、RP ID、签名和用户归属;
- 密码和安全设置变化撤销 Session;
- 超级管理员仅通过本地维护通道恢复密码;
- 超级管理员重置其他用户密码时撤销 WebAuthn、恢复码、Session、恢复流程和票据;
- 外部安全邮箱验证码默认关闭,未通过完整安全验收时不能形成可用恢复路径;默认关闭仅用于受控启停,不得缩减本轮实现范围。
28.3 插件
- 固定插件逻辑 ID 和可执行文件映射;
- 插件只获取自身配置;
- 插件不读取主配置、DSN 或主数据库;
- 插件 Secret 加密存入数据库,主密钥与数据库分离;
- 首版强制校验目标主机、端口和协议白名单;
- Web Push 作为受控插件能力,不允许插件直接读取订阅数据库;
- 进程、文件、网络和 RPC 隔离;
- 配置和响应大小限制;
- 插件失败隔离;
- 用户设置执行对象级授权;
- 逐步淘汰插件自带 HTML 设置页面。
28.4 管理界面
- 后端返回能力列表;
- 前端不缓存 Secret;
- 高风险操作二次确认;
- 恢复码页面禁止缓存;
- WebAuthn 不可用时不弱化恢复要求;
- 审计页面按角色裁剪;
- 生产环境要求 HTTPS。
29. 测试设计
29.1 单元测试
| 模块 | 必测内容 |
|---|---|
| 严格解析器 | 未知字段、重复键、BOM、尾随内容、超限、无效 UTF-8 |
| 配置验证器 | 端口冲突、路径逃逸、枚举、跨分区依赖 |
| 配置事务 | 代次冲突、各阶段崩溃恢复、备份校验、回滚 |
| Secret | keep/replace/clear、密文篡改、密钥版本、日志脱敏 |
| 数据库配置 | 三种类型结构化验证、内存 DSN、自动建库建表、SQLite 受控路径 |
| 数据库名称迁移 | 权限、维护锁、断点复制、校验、原子切换、失败保留旧库 |
| 标识解析 | 用户名、完整邮箱、重复绑定、域名大小写、@ 截断回归 |
| 密码 | Argon2id、虚假哈希、策略、参数升级 |
| 授权 | 角色能力、目标用户角色、最后一个超级管理员保护 |
| 密保问答 | 规范化、哈希、限流、单因素绕过 |
| 恢复码 | 随机性边界、哈希、单次消费、并发消费、整组撤销 |
| WebAuthn | Challenge、Origin、RP ID、签名、用户归属、重放 |
| 外部邮箱与验证码 | 仅内存任务、容量上限、HMAC、原子消费、重启失效、SMTP 投递、限流和定期清理 |
| 插件 | 配置裁剪、Secret 隔离、强制网络白名单、Web Push 能力、RPC 鉴权、超时、超限、Hook 注销 |
| 审计与重启 | 差异脱敏、追加写入、导出审计、回滚预览、固定服务标识 |
29.2 集成测试
- 从旧
config.json迁移到分区结构; - MySQL、PostgreSQL、SQLite 执行全部数据库迁移;
- 用户名和完整邮箱分别通过 Web、SMTP、IMAP、POP3 登录;
- 角色变更后旧 Session 立即失效;
- 管理员只能修改普通用户插件设置;
- 普通用户只能修改自己的插件设置;
- 插件配置更新失败时配置和运行时保持旧版本;
- 恢复码消费和重置票据在事务中保持一致;
- WebAuthn 注册和恢复完整流程;
new、bootstrap、ready、degraded服务矩阵;- MySQL 和 PostgreSQL 初始化自动建库建表,SQLite 创建受控本地库;
- 数据库名称迁移成功、复制失败、校验失败和重启失败场景均继续保留旧数据库;
- 配置审计筛选、脱敏导出、回滚预览和回滚生成新代次;
- Windows Service 与 systemd 适配器只重启固定 Smail 服务;
- 外部安全邮箱验证码关闭时始终不能选择路径 B;启用时完整验证绑定、登录二次验证、普通用户恢复和管理员组合恢复;
- 超级管理员和管理员跨用户重置均满足目标角色矩阵、凭据撤销和审计要求。
29.3 安全测试
- 越权访问和 IDOR;
- CSRF、跨源请求和 Session 固定;
- 登录、密保、恢复码和 WebAuthn 防枚举;
- 多维限流绕过;
- JSON 重复键和请求字段歧义;
- 配置路径穿越、符号链接和竞态替换;
- Secret 在 API、日志、审计、前端状态和崩溃信息中的泄露;
- 插件读取主配置、主数据库和其他插件配置的隔离验证;
- 插件 SSRF、超大响应、无限等待和异常退出;
- 最后一个超级管理员竞态保护;
- 本地维护接口网络暴露检查;
- 旧算法和弱恢复方式降级绕过;
- 数据库名称迁移越权、跨服务器参数注入和维护模式绕过;
- 外部邮箱验证码内存泄露、容量耗尽、重放、跨用途消费、功能开关和投递失败降级绕过;
- 配置审计导出泄密、审计删除、差异摘要注入和回滚越界;
- 受控重启命令注入、服务名替换和重放;
- 插件网络白名单绕过、DNS 重绑定、跨白名单重定向和私网访问。
29.4 前端验证
- 三种角色分别登录并检查菜单和操作;
- 直接调用隐藏接口仍被后端拒绝;
- 首次登录立即显示恢复码提示;
- 一次性展示离开后不能重新查看;
- WebAuthn 不支持、取消和失败状态有中文提示;
- 配置代次冲突可恢复;
- 桌面、低高度和移动视口无关键内容遮挡;
- 浏览器存储、网络日志和控制台不存在 Secret;
- 超级管理员可以查看完整脱敏审计字段、预览回滚并导出,其他角色不能访问;
- 数据库迁移和受控重启具有明确阶段、失败和恢复提示;
- 外部安全邮箱页面完成绑定、验证、变更、删除和脱敏展示;默认关闭时不会显示可用的验证码恢复入口;
- 登录邮箱二次验证、验证码重发和 WebAuthn 替代路径在真实浏览器中完成验证。
29.5 验收证据
每项关键任务至少保留:
- 测试命令和退出码;
- 关键测试用例名称;
- 数据库迁移前后版本;
- 页面截图或浏览器 DOM 验证结果;
- 脱敏后的配置代次和审计记录;
- 失败项、限制和未验证平台说明。
30. 部署、兼容和回退顺序
30.1 推荐实施顺序
建立显式数据库迁移
→ 增加 role 字段并双读验证
→ 实现 ConfigManager 和只读快照
→ 迁移 SSL 与插件分区
→ 迁移插件 Secret 到数据库并启用强制网络白名单
→ 迁移 SMTP 与邮箱策略分区
→ 实现能力授权和新 API
→ 升级登录标识解析和协议认证
→ 实现密保、恢复码和 WebAuthn
→ 实现外部安全邮箱、内存验证码、SMTP 投递、登录二次验证、密码恢复和定期清理
→ 改造三个插件及设置页面
→ 实现数据库名称迁移、配置审计和受控重启
→ 前端分角色页面与审计页面
→ 完整安全和跨平台复审
→ 停止旧接口写入
30.2 功能开关
迁移期只允许使用明确、短期、可审计的功能开关:
config_manager_v2
role_authorization_v2
identifier_login_v2
plugin_config_v2
account_recovery_v2
external_email_recovery
开关不能用于跳过授权、Secret 加密或安全校验。external_email_recovery 默认关闭,只有独立验证码模块的发送、投递、验证、限流、审计和故障处理全部验收后才能启用。生产切换后必须制定移除旧路径的任务。
30.3 回退原则
- 数据库迁移优先采用向前兼容列,不在同一版本立即删除旧列;
- 配置迁移保留旧文件只读备份;
- 新服务切换前验证旧版本是否能读取保留的兼容结构;
- 一旦创建新安全凭据,不得回退到明文或旧弱算法;
- 回退应用版本时,新安全表可以保留但不得被旧代码误读;
- 插件配置 V2 失败时停用插件,不允许把完整主配置重新交给插件;
- 任何需要删除文件、列或旧接口的步骤都必须另行获得用户许可。
30.4 平台验证
必须分别验证:
Windows 开发模式
Ubuntu 22.04
Ubuntu 24.04
MySQL
PostgreSQL
SQLite
插件进程隔离在不同平台能力不同。生产环境若无法提供最低隔离能力,应拒绝启动对应插件,而不是静默使用开发兼容模式。
31. 需求映射和确认决策
31.1 需求到设计映射
| 需求主题 | 技术设计章节 |
|---|---|
| 主入口和分区配置 | 第 5~10 章 |
| 配置迁移和状态机 | 第 11~12 章 |
| 三级角色 | 第 13~14 章 |
| 用户名或邮箱登录 | 第 15 章 |
| 密码修改与超级管理员维护 | 第 16 章 |
| 密保问答 | 第 17 章 |
| 离线恢复码 | 第 18 章 |
| WebAuthn | 第 19 章 |
| 忘记密码 | 第 20 章 |
| 插件配置与隔离 | 第 21~22 章 |
| API 和数据库 | 第 23~24 章 |
| 前端、审计和错误 | 第 25~27 章 |
| 安全、测试、部署 | 第 28~30 章 |
| 数据库名称在线迁移 | 第 11.7、23、24、29 章 |
| 受控服务重启 | 第 11.8、23、25、29~30 章 |
| 外部安全邮箱验证码完整业务 | 第 20、23~25、28~30 章及独立需求和实施清单 |
| 超级管理员配置审计页面 | 第 23、25~26、29 章 |
31.2 已确认的技术决策
本设计按已经确认的产品决定执行:
setupState替代isInit;- 数据库继续支持 MySQL、PostgreSQL 和 SQLite,主模型使用结构化字段,DSN 只在内存临时构造;
- 初始化只收集必要数据库连接字段,由后端自动创建数据库和表结构;
- 只有超级管理员可以在同类型、同服务器、同端口和同账户内迁移数据库名称,SQLite 不支持该在线迁移;
- 初始化继续配置主域名和 SSL,SSL 使用默认受控目录和自定义上传后托管双模式;
- SSL 成功前 HTTP 只允许初始化范围,成功后生产环境管理后台强制 HTTPS;
- SMTP Relay 纳入本轮配置系统重构;
- 外部安全邮箱验证码完整业务纳入本轮,验证码秘密仅存在于签发进程内存,功能开关默认关闭并在完整验收后受控启用;
- 超级管理员配置审计页面采用完整脱敏审计、导出和受控回滚范围,记录至少保留 365 天;
- 配置备份每 7 天创建一次,最长保留 30 天,最多 4 份,始终保留至少一个已验证有效版本;
- 需要重启时由超级管理员点击“重启 Smail”,后端仅调用固定 Windows Service 或 systemd 适配器;
- 密保答案采用固定的大小写不敏感规范化、Argon2id、独立盐和版本字段;
- 超级管理员可重置管理员和普通用户,管理员仅可重置普通用户;跨用户重置同时撤销 WebAuthn、恢复码、Session、恢复流程和票据;
- 重置后要求用户生成新恢复码、重新绑定 WebAuthn,并向已验证外部安全邮箱发送通知;
- 插件逻辑 ID 与可执行文件名分离;
- Telegram 用户设置保存在数据库;
- 插件 Token、Secret、API Key 等关键信息加密保存在数据库,主密钥与密文数据库分离;
- 插件首版强制执行目标主机、端口和协议白名单;
- Web Push 作为插件能力管理;
- 至少保留最近一个有效配置代次;
- WebAuthn 由用户自行选择启用,离线恢复码未配置时每次新登录立即提示;
- 当前可用恢复路径固定为密保问答加离线恢复码或 WebAuthn,不支持旧弱算法回退;
- 本人、跨用户和批量插件设置使用独立服务与 API,批量修改必须先预览;
- 插件启停和插件重启使用独立能力,管理员只能重启明确授权实例;
- 超级管理员不能通过 Web 修改或恢复密码,可以不配置离线恢复码;
- 超级管理员密码只能通过 Linux 固定入口
systemctl start smail-admin或 Windows 等价服务启动语义开启短时一次性专用重置通道,该通道使用受保护本机 IPC 和安全密码代理; - 管理员和普通用户未配置有效离线恢复码时,禁止其执行被标记为需要恢复保障的高风险操作;角色变更时检查适用的目标用户;
- 数据库名称迁移和配置回滚不要求超级管理员配置恢复码,但仍要求近期重新认证或 WebAuthn 二次确认、能力授权、审计和事务保护;
- 部署端重置命令、专用重置通道、Windows 和 Ubuntu 平台适配器及安全测试未完成前,不得通过最终验收。
31.3 已确认的恢复码例外和验收边界
- 超级管理员可以不配置离线恢复码,不显示恢复码提示,也不能通过 Web 找回密码;
- 管理员和普通用户未配置有效恢复码时,禁止其执行适用的高风险操作;
- 角色变更时,目标用户为管理员或普通用户的必须已经配置有效恢复码;
- 数据库名称迁移和配置回滚对超级管理员豁免恢复码检查,但不得放宽其他安全控制;
- 超级管理员部署端专用重置通道属于正式实施范围,未完成安全密码交互、本机 IPC 和跨平台验证前不得验收。
31.4 技术评审通过条件
- 模块边界和目录结构确认;
- 配置事务和崩溃恢复方案确认;
- 数据库迁移顺序确认;
- 三级角色能力矩阵确认;
- 统一登录标识和协议认证边界确认;
- 密保问答加离线恢复码、WebAuthn 或外部邮箱验证码流程确认;
- 插件 DTO、RPC 和设置页面迁移方案确认;
- 本地超级管理员维护接口、
systemctl start smail-admin和安全密码交互边界确认; - 完整验证码、测试和跨平台验收范围确认;
- 回退期间不恢复旧算法和不删除文件的原则确认。
技术评审已通过。后续按照 docs/配置系统重构实施任务清单.md 的 CG 父任务和 docs/超级管理员重置与邮箱验证码实施任务清单.md 的 AD 子任务分阶段实施、测试和复审;文档评审通过不代表任何编码任务已经完成。