diff --git a/backend/config/runtime_access.go b/backend/config/runtime_access.go index 28ec161..dec8bec 100644 --- a/backend/config/runtime_access.go +++ b/backend/config/runtime_access.go @@ -133,8 +133,8 @@ func CurrentWebDomain() string { return cfg.WebDomain } -// CurrentAdminRecoveryDomain 返回超级管理员本机恢复通道的部署标识域名。 -// 此值不用于提供公网重置路由;未配置时沿用主邮件域名。 +// CurrentAdminRecoveryDomain 仅用于读取旧配置。 +// 公网一次性恢复入口统一使用 CurrentWebDomain,不再使用独立恢复域名。 func CurrentAdminRecoveryDomain() string { if manager := CurrentManager(); manager != nil { runtime := manager.Snapshot() diff --git a/backend/config/ssl_custom.go b/backend/config/ssl_custom.go index 8a06750..12c6ef7 100644 --- a/backend/config/ssl_custom.go +++ b/backend/config/ssl_custom.go @@ -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 == "" { diff --git a/backend/config/ssl_storage_test.go b/backend/config/ssl_storage_test.go index b8a0f34..d93bb1d 100644 --- a/backend/config/ssl_storage_test.go +++ b/backend/config/ssl_storage_test.go @@ -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 { diff --git a/backend/controllers/security.go b/backend/controllers/security.go index 1983e12..73718c7 100644 --- a/backend/controllers/security.go +++ b/backend/controllers/security.go @@ -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}) diff --git a/backend/controllers/security_public_response_test.go b/backend/controllers/security_public_response_test.go index d99b88f..46c8218 100644 --- a/backend/controllers/security_public_response_test.go +++ b/backend/controllers/security_public_response_test.go @@ -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{} diff --git a/backend/listen/http_server/public_recovery.go b/backend/listen/http_server/public_recovery.go index 1d302f8..206bc35 100644 --- a/backend/listen/http_server/public_recovery.go +++ b/backend/listen/http_server/public_recovery.go @@ -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) { diff --git a/backend/listen/http_server/public_recovery_test.go b/backend/listen/http_server/public_recovery_test.go index b4cafce..ff82219 100644 --- a/backend/listen/http_server/public_recovery_test.go +++ b/backend/listen/http_server/public_recovery_test.go @@ -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) + } +} diff --git a/backend/res_init/verification_delivery.go b/backend/res_init/verification_delivery.go index 9296ee9..58f89b4 100644 --- a/backend/res_init/verification_delivery.go +++ b/backend/res_init/verification_delivery.go @@ -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) diff --git a/backend/res_init/verification_delivery_test.go b/backend/res_init/verification_delivery_test.go index 97d74f0..544b919 100644 --- a/backend/res_init/verification_delivery_test.go +++ b/backend/res_init/verification_delivery_test.go @@ -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") + } +} diff --git a/backend/services/setup/ssl/ssl.go b/backend/services/setup/ssl/ssl.go index 5edbfa7..99118ca 100644 --- a/backend/services/setup/ssl/ssl.go +++ b/backend/services/setup/ssl/ssl.go @@ -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("附加邮箱域名的服务地址不在主域泛域名证书覆盖范围内") diff --git a/backend/services/setup/ssl/wildcard_test.go b/backend/services/setup/ssl/wildcard_test.go index 2ec9555..7a9d051 100644 --- a/backend/services/setup/ssl/wildcard_test.go +++ b/backend/services/setup/ssl/wildcard_test.go @@ -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) } } } diff --git a/deploy/runtime-config.mjs b/deploy/runtime-config.mjs index 2cdc4a8..ca67372 100644 --- a/deploy/runtime-config.mjs +++ b/deploy/runtime-config.mjs @@ -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 diff --git a/docs/密码恢复实机验收.md b/docs/密码恢复实机验收.md index 7cb4958..918a0c9 100644 --- a/docs/密码恢复实机验收.md +++ b/docs/密码恢复实机验收.md @@ -5,7 +5,7 @@ - [~] 管理员、普通用户通过已验证外部邮箱及离线安全凭据恢复密码。 - [~] 超级管理员登录后通过外部安全邮箱验证码修改密码。 - [~] 超级管理员通过部署端受保护本机通道重置密码。 -- [!] 独立恢复域名与邮箱主域名联动:待确认是恢复指引入口还是新增网页重置入口,不能在确认前把本机通道暴露到公网。 +- [x] 管理员、普通用户的一次性恢复页面复用现有 Web 域名和 HTTPS 证书,固定入口为 `https://Web域名/rest/`。 ## 当前实现边界 @@ -13,6 +13,8 @@ - 管理员邮箱恢复通过后仍需要未使用的离线恢复码。 - 离线恢复需要密保问题、密保答案和离线恢复码,不是只填写一个安全码。 - 超级管理员不能通过公开忘记密码接口恢复。登录后安全中心使用已验证外部邮箱验证码改密。 +- 公开恢复邮件由 `no-reply@主邮件域名` 发送,邮件中的一次性链接使用 Web 域名,不再配置独立恢复域名或额外证书。 +- 旧 `adminRecovery` 配置仅保留兼容读取,不在配置页面展示,也不参与恢复路由和证书域名计算。 - 改密成功会使旧会话和相关恢复、客户端凭据失效;测试后需要重新配置下一轮所需凭据。 ## Linux 本机通道部署整改 diff --git a/frontend/src/views/components/ConfigSettings/ConfigForm/ConfigForm.js b/frontend/src/views/components/ConfigSettings/ConfigForm/ConfigForm.js index 23db702..082ddc6 100644 --- a/frontend/src/views/components/ConfigSettings/ConfigForm/ConfigForm.js +++ b/frontend/src/views/components/ConfigSettings/ConfigForm/ConfigForm.js @@ -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'] = '接收域名不能重复,大小写和末尾点视为同一域名' diff --git a/frontend/vite.config.js b/frontend/vite.config.js index 822220f..1e894e1 100644 --- a/frontend/vite.config.js +++ b/frontend/vite.config.js @@ -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, diff --git a/test/frontend/config-form.test.mjs b/test/frontend/config-form.test.mjs index 6414f31..c545d80 100644 --- a/test/frontend/config-form.test.mjs +++ b/test/frontend/config-form.test.mjs @@ -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']) }) diff --git a/test/frontend/output-paths.test.mjs b/test/frontend/output-paths.test.mjs index 20c398a..4772d89 100644 --- a/test/frontend/output-paths.test.mjs +++ b/test/frontend/output-paths.test.mjs @@ -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 () => { diff --git a/test/frontend/runtime-deployment.test.mjs b/test/frontend/runtime-deployment.test.mjs index 1ca9a9a..36ca4c4 100644 --- a/test/frontend/runtime-deployment.test.mjs +++ b/test/frontend/runtime-deployment.test.mjs @@ -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 () => {