From cfe4401b706f392bd6bef13631650ad4deebb7e9 Mon Sep 17 00:00:00 2001 From: smail Date: Mon, 7 Sep 2026 15:24:41 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=8C=E6=88=90=E9=82=AE=E4=BB=B6=E6=9C=8D?= =?UTF-8?q?=E5=8A=A1=E7=AD=96=E7=95=A5=E6=95=B4=E5=90=88=E4=B8=8E=E5=AE=89?= =?UTF-8?q?=E5=85=A8=E9=85=8D=E7=BD=AE=E8=B0=83=E6=95=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/config/audit_query.go | 2 +- backend/config/audit_query_test.go | 22 +- backend/config/config.go | 41 ++- backend/config/manager_v2.go | 30 +- backend/config/manager_v2_test.go | 266 +++++++++++++++++- backend/config/migrate.go | 22 +- backend/config/migrate_test.go | 59 ++++ backend/config/partitioned_compatibility.go | 2 +- backend/config/sections.go | 11 + backend/config/validator.go | 71 ++--- backend/config/validator_security_test.go | 51 ++++ backend/controllers/config.go | 23 ++ backend/controllers/config_mail_ports_test.go | 24 ++ .../config_public_response_test.go | 16 ++ backend/services/setup/finish.go | 6 + backend/services/setup/finish_test.go | 27 ++ .../ConfigEditor/ConfigEditor.vue | 25 -- .../ConfigSettings/ConfigForm/ConfigForm.css | 12 +- .../ConfigSettings/ConfigForm/ConfigForm.js | 65 ++--- .../ConfigSettings/ConfigForm/ConfigForm.vue | 3 - .../ConfigSettings/ConfigNavigation.js | 8 +- .../ConfigSettings/ConfigSettings.js | 67 ++--- .../EmailConfig/EmailConfig.vue | 2 +- .../GlobalSecurityPolicy.css | 3 + .../GlobalSecurityPolicy.js | 1 + .../GlobalSecurityPolicy.vue | 17 ++ .../SecuritySettings/SecuritySettings.js | 14 +- .../SecuritySettings/SecuritySettings.vue | 7 + test/backend/mail_listeners_test.go | 10 +- test/frontend/config-form.test.mjs | 88 +++--- test/frontend/config-navigation.test.mjs | 12 +- test/frontend/config-settings.test.mjs | 204 +++++++++----- 32 files changed, 893 insertions(+), 318 deletions(-) create mode 100644 frontend/src/views/components/SecuritySettings/GlobalSecurityPolicy/GlobalSecurityPolicy.css create mode 100644 frontend/src/views/components/SecuritySettings/GlobalSecurityPolicy/GlobalSecurityPolicy.js create mode 100644 frontend/src/views/components/SecuritySettings/GlobalSecurityPolicy/GlobalSecurityPolicy.vue diff --git a/backend/config/audit_query.go b/backend/config/audit_query.go index 886b341..8d42773 100644 --- a/backend/config/audit_query.go +++ b/backend/config/audit_query.go @@ -30,7 +30,7 @@ func ValidateConfigAuditQuery(query ConfigAuditQuery) error { if query.Operation != "" && query.Operation != ConfigOperationUpdate && query.Operation != ConfigOperationRollback && query.Operation != ConfigOperationMigration { return ErrInvalidAuditQuery } - if query.Section != "" && query.Section != SectionRoot && query.Section != SectionSMTP && query.Section != SectionEmail && query.Section != SectionSSL && query.Section != SectionPlugins { + if query.Section != "" && query.Section != SectionRoot && query.Section != SectionMail && query.Section != SectionSMTP && query.Section != SectionEmail && query.Section != SectionSSL && query.Section != SectionPlugins { return ErrInvalidAuditQuery } return nil diff --git a/backend/config/audit_query_test.go b/backend/config/audit_query_test.go index 5ce6616..785ca65 100644 --- a/backend/config/audit_query_test.go +++ b/backend/config/audit_query_test.go @@ -1,6 +1,7 @@ package config import ( + "context" "errors" "strings" "testing" @@ -13,7 +14,7 @@ func TestValidateConfigAuditQueryAcceptsSupportedFilters(t *testing.T) { To: time.Date(2026, 8, 30, 0, 0, 0, 0, time.UTC), ActorID: "42", Operation: ConfigOperationUpdate, - Section: SectionSMTP, + Section: SectionMail, Limit: 1000, } if err := ValidateConfigAuditQuery(query); err != nil { @@ -21,6 +22,25 @@ func TestValidateConfigAuditQueryAcceptsSupportedFilters(t *testing.T) { } } +func TestConfigAuditQueryFiltersCombinedMailSection(t *testing.T) { + writer := &MemoryAuditWriter{} + for _, event := range []ConfigAuditEvent{ + {Operation: ConfigOperationUpdate, Section: SectionMail, ActorID: "42", ActorRole: "super_admin", FromGeneration: 1, ToGeneration: 2, Result: "success", CreatedAt: time.Now().UTC()}, + {Operation: ConfigOperationUpdate, Section: SectionSMTP, ActorID: "42", ActorRole: "super_admin", FromGeneration: 2, ToGeneration: 3, Result: "success", CreatedAt: time.Now().UTC()}, + } { + if err := writer.WriteConfigAudit(context.Background(), event); err != nil { + t.Fatal(err) + } + } + items, err := writer.Query(context.Background(), ConfigAuditQuery{Section: SectionMail, Limit: 10}) + if err != nil { + t.Fatal(err) + } + if len(items) != 1 || items[0].Section != SectionMail { + t.Fatalf("mail audit filter returned %+v", items) + } +} + func TestValidateConfigAuditQueryRejectsAmbiguousFilters(t *testing.T) { tests := []ConfigAuditQuery{ {Limit: -1}, diff --git a/backend/config/config.go b/backend/config/config.go index 7afb1c6..10702cb 100644 --- a/backend/config/config.go +++ b/backend/config/config.go @@ -199,7 +199,7 @@ func Init() error { if err = json.Unmarshal(cfgData, loaded); err != nil { return errors.Wrap(err) } - applyLegacyLoginPolicy(cfgData, loaded) + applyLegacySecurityDefaults(cfgData, loaded) migrated, err := unprotectCredentials(loaded) if err != nil { return errors.Wrap(err) @@ -331,9 +331,12 @@ func ReadConfig() (*Config, error) { func readConfig() (*Config, error) { configData := Config{ - DkimPrivateKeyPath: ROOT_PATH + "config/dkim/dkim.priv", - SSLPrivateKeyPath: ROOT_PATH + "config/ssl/private.key", - SSLPublicKeyPath: ROOT_PATH + "config/ssl/public.crt", + DkimPrivateKeyPath: ROOT_PATH + "config/dkim/dkim.priv", + SSLPrivateKeyPath: ROOT_PATH + "config/ssl/private.key", + SSLPublicKeyPath: ROOT_PATH + "config/ssl/public.crt", + VerificationEnabled: true, + ExternalRecoveryEnabled: true, + LoginPolicy: "new_device", } cfgData, err := readConfigFile("config.json") if os.IsNotExist(err) { @@ -362,7 +365,7 @@ func readConfig() (*Config, error) { log.Error("Read Config Unmarshal Error") return nil, errors.Wrap(err) } - applyLegacyLoginPolicy(cfgData, &configData) + applyLegacySecurityDefaults(cfgData, &configData) migrated, err := unprotectCredentials(&configData) if err != nil { log.Error("Read Config Credential Error") @@ -378,16 +381,34 @@ func readConfig() (*Config, error) { return &configData, nil } -func applyLegacyLoginPolicy(data []byte, cfg *Config) { - if cfg == nil || strings.TrimSpace(cfg.LoginPolicy) != "" { +func applyLegacySecurityDefaults(data []byte, cfg *Config) { + if cfg == nil { return } var legacy struct { - VerificationLoginPolicy string `json:"verificationLoginPolicy"` + VerificationEnabled *bool `json:"verificationEnabled"` + ExternalRecoveryEnabled *bool `json:"externalRecoveryEnabled"` + LoginPolicy *string `json:"loginPolicy"` + VerificationLoginPolicy string `json:"verificationLoginPolicy"` } - if json.Unmarshal(data, &legacy) == nil { - cfg.LoginPolicy = strings.TrimSpace(legacy.VerificationLoginPolicy) + if json.Unmarshal(data, &legacy) != nil { + return } + if legacy.VerificationEnabled == nil { + cfg.VerificationEnabled = true + } + if legacy.ExternalRecoveryEnabled == nil { + cfg.ExternalRecoveryEnabled = true + } + if legacy.LoginPolicy != nil && strings.TrimSpace(*legacy.LoginPolicy) != "" { + cfg.LoginPolicy = strings.TrimSpace(*legacy.LoginPolicy) + return + } + if policy := strings.TrimSpace(legacy.VerificationLoginPolicy); policy != "" { + cfg.LoginPolicy = policy + return + } + cfg.LoginPolicy = "new_device" } func (c *Config) fixPath() { diff --git a/backend/config/manager_v2.go b/backend/config/manager_v2.go index f50d7ba..70c4d58 100644 --- a/backend/config/manager_v2.go +++ b/backend/config/manager_v2.go @@ -744,6 +744,8 @@ func servicesForSection(section Section) []ServiceName { return []ServiceName{ServiceSMTP} case SectionEmail: return []ServiceName{ServiceEmail} + case SectionMail: + return []ServiceName{ServiceSMTP, ServiceEmail} case SectionSSL: return []ServiceName{ServiceHTTP, ServiceSMTP, ServiceSSL} case SectionPlugins: @@ -813,12 +815,38 @@ func applySectionPayload(current RuntimeConfig, section Section, payload json.Ra } value.SMTPSectionConfig.Relay.Password = current.SMTP.Relay.Password candidate.SMTP = value.SMTPSectionConfig + candidate.Email.Policy.LoginVerificationPolicy = candidate.SMTP.Relay.LoginPolicy case SectionEmail: var value EmailSectionConfig if err := ParseStrict(payload, &value); err != nil { return RuntimeConfig{}, err } candidate.Email = value + candidate.SMTP.Relay.LoginPolicy = candidate.Email.Policy.LoginVerificationPolicy + case SectionMail: + var value struct { + MailSectionConfig + RelayPasswordStatus json.RawMessage `json:"relayPasswordStatus"` + } + if err := ParseStrict(payload, &value); err != nil { + return RuntimeConfig{}, err + } + if value.MailSectionConfig.Relay.Password.Configured() { + return RuntimeConfig{}, ErrInvalidSecretChange + } + value.MailSectionConfig.Relay.Password = current.SMTP.Relay.Password + candidate.SMTP = SMTPSectionConfig{ + Meta: value.Meta, + Listener: value.Listener, + Relay: value.Relay, + } + candidate.Email = EmailSectionConfig{ + Meta: value.Meta, + IMAP: value.IMAP, + POP3: value.POP3, + Policy: value.Policy, + } + candidate.Email.Policy.LoginVerificationPolicy = candidate.SMTP.Relay.LoginPolicy case SectionSSL: var value SSLSectionConfig if err := ParseStrict(payload, &value); err != nil { @@ -901,7 +929,7 @@ func applySecretChanges(candidate *RuntimeConfig, section Section, changes map[s return err } candidate.Root.Database.Password = stored - case section == SectionSMTP && name == "relay.password": + case (section == SectionSMTP || section == SectionMail) && name == "relay.password": stored, err := ApplySecretChange(vault, candidate.SMTP.Relay.Password, change, SecretPolicy{Required: candidate.SMTP.Relay.Username != "", AllowClear: candidate.SMTP.Relay.Username == "", MaxLength: 4096}, now) if err != nil { return err diff --git a/backend/config/manager_v2_test.go b/backend/config/manager_v2_test.go index 315173b..31cd479 100644 --- a/backend/config/manager_v2_test.go +++ b/backend/config/manager_v2_test.go @@ -96,6 +96,17 @@ func authorizedConfigActor() AuditActor { return AuditActor{UserID: "1", Role: "super_admin", SessionID: "test-session", RecentAuth: true, RecoveryCodeConfigured: true} } +func mailSectionFromRuntime(runtime RuntimeConfig) MailSectionConfig { + return MailSectionConfig{ + Meta: runtime.SMTP.Meta, + Listener: runtime.SMTP.Listener, + Relay: runtime.SMTP.Relay, + IMAP: runtime.Email.IMAP, + POP3: runtime.Email.POP3, + Policy: runtime.Email.Policy, + } +} + type rejectingRuntimeSnapshotStore struct { current RuntimeConfig err error @@ -372,6 +383,249 @@ func TestConfigManagerUpdateApplyUsesPendingGeneration(t *testing.T) { } } +func TestConfigManagerMailSectionRejectsNonStrictPayloadsAndDirectSecrets(t *testing.T) { + root := t.TempDir() + writeRuntimeFixture(t, root, validRuntimeConfig()) + manager, err := NewConfigManager(root, ConfigLoaderOptions{Validation: RuntimeValidationOptions{HasSuperAdmin: true, Production: true}}) + if err != nil { + t.Fatal(err) + } + mail := mailSectionFromRuntime(manager.Snapshot()) + payload, err := json.Marshal(mail) + if err != nil { + t.Fatal(err) + } + + unknown := append([]byte(nil), payload[:len(payload)-1]...) + unknown = append(unknown, []byte(`,"unknown":true}`)...) + if _, err := manager.Validate(context.Background(), ValidateRequest{Section: SectionMail, Payload: unknown}); err == nil { + t.Fatal("mail section accepted an unknown JSON field") + } + + meta, err := json.Marshal(mail.Meta) + if err != nil { + t.Fatal(err) + } + duplicate := append([]byte(`{"meta":`), meta...) + duplicate = append(duplicate, ',') + duplicate = append(duplicate, payload[1:]...) + if _, err := manager.Validate(context.Background(), ValidateRequest{Section: SectionMail, Payload: duplicate}); err == nil { + t.Fatal("mail section accepted a duplicate JSON field") + } + + mail.Relay.Password = StoredSecret{Ciphertext: "enc:v1:direct-secret", Version: credential.CurrentVersion, UpdatedAt: time.Now().UTC()} + directSecret, err := json.Marshal(mail) + if err != nil { + t.Fatal(err) + } + if _, err := manager.Validate(context.Background(), ValidateRequest{Section: SectionMail, Payload: directSecret}); !errors.Is(err, ErrInvalidSecretChange) { + t.Fatalf("direct relay secret error = %v, want invalid secret change", err) + } +} + +func TestConfigManagerMailSectionAppliesBothPartitionsWithoutMailFile(t *testing.T) { + root := t.TempDir() + runtime := validRuntimeConfig() + vault, err := credential.NewVault(root) + if err != nil { + t.Fatal(err) + } + stored, err := ApplySecretChange(vault, StoredSecret{}, SecretChange{ + Operation: SecretReplace, + Value: &SecretInput{Plaintext: "existing-relay-secret"}, + }, SecretPolicy{Required: true, MaxLength: 4096}, time.Now().UTC()) + if err != nil { + t.Fatal(err) + } + runtime.SMTP.Relay = SMTPConfig{ + RelayHost: "smtp.example.com", + RelayPort: 587, + Username: "relay-user", + Password: stored, + Security: "starttls", + From: "no-reply@example.com", + FromDomain: "example.com", + VerificationEnabled: true, + LoginPolicy: "new_device", + } + runtime.Email.Policy.LoginVerificationPolicy = "new_device" + runtime.Email.Policy.ExternalRecoveryEnabled = true + writeRuntimeFixture(t, root, runtime) + options := ConfigLoaderOptions{Validation: RuntimeValidationOptions{HasSuperAdmin: true, Production: true}} + manager, err := NewConfigManager(root, options) + if err != nil { + t.Fatal(err) + } + + mail := mailSectionFromRuntime(manager.Snapshot()) + mail.Relay.Password = StoredSecret{} + mail.Listener = SMTPListenerConfig{Port: 2525, STARTTLSPort: 2587, TLSPort: 2465} + mail.IMAP.TLSPort = 2993 + mail.POP3 = POP3ListenerConfig{Port: 2110, TLSPort: 2995} + mail.Policy.MaxMessageBytes = 8 << 20 + mail.Policy.SpamFilterLevel = 2 + payload, err := json.Marshal(mail) + if err != nil { + t.Fatal(err) + } + updated, err := manager.Update(context.Background(), UpdateRequest{ + Section: SectionMail, ExpectedGeneration: 1, Payload: payload, Actor: authorizedConfigActor(), + }) + if err != nil { + t.Fatal(err) + } + if updated.Candidate.SMTP.Relay.Password != stored { + t.Fatal("mail candidate did not preserve the existing relay secret") + } + status := manager.Status() + if len(status.PendingRestart) != 2 || status.PendingRestart[0] != ServiceSMTP || status.PendingRestart[1] != ServiceEmail { + t.Fatalf("mail pending restart services = %+v, want smtp and email", status.PendingRestart) + } + if _, err := manager.ApplyPendingAuthorized(context.Background(), updated.Generation, "wrong-candidate", SectionMail, authorizedConfigActor()); !errors.Is(err, ErrGenerationConflict) { + t.Fatalf("wrong candidate ID error = %v, want generation conflict", err) + } + if _, err := manager.ApplyPendingAuthorized(context.Background(), updated.Generation, updated.CandidateID, SectionSMTP, authorizedConfigActor()); !errors.Is(err, ErrGenerationConflict) { + t.Fatalf("wrong section error = %v, want generation conflict", err) + } + if _, err := manager.ApplyPendingAuthorized(context.Background(), updated.Generation+1, updated.CandidateID, SectionMail, authorizedConfigActor()); !errors.Is(err, ErrGenerationConflict) { + t.Fatalf("wrong generation error = %v, want generation conflict", err) + } + if _, err := manager.ApplyPendingAuthorized(context.Background(), updated.Generation, updated.CandidateID, SectionMail, authorizedConfigActor()); err != nil { + t.Fatal(err) + } + + configDirectory := filepath.Join(root, "config") + if _, err := os.Stat(filepath.Join(configDirectory, "mail.json")); !os.IsNotExist(err) { + t.Fatalf("combined mail view was persisted as mail.json: %v", err) + } + for _, name := range []string{"smtp.json", "email.json"} { + data, err := os.ReadFile(filepath.Join(configDirectory, name)) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(data), `"generation":2`) { + t.Fatalf("%s was not updated to generation 2: %s", name, data) + } + } + reloaded, err := NewConfigLoader(root, options).Load() + if err != nil { + t.Fatal(err) + } + wantPorts := MailPorts{SMTP: 2525, SMTPSTARTTLS: 2587, SMTPTLS: 2465, IMAP: 2993, POP3: 2110, POP3TLS: 2995} + if got := ResolveMailPorts(reloaded.SMTP, reloaded.Email); got != wantPorts { + t.Fatalf("reloaded mail ports = %+v, want %+v", got, wantPorts) + } + if reloaded.Email.Policy.MaxMessageBytes != 8<<20 || reloaded.Email.Policy.SpamFilterLevel != 2 { + t.Fatalf("reloaded mail policy is incomplete: %+v", reloaded.Email.Policy) + } + if reloaded.SMTP.Relay.Password != stored { + t.Fatal("reloaded relay secret changed without an explicit secret operation") + } +} + +func TestConfigManagerMailSectionSecretReplacementUsesVault(t *testing.T) { + root := t.TempDir() + writeRuntimeFixture(t, root, validRuntimeConfig()) + manager, err := NewConfigManager(root, ConfigLoaderOptions{Validation: RuntimeValidationOptions{HasSuperAdmin: true, Production: true}}) + if err != nil { + t.Fatal(err) + } + mail := mailSectionFromRuntime(manager.Snapshot()) + mail.Relay = SMTPConfig{ + RelayHost: "smtp.example.com", + RelayPort: 587, + Username: "relay-user", + Security: "starttls", + From: "no-reply@example.com", + FromDomain: "example.com", + } + payload, err := json.Marshal(mail) + if err != nil { + t.Fatal(err) + } + request := UpdateRequest{ + Section: SectionMail, ExpectedGeneration: 1, Payload: payload, Actor: authorizedConfigActor(), + SecretChanges: map[string]SecretChange{ + "relay.password": {Operation: SecretReplace, Value: &SecretInput{Plaintext: "new-relay-secret"}}, + }, + } + if _, err := manager.Update(context.Background(), request); !errors.Is(err, ErrStepUpRequired) { + t.Fatalf("mail secret replacement without step-up error = %v, want step-up required", err) + } + request.Confirmation = StepUpConfirmation{Confirmed: true, Method: "recent_auth"} + updated, err := manager.Update(context.Background(), request) + if err != nil { + t.Fatal(err) + } + stored := updated.Candidate.SMTP.Relay.Password + if !stored.Configured() || stored.Ciphertext == "new-relay-secret" || !credential.IsEncrypted(stored.Ciphertext) { + t.Fatalf("relay secret was not stored as managed ciphertext: %+v", stored) + } + plaintext, err := manager.vault.Decrypt(stored.Version, stored.Ciphertext) + if err != nil || plaintext != "new-relay-secret" { + t.Fatalf("relay secret cannot be recovered from the managed vault: value=%q error=%v", plaintext, err) + } +} + +func TestConfigManagerMailSectionPreservesFieldsAcrossPageSpecificEdits(t *testing.T) { + root := t.TempDir() + runtime := validRuntimeConfig() + runtime.SMTP.Listener = SMTPListenerConfig{Port: 2525, STARTTLSPort: 2587, TLSPort: 2465} + runtime.Email.IMAP.TLSPort = 2993 + runtime.Email.POP3 = POP3ListenerConfig{Port: 2110, TLSPort: 2995} + runtime.Email.Policy.MaxMessageBytes = 4 << 20 + writeRuntimeFixture(t, root, runtime) + options := ConfigLoaderOptions{Validation: RuntimeValidationOptions{HasSuperAdmin: true, Production: true}} + manager, err := NewConfigManager(root, options) + if err != nil { + t.Fatal(err) + } + + securityEdit := mailSectionFromRuntime(manager.Snapshot()) + securityEdit.Relay.VerificationEnabled = true + securityEdit.Relay.LoginPolicy = "new_device" + securityEdit.Policy.ExternalRecoveryEnabled = true + securityPayload, err := json.Marshal(securityEdit) + if err != nil { + t.Fatal(err) + } + updated, err := manager.Update(context.Background(), UpdateRequest{ + Section: SectionMail, ExpectedGeneration: 1, Payload: securityPayload, Actor: authorizedConfigActor(), + }) + if err != nil { + t.Fatal(err) + } + if _, err := manager.ApplyPendingAuthorized(context.Background(), updated.Generation, updated.CandidateID, SectionMail, authorizedConfigActor()); err != nil { + t.Fatal(err) + } + afterSecurity := manager.Snapshot() + wantPorts := MailPorts{SMTP: 2525, SMTPSTARTTLS: 2587, SMTPTLS: 2465, IMAP: 2993, POP3: 2110, POP3TLS: 2995} + if got := ResolveMailPorts(afterSecurity.SMTP, afterSecurity.Email); got != wantPorts || afterSecurity.Email.Policy.MaxMessageBytes != 4<<20 { + t.Fatal("security policy edit overwrote mail listener or policy fields") + } + + mailEdit := mailSectionFromRuntime(afterSecurity) + mailEdit.Policy.MaxMessageBytes = 12 << 20 + mailPayload, err := json.Marshal(mailEdit) + if err != nil { + t.Fatal(err) + } + updated, err = manager.Update(context.Background(), UpdateRequest{ + Section: SectionMail, ExpectedGeneration: 2, Payload: mailPayload, Actor: authorizedConfigActor(), + }) + if err != nil { + t.Fatal(err) + } + if _, err := manager.ApplyPendingAuthorized(context.Background(), updated.Generation, updated.CandidateID, SectionMail, authorizedConfigActor()); err != nil { + t.Fatal(err) + } + afterMail := manager.Snapshot() + if !afterMail.SMTP.Relay.VerificationEnabled || afterMail.SMTP.Relay.LoginPolicy != "new_device" || + afterMail.Email.Policy.LoginVerificationPolicy != "new_device" || !afterMail.Email.Policy.ExternalRecoveryEnabled { + t.Fatal("mail page edit overwrote security policy fields") + } +} + func TestTransactionRecoveryRestoresOldGenerationAfterSectionFailure(t *testing.T) { root := t.TempDir() current := validRuntimeConfig() @@ -688,7 +942,13 @@ func TestConfigManagerSecretChangesRequireStepUpAndPreserveStoredSecret(t *testi func TestConfigManagerSecretClearRequiresExplicitConfirmation(t *testing.T) { root := t.TempDir() runtime := validRuntimeConfig() - runtime.SMTP.Relay = SMTPConfig{} + runtime.SMTP.Relay = SMTPConfig{ + RelayHost: "smtp.example.com", + RelayPort: 587, + Security: "starttls", + From: "no-reply@example.com", + FromDomain: "example.com", + } writeRuntimeFixture(t, root, runtime) options := ConfigLoaderOptions{Validation: RuntimeValidationOptions{HasSuperAdmin: true, Production: true}} manager, err := NewConfigManager(root, options) @@ -696,6 +956,7 @@ func TestConfigManagerSecretClearRequiresExplicitConfirmation(t *testing.T) { t.Fatal(err) } smtp := manager.Snapshot().SMTP + smtp.Relay.Username = "relay-user" payload, err := json.Marshal(smtp) if err != nil { t.Fatal(err) @@ -713,6 +974,7 @@ func TestConfigManagerSecretClearRequiresExplicitConfirmation(t *testing.T) { t.Fatal(err) } smtp = manager.Snapshot().SMTP + smtp.Relay.Username = "" smtp.Relay.Password = StoredSecret{} payload, err = json.Marshal(smtp) if err != nil { @@ -1255,7 +1517,7 @@ func TestConfigManagerStatusReportsPendingRestartServices(t *testing.T) { } current := manager.Snapshot() smtp := current.SMTP - smtp.Relay.RelayPort++ + smtp.Listener.Port++ payload, err := json.Marshal(smtp) if err != nil { t.Fatal(err) diff --git a/backend/config/migrate.go b/backend/config/migrate.go index 838f979..8600655 100644 --- a/backend/config/migrate.go +++ b/backend/config/migrate.go @@ -78,8 +78,8 @@ type legacyMigrationConfig struct { SMTPSecurity string `json:"smtpSecurity"` SMTPFrom string `json:"smtpFrom"` SMTPFromDomain string `json:"smtpFromDomain"` - VerificationEnabled bool `json:"verificationEnabled"` - ExternalRecoveryEnabled bool `json:"externalRecoveryEnabled"` + VerificationEnabled *bool `json:"verificationEnabled"` + ExternalRecoveryEnabled *bool `json:"externalRecoveryEnabled"` LoginPolicy string `json:"loginPolicy"` VerificationLoginPolicy string `json:"verificationLoginPolicy"` PluginAllowList []string `json:"pluginAllowList"` @@ -514,7 +514,7 @@ func legacyRuntimeConfig(root string, legacy legacyMigrationConfig) (RuntimeConf POP3Port: 110, SpamFilterLevel: legacy.SpamFilterLevel, LoginVerificationPolicy: legacyLoginPolicy(legacy), - ExternalRecoveryEnabled: legacy.ExternalRecoveryEnabled, + ExternalRecoveryEnabled: legacyBoolDefault(legacy.ExternalRecoveryEnabled, true), }}, SSL: SSLSectionConfig{Meta: meta, TLS: ssl}, Plugins: PluginsSectionConfig{Meta: meta, Plugins: plugins}, @@ -705,13 +705,13 @@ func legacySMTPConfig(root string, vault *credential.Vault, legacy legacyMigrati Security: legacy.SMTPSecurity, From: legacy.SMTPFrom, FromDomain: legacy.SMTPFromDomain, - VerificationEnabled: legacy.VerificationEnabled, + VerificationEnabled: legacyBoolDefault(legacy.VerificationEnabled, true), LoginPolicy: legacyLoginPolicy(legacy), } if relay.FromDomain == "" { relay.FromDomain = legacy.Domain } - if !relay.VerificationEnabled && relay.RelayHost == "" && relay.RelayPort == 0 && relay.Username == "" && legacy.SMTPPassword == "" { + if relay.RelayHost == "" && relay.RelayPort == 0 && relay.Username == "" && legacy.SMTPPassword == "" { return relay, nil, nil } if relay.RelayPort == 0 { @@ -871,7 +871,17 @@ func legacyLoginPolicy(legacy legacyMigrationConfig) string { if legacy.LoginPolicy != "" { return legacy.LoginPolicy } - return legacy.VerificationLoginPolicy + if legacy.VerificationLoginPolicy != "" { + return legacy.VerificationLoginPolicy + } + return "new_device" +} + +func legacyBoolDefault(value *bool, fallback bool) bool { + if value == nil { + return fallback + } + return *value } func legacyControlledReference(value string) (string, bool) { diff --git a/backend/config/migrate_test.go b/backend/config/migrate_test.go index 11276a0..b99f473 100644 --- a/backend/config/migrate_test.go +++ b/backend/config/migrate_test.go @@ -100,6 +100,65 @@ func TestMigrateLegacyConfigPreservesBackupAndLoadsPartitions(t *testing.T) { } } +func TestLegacySecurityDefaultsPreserveExplicitValues(t *testing.T) { + falseValue := false + tests := []struct { + name string + legacy legacyMigrationConfig + verification bool + recovery bool + policy string + }{ + { + name: "missing values use secure defaults", + verification: true, + recovery: true, + policy: "new_device", + }, + { + name: "explicit false values are preserved", + legacy: legacyMigrationConfig{ + VerificationEnabled: &falseValue, + ExternalRecoveryEnabled: &falseValue, + LoginPolicy: "off", + }, + policy: "off", + }, + { + name: "legacy policy is preserved", + legacy: legacyMigrationConfig{ + VerificationLoginPolicy: "always", + }, + verification: true, + recovery: true, + policy: "always", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + runtime, manualFields, err := legacyRuntimeConfig(t.TempDir(), test.legacy) + if err != nil { + t.Fatal(err) + } + if len(manualFields) != 0 { + t.Fatalf("unexpected manual fields: %+v", manualFields) + } + if runtime.SMTP.Relay.VerificationEnabled != test.verification || + runtime.Email.Policy.ExternalRecoveryEnabled != test.recovery || + runtime.SMTP.Relay.LoginPolicy != test.policy || + runtime.Email.Policy.LoginVerificationPolicy != test.policy { + t.Fatalf("security migration = verification %t, recovery %t, smtp policy %q, email policy %q", + runtime.SMTP.Relay.VerificationEnabled, + runtime.Email.Policy.ExternalRecoveryEnabled, + runtime.SMTP.Relay.LoginPolicy, + runtime.Email.Policy.LoginVerificationPolicy, + ) + } + }) + } +} + func TestMigrateLegacyConfigResumesAfterNonSecretPartitionWrite(t *testing.T) { root := t.TempDir() original := writeLegacyMigrationFixture(t, root, map[string]any{ diff --git a/backend/config/partitioned_compatibility.go b/backend/config/partitioned_compatibility.go index edfc030..5330e3c 100644 --- a/backend/config/partitioned_compatibility.go +++ b/backend/config/partitioned_compatibility.go @@ -61,7 +61,7 @@ func runtimeCompatibility(runtime RuntimeConfig) (*Config, error) { DkimPrivateKeyPath: filepath.Join(ROOT_PATH, "config", "dkim", "dkim.priv"), SSLType: SSLTypeUser, SpamFilterLevel: runtime.Email.Policy.SpamFilterLevel, - LoginPolicy: runtime.Email.Policy.LoginVerificationPolicy, + LoginPolicy: runtime.SMTP.Relay.LoginPolicy, ExternalRecoveryEnabled: runtime.Email.Policy.ExternalRecoveryEnabled, SMTPHost: runtime.SMTP.Relay.RelayHost, SMTPPort: runtime.SMTP.Relay.RelayPort, SMTPUsername: runtime.SMTP.Relay.Username, SMTPSecurity: runtime.SMTP.Relay.Security, diff --git a/backend/config/sections.go b/backend/config/sections.go index 8721866..e4bc717 100644 --- a/backend/config/sections.go +++ b/backend/config/sections.go @@ -13,6 +13,7 @@ type Section string const ( SectionRoot Section = "root" + SectionMail Section = "mail" SectionSMTP Section = "smtp" SectionEmail Section = "email" SectionSSL Section = "ssl" @@ -66,6 +67,16 @@ type EmailSectionConfig struct { POP3 POP3ListenerConfig `json:"pop3,omitempty"` } +// MailSectionConfig 是 API 与候选配置使用的组合视图,磁盘仍分别保存 smtp.json 和 email.json。 +type MailSectionConfig struct { + Meta SectionMeta `json:"meta"` + Listener SMTPListenerConfig `json:"listener"` + Relay SMTPConfig `json:"relay"` + IMAP IMAPListenerConfig `json:"imap"` + POP3 POP3ListenerConfig `json:"pop3"` + Policy EmailConfig `json:"policy"` +} + type SSLSectionConfig struct { Meta SectionMeta `json:"meta"` TLS SSLConfig `json:"tls"` diff --git a/backend/config/validator.go b/backend/config/validator.go index c218d9c..350c30a 100644 --- a/backend/config/validator.go +++ b/backend/config/validator.go @@ -117,68 +117,53 @@ func validateSMTP(smtp SMTPConfig) error { if !validLoginVerificationPolicy(smtp.LoginPolicy) { return ErrInvalidSMTP } + if smtp.RelayHost != "" && !validSMTPHost(smtp.RelayHost) { + return ErrInvalidSMTP + } + if smtp.RelayPort != 0 && !validPort(smtp.RelayPort) { + return ErrInvalidSMTP + } + if smtp.FromDomain != "" && !validCanonicalDomain(smtp.FromDomain) { + return ErrInvalidSMTP + } + if strings.TrimSpace(smtp.Username) != smtp.Username { + return ErrInvalidSMTP + } + if err := ValidateStoredSecret(smtp.Password, false); err != nil { + return ErrInvalidSMTP + } if smtp.From != "" { parsedFrom, err := mail.ParseAddress(smtp.From) if err != nil || parsedFrom.Address != smtp.From || strings.ContainsAny(smtp.From, "\r\n") { return ErrInvalidSMTP } } - if !smtp.VerificationEnabled { - if smtp.RelayHost != "" && !validSMTPHost(smtp.RelayHost) { - return ErrInvalidSMTP - } - if smtp.RelayPort != 0 && !validPort(smtp.RelayPort) { - return ErrInvalidSMTP - } - if smtp.FromDomain != "" && !validCanonicalDomain(smtp.FromDomain) { - return ErrInvalidSMTP - } - if strings.TrimSpace(smtp.Username) != smtp.Username { - return ErrInvalidSMTP - } - if err := ValidateStoredSecret(smtp.Password, false); err != nil { - return ErrInvalidSMTP - } - if smtp.Username != "" { - if err := ValidateStoredSecret(smtp.Password, true); err != nil { - return ErrInvalidSMTP - } - } - if smtp.Security != "" { - switch smtp.Security { - case "starttls", "implicit_tls", "tls": - case "plain": - if !isLocalSMTPHost(smtp.RelayHost) { - return ErrInvalidSMTP - } - default: + if smtp.Security != "" { + switch smtp.Security { + case "starttls", "implicit_tls", "tls": + case "plain": + if !isLocalSMTPHost(smtp.RelayHost) { return ErrInvalidSMTP } + default: + return ErrInvalidSMTP } + } + relayConfigured := smtp.RelayHost != "" || smtp.RelayPort != 0 || smtp.Username != "" || + smtp.Password.Configured() || smtp.Security != "" || smtp.From != "" + if !relayConfigured { return nil } - if !validSMTPHost(smtp.RelayHost) || !validPort(smtp.RelayPort) || !validCanonicalDomain(smtp.FromDomain) || smtp.From == "" { - return ErrInvalidSMTP - } - if strings.TrimSpace(smtp.Username) != smtp.Username { + if smtp.RelayHost == "" || smtp.RelayPort == 0 || smtp.Security == "" || smtp.From == "" || smtp.FromDomain == "" { return ErrInvalidSMTP } if smtp.Username == "" { - if err := ValidateStoredSecret(smtp.Password, false); err != nil || smtp.Password.Configured() { + if smtp.Password.Configured() { return ErrInvalidSMTP } } else if err := ValidateStoredSecret(smtp.Password, true); err != nil { return ErrInvalidSMTP } - switch smtp.Security { - case "starttls", "implicit_tls", "tls": - case "plain": - if !isLocalSMTPHost(smtp.RelayHost) { - return ErrInvalidSMTP - } - default: - return ErrInvalidSMTP - } return nil } diff --git a/backend/config/validator_security_test.go b/backend/config/validator_security_test.go index 79a06c4..dfc3704 100644 --- a/backend/config/validator_security_test.go +++ b/backend/config/validator_security_test.go @@ -168,6 +168,57 @@ func TestValidateRuntimeConfigRejectsUnsafeCrossSectionValues(t *testing.T) { } } +func TestValidateSMTPKeepsLoginVerificationIndependentFromRelay(t *testing.T) { + tests := []struct { + name string + smtp SMTPConfig + valid bool + }{ + { + name: "verification enabled without relay", + smtp: SMTPConfig{ + VerificationEnabled: true, + LoginPolicy: "new_device", + FromDomain: "example.com", + }, + valid: true, + }, + { + name: "partially configured relay", + smtp: SMTPConfig{ + RelayHost: "smtp.example.com", + RelayPort: 587, + Security: "starttls", + LoginPolicy: "new_device", + }, + }, + { + name: "complete relay", + smtp: SMTPConfig{ + RelayHost: "smtp.example.com", + RelayPort: 587, + Security: "starttls", + From: "no-reply@example.com", + FromDomain: "example.com", + LoginPolicy: "new_device", + }, + valid: true, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + err := validateSMTP(test.smtp) + if test.valid && err != nil { + t.Fatalf("validateSMTP() error = %v", err) + } + if !test.valid && !errors.Is(err, ErrInvalidSMTP) { + t.Fatalf("validateSMTP() error = %v, want %v", err, ErrInvalidSMTP) + } + }) + } +} + func TestSnapshotStoreRejectsInvalidCandidateWithoutChangingSnapshot(t *testing.T) { initial := validRuntimeConfig() store, err := NewSnapshotStore(initial, RuntimeValidationOptions{HasSuperAdmin: true, Production: true}) diff --git a/backend/controllers/config.go b/backend/controllers/config.go index 275ebac..a79f36a 100644 --- a/backend/controllers/config.go +++ b/backend/controllers/config.go @@ -352,6 +352,8 @@ func configSectionFromPath(path string) (config.Section, bool) { switch parts[3] { case "base": return config.SectionRoot, true + case "mail": + return config.SectionMail, true case "smtp": return config.SectionSMTP, true case "email": @@ -461,6 +463,27 @@ func publicConfigSection(runtime config.RuntimeConfig, section config.Section) ( }, "relayPasswordStatus": publicSecret(runtime.SMTP.Relay.Password), }, nil + case config.SectionMail: + ports := config.ResolveMailPorts(runtime.SMTP, runtime.Email) + return map[string]any{ + "meta": runtime.SMTP.Meta, + "listener": config.SMTPListenerConfig{Port: ports.SMTP, STARTTLSPort: ports.SMTPSTARTTLS, TLSPort: ports.SMTPTLS}, + "relay": map[string]any{ + "relayHost": runtime.SMTP.Relay.RelayHost, + "relayPort": runtime.SMTP.Relay.RelayPort, + "username": runtime.SMTP.Relay.Username, + "security": runtime.SMTP.Relay.Security, + "from": runtime.SMTP.Relay.From, + "fromDomain": runtime.SMTP.Relay.FromDomain, + "verificationEnabled": runtime.SMTP.Relay.VerificationEnabled, + "loginPolicy": runtime.SMTP.Relay.LoginPolicy, + "password": nil, + }, + "imap": config.IMAPListenerConfig{TLSPort: ports.IMAP}, + "pop3": config.POP3ListenerConfig{Port: ports.POP3, TLSPort: ports.POP3TLS}, + "policy": runtime.Email.Policy, + "relayPasswordStatus": publicSecret(runtime.SMTP.Relay.Password), + }, nil case config.SectionEmail: ports := config.ResolveMailPorts(runtime.SMTP, runtime.Email) value := runtime.Email diff --git a/backend/controllers/config_mail_ports_test.go b/backend/controllers/config_mail_ports_test.go index 090c135..df04c82 100644 --- a/backend/controllers/config_mail_ports_test.go +++ b/backend/controllers/config_mail_ports_test.go @@ -20,6 +20,30 @@ func TestPublicMailPortsMatchListenersWithoutChangingSnapshot(t *testing.T) { } before := runtime.Email want := config.ResolveMailPorts(runtime.SMTP, runtime.Email) + mailPublic, err := publicConfigSection(runtime, config.SectionMail) + if err != nil { + t.Fatal(err) + } + mailData, err := json.Marshal(mailPublic) + if err != nil { + t.Fatal(err) + } + var writableMail map[string]json.RawMessage + if err := json.Unmarshal(mailData, &writableMail); err != nil { + t.Fatal(err) + } + delete(writableMail, "relayPasswordStatus") + mailData, err = json.Marshal(writableMail) + if err != nil { + t.Fatal(err) + } + var mail config.MailSectionConfig + if err := config.ParseStrict(mailData, &mail); err != nil { + t.Fatal(err) + } + if mail.Listener.Port != want.SMTP || mail.Listener.STARTTLSPort != want.SMTPSTARTTLS || mail.Listener.TLSPort != want.SMTPTLS || mail.IMAP.TLSPort != want.IMAP || mail.POP3.Port != want.POP3 || mail.POP3.TLSPort != want.POP3TLS { + t.Fatal("combined public mail ports differ from listener configuration") + } smtpPublic, err := publicConfigSection(runtime, config.SectionSMTP) if err != nil { t.Fatal(err) diff --git a/backend/controllers/config_public_response_test.go b/backend/controllers/config_public_response_test.go index 52d279d..1424176 100644 --- a/backend/controllers/config_public_response_test.go +++ b/backend/controllers/config_public_response_test.go @@ -63,6 +63,10 @@ func TestPublicConfigSectionsDoNotExposeSecretsAndRemainStrictlyDecodable(t *tes var value config.SMTPSectionConfig return config.ParseStrict(data, &value) }}, + {config.SectionMail, func(data []byte) error { + var value config.MailSectionConfig + return config.ParseStrict(data, &value) + }}, {config.SectionEmail, func(data []byte) error { var value config.EmailSectionConfig return config.ParseStrict(data, &value) @@ -90,12 +94,24 @@ func TestPublicConfigSectionsDoNotExposeSecretsAndRemainStrictlyDecodable(t *tes if strings.Contains(string(data), "database-secret") || strings.Contains(string(data), "relay-secret") { t.Fatalf("public configuration contains secret data: %s", data) } + if strings.Contains(string(data), "enc:v1:") { + t.Fatalf("public configuration contains encrypted secret material: %s", data) + } writableData := make(map[string]json.RawMessage) if err := json.Unmarshal(data, &writableData); err != nil { t.Fatalf("public configuration is not a JSON object: %v", err) } delete(writableData, "databasePasswordStatus") delete(writableData, "relayPasswordStatus") + if test.section == config.SectionMail { + var relay map[string]json.RawMessage + if err := json.Unmarshal(writableData["relay"], &relay); err != nil { + t.Fatalf("mail relay public configuration is not an object: %v", err) + } + if password, ok := relay["password"]; !ok || string(password) != "null" { + t.Fatalf("mail relay password placeholder is invalid: %s", password) + } + } if test.section == config.SectionPlugins { var plugins map[string]json.RawMessage if err := json.Unmarshal(writableData["plugins"], &plugins); err != nil { diff --git a/backend/services/setup/finish.go b/backend/services/setup/finish.go index 63e6444..5b6cdf6 100644 --- a/backend/services/setup/finish.go +++ b/backend/services/setup/finish.go @@ -17,6 +17,7 @@ func Finish() error { if err != nil { return errors.Wrap(err) } + initializing := !cfg.IsInit && cfg.SetupState != config.SetupStateReady portsChanged := applyServicePorts(cfg) if cfg.IsSetupReady() { if portsChanged { @@ -29,6 +30,11 @@ func Finish() error { } cfg.SetupState = config.SetupStateReady cfg.IsInit = true + if initializing { + cfg.VerificationEnabled = true + cfg.ExternalRecoveryEnabled = true + cfg.LoginPolicy = "new_device" + } err = config.WriteConfig(cfg) if err != nil { diff --git a/backend/services/setup/finish_test.go b/backend/services/setup/finish_test.go index 7c70ca1..a555709 100644 --- a/backend/services/setup/finish_test.go +++ b/backend/services/setup/finish_test.go @@ -33,6 +33,33 @@ func TestFinishPersistsServicePorts(t *testing.T) { if loaded.SetupState != config.SetupStateReady || !loaded.IsInit { t.Fatalf("setup state = %q, isInit = %t", loaded.SetupState, loaded.IsInit) } + if !loaded.VerificationEnabled || !loaded.ExternalRecoveryEnabled || loaded.LoginPolicy != "new_device" { + t.Fatalf("security defaults = verification %t, recovery %t, policy %q", loaded.VerificationEnabled, loaded.ExternalRecoveryEnabled, loaded.LoginPolicy) + } +} + +func TestFinishPreservesExplicitlyDisabledSecurityOptions(t *testing.T) { + prepareFinishConfigTest(t) + if err := config.WriteConfig(&config.Config{ + SetupState: config.SetupStateReady, + IsInit: true, + VerificationEnabled: false, + ExternalRecoveryEnabled: false, + LoginPolicy: "off", + }); err != nil { + t.Fatal(err) + } + + if err := Finish(); err != nil { + t.Fatal(err) + } + loaded, err := config.ReadConfig() + if err != nil { + t.Fatal(err) + } + if loaded.VerificationEnabled || loaded.ExternalRecoveryEnabled || loaded.LoginPolicy != "off" { + t.Fatalf("explicit security options changed: verification %t, recovery %t, policy %q", loaded.VerificationEnabled, loaded.ExternalRecoveryEnabled, loaded.LoginPolicy) + } } func TestFinishRepairsReadyConfigWithMissingPorts(t *testing.T) { diff --git a/frontend/src/views/components/ConfigSettings/ConfigEditor/ConfigEditor.vue b/frontend/src/views/components/ConfigSettings/ConfigEditor/ConfigEditor.vue index 4178144..3163441 100644 --- a/frontend/src/views/components/ConfigSettings/ConfigEditor/ConfigEditor.vue +++ b/frontend/src/views/components/ConfigSettings/ConfigEditor/ConfigEditor.vue @@ -5,9 +5,6 @@

{{ currentSection.label }}

- - 运行状态:{{ stateLabel }} -
@@ -36,15 +33,6 @@ - -
重新检查 @@ -52,10 +40,6 @@
-

- 当前由反向代理提供 HTTPS,Smail 不自动申请或续期 SSL 证书。下方配置仅用于本地邮件服务证书;外部续期后请重新导入并应用,不能仅更新反代服务器上的证书。 -

- - -

{{ sensitiveHint }}。

-
@@ -129,7 +110,6 @@ {{ savedGeneration ? `应用代次 ${savedGeneration}` : '应用配置' }}
-

保存不会立即生效;需要继续修改时先取消候选。跨会话恢复或取消需要近期认证并勾选确认。

{{ validationResult.valid ? '验证通过' : '验证未通过' }} @@ -149,16 +129,12 @@ diff --git a/frontend/src/views/components/SecuritySettings/GlobalSecurityPolicy/GlobalSecurityPolicy.css b/frontend/src/views/components/SecuritySettings/GlobalSecurityPolicy/GlobalSecurityPolicy.css new file mode 100644 index 0000000..be03433 --- /dev/null +++ b/frontend/src/views/components/SecuritySettings/GlobalSecurityPolicy/GlobalSecurityPolicy.css @@ -0,0 +1,3 @@ +.global-security-policy { + min-width: 0; +} diff --git a/frontend/src/views/components/SecuritySettings/GlobalSecurityPolicy/GlobalSecurityPolicy.js b/frontend/src/views/components/SecuritySettings/GlobalSecurityPolicy/GlobalSecurityPolicy.js new file mode 100644 index 0000000..416a96f --- /dev/null +++ b/frontend/src/views/components/SecuritySettings/GlobalSecurityPolicy/GlobalSecurityPolicy.js @@ -0,0 +1 @@ +export const globalSecurityPolicySection = 'security_policy' diff --git a/frontend/src/views/components/SecuritySettings/GlobalSecurityPolicy/GlobalSecurityPolicy.vue b/frontend/src/views/components/SecuritySettings/GlobalSecurityPolicy/GlobalSecurityPolicy.vue new file mode 100644 index 0000000..9715c66 --- /dev/null +++ b/frontend/src/views/components/SecuritySettings/GlobalSecurityPolicy/GlobalSecurityPolicy.vue @@ -0,0 +1,17 @@ + + + + + diff --git a/frontend/src/views/components/SecuritySettings/SecuritySettings.js b/frontend/src/views/components/SecuritySettings/SecuritySettings.js index 5483cd6..4484076 100644 --- a/frontend/src/views/components/SecuritySettings/SecuritySettings.js +++ b/frontend/src/views/components/SecuritySettings/SecuritySettings.js @@ -12,8 +12,12 @@ export function usePage() { const isSuperAdmin = computed(() => globalStatus.role === 'super_admin') const supported = webAuthnSupported() const webAuthnEnabled = computed(() => globalStatus.userInfos.webAuthnEnabled === true) + const canReadGlobalPolicy = computed(() => globalStatus.hasCapability('config.read')) const sectionOptions = computed(() => { const options = [] + if (canReadGlobalPolicy.value) { + options.push({ value: 'security-policy', label: '安全策略' }) + } if (!isSuperAdmin.value) { options.push({ value: 'password', label: lang.modify_pwd }) } @@ -31,10 +35,11 @@ export function usePage() { set: value => { globalStatus.securityActiveSection = value } }) - const selectSection = value => { - if (sectionOptions.value.some(item => item.value === value)) { - selectedSection.value = value - } + const globalSecurityPolicyPage = ref(null) + const selectSection = async value => { + if (!sectionOptions.value.some(item => item.value === value) || value === selectedSection.value) return + if (selectedSection.value === 'security-policy' && !await (globalSecurityPolicyPage.value?.beforeLeave?.() ?? true)) return + selectedSection.value = value } watch(sectionOptions, options => { @@ -290,6 +295,7 @@ export function usePage() { return { lang, isSuperAdmin, supported, webAuthnEnabled, sectionOptions, selectedSection, selectSection, + globalSecurityPolicyPage, registering, credentialsLoading, credentials, securityLoading, securityQuestion, questionForm, recoveryConfigured, generatedCodes, externalEmails, externalLoading, bindForm, ruleForm, rules, submit, registerCredential, renameCredential, revokeCredential, diff --git a/frontend/src/views/components/SecuritySettings/SecuritySettings.vue b/frontend/src/views/components/SecuritySettings/SecuritySettings.vue index 4f5029d..48619b4 100644 --- a/frontend/src/views/components/SecuritySettings/SecuritySettings.vue +++ b/frontend/src/views/components/SecuritySettings/SecuritySettings.vue @@ -9,6 +9,11 @@ />
+ +

{{ lang.modify_pwd }}

@@ -95,9 +100,11 @@