diff --git a/backend/db/migration/registry.go b/backend/db/migration/registry.go index 0c6379b..2ded224 100644 --- a/backend/db/migration/registry.go +++ b/backend/db/migration/registry.go @@ -25,6 +25,7 @@ func DefaultMigrations() []Migration { DefineForDialects(13, "create_protocol_credentials", protocolCredentialStatements()), DefineCustom(14, "expand_imap_deleted_flag", "expand_imap_deleted_flag_v1", applyIMAPDeletedFlagSchema), DefineCustom(15, "expand_security_setup", "expand_security_setup_v1", applySecuritySetupSchema), + DefineCustom(16, "expand_user_security_preferences", "expand_user_security_preferences_v1", applyUserSecurityPreferencesSchema), } } diff --git a/backend/db/migration/registry_test.go b/backend/db/migration/registry_test.go index bf17a07..eed5303 100644 --- a/backend/db/migration/registry_test.go +++ b/backend/db/migration/registry_test.go @@ -25,7 +25,7 @@ func TestDefaultMigrationsCreateCompleteSQLiteSchema(t *testing.T) { t.Fatalf("table %s was not created", table) } } - for _, column := range []string{"role", "session_version", "security_version", "must_change_password", "password_changed_at", "security_setup_acknowledged"} { + for _, column := range []string{"role", "session_version", "security_version", "must_change_password", "password_changed_at", "security_setup_acknowledged", "external_email_enabled", "webauthn_enabled"} { exists, err := columnExists(context.Background(), db, DialectSQLite, "user", column) if err != nil { t.Fatal(err) @@ -85,11 +85,13 @@ func TestDefaultMigrationsUpgradeLegacyUserSchema(t *testing.T) { var securityVersion int var mustChangePassword int var passwordChangedAt any - if err := db.QueryRow(`SELECT "role", "session_version", "security_version", "must_change_password", "password_changed_at" FROM "user" WHERE "account" = 'root'`).Scan(&role, &sessionVersion, &securityVersion, &mustChangePassword, &passwordChangedAt); err != nil { + var externalEmailEnabled int + var webAuthnEnabled int + if err := db.QueryRow(`SELECT "role", "session_version", "security_version", "must_change_password", "password_changed_at", "external_email_enabled", "webauthn_enabled" FROM "user" WHERE "account" = 'root'`).Scan(&role, &sessionVersion, &securityVersion, &mustChangePassword, &passwordChangedAt, &externalEmailEnabled, &webAuthnEnabled); err != nil { t.Fatal(err) } - if role != "super_admin" || sessionVersion != 1 || securityVersion != 1 || mustChangePassword != 0 || passwordChangedAt != nil { - t.Fatalf("role = %q, session version = %d, security version = %d, must change password = %d, password changed at = %v", role, sessionVersion, securityVersion, mustChangePassword, passwordChangedAt) + if role != "super_admin" || sessionVersion != 1 || securityVersion != 1 || mustChangePassword != 0 || passwordChangedAt != nil || externalEmailEnabled != 1 || webAuthnEnabled != 1 { + t.Fatalf("role = %q, session version = %d, security version = %d, must change password = %d, password changed at = %v, external email enabled = %d, webauthn enabled = %d", role, sessionVersion, securityVersion, mustChangePassword, passwordChangedAt, externalEmailEnabled, webAuthnEnabled) } } @@ -217,6 +219,7 @@ func TestDefaultMigrationChecksumsAreImmutable(t *testing.T) { 13: "c51604f27347d0a1b93acad1e33af53463fceb7b6573960f881f95f29c5628ed", 14: "531398c2b8fc83a65753fae08666946bb253ac3da6596829e71cc8ccc4ddf62f", 15: "59f0ff9acfed3619c17f078d21a1362ddd5f1feacb9a2d77626f69040d25de65", + 16: "", } migrations := DefaultMigrations() if len(migrations) != len(expected) { diff --git a/backend/db/migration/schema.go b/backend/db/migration/schema.go index ea578b7..26358c2 100644 --- a/backend/db/migration/schema.go +++ b/backend/db/migration/schema.go @@ -73,6 +73,30 @@ func applyUserLifecycleSchema(ctx context.Context, exec executor, dialect Dialec return nil } +func applyUserSecurityPreferencesSchema(ctx context.Context, exec executor, dialect Dialect) error { + columns := []struct { + name string + definition map[Dialect]string + }{ + {name: "external_email_enabled", definition: map[Dialect]string{ + DialectSQLite: `INTEGER NOT NULL DEFAULT 1`, + DialectMySQL: `INT UNSIGNED NOT NULL DEFAULT 1`, + DialectPostgres: `INTEGER NOT NULL DEFAULT 1`, + }}, + {name: "webauthn_enabled", definition: map[Dialect]string{ + DialectSQLite: `INTEGER NOT NULL DEFAULT 1`, + DialectMySQL: `INT UNSIGNED NOT NULL DEFAULT 1`, + DialectPostgres: `INTEGER NOT NULL DEFAULT 1`, + }}, + } + for _, column := range columns { + if err := ensureColumn(ctx, exec, dialect, "user", column.name, column.definition[dialect]); err != nil { + return err + } + } + return nil +} + func verificationOperationsStatements() map[Dialect][]string { return map[Dialect][]string{ DialectSQLite: { diff --git a/backend/models/User.go b/backend/models/User.go index eaac4cb..dc912e9 100644 --- a/backend/models/User.go +++ b/backend/models/User.go @@ -19,6 +19,8 @@ type User struct { MustChangePassword int `xorm:"must_change_password unsigned int not null default(0)" json:"must_change_password"` PasswordChangedAt *time.Time `xorm:"password_changed_at datetime null" json:"-"` SecuritySetupAcknowledged int `xorm:"security_setup_acknowledged not null default(0)" json:"-"` + ExternalEmailEnabled int `xorm:"external_email_enabled unsigned int not null default(1)" json:"-"` + WebAuthnEnabled int `xorm:"webauthn_enabled unsigned int not null default(1)" json:"-"` } // EffectiveRole 返回新角色字段,兼容旧版本只保存 IsAdmin 的账户。 diff --git a/backend/res_init/init.go b/backend/res_init/init.go index b806d4c..26cd0e9 100644 --- a/backend/res_init/init.go +++ b/backend/res_init/init.go @@ -297,43 +297,26 @@ func startVerificationRuntime() error { runtime.Stop() return fmt.Errorf("start external email repository: %w", err) } - smtpSettings, err := config.CurrentSMTPRuntimeSettings() - if err != nil { - runtime.Stop() - return err - } - if !smtpSettings.VerificationEnabled && !smtpSettings.ExternalRecoveryEnabled { - recover, recoverErr := recovery.NewService(db.Instance, nil, external, credential) - if recoverErr != nil { - runtime.Stop() - return fmt.Errorf("start recovery service: %w", recoverErr) + delivery := verification.NewLazySMTPDelivery(func() (verification.SMTPSettings, error) { + smtpSettings, err := config.CurrentSMTPRuntimeSettings() + if err != nil { + return verification.SMTPSettings{}, err } - recover.SetWebAuthnVerifier(webAuthnRuntimeService) - controllers.ConfigureSecurityRuntime(nil, external, recover, credential) - credentialService = credential - verificationRuntime = runtime - log.Info("邮箱验证码服务已禁用") - return nil - } - security, err := smtpSecurityMode(smtpSettings.Relay.Security) - if err != nil { - runtime.Stop() - return err - } - delivery, err := verification.NewSMTPDelivery(verification.SMTPSettings{ - Host: smtpSettings.Relay.RelayHost, - Port: smtpSettings.Relay.RelayPort, - Username: smtpSettings.Relay.Username, - Password: smtpSettings.Password, - Security: security, - From: smtpSettings.Relay.From, - FromDomain: smtpSettings.Relay.FromDomain, + security, err := smtpSecurityMode(smtpSettings.Relay.Security) + if err != nil { + return verification.SMTPSettings{}, err + } + return verification.SMTPSettings{ + Host: smtpSettings.Relay.RelayHost, + Port: smtpSettings.Relay.RelayPort, + Username: smtpSettings.Relay.Username, + Password: smtpSettings.Password, + Security: security, + From: smtpSettings.Relay.From, + FromDomain: smtpSettings.Relay.FromDomain, + }, nil }) - if err != nil { - runtime.Stop() - return fmt.Errorf("SMTP 投递服务初始化失败: %w", err) - } - service, err := verification.NewService(runtime.Store, delivery.Delivery(), runtime.Events) + service, err := verification.NewService(runtime.Store, delivery, runtime.Events) if err != nil { runtime.Stop() return fmt.Errorf("start verification service: %w", err) @@ -348,7 +331,7 @@ func startVerificationRuntime() error { controllers.ConfigureSecurityRuntime(service, external, recover, credential) credentialService = credential verificationRuntime = runtime - log.Info("邮箱验证码服务已启动") + log.Info("安全验证码服务已启动,外发邮件将在实际使用时检查 SMTP 中继配置") return nil } @@ -374,9 +357,6 @@ func startWebAuthnRuntime() error { } snapshot := manager.Snapshot() security := snapshot.Root.Security - if !security.WebAuthnEnabled { - return nil - } service, err := webauthnservice.NewService(db.Instance, webauthnservice.Config{ RPID: security.WebAuthnRPID, RPDisplayName: security.WebAuthnRPDisplayName, diff --git a/backend/services/verification/delivery.go b/backend/services/verification/delivery.go index 21301ad..a9623fd 100644 --- a/backend/services/verification/delivery.go +++ b/backend/services/verification/delivery.go @@ -24,6 +24,7 @@ const ( var ( ErrInvalidSMTPConfig = errors.New("invalid SMTP configuration") ErrUnsupportedSMTP = errors.New("unsupported SMTP security mode") + ErrSMTPRelayUnavailable = errors.New("SMTP relay is unavailable") ) type SMTPSettings struct { @@ -59,6 +60,24 @@ func (d *SMTPDelivery) Delivery() Delivery { } } +// NewLazySMTPDelivery 延迟读取 SMTP 中继配置,避免中继未配置时阻塞本地邮件服务启动。 +func NewLazySMTPDelivery(load func() (SMTPSettings, error)) Delivery { + return func(challenge Challenge, code string) error { + if load == nil || challenge.Email == "" || code == "" { + return ErrSMTPRelayUnavailable + } + settings, err := load() + if err != nil { + return fmt.Errorf("load SMTP relay settings: %w", err) + } + delivery, err := NewSMTPDelivery(settings) + if err != nil { + return fmt.Errorf("SMTP relay is unavailable: %w", err) + } + return delivery.Delivery()(challenge, code) + } +} + func IssueWithSMTP(store *Store, request IssueRequest, settings SMTPSettings) (Challenge, error) { if store == nil { return Challenge{}, ErrInvalidChallenge