完善本地邮件服务与中继配置边界

This commit is contained in:
smail 2026-09-06 21:07:21 +08:00
parent 5dbd23e1a6
commit 2ebab39973
6 changed files with 72 additions and 43 deletions

View File

@ -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),
}
}

View File

@ -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) {

View File

@ -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: {

View File

@ -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 的账户。

View File

@ -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,

View File

@ -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