This commit is contained in:
parent
74493f0d5b
commit
606861822b
@ -133,8 +133,8 @@ func CurrentWebDomain() string {
|
||||
return cfg.WebDomain
|
||||
}
|
||||
|
||||
// CurrentAdminRecoveryDomain 返回超级管理员本机恢复通道的部署标识域名。
|
||||
// 此值不用于提供公网重置路由;未配置时沿用主邮件域名。
|
||||
// CurrentAdminRecoveryDomain 仅用于读取旧配置。
|
||||
// 公网一次性恢复入口统一使用 CurrentWebDomain,不再使用独立恢复域名。
|
||||
func CurrentAdminRecoveryDomain() string {
|
||||
if manager := CurrentManager(); manager != nil {
|
||||
runtime := manager.Snapshot()
|
||||
|
||||
@ -192,9 +192,6 @@ func validationCertificateDomains(candidate *RuntimeConfig) []string {
|
||||
if candidate.Root.Domains.Web != "" {
|
||||
domains = append(domains, candidate.Root.Domains.Web)
|
||||
}
|
||||
if candidate.Root.Domains.AdminRecovery != "" {
|
||||
domains = append(domains, candidate.Root.Domains.AdminRecovery)
|
||||
}
|
||||
for _, domain := range candidate.Root.Domains.Accepted {
|
||||
domains = append(domains, "smtp."+domain, "imap."+domain, "pop."+domain)
|
||||
}
|
||||
@ -204,11 +201,11 @@ func validationCertificateDomains(candidate *RuntimeConfig) []string {
|
||||
}
|
||||
|
||||
func runtimeCertificateDomains(candidate *RuntimeConfig) []string {
|
||||
return SSLCertificateDomains(candidate.Root.Domains.Primary, candidate.Root.Domains.Web, candidate.Root.Domains.Accepted, candidate.Root.Domains.AdminRecovery)
|
||||
return SSLCertificateDomains(candidate.Root.Domains.Primary, candidate.Root.Domains.Web, candidate.Root.Domains.Accepted)
|
||||
}
|
||||
|
||||
// SSLCertificateDomains 统一初始化和配置管理所需的 Web、主域、邮件协议及恢复通道域名。
|
||||
func SSLCertificateDomains(primary, web string, accepted []string, adminRecovery ...string) []string {
|
||||
// SSLCertificateDomains 统一初始化和配置管理所需的 Web、主域及邮件协议域名。
|
||||
func SSLCertificateDomains(primary, web string, accepted []string) []string {
|
||||
seen := make(map[string]bool)
|
||||
var domains []string
|
||||
appendDomain := func(value string) {
|
||||
@ -220,9 +217,6 @@ func SSLCertificateDomains(primary, web string, accepted []string, adminRecovery
|
||||
}
|
||||
appendDomain(web)
|
||||
appendDomain(primary)
|
||||
for _, domain := range adminRecovery {
|
||||
appendDomain(domain)
|
||||
}
|
||||
for _, domain := range accepted {
|
||||
domain = strings.TrimSpace(domain)
|
||||
if domain == "" {
|
||||
|
||||
@ -191,9 +191,9 @@ func TestValidateSSLMaterialRejectsDomainExpiryAndWeakKeys(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSSLCertificateDomainsIncludesConfiguredAdminRecoveryDomain(t *testing.T) {
|
||||
domains := SSLCertificateDomains("example.com", "mail.example.com", []string{"example.com"}, "recovery.example.com")
|
||||
for _, want := range []string{"example.com", "mail.example.com", "recovery.example.com", "smtp.example.com", "imap.example.com", "pop.example.com"} {
|
||||
func TestSSLCertificateDomainsIncludesWebAndMailServiceDomains(t *testing.T) {
|
||||
domains := SSLCertificateDomains("example.com", "mail.example.com", []string{"example.com"})
|
||||
for _, want := range []string{"example.com", "mail.example.com", "smtp.example.com", "imap.example.com", "pop.example.com"} {
|
||||
found := false
|
||||
for _, got := range domains {
|
||||
if got == want {
|
||||
|
||||
@ -43,13 +43,13 @@ func PublicRecoveryEnabled() bool {
|
||||
return hasConfiguredRecoveryHost(hosts...)
|
||||
}
|
||||
|
||||
// PublicMailRecoveryEnabled 仅判断主邮件域名下匿名恢复链接入口是否可用。
|
||||
// PublicMailRecoveryEnabled 仅判断 Web 域名下匿名恢复链接入口是否可用。
|
||||
// 旧主站恢复流程仍使用 PublicRecoveryEnabled,以保持原有兼容行为。
|
||||
func PublicMailRecoveryEnabled() bool {
|
||||
if !config.IsSetupReady() || !config.CurrentEmailPolicy().ExternalRecoveryEnabled {
|
||||
return false
|
||||
}
|
||||
return hasConfiguredRecoveryHost(config.CurrentMailDomains().Primary)
|
||||
return hasConfiguredRecoveryHost(config.CurrentWebDomain())
|
||||
}
|
||||
|
||||
func hasConfiguredRecoveryHost(values ...string) bool {
|
||||
@ -655,7 +655,7 @@ func CompletePasswordRecovery(ctx *context.Context, w http.ResponseWriter, req *
|
||||
response.NewSuccessResponse(map[string]any{"reset": true}).FPrint(w)
|
||||
}
|
||||
|
||||
// BeginPublicLinkPasswordRecovery 是主邮件域名 /rest/ 的无会话恢复入口。
|
||||
// BeginPublicLinkPasswordRecovery 是 Web 域名 /rest/ 的无会话恢复入口。
|
||||
// 所有申请结果统一返回 accepted,避免泄露账户、邮箱绑定和角色状态。
|
||||
func BeginPublicLinkPasswordRecovery(w http.ResponseWriter, req *http.Request) {
|
||||
writePublicRecoveryJSON(w, http.StatusOK, map[string]bool{"accepted": true})
|
||||
|
||||
@ -121,7 +121,7 @@ func enablePublicRecoveryForTest(t *testing.T) {
|
||||
|
||||
func invokePublicLinkRequirements(t *testing.T, token string) (int, map[string]bool, http.Header) {
|
||||
t.Helper()
|
||||
request := httptest.NewRequest(http.MethodPost, "https://mail.example.com/rest/api/recovery/requirements", strings.NewReader(`{"token":"`+token+`"}`))
|
||||
request := httptest.NewRequest(http.MethodPost, "https://web.example.com/rest/api/recovery/requirements", strings.NewReader(`{"token":"`+token+`"}`))
|
||||
recorder := httptest.NewRecorder()
|
||||
PublicLinkPasswordRecoveryRequirements(recorder, request)
|
||||
result := map[string]bool{}
|
||||
|
||||
@ -53,7 +53,7 @@ func (h *publicRecoveryHandler) ServeHTTP(w http.ResponseWriter, r *http.Request
|
||||
return
|
||||
}
|
||||
|
||||
if !isPublicRecoveryMailHost(r) {
|
||||
if !isPublicRecoveryWebHost(r) {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
@ -133,7 +133,7 @@ func publicRecoveryOrSessionHandler(publicRecovery, mainSite http.Handler) http.
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
path := normalizedPublicRecoveryPath(r.URL.Path)
|
||||
if path == "/public-password-recovery" || isPublicRecoveryReservedPath(path) ||
|
||||
(isPublicRecoveryMailHost(r) && isPublicRecoveryAssetPath(path)) {
|
||||
(controllers.PublicMailRecoveryEnabled() && isPublicRecoveryWebHost(r) && isPublicRecoveryAssetPath(path)) {
|
||||
publicRecovery.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
@ -213,7 +213,7 @@ func isPublicRecoveryOrigin(value string) bool {
|
||||
if err != nil || u.User != nil || !strings.EqualFold(u.Scheme, "https") || u.Host == "" {
|
||||
return false
|
||||
}
|
||||
expectedHost, expectedPort, ok := configuredHTTPSAuthority(config.CurrentMailDomains().Primary)
|
||||
expectedHost, expectedPort, ok := configuredHTTPSAuthority(config.CurrentWebDomain())
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
@ -221,8 +221,8 @@ func isPublicRecoveryOrigin(value string) bool {
|
||||
return ok && originHost == expectedHost && originPort == expectedPort
|
||||
}
|
||||
|
||||
func isPublicRecoveryMailHost(r *http.Request) bool {
|
||||
return requestMatchesHTTPSAuthority(r, config.CurrentMailDomains().Primary)
|
||||
func isPublicRecoveryWebHost(r *http.Request) bool {
|
||||
return requestMatchesHTTPSAuthority(r, config.CurrentWebDomain())
|
||||
}
|
||||
|
||||
func isCurrentWebHost(r *http.Request) bool {
|
||||
@ -255,7 +255,7 @@ func currentHTTPSPort() int {
|
||||
}
|
||||
|
||||
func publicRecoveryBaseURL() (string, bool) {
|
||||
return configuredHTTPSURL(config.CurrentMailDomains().Primary, "/rest/", "")
|
||||
return configuredHTTPSURL(config.CurrentWebDomain(), "/rest/", "")
|
||||
}
|
||||
|
||||
func publicRecoveryLoginURL() (string, bool) {
|
||||
|
||||
@ -38,7 +38,7 @@ func publicRecoveryRequest(method, target string) *http.Request {
|
||||
return request
|
||||
}
|
||||
|
||||
func TestPublicRecoveryPageIsServedOnlyFromMailDomain(t *testing.T) {
|
||||
func TestPublicRecoveryPageIsServedOnlyFromWebDomain(t *testing.T) {
|
||||
configurePublicRecoveryRouteTest(t)
|
||||
handler, err := newPublicRecoveryHandler()
|
||||
if err != nil {
|
||||
@ -46,9 +46,9 @@ func TestPublicRecoveryPageIsServedOnlyFromMailDomain(t *testing.T) {
|
||||
}
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
handler.ServeHTTP(recorder, publicRecoveryRequest(http.MethodGet, "https://mx.example.com/rest/"))
|
||||
handler.ServeHTTP(recorder, publicRecoveryRequest(http.MethodGet, "https://web.example.com/rest/"))
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Fatalf("mail recovery page returned %d", recorder.Code)
|
||||
t.Fatalf("web recovery page returned %d", recorder.Code)
|
||||
}
|
||||
if !strings.Contains(recorder.Body.String(), `data-login-url="https://web.example.com/#/login"`) {
|
||||
t.Fatal("mail recovery page did not contain the configured web login URL")
|
||||
@ -58,9 +58,9 @@ func TestPublicRecoveryPageIsServedOnlyFromMailDomain(t *testing.T) {
|
||||
}
|
||||
|
||||
recorder = httptest.NewRecorder()
|
||||
handler.ServeHTTP(recorder, publicRecoveryRequest(http.MethodGet, "https://web.example.com/rest/"))
|
||||
handler.ServeHTTP(recorder, publicRecoveryRequest(http.MethodGet, "https://mx.example.com/rest/"))
|
||||
if recorder.Code != http.StatusNotFound {
|
||||
t.Fatalf("web domain recovery page returned %d, want 404", recorder.Code)
|
||||
t.Fatalf("mail domain recovery page returned %d, want 404", recorder.Code)
|
||||
}
|
||||
}
|
||||
|
||||
@ -84,7 +84,7 @@ func TestPublicRecoveryServesUnderscorePrefixedBuildAssets(t *testing.T) {
|
||||
}
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
request := publicRecoveryRequest(http.MethodGet, "https://mx.example.com/"+matches[0])
|
||||
request := publicRecoveryRequest(http.MethodGet, "https://web.example.com/"+matches[0])
|
||||
handler.ServeHTTP(recorder, request)
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Fatalf("underscore-prefixed asset returned %d, want %d", recorder.Code, http.StatusOK)
|
||||
@ -100,14 +100,14 @@ func TestPublicRecoveryPageRequiresConfiguredHTTPSPort(t *testing.T) {
|
||||
}
|
||||
|
||||
allowed := httptest.NewRecorder()
|
||||
handler.ServeHTTP(allowed, publicRecoveryRequest(http.MethodGet, "https://mx.example.com:8443/rest/"))
|
||||
handler.ServeHTTP(allowed, publicRecoveryRequest(http.MethodGet, "https://web.example.com:8443/rest/"))
|
||||
if allowed.Code != http.StatusOK {
|
||||
t.Fatalf("configured HTTPS port returned %d, want %d", allowed.Code, http.StatusOK)
|
||||
}
|
||||
|
||||
for _, target := range []string{
|
||||
"https://mx.example.com/rest/",
|
||||
"https://mx.example.com:9443/rest/",
|
||||
"https://web.example.com/rest/",
|
||||
"https://web.example.com:9443/rest/",
|
||||
} {
|
||||
t.Run(target, func(t *testing.T) {
|
||||
recorder := httptest.NewRecorder()
|
||||
@ -128,7 +128,7 @@ func TestPublicRecoveryLoginEntryRedirectsOnlyFromWebDomain(t *testing.T) {
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
handler.ServeHTTP(recorder, publicRecoveryRequest(http.MethodGet, "https://web.example.com/public-password-recovery"))
|
||||
if recorder.Code != http.StatusFound || recorder.Header().Get("Location") != "https://mx.example.com/rest/" {
|
||||
if recorder.Code != http.StatusFound || recorder.Header().Get("Location") != "https://web.example.com/rest/" {
|
||||
t.Fatalf("unexpected login entry redirect: status=%d location=%q", recorder.Code, recorder.Header().Get("Location"))
|
||||
}
|
||||
|
||||
@ -158,7 +158,7 @@ func TestPublicRecoveryAPIRejectsCookiesAndCrossSiteRequests(t *testing.T) {
|
||||
}
|
||||
for _, test := range cases {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
request := publicRecoveryRequest(http.MethodPost, "https://mx.example.com/rest/api/recovery/begin")
|
||||
request := publicRecoveryRequest(http.MethodPost, "https://web.example.com/rest/api/recovery/begin")
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
test.prepare(request)
|
||||
recorder := httptest.NewRecorder()
|
||||
@ -182,7 +182,7 @@ func TestPublicRecoveryAPIUsesNoMainSessionHandler(t *testing.T) {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
|
||||
request := publicRecoveryRequest(http.MethodPost, "https://mx.example.com/rest/api/recovery/begin")
|
||||
request := publicRecoveryRequest(http.MethodPost, "https://web.example.com/rest/api/recovery/begin")
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
recorder := httptest.NewRecorder()
|
||||
handler.ServeHTTP(recorder, request)
|
||||
@ -207,8 +207,56 @@ func TestPublicRecoveryStaticAssetsBypassMainSessionHandler(t *testing.T) {
|
||||
}))
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
handler.ServeHTTP(recorder, publicRecoveryRequest(http.MethodGet, "https://mx.example.com/smail-logo.png"))
|
||||
handler.ServeHTTP(recorder, publicRecoveryRequest(http.MethodGet, "https://web.example.com/smail-logo.png"))
|
||||
if recorder.Code != http.StatusOK || mainCalls != 0 {
|
||||
t.Fatalf("public recovery asset used main handler: status=%d calls=%d", recorder.Code, mainCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublicRecoveryDoesNotInterceptUnrelatedMainSitePaths(t *testing.T) {
|
||||
configurePublicRecoveryRouteTest(t)
|
||||
publicRecovery, err := newPublicRecoveryHandler()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mainCalls := 0
|
||||
handler := publicRecoveryOrSessionHandler(publicRecovery, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
mainCalls++
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
|
||||
for _, target := range []string{
|
||||
"https://web.example.com/",
|
||||
"https://web.example.com/api/v1/user/info",
|
||||
"https://mx.example.com/assets/main.js",
|
||||
} {
|
||||
recorder := httptest.NewRecorder()
|
||||
handler.ServeHTTP(recorder, publicRecoveryRequest(http.MethodGet, target))
|
||||
if recorder.Code != http.StatusNoContent {
|
||||
t.Fatalf("unrelated path %q returned %d, want %d", target, recorder.Code, http.StatusNoContent)
|
||||
}
|
||||
}
|
||||
if mainCalls != 3 {
|
||||
t.Fatalf("main handler calls = %d, want 3", mainCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDisabledPublicRecoveryAssetsUseMainSite(t *testing.T) {
|
||||
configurePublicRecoveryRouteTest(t)
|
||||
config.Instance.ExternalRecoveryEnabled = false
|
||||
publicRecovery, err := newPublicRecoveryHandler()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mainCalls := 0
|
||||
handler := publicRecoveryOrSessionHandler(publicRecovery, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
mainCalls++
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
handler.ServeHTTP(recorder, publicRecoveryRequest(http.MethodGet, "https://web.example.com/smail-logo.png"))
|
||||
if recorder.Code != http.StatusNoContent || mainCalls != 1 {
|
||||
t.Fatalf("disabled recovery intercepted main asset: status=%d calls=%d", recorder.Code, mainCalls)
|
||||
}
|
||||
}
|
||||
|
||||
@ -142,24 +142,25 @@ func deliverVerificationWithProjectMail(challenge verification.Challenge, code s
|
||||
// newPublicRecoveryLinkDelivery 固定使用项目自身邮件服务,不能走外部 SMTP 中继。
|
||||
func newPublicRecoveryLinkDelivery() recovery.PublicLinkDelivery {
|
||||
return func(email, token string) error {
|
||||
domain := strings.TrimSpace(config.CurrentMailDomains().Primary)
|
||||
if domain == "" || strings.TrimSpace(email) == "" || strings.TrimSpace(token) == "" {
|
||||
mailDomain := strings.TrimSpace(config.CurrentMailDomains().Primary)
|
||||
webDomain := strings.TrimSpace(config.CurrentWebDomain())
|
||||
if mailDomain == "" || webDomain == "" || strings.TrimSpace(email) == "" || strings.TrimSpace(token) == "" {
|
||||
return verification.ErrProjectMailUnavailable
|
||||
}
|
||||
link, err := publicRecoveryLink(domain, token)
|
||||
link, err := publicRecoveryLink(webDomain, token)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
messageID, err := parsemail.GenerateMsgID(domain)
|
||||
messageID, err := parsemail.GenerateMsgID(mailDomain)
|
||||
if err != nil {
|
||||
return fmt.Errorf("generate public recovery message id: %w", err)
|
||||
}
|
||||
sender := &parsemail.User{Name: "Smail 安全中心", EmailAddress: "no-reply@" + domain}
|
||||
sender := &parsemail.User{Name: "Smail 安全中心", EmailAddress: "no-reply@" + mailDomain}
|
||||
message := &parsemail.Email{
|
||||
From: sender, Sender: sender, To: []*parsemail.User{{EmailAddress: email}},
|
||||
Subject: "Smail 密码恢复链接",
|
||||
Text: []byte("您好,您正在恢复 Smail 密码。请在五分钟内打开以下一次性链接完成重置:\r\n" + link + "\r\n\r\n如果并非本人操作,请忽略此邮件。"),
|
||||
MsgID: messageID,
|
||||
Text: []byte("您好,您正在恢复 Smail 密码。请在五分钟内打开以下一次性链接完成重置:\r\n" + link + "\r\n\r\n如果并非本人操作,请忽略此邮件。"),
|
||||
MsgID: messageID,
|
||||
}
|
||||
ctx := &appcontext.Context{Context: context.Background(), Values: make(map[string]any)}
|
||||
if deliveryErr, _ := send.SendTriggered(ctx, message, "系统密码恢复链接"); deliveryErr != nil {
|
||||
@ -169,10 +170,10 @@ func newPublicRecoveryLinkDelivery() recovery.PublicLinkDelivery {
|
||||
}
|
||||
}
|
||||
|
||||
func publicRecoveryLink(domain, token string) (string, error) {
|
||||
parsed, err := url.Parse("https://" + strings.TrimSpace(domain))
|
||||
func publicRecoveryLink(webDomain, token string) (string, error) {
|
||||
parsed, err := url.Parse("https://" + strings.TrimSpace(webDomain))
|
||||
if err != nil || parsed.Hostname() == "" || parsed.User != nil || parsed.Path != "" || parsed.RawQuery != "" || parsed.Fragment != "" {
|
||||
return "", fmt.Errorf("invalid primary mail domain")
|
||||
return "", fmt.Errorf("invalid web domain")
|
||||
}
|
||||
if port := config.CurrentHTTPConfig().HTTPSPort; port > 0 && port != 443 && parsed.Port() == "" {
|
||||
parsed.Host = parsed.Hostname() + fmt.Sprintf(":%d", port)
|
||||
|
||||
@ -74,3 +74,16 @@ func TestVerificationDeliveryPreservesSettingsError(t *testing.T) {
|
||||
t.Fatalf("error = %v, want wrapped settings error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublicRecoveryLinkUsesWebDomain(t *testing.T) {
|
||||
link, err := publicRecoveryLink("web.example.com", "one-time-token")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if link != "https://web.example.com/rest/#/reset-password/one-time-token" {
|
||||
t.Fatalf("public recovery link = %q", link)
|
||||
}
|
||||
if _, err := publicRecoveryLink("https://web.example.com/path", "one-time-token"); err == nil {
|
||||
t.Fatal("invalid web domain was accepted")
|
||||
}
|
||||
}
|
||||
|
||||
@ -306,7 +306,7 @@ func sslDomains(cfg *config.Config) []string {
|
||||
if cfg == nil {
|
||||
return nil
|
||||
}
|
||||
return config.SSLCertificateDomains(cfg.Domain, cfg.WebDomain, cfg.Domains, cfg.AdminRecoveryDomain)
|
||||
return config.SSLCertificateDomains(cfg.Domain, cfg.WebDomain, cfg.Domains)
|
||||
}
|
||||
|
||||
func certificateMatchesConfiguredDomains(cert *x509.Certificate, cfg *config.Config) bool {
|
||||
@ -343,9 +343,6 @@ func validateWildcardConfiguration(cfg *config.Config) error {
|
||||
if cfg.WebDomain != "" && !covered(cfg.WebDomain) {
|
||||
return newUserCertificateError("Web 域名必须是主域名的一级子域名,泛域名证书不覆盖根域名或多级子域名")
|
||||
}
|
||||
if cfg.AdminRecoveryDomain != "" && !covered(cfg.AdminRecoveryDomain) {
|
||||
return newUserCertificateError("超级管理员恢复域名必须是主邮件域名的一级子域名,才能由 DNS 泛域名证书覆盖")
|
||||
}
|
||||
for _, domain := range cfg.Domains {
|
||||
if !covered("smtp."+domain) || !covered("imap."+domain) || !covered("pop."+domain) {
|
||||
return newUserCertificateError("附加邮箱域名的服务地址不在主域泛域名证书覆盖范围内")
|
||||
|
||||
@ -52,18 +52,17 @@ func TestWildcardCertificateDomains(t *testing.T) {
|
||||
|
||||
func TestCertificateDomainsFollowChallengeMode(t *testing.T) {
|
||||
cfg := &config.Config{
|
||||
Domain: "xuans.com",
|
||||
WebDomain: "web.xuans.com",
|
||||
AdminRecoveryDomain: "recovery.xuans.com",
|
||||
Domains: []string{"xuans.com"},
|
||||
Domain: "xuans.com",
|
||||
WebDomain: "web.xuans.com",
|
||||
Domains: []string{"xuans.com"},
|
||||
}
|
||||
|
||||
cfg.SSLType = config.SSLTypeAutoHTTP
|
||||
httpDomains := certificateDomains(cfg)
|
||||
if len(httpDomains) != 6 {
|
||||
t.Fatalf("got %d HTTP domains, want 6: %v", len(httpDomains), httpDomains)
|
||||
if len(httpDomains) != 5 {
|
||||
t.Fatalf("got %d HTTP domains, want 5: %v", len(httpDomains), httpDomains)
|
||||
}
|
||||
if httpDomains[0] != "web.xuans.com" || httpDomains[1] != "xuans.com" || httpDomains[2] != "recovery.xuans.com" {
|
||||
if httpDomains[0] != "web.xuans.com" || httpDomains[1] != "xuans.com" || httpDomains[2] != "smtp.xuans.com" {
|
||||
t.Fatalf("unexpected HTTP domains: %v", httpDomains)
|
||||
}
|
||||
|
||||
@ -74,7 +73,7 @@ func TestCertificateDomainsFollowChallengeMode(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDNSWildcardRejectsUncoveredAdminRecoveryDomain(t *testing.T) {
|
||||
func TestDNSWildcardIgnoresLegacyAdminRecoveryDomain(t *testing.T) {
|
||||
base := &config.Config{
|
||||
Domain: "example.com",
|
||||
WebDomain: "mail.example.com",
|
||||
@ -83,13 +82,13 @@ func TestDNSWildcardRejectsUncoveredAdminRecoveryDomain(t *testing.T) {
|
||||
AdminRecoveryDomain: "recovery.example.com",
|
||||
}
|
||||
if err := validateWildcardConfiguration(base); err != nil {
|
||||
t.Fatalf("covered recovery domain was rejected: %v", err)
|
||||
t.Fatalf("legacy recovery domain changed wildcard validation: %v", err)
|
||||
}
|
||||
for _, value := range []string{"example.com", "a.recovery.example.com", "recovery.other.example"} {
|
||||
candidate := *base
|
||||
candidate.AdminRecoveryDomain = value
|
||||
if err := validateWildcardConfiguration(&candidate); err == nil {
|
||||
t.Fatalf("uncovered recovery domain %q was accepted", value)
|
||||
if err := validateWildcardConfiguration(&candidate); err != nil {
|
||||
t.Fatalf("legacy recovery domain %q changed wildcard validation: %v", value, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -90,15 +90,10 @@ export function readDevelopmentRuntime(root = runtimeRoot, { previous = false }
|
||||
if (typeof webDomain !== 'string' || !/^[a-z0-9.-]+(?::\d+)?$/i.test(webDomain)) {
|
||||
throw new Error('请先在 yunx-test/config/config.json 配置有效的 Web 域名')
|
||||
}
|
||||
const mailDomain = config.domains?.primary || webDomain
|
||||
if (typeof mailDomain !== 'string' || !/^[a-z0-9.-]+(?::\d+)?$/i.test(mailDomain)) {
|
||||
throw new Error('请先在 yunx-test/config/config.json 配置有效的主邮件域名')
|
||||
}
|
||||
const certificatePath = resolveDevelopmentCertificate(root, previous ? ssl.tls?.previousCertificateRef : ssl.tls?.certificateRef)
|
||||
const privateKeyPath = resolveDevelopmentCertificate(root, previous ? ssl.tls?.previousPrivateKeyRef : ssl.tls?.privateKeyRef, true)
|
||||
return {
|
||||
origin: `https://${webDomain}`,
|
||||
mailOrigin: `https://${mailDomain}`,
|
||||
certificatePath,
|
||||
privateKeyPath,
|
||||
generation: config.generation
|
||||
|
||||
@ -5,7 +5,7 @@
|
||||
- [~] 管理员、普通用户通过已验证外部邮箱及离线安全凭据恢复密码。
|
||||
- [~] 超级管理员登录后通过外部安全邮箱验证码修改密码。
|
||||
- [~] 超级管理员通过部署端受保护本机通道重置密码。
|
||||
- [!] 独立恢复域名与邮箱主域名联动:待确认是恢复指引入口还是新增网页重置入口,不能在确认前把本机通道暴露到公网。
|
||||
- [x] 管理员、普通用户的一次性恢复页面复用现有 Web 域名和 HTTPS 证书,固定入口为 `https://Web域名/rest/`。
|
||||
|
||||
## 当前实现边界
|
||||
|
||||
@ -13,6 +13,8 @@
|
||||
- 管理员邮箱恢复通过后仍需要未使用的离线恢复码。
|
||||
- 离线恢复需要密保问题、密保答案和离线恢复码,不是只填写一个安全码。
|
||||
- 超级管理员不能通过公开忘记密码接口恢复。登录后安全中心使用已验证外部邮箱验证码改密。
|
||||
- 公开恢复邮件由 `no-reply@主邮件域名` 发送,邮件中的一次性链接使用 Web 域名,不再配置独立恢复域名或额外证书。
|
||||
- 旧 `adminRecovery` 配置仅保留兼容读取,不在配置页面展示,也不参与恢复路由和证书域名计算。
|
||||
- 改密成功会使旧会话和相关恢复、客户端凭据失效;测试后需要重新配置下一轮所需凭据。
|
||||
|
||||
## Linux 本机通道部署整改
|
||||
|
||||
@ -88,7 +88,6 @@ const baseGroups = [
|
||||
group('site', '站点与域名', '', [
|
||||
field('domains.primary', '主邮件域名', 'text', { required: true, placeholder: 'example.com' }),
|
||||
field('domains.web', '网页访问域名', 'text', { required: true, placeholder: 'mail.example.com' }),
|
||||
field('domains.adminRecovery', '超级管理员恢复域名', 'text', { placeholder: 'recovery.example.com', hint: '留空时使用主邮件域名。该配置仅用于本机恢复通道的部署标识与证书规划,不开放公网重置页面。' }),
|
||||
field('domains.accepted', '接收邮件的域名', 'list', { required: true, wide: false, hint: '需包含主邮件域名,每个域名单独填写。', placeholder: 'example.com' }),
|
||||
field('logLevel', '日志级别', 'select', { options: options([['', '默认级别'], ['debug', '调试'], ['info', '信息'], ['warn', '警告'], ['error', '错误']]) })
|
||||
]),
|
||||
@ -327,9 +326,6 @@ export function validateFormConfig(section, config) {
|
||||
for (const name of ['primary', 'web']) {
|
||||
if (!validDomain(domains[name])) errors[`domains.${name}`] = '请填写有效域名,不包含协议、路径或空格'
|
||||
}
|
||||
if (domains.adminRecovery !== undefined && domains.adminRecovery !== '' && !validDomain(domains.adminRecovery)) {
|
||||
errors['domains.adminRecovery'] = '请填写有效域名,不包含协议、路径或空格'
|
||||
}
|
||||
if (Array.isArray(domains.accepted) && domains.accepted.every(validDomain)) {
|
||||
const accepted = domains.accepted.map(normalizedDomain)
|
||||
if (new Set(accepted).size !== accepted.length) errors['domains.accepted'] = '接收域名不能重复,大小写和末尾点视为同一域名'
|
||||
|
||||
@ -52,7 +52,6 @@ export default defineConfig(({ mode, command }) => {
|
||||
const developmentTLS = command === 'serve' ? readDevelopmentTLS() : undefined
|
||||
const runtime = developmentTLS?.runtime
|
||||
const backendOrigin = runtime?.origin
|
||||
const publicRecoveryOrigin = runtime?.mailOrigin
|
||||
const frontendOrigin = runtime?.origin
|
||||
|
||||
return {
|
||||
@ -81,9 +80,9 @@ export default defineConfig(({ mode, command }) => {
|
||||
cors: true,
|
||||
proxy: {
|
||||
"/api": developmentBackendProxy(backendUrl, backendOrigin),
|
||||
// 匿名恢复入口由后端按 Web 域名计算跳转,恢复 API 则必须保持主邮件域名的同源校验。
|
||||
// 匿名恢复页面与恢复 API 均复用 Web 域名和现有 HTTPS 证书。
|
||||
"/public-password-recovery": developmentBackendProxy(backendUrl, backendOrigin),
|
||||
"/rest/api/recovery": developmentBackendProxy(backendUrl, publicRecoveryOrigin),
|
||||
"/rest/api/recovery": developmentBackendProxy(backendUrl, backendOrigin),
|
||||
"/attachments": {
|
||||
target: backendUrl,
|
||||
changeOrigin: true,
|
||||
|
||||
@ -278,23 +278,18 @@ test('域名校验兼容大小写和末尾点,拒绝重复项、空白和路
|
||||
const valid = { database: { type: 'sqlite' }, domains: { primary: 'Example.com.', web: 'mail.example.com', accepted: ['example.com'] } }
|
||||
assert.deepEqual(validateFormConfig('base', valid), {})
|
||||
const fields = getFieldGroups('base', valid).flatMap(group => group.fields)
|
||||
const recovery = fields.find(field => field.path.join('.') === 'domains.adminRecovery')
|
||||
assert.ok(recovery)
|
||||
assert.match(recovery.hint, /不开放公网重置页面/)
|
||||
assert.deepEqual(validateFormConfig('base', { ...valid, domains: { ...valid.domains, adminRecovery: '' } }), {})
|
||||
assert.deepEqual(validateFormConfig('base', { ...valid, domains: { ...valid.domains, adminRecovery: 'recovery.example.com' } }), {})
|
||||
assert.equal(fields.some(field => field.path.join('.') === 'domains.adminRecovery'), false)
|
||||
for (const accepted of [[], ['example.com', 'Example.com.'], [''], [' https://example.com'], ['other.example.com'], 'example.com', [null]]) {
|
||||
assert.ok(validateFormConfig('base', { ...valid, domains: { ...valid.domains, accepted } })['domains.accepted'])
|
||||
}
|
||||
for (const primary of ['https://example.com', 'example.com/path', 'bad..com', '-bad.com', '127.0.0.1', '例子.com']) {
|
||||
assert.ok(validateFormConfig('base', { ...valid, domains: { ...valid.domains, primary } })['domains.primary'])
|
||||
}
|
||||
for (const adminRecovery of ['https://example.com', 'example.com/path', 'bad..com', '-bad.com', '127.0.0.1', '例子.com', ' recovery.example.com']) {
|
||||
assert.ok(validateFormConfig('base', { ...valid, domains: { ...valid.domains, adminRecovery } })['domains.adminRecovery'])
|
||||
}
|
||||
const text = JSON.stringify({ ...valid, domains: { ...valid.domains, accepted: ['example.com'], accessHosts: ['192.0.2.10'], adminRecovery: '' } })
|
||||
const updated = readFormConfig(updateFormConfig(text, 'base', ['domains', 'adminRecovery'], 'recovery.example.com'))
|
||||
assert.equal(updated.domains.adminRecovery, 'recovery.example.com')
|
||||
const text = JSON.stringify({ ...valid, domains: { ...valid.domains, accepted: ['example.com'], accessHosts: ['192.0.2.10'], adminRecovery: 'legacy.example.com' } })
|
||||
const unchanged = updateFormConfig(text, 'base', ['domains', 'adminRecovery'], 'recovery.example.com')
|
||||
assert.equal(unchanged, text)
|
||||
const updated = readFormConfig(unchanged)
|
||||
assert.equal(updated.domains.adminRecovery, 'legacy.example.com')
|
||||
assert.deepEqual(updated.domains.accepted, ['example.com'])
|
||||
assert.deepEqual(updated.domains.accessHosts, ['192.0.2.10'])
|
||||
})
|
||||
|
||||
@ -45,7 +45,8 @@ test('前端入口按需加载 Element Plus,并延迟设置页和写信编辑
|
||||
test('开发服务器将匿名恢复入口和接口安全转发到后端', async () => {
|
||||
const vite = await read('frontend/vite.config.js')
|
||||
assert.match(vite, /"\/public-password-recovery": developmentBackendProxy\(backendUrl, backendOrigin\)/)
|
||||
assert.match(vite, /"\/rest\/api\/recovery": developmentBackendProxy\(backendUrl, publicRecoveryOrigin\)/)
|
||||
assert.match(vite, /"\/rest\/api\/recovery": developmentBackendProxy\(backendUrl, backendOrigin\)/)
|
||||
assert.doesNotMatch(vite, /publicRecoveryOrigin|mailOrigin/)
|
||||
})
|
||||
|
||||
test('开发启动必须固定运行配置根目录并集中保存日志', async () => {
|
||||
|
||||
@ -21,7 +21,6 @@ test('开发配置只从模拟部署目录读取且不返回凭据字段', async
|
||||
await writeFile(path.join(root, 'ssl/private.key'), 'key', { mode: 0o600 })
|
||||
const runtime = readDevelopmentRuntime(root)
|
||||
assert.equal(runtime.origin, 'https://mail.example.test')
|
||||
assert.equal(runtime.mailOrigin, 'https://mail.example.test')
|
||||
assert.equal(runtime.generation, 9)
|
||||
assert.equal(runtime.privateKeyPath, path.join(root, 'ssl/private.key'))
|
||||
assert.equal(JSON.stringify(runtime).includes('not-exported'), false)
|
||||
@ -32,7 +31,7 @@ test('开发配置只从模拟部署目录读取且不返回凭据字段', async
|
||||
assert.throws(() => readDevelopmentRuntime(root), /事务尚未完成/)
|
||||
})
|
||||
|
||||
test('开发运行配置单独保留主邮件域名供匿名恢复接口校验', async () => {
|
||||
test('开发恢复接口与页面统一使用 Web 域名', async () => {
|
||||
const root = await mkdtemp(path.join(os.tmpdir(), 'smail-runtime-recovery-domain-'))
|
||||
await mkdir(path.join(root, 'config'))
|
||||
await mkdir(path.join(root, 'ssl'))
|
||||
@ -49,7 +48,7 @@ test('开发运行配置单独保留主邮件域名供匿名恢复接口校验',
|
||||
|
||||
const runtime = readDevelopmentRuntime(root)
|
||||
assert.equal(runtime.origin, 'https://web.example.test')
|
||||
assert.equal(runtime.mailOrigin, 'https://mail.example.test')
|
||||
assert.equal('mailOrigin' in runtime, false)
|
||||
})
|
||||
|
||||
test('源码指纹检测编辑且忽略编译嵌入目录', async () => {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user