This commit is contained in:
parent
20a6eea3b3
commit
f530ab0078
@ -71,6 +71,10 @@ func ValidateRuntimeConfig(runtime RuntimeConfig, options RuntimeValidationOptio
|
||||
if runtime.Root.HTTP.HTTPSEnabled && runtime.Root.HTTP.HTTPSPort == 0 {
|
||||
return ErrInvalidRuntimeConfig
|
||||
}
|
||||
// 在保存候选配置时拒绝无效的安全密钥来源,避免保存成功却在重启时失败。
|
||||
if _, err := ResolveWebAuthnSecurity(runtime.Root); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if options.Production && runtime.SetupState == SetupStateDegraded && !runtime.Root.HTTP.HTTPSEnabled && runtime.Root.HTTP.TLSMode != HTTPSTLSModeProxy {
|
||||
return ErrInvalidRuntimeConfig
|
||||
|
||||
72
backend/config/webauthn.go
Normal file
72
backend/config/webauthn.go
Normal file
@ -0,0 +1,72 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ResolveWebAuthnSecurity 为旧配置补足安全密钥站点信息,不修改持久化配置或用户开关。
|
||||
// 信赖方只能来自受控配置,不能根据请求 Host 或转发头自动扩展。
|
||||
func ResolveWebAuthnSecurity(root RootConfig) (SecurityConfig, error) {
|
||||
security := root.Security
|
||||
security.WebAuthnRPOrigins = append([]string(nil), security.WebAuthnRPOrigins...)
|
||||
web, err := normalizeConfigDomain(root.Domains.Web)
|
||||
if err != nil {
|
||||
return SecurityConfig{}, fmt.Errorf("安全密钥缺少有效的站点域名")
|
||||
}
|
||||
if security.WebAuthnRPID == "" {
|
||||
security.WebAuthnRPID = web
|
||||
}
|
||||
rpID, err := normalizeConfigDomain(security.WebAuthnRPID)
|
||||
if err != nil || rpID != security.WebAuthnRPID {
|
||||
return SecurityConfig{}, fmt.Errorf("安全密钥信赖方标识必须是规范域名")
|
||||
}
|
||||
if security.WebAuthnRPDisplayName == "" {
|
||||
security.WebAuthnRPDisplayName = "Smail"
|
||||
}
|
||||
if strings.TrimSpace(security.WebAuthnRPDisplayName) == "" {
|
||||
return SecurityConfig{}, fmt.Errorf("安全密钥站点显示名称不能为空")
|
||||
}
|
||||
if len(security.WebAuthnRPOrigins) == 0 {
|
||||
origin := "https://" + web
|
||||
// 反代的外部端口不等于内部 HTTP 端口,非标准外部端口需显式配置。
|
||||
if root.HTTP.TLSMode != HTTPSTLSModeProxy {
|
||||
port := ResolveHTTPPortDefaults(root.HTTP, 0).HTTPSPort
|
||||
if !validServicePort(port) {
|
||||
return SecurityConfig{}, fmt.Errorf("安全密钥站点 HTTPS 端口无效")
|
||||
}
|
||||
if port != 443 {
|
||||
origin += ":" + strconv.Itoa(port)
|
||||
}
|
||||
}
|
||||
security.WebAuthnRPOrigins = []string{origin}
|
||||
}
|
||||
for _, origin := range security.WebAuthnRPOrigins {
|
||||
parsed, err := url.Parse(origin)
|
||||
if err != nil || parsed.Hostname() == "" || parsed.User != nil || parsed.Opaque != "" ||
|
||||
parsed.Path != "" || parsed.RawQuery != "" || parsed.ForceQuery || parsed.Fragment != "" ||
|
||||
strings.ContainsAny(origin, "#?\\") {
|
||||
return SecurityConfig{}, fmt.Errorf("安全密钥来源必须是完整且不含路径的站点地址")
|
||||
}
|
||||
host := parsed.Hostname()
|
||||
normalizedHost, err := normalizeConfigDomain(host)
|
||||
if err != nil || normalizedHost != host || strings.HasSuffix(parsed.Host, ":") {
|
||||
return SecurityConfig{}, fmt.Errorf("安全密钥来源必须使用规范域名和有效端口")
|
||||
}
|
||||
if host != rpID && !strings.HasSuffix(host, "."+rpID) {
|
||||
return SecurityConfig{}, fmt.Errorf("安全密钥来源不属于配置信赖方")
|
||||
}
|
||||
if parsed.Scheme != "https" && !(parsed.Scheme == "http" && host == "localhost") {
|
||||
return SecurityConfig{}, fmt.Errorf("安全密钥来源必须使用 HTTPS,本机 localhost 除外")
|
||||
}
|
||||
if port := parsed.Port(); port != "" {
|
||||
number, err := strconv.Atoi(port)
|
||||
if err != nil || !validServicePort(number) {
|
||||
return SecurityConfig{}, fmt.Errorf("安全密钥来源端口无效")
|
||||
}
|
||||
}
|
||||
}
|
||||
return security, nil
|
||||
}
|
||||
@ -219,7 +219,7 @@ func TestDefaultMigrationChecksumsAreImmutable(t *testing.T) {
|
||||
13: "c51604f27347d0a1b93acad1e33af53463fceb7b6573960f881f95f29c5628ed",
|
||||
14: "531398c2b8fc83a65753fae08666946bb253ac3da6596829e71cc8ccc4ddf62f",
|
||||
15: "59f0ff9acfed3619c17f078d21a1362ddd5f1feacb9a2d77626f69040d25de65",
|
||||
16: "",
|
||||
16: "00cc275529e522d93b41e608692fb6e2271f1aa9300ebe020fcaed4d68d0ff76",
|
||||
}
|
||||
migrations := DefaultMigrations()
|
||||
if len(migrations) != len(expected) {
|
||||
|
||||
@ -356,7 +356,10 @@ func startWebAuthnRuntime() error {
|
||||
return nil
|
||||
}
|
||||
snapshot := manager.Snapshot()
|
||||
security := snapshot.Root.Security
|
||||
security, err := config.ResolveWebAuthnSecurity(snapshot.Root)
|
||||
if err != nil {
|
||||
return fmt.Errorf("加载安全密钥站点配置失败: %w", err)
|
||||
}
|
||||
service, err := webauthnservice.NewService(db.Instance, webauthnservice.Config{
|
||||
RPID: security.WebAuthnRPID,
|
||||
RPDisplayName: security.WebAuthnRPDisplayName,
|
||||
|
||||
@ -106,12 +106,16 @@ const baseGroups = [
|
||||
hint: '默认使用 443;需要避开端口占用时可手动修改。'
|
||||
})
|
||||
]),
|
||||
group('security', '全局安全选项', '只调整功能开关与站点信息,密码算法和加密密钥由系统维护。', [
|
||||
field('security.externalEmailEnabled', '启用外部邮箱功能', 'switch'),
|
||||
field('security.webAuthnEnabled', '启用通行密钥', 'switch'),
|
||||
field('security.webAuthnRpId', '通行密钥站点域名', 'text', { show: config => config.security?.webAuthnEnabled, placeholder: 'mail.example.com' }),
|
||||
field('security.webAuthnRpDisplayName', '通行密钥显示名称', 'text', { show: config => config.security?.webAuthnEnabled, placeholder: 'Smail' }),
|
||||
field('security.webAuthnRpOrigins', '通行密钥允许的来源', 'list', { show: config => config.security?.webAuthnEnabled, placeholder: 'https://mail.example.com' })
|
||||
group('security', '通行密钥站点信息', '外部邮箱与通行密钥服务默认可用,由用户在安全设置中自行选择。留空时使用当前站点的安全默认值;密码算法和加密密钥由系统维护。', [
|
||||
field('security.webAuthnRpId', '通行密钥站点域名', 'text', {
|
||||
placeholder: '默认使用网页访问域名',
|
||||
hint: '已绑定通行密钥后不要随意更换域名,否则原密钥可能无法使用。'
|
||||
}),
|
||||
field('security.webAuthnRpDisplayName', '通行密钥显示名称', 'text', { placeholder: 'Smail' }),
|
||||
field('security.webAuthnRpOrigins', '通行密钥允许的来源', 'list', {
|
||||
placeholder: 'https://mail.example.com',
|
||||
hint: '每项填写不含路径的完整 HTTPS 来源;反代使用非标准外部端口时,请显式填写端口。'
|
||||
})
|
||||
])
|
||||
]
|
||||
|
||||
|
||||
@ -3,7 +3,7 @@
|
||||
## 目录用途
|
||||
|
||||
- `frontend/`:前端自动测试及其构建路径约束测试,直接复用 `frontend/node_modules`。
|
||||
- `backend/`:集中保存邮件监听、账号协议隔离和凭据竞态回归,通过默认入口的 Go overlay 注入相应业务包;不复制测试文件到源码目录。
|
||||
- `backend/`:集中保存邮件监听、账号协议隔离、凭据竞态及 WebAuthn 站点配置和启动回归,通过默认入口的 Go overlay 注入相应业务包;不复制测试文件到源码目录。
|
||||
- `tools/password-check/`:离线密码哈希校验工具,只通过环境变量接收输入,不输出密码和哈希。
|
||||
- `manual/smtp/`:人工 SMTP 发信脚本,可能向外部发送真实邮件,不包含在自动测试中。运行前需自行检查收发件人和服务器配置。
|
||||
- `plugins/spam_block/`:垃圾邮件模型测试,需要额外安装 TensorFlow 并准备相应模型;不包含在默认测试中。
|
||||
|
||||
85
test/backend/webauthn_config_test.go
Normal file
85
test/backend/webauthn_config_test.go
Normal file
@ -0,0 +1,85 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestWebAuthnDefaultsUseConfiguredSiteOnly(t *testing.T) {
|
||||
t.Setenv("SMAIL_HTTPS_PORT", "")
|
||||
for _, item := range []struct {
|
||||
name string
|
||||
http HTTPConfig
|
||||
want string
|
||||
}{
|
||||
{"legacy", HTTPConfig{}, "https://mail.example.com"},
|
||||
{"direct", HTTPConfig{HTTPSPort: 9443, HTTPSEnabled: true}, "https://mail.example.com:9443"},
|
||||
{"proxy", HTTPConfig{Port: 8080, TLSMode: HTTPSTLSModeProxy}, "https://mail.example.com"},
|
||||
} {
|
||||
t.Run(item.name, func(t *testing.T) {
|
||||
root := RootConfig{Domains: DomainConfig{Web: "mail.example.com", AccessHosts: []string{"untrusted.example.net"}}, HTTP: item.http}
|
||||
got, err := ResolveWebAuthnSecurity(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.WebAuthnRPID != "mail.example.com" || got.WebAuthnRPDisplayName != "Smail" || !reflect.DeepEqual(got.WebAuthnRPOrigins, []string{item.want}) {
|
||||
t.Fatalf("站点默认值错误:%+v", got)
|
||||
}
|
||||
if root.Security.WebAuthnRPID != "" || len(root.Security.WebAuthnRPOrigins) != 0 {
|
||||
t.Fatal("解析默认值改写了原始配置")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebAuthnExplicitConfigurationIsPreserved(t *testing.T) {
|
||||
root := RootConfig{Domains: DomainConfig{Web: "mail.example.com"}, Security: SecurityConfig{
|
||||
WebAuthnRPID: "example.com", WebAuthnRPDisplayName: "企业邮箱", WebAuthnRPOrigins: []string{"https://mail.example.com:8443"},
|
||||
}}
|
||||
got, err := ResolveWebAuthnSecurity(root)
|
||||
if err != nil || !reflect.DeepEqual(got, root.Security) {
|
||||
t.Fatalf("显式配置被覆盖:%v", err)
|
||||
}
|
||||
got.WebAuthnRPOrigins[0] = "https://changed.example.com"
|
||||
if root.Security.WebAuthnRPOrigins[0] != "https://mail.example.com:8443" {
|
||||
t.Fatal("解析结果与原始配置共用来源切片")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebAuthnRejectsUnsafeConfiguredOrigins(t *testing.T) {
|
||||
for _, origin := range []string{
|
||||
"http://mail.example.com", "https://attacker.example.net", "https://badexample.com",
|
||||
"https://mail.example.com/path", "https://mail.example.com?", "https://mail.example.com#",
|
||||
"https://user@mail.example.com", "https://mail.example.com:65536", "https://*.example.com", "",
|
||||
"https://mail.example.com:", "https://mail.example.com:0", "https://mail.example.com:invalid",
|
||||
} {
|
||||
t.Run(origin, func(t *testing.T) {
|
||||
root := RootConfig{Domains: DomainConfig{Web: "mail.example.com"}, Security: SecurityConfig{
|
||||
WebAuthnRPID: "example.com", WebAuthnRPOrigins: []string{origin},
|
||||
}}
|
||||
if _, err := ResolveWebAuthnSecurity(root); err == nil {
|
||||
t.Fatal("不安全的显式来源未被拒绝")
|
||||
}
|
||||
})
|
||||
}
|
||||
if _, err := ResolveWebAuthnSecurity(RootConfig{}); err == nil {
|
||||
t.Fatal("缺少站点配置时不应猜测信赖方")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebAuthnDefaultsRespectExplicitLocalDevelopmentOrigin(t *testing.T) {
|
||||
root := RootConfig{Domains: DomainConfig{Web: "localhost"}, Security: SecurityConfig{
|
||||
WebAuthnRPOrigins: []string{"http://localhost:15173"},
|
||||
}}
|
||||
if _, err := ResolveWebAuthnSecurity(root); err != nil {
|
||||
t.Fatalf("显式 localhost 来源被拒绝:%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRuntimeValidationRejectsInvalidWebAuthnBeforeSaving(t *testing.T) {
|
||||
runtime := validRuntimeConfig()
|
||||
runtime.Root.Security.WebAuthnRPOrigins = []string{"https://attacker.example.net"}
|
||||
if err := ValidateRuntimeConfig(runtime, RuntimeValidationOptions{HasSuperAdmin: true, Production: true}); err == nil {
|
||||
t.Fatal("候选配置接受了不属于站点信赖方的来源")
|
||||
}
|
||||
}
|
||||
71
test/backend/webauthn_runtime_test.go
Normal file
71
test/backend/webauthn_runtime_test.go
Normal file
@ -0,0 +1,71 @@
|
||||
package res_init
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"git.pcux.cn/xuans/Smail/config"
|
||||
"git.pcux.cn/xuans/Smail/controllers"
|
||||
"git.pcux.cn/xuans/Smail/db"
|
||||
"git.pcux.cn/xuans/Smail/models"
|
||||
_ "modernc.org/sqlite"
|
||||
"xorm.io/xorm"
|
||||
)
|
||||
|
||||
func TestWebAuthnStartsWithMigratedSiteDefaults(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
previousRoot, previousDevelopment, previousDB := config.ROOT_PATH, config.DevelopmentMode, db.Instance
|
||||
previousManager, previousService := config.CurrentManager(), webAuthnRuntimeService
|
||||
config.ROOT_PATH, config.DevelopmentMode = root, false
|
||||
t.Cleanup(func() {
|
||||
stopConfigManager()
|
||||
config.SetRuntimeManager(previousManager)
|
||||
config.ROOT_PATH, config.DevelopmentMode, db.Instance = previousRoot, previousDevelopment, previousDB
|
||||
webAuthnRuntimeService = previousService
|
||||
controllers.ConfigureWebAuthnRuntime(previousService)
|
||||
})
|
||||
if err := os.MkdirAll(filepath.Join(root, "config"), 0700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
data, err := json.Marshal(map[string]any{
|
||||
"domain": "example.com", "webDomain": "mail.example.com", "dbType": "sqlite",
|
||||
"dbDSN": "./config/existing.db", "isInit": true, "httpsEnabled": 1, "httpsPort": 443,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(root, "config", "config.json"), data, 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := config.MigrateLegacyConfig(root, config.RuntimeValidationOptions{HasSuperAdmin: true, Production: true}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := startConfigManager(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
engine, err := xorm.NewEngine("sqlite", filepath.Join(root, "config", "existing.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer engine.Close()
|
||||
db.Instance = engine
|
||||
if err := engine.Sync2(&models.User{}, &models.WebAuthnCredential{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
user := models.User{Account: "test", Name: "测试用户", Password: "unused", Role: "user"}
|
||||
if _, err := engine.Insert(&user); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := startWebAuthnRuntime(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if webAuthnRuntimeService == nil {
|
||||
t.Fatal("未设置旧全局开关时安全密钥服务仍应可用")
|
||||
}
|
||||
options, _, err := webAuthnRuntimeService.BeginRegistration(user.ID)
|
||||
if err != nil || options.Response.RelyingParty.ID != "mail.example.com" {
|
||||
t.Fatalf("安全密钥注册未使用受控站点标识:%v", err)
|
||||
}
|
||||
}
|
||||
@ -10,6 +10,19 @@ const { readFormConfig, getFieldGroups, updateFormConfig, validateFormConfig } =
|
||||
source.replaceAll('export function ', 'function ') + '\nreturn { readFormConfig, getFieldGroups, updateFormConfig, validateFormConfig }'
|
||||
)()
|
||||
|
||||
test('站点安全配置不再依赖旧全局开关,用户权限仍由账号设置管理', () => {
|
||||
const config = { security: { webAuthnEnabled: false, externalEmailEnabled: false } }
|
||||
const groups = getFieldGroups('base', config)
|
||||
const security = groups.find(group => group.id === 'security')
|
||||
assert.ok(security)
|
||||
const keys = security.fields.map(field => field.path.join('.'))
|
||||
assert.deepEqual(keys, ['security.webAuthnRpId', 'security.webAuthnRpDisplayName', 'security.webAuthnRpOrigins'])
|
||||
const updated = readFormConfig(updateFormConfig(JSON.stringify(config), 'base', ['security', 'webAuthnRpId'], 'mail.example.com'))
|
||||
assert.equal(updated.security.webAuthnRpId, 'mail.example.com')
|
||||
assert.equal(updated.security.webAuthnEnabled, false)
|
||||
assert.equal(updated.security.externalEmailEnabled, false)
|
||||
})
|
||||
|
||||
test('七个页面使用分组表单,控件和样式能编译且不再提供 JSON 编辑器', () => {
|
||||
const require = createRequire(new URL('../../frontend/package.json', import.meta.url))
|
||||
const { parse, compileScript, compileStyle } = require('@vue/compiler-sfc')
|
||||
@ -167,7 +180,8 @@ test('基础配置隐藏额外访问主机、代理网段和数据库只读信
|
||||
assert.equal(updateFormConfig(text, 'base', key.split('.'), 'changed'), text)
|
||||
}
|
||||
for (const key of ['security.externalEmailEnabled', 'security.webAuthnEnabled']) {
|
||||
assert.ok(keys.includes(key))
|
||||
assert.ok(!keys.includes(key))
|
||||
assert.equal(updateFormConfig(text, 'base', key.split('.'), true), text)
|
||||
}
|
||||
const updated = readFormConfig(updateFormConfig(text, 'base', ['http', 'port'], 9080))
|
||||
assert.deepEqual(updated, { ...config, http: { ...config.http, port: 9080 } })
|
||||
@ -188,8 +202,10 @@ test('开关、数字、空数字和列表保持真实类型,代理 HTTPS 正
|
||||
text = updateFormConfig(text, 'base', ['http', 'tlsMode'], 'proxy')
|
||||
assert.deepEqual(readFormConfig(text).http, { tlsMode: 'proxy', httpsEnabled: false, httpsPort: 0, port: 8080 })
|
||||
assert.equal(updateFormConfig(text, 'base', ['http', 'httpsEnabled'], true), text)
|
||||
text = updateFormConfig(text, 'base', ['security', 'externalEmailEnabled'], true)
|
||||
assert.equal(readFormConfig(text).security.externalEmailEnabled, true)
|
||||
const direct = updateFormConfig(text, 'base', ['http', 'tlsMode'], 'direct')
|
||||
const disabled = readFormConfig(updateFormConfig(direct, 'base', ['http', 'httpsEnabled'], false))
|
||||
assert.equal(disabled.http.httpsEnabled, false)
|
||||
assert.equal(disabled.http.httpsPort, 0)
|
||||
text = updateFormConfig(text, 'base', ['http', 'port'], 8080)
|
||||
assert.equal(readFormConfig(text).http.port, 8080)
|
||||
text = updateFormConfig(text, 'base', ['http', 'port'], undefined)
|
||||
|
||||
@ -42,6 +42,8 @@ if (suite !== 'frontend' && result === 0) {
|
||||
const args = extra[0] === '--' ? extra.slice(1) : extra
|
||||
// 独立回归源码集中放在 test/backend,通过 overlay 保留同包测试语义。
|
||||
const replacements = {
|
||||
'backend/config/webauthn_defaults_test.go': 'test/backend/webauthn_config_test.go',
|
||||
'backend/res_init/webauthn_runtime_test.go': 'test/backend/webauthn_runtime_test.go',
|
||||
'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',
|
||||
|
||||
Loading…
Reference in New Issue
Block a user