From b365c2535f4458b9654227fcf57c705474e25c74 Mon Sep 17 00:00:00 2001 From: smail Date: Sun, 30 Aug 2026 23:33:38 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=9E=E7=8E=B0=E7=94=A8=E6=88=B7=E9=82=AE?= =?UTF-8?q?=E7=AE=B1=E7=BB=91=E5=AE=9A=E5=92=8C=E7=99=BB=E5=BD=95=E6=A0=87?= =?UTF-8?q?=E8=AF=86=E8=A7=A3=E6=9E=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/db/init_migration_test.go | 9 +- server/db/migration/index.go | 59 +++++++++ server/db/migration/integration_test.go | 12 +- server/db/migration/registry.go | 1 + server/db/migration/registry_test.go | 86 +++++++++++- server/db/migration/schema.go | 10 +- server/db/migration/user_email.go | 69 ++++++++++ server/models/user_email.go | 20 ++- server/services/auth/auth.go | 79 ++++-------- server/services/auth/auth_test.go | 11 +- server/services/auth/identifier.go | 158 +++++++++++++++++++++++ server/services/auth/identifier_test.go | 92 +++++++++++++ server/services/auth/protocol_limiter.go | 4 +- 13 files changed, 533 insertions(+), 77 deletions(-) create mode 100644 server/db/migration/index.go create mode 100644 server/db/migration/user_email.go create mode 100644 server/services/auth/identifier.go create mode 100644 server/services/auth/identifier_test.go diff --git a/server/db/init_migration_test.go b/server/db/init_migration_test.go index 2d5522e..5c55249 100644 --- a/server/db/init_migration_test.go +++ b/server/db/init_migration_test.go @@ -26,19 +26,20 @@ func TestInitAppliesSchemaMigration(t *testing.T) { t.Fatal(err) } runner := migration.Runner{DB: Instance.DB().DB, Dialect: migration.DialectSQLite, Migrations: migration.DefaultMigrations()} + wantVersion := int64(len(migration.DefaultMigrations())) version, err := runner.CurrentVersion(context.Background()) if err != nil { t.Fatal(err) } - if version != 11 { - t.Fatalf("schema version = %d, want 11", version) + if version != wantVersion { + t.Fatalf("schema version = %d, want %d", version, wantVersion) } var count int if _, err = Instance.SQL("SELECT count(*) FROM schema_migration").Get(&count); err != nil { t.Fatal(err) } - if count != 11 { - t.Fatalf("schema migration rows = %d, want 11", count) + if count != int(wantVersion) { + t.Fatalf("schema migration rows = %d, want %d", count, wantVersion) } } diff --git a/server/db/migration/index.go b/server/db/migration/index.go new file mode 100644 index 0000000..46e5918 --- /dev/null +++ b/server/db/migration/index.go @@ -0,0 +1,59 @@ +package migration + +import ( + "context" + "fmt" + "strings" +) + +func ensureIndex(ctx context.Context, exec executor, dialect Dialect, table, index string, unique bool, columns []string) error { + exists, err := indexExists(ctx, exec, dialect, table, index) + if err != nil { + return fmt.Errorf("check index %s: %w", index, err) + } + if exists { + return nil + } + quotedTable, quotedIndex, err := quotedIdentifiers(dialect, table, index) + if err != nil { + return err + } + quotedColumns := make([]string, 0, len(columns)) + for _, column := range columns { + _, quotedColumn, err := quotedIdentifiers(dialect, table, column) + if err != nil { + return err + } + quotedColumns = append(quotedColumns, quotedColumn) + } + indexType := "INDEX" + if unique { + indexType = "UNIQUE INDEX" + } + statement := fmt.Sprintf("CREATE %s %s ON %s (%s)", indexType, quotedIndex, quotedTable, strings.Join(quotedColumns, ", ")) + if _, err := exec.ExecContext(ctx, statement); err != nil { + return fmt.Errorf("create index %s: %w", index, err) + } + return nil +} + +func indexExists(ctx context.Context, exec executor, dialect Dialect, table, index string) (bool, error) { + var count int + switch dialect { + case DialectSQLite: + if err := exec.QueryRowContext(ctx, `SELECT COUNT(*) FROM pragma_index_list(?) WHERE name = ?`, table, index).Scan(&count); err != nil { + return false, err + } + case DialectMySQL: + if err := exec.QueryRowContext(ctx, "SELECT COUNT(*) FROM information_schema.statistics WHERE table_schema = DATABASE() AND table_name = ? AND index_name = ?", table, index).Scan(&count); err != nil { + return false, err + } + case DialectPostgres: + if err := exec.QueryRowContext(ctx, "SELECT COUNT(*) FROM pg_indexes WHERE schemaname = current_schema() AND tablename = $1 AND indexname = $2", table, index).Scan(&count); err != nil { + return false, err + } + default: + return false, fmt.Errorf("unsupported index dialect %q", dialect) + } + return count > 0, nil +} diff --git a/server/db/migration/integration_test.go b/server/db/migration/integration_test.go index 58720f6..2d9a81f 100644 --- a/server/db/migration/integration_test.go +++ b/server/db/migration/integration_test.go @@ -41,12 +41,13 @@ func runIntegrationSuite(t *testing.T, dialect Dialect, factory integrationDatab db, cleanup := factory(t) defer cleanup() runner := Runner{DB: db, Dialect: dialect, Migrations: DefaultMigrations(), LockWait: 10 * time.Second} + wantVersion := int64(len(DefaultMigrations())) first := runIntegrationMigrations(t, runner) - if first.CurrentVersion != 10 || len(first.Applied) != 10 { + if first.CurrentVersion != wantVersion || len(first.Applied) != int(wantVersion) { t.Fatalf("first report = %+v", first) } second := runIntegrationMigrations(t, runner) - if second.CurrentVersion != 10 || len(second.Applied) != 0 { + if second.CurrentVersion != wantVersion || len(second.Applied) != 0 { t.Fatalf("second report = %+v", second) } }) @@ -83,10 +84,11 @@ func runIntegrationSuite(t *testing.T, dialect Dialect, factory integrationDatab for report := range reports { applied += len(report.Applied) } - if applied != 10 { - t.Fatalf("applied migration count = %d, want 10", applied) + wantVersion := len(DefaultMigrations()) + if applied != wantVersion { + t.Fatalf("applied migration count = %d, want %d", applied, wantVersion) } - assertIntegrationCount(t, db, "schema_migration", 10) + assertIntegrationCount(t, db, "schema_migration", wantVersion) }) t.Run("failed_migration_recovery", func(t *testing.T) { diff --git a/server/db/migration/registry.go b/server/db/migration/registry.go index 93e1777..9d8de05 100644 --- a/server/db/migration/registry.go +++ b/server/db/migration/registry.go @@ -13,5 +13,6 @@ func DefaultMigrations() []Migration { DefineForDialects(9, "create_totp_authenticator", totpAuthenticatorStatements()), DefineCustom(10, "expand_user_lifecycle_security", "expand_user_lifecycle_security_v1", applyUserLifecycleSchema), DefineForDialects(11, "create_plugin_configuration_tables", pluginSettingsStatements()), + DefineCustom(12, "expand_user_email_login_addresses", "expand_user_email_login_addresses_v1", applyUserEmailAddressSchema), } } diff --git a/server/db/migration/registry_test.go b/server/db/migration/registry_test.go index 47379ab..82ed909 100644 --- a/server/db/migration/registry_test.go +++ b/server/db/migration/registry_test.go @@ -2,6 +2,7 @@ package migration import ( "context" + "database/sql" "strings" "testing" ) @@ -13,7 +14,8 @@ func TestDefaultMigrationsCreateCompleteSQLiteSchema(t *testing.T) { if err != nil { t.Fatal(err) } - if report.CurrentVersion != 11 || len(report.Applied) != 11 { + wantVersion := int64(len(DefaultMigrations())) + if report.CurrentVersion != wantVersion || len(report.Applied) != int(wantVersion) { t.Fatalf("report = %+v", report) } @@ -38,6 +40,29 @@ func TestDefaultMigrationsCreateCompleteSQLiteSchema(t *testing.T) { if !exists { t.Fatal("column webauthn_credential.discoverable was not created") } + for _, column := range []string{"address", "address_canonical", "is_primary", "verified_at", "created_at", "updated_at"} { + exists, err := columnExists(context.Background(), db, DialectSQLite, "user_email", column) + if err != nil { + t.Fatal(err) + } + if !exists { + t.Fatalf("column user_email.%s was not created", column) + } + } + for _, index := range []string{"uq_user_email_address_canonical", "idx_user_email_user_primary"} { + if !sqliteIndexExists(t, db, index) { + t.Fatalf("index %s was not created", index) + } + } +} + +func sqliteIndexExists(t *testing.T, db *sql.DB, index string) bool { + t.Helper() + var count int + if err := db.QueryRow("SELECT count(*) FROM sqlite_master WHERE type='index' AND name=?", index).Scan(&count); err != nil { + t.Fatal(err) + } + return count == 1 } func TestDefaultMigrationsUpgradeLegacyUserSchema(t *testing.T) { @@ -67,6 +92,65 @@ func TestDefaultMigrationsUpgradeLegacyUserSchema(t *testing.T) { } } +func TestUserEmailLoginAddressMigrationUpgradesLegacyTableAndIsIdempotent(t *testing.T) { + db := openSQLite(t) + if _, err := db.Exec(`CREATE TABLE "user_email" ("id" INTEGER PRIMARY KEY AUTOINCREMENT, "user_id" INTEGER NOT NULL, "email_id" INTEGER NOT NULL, "is_read" INTEGER NOT NULL DEFAULT 0, "group_id" INTEGER NOT NULL DEFAULT 0, "status" INTEGER NOT NULL DEFAULT 0, "create" DATETIME NULL)`); err != nil { + t.Fatal(err) + } + + runner := Runner{DB: db, Dialect: DialectSQLite, Migrations: DefaultMigrations()} + if _, err := runner.Run(context.Background()); err != nil { + t.Fatal(err) + } + for _, column := range []string{"address", "address_canonical", "is_primary", "verified_at", "created_at", "updated_at"} { + exists, err := columnExists(context.Background(), db, DialectSQLite, "user_email", column) + if err != nil { + t.Fatal(err) + } + if !exists { + t.Fatalf("legacy table is missing migrated column %s", column) + } + } + for _, index := range []string{"uq_user_email_address_canonical", "idx_user_email_user_primary"} { + if !sqliteIndexExists(t, db, index) { + t.Fatalf("legacy table is missing migrated index %s", index) + } + } + + report, err := runner.Run(context.Background()) + if err != nil { + t.Fatal(err) + } + if len(report.Applied) != 0 || report.CurrentVersion != int64(len(DefaultMigrations())) { + t.Fatalf("repeated migration report = %+v", report) + } +} + +func TestUserEmailLoginAddressMigrationRejectsDuplicateCanonicalAddresses(t *testing.T) { + db := openSQLite(t) + if _, err := db.Exec(`CREATE TABLE "user_email" ("id" INTEGER PRIMARY KEY AUTOINCREMENT, "user_id" INTEGER NOT NULL, "email_id" INTEGER NOT NULL, "address" TEXT NULL, "address_canonical" TEXT NULL, "is_primary" INTEGER NOT NULL DEFAULT 0, "verified_at" DATETIME NULL, "created_at" DATETIME NULL, "updated_at" DATETIME NULL, "is_read" INTEGER NOT NULL DEFAULT 0, "group_id" INTEGER NOT NULL DEFAULT 0, "status" INTEGER NOT NULL DEFAULT 0, "create" DATETIME NULL)`); err != nil { + t.Fatal(err) + } + if _, err := db.Exec(`INSERT INTO "user_email"("user_id", "email_id", "address", "address_canonical") VALUES (1, 1, 'one@example.com', 'duplicate@example.com'), (2, 2, 'two@example.com', 'duplicate@example.com')`); err != nil { + t.Fatal(err) + } + + err := applyUserEmailAddressSchema(context.Background(), db, DialectSQLite) + if err == nil || !strings.Contains(err.Error(), "conflict count: 1") { + t.Fatalf("duplicate canonical address error = %v", err) + } + if sqliteIndexExists(t, db, "uq_user_email_address_canonical") { + t.Fatal("unique index was created despite duplicate canonical addresses") + } +} + +func TestUserEmailLoginAddressMigrationRejectsUnexpectedDialect(t *testing.T) { + err := applyUserEmailAddressSchema(context.Background(), nil, Dialect("unsupported")) + if err == nil || !strings.Contains(err.Error(), "unsupported") { + t.Fatalf("unexpected dialect error = %v", err) + } +} + func TestSecurityEmailMySQLSchemaDoesNotCreateDuplicateIndex(t *testing.T) { statements := securityEmailStatements()[DialectMySQL] for _, statement := range statements { diff --git a/server/db/migration/schema.go b/server/db/migration/schema.go index 6e06953..3bcd4ba 100644 --- a/server/db/migration/schema.go +++ b/server/db/migration/schema.go @@ -15,8 +15,10 @@ func coreSchemaStatements() map[Dialect][]string { `CREATE TABLE IF NOT EXISTS "rule" ("id" INTEGER PRIMARY KEY AUTOINCREMENT, "user_id" INTEGER NOT NULL DEFAULT 0, "name" TEXT NOT NULL DEFAULT '', "value" TEXT NULL, "action" INTEGER NOT NULL DEFAULT 0, "params" TEXT NOT NULL DEFAULT '', "sort" INTEGER NOT NULL DEFAULT 0)`, `CREATE TABLE IF NOT EXISTS "sessions" ("token" TEXT PRIMARY KEY, "data" BLOB NOT NULL, "expiry" REAL NOT NULL)`, `CREATE INDEX IF NOT EXISTS "idx_sessions_expiry" ON "sessions"("expiry")`, - `CREATE TABLE IF NOT EXISTS "user_email" ("id" INTEGER PRIMARY KEY AUTOINCREMENT, "user_id" INTEGER NOT NULL, "email_id" INTEGER NOT NULL, "is_read" INTEGER NOT NULL DEFAULT 0, "group_id" INTEGER NOT NULL DEFAULT 0, "status" INTEGER NOT NULL DEFAULT 0, "create" DATETIME NULL)`, + `CREATE TABLE IF NOT EXISTS "user_email" ("id" INTEGER PRIMARY KEY AUTOINCREMENT, "user_id" INTEGER NOT NULL, "email_id" INTEGER NOT NULL, "address" TEXT NULL, "address_canonical" TEXT NULL, "is_primary" INTEGER NOT NULL DEFAULT 0, "verified_at" DATETIME NULL, "created_at" DATETIME NULL, "updated_at" DATETIME NULL, "is_read" INTEGER NOT NULL DEFAULT 0, "group_id" INTEGER NOT NULL DEFAULT 0, "status" INTEGER NOT NULL DEFAULT 0, "create" DATETIME NULL)`, `CREATE INDEX IF NOT EXISTS "idx_eid" ON "user_email"("user_id", "email_id")`, + `CREATE UNIQUE INDEX IF NOT EXISTS "uq_user_email_address_canonical" ON "user_email"("address_canonical")`, + `CREATE INDEX IF NOT EXISTS "idx_user_email_user_primary" ON "user_email"("user_id", "is_primary")`, `CREATE INDEX IF NOT EXISTS "idx_create_time" ON "user_email"("create")`, `CREATE TABLE IF NOT EXISTS "version" ("id" INTEGER PRIMARY KEY AUTOINCREMENT, "info" TEXT NOT NULL)`, }, @@ -26,7 +28,7 @@ func coreSchemaStatements() map[Dialect][]string { "CREATE TABLE IF NOT EXISTS `group` (`id` INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, `name` VARCHAR(10) NOT NULL DEFAULT '', `parent_id` INT UNSIGNED NOT NULL DEFAULT 0, `user_id` INT UNSIGNED NOT NULL DEFAULT 0, `full_path` VARCHAR(600) NULL) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4", "CREATE TABLE IF NOT EXISTS `rule` (`id` INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, `user_id` INT NOT NULL DEFAULT 0, `name` VARCHAR(255) NOT NULL DEFAULT '', `value` TEXT NULL, `action` INT NOT NULL DEFAULT 0, `params` VARCHAR(255) NOT NULL DEFAULT '', `sort` INT NOT NULL DEFAULT 0) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4", "CREATE TABLE IF NOT EXISTS `sessions` (`token` CHAR(43) NOT NULL PRIMARY KEY, `data` BLOB NOT NULL, `expiry` TIMESTAMP(6) NOT NULL, KEY `idx_sessions_expiry` (`expiry`)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4", - "CREATE TABLE IF NOT EXISTS `user_email` (`id` INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, `user_id` INT NOT NULL, `email_id` INT NOT NULL, `is_read` TINYINT NOT NULL DEFAULT 0, `group_id` INT NOT NULL DEFAULT 0, `status` TINYINT NOT NULL DEFAULT 0, `create` DATETIME(6) NULL, KEY `idx_eid` (`user_id`, `email_id`), KEY `idx_create_time` (`create`)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4", + "CREATE TABLE IF NOT EXISTS `user_email` (`id` INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, `user_id` INT NOT NULL, `email_id` INT NOT NULL, `address` VARCHAR(254) NULL, `address_canonical` VARCHAR(254) NULL, `is_primary` TINYINT NOT NULL DEFAULT 0, `verified_at` DATETIME(6) NULL, `created_at` DATETIME(6) NULL, `updated_at` DATETIME(6) NULL, `is_read` TINYINT NOT NULL DEFAULT 0, `group_id` INT NOT NULL DEFAULT 0, `status` TINYINT NOT NULL DEFAULT 0, `create` DATETIME(6) NULL, KEY `idx_eid` (`user_id`, `email_id`), UNIQUE KEY `uq_user_email_address_canonical` (`address_canonical`), KEY `idx_user_email_user_primary` (`user_id`, `is_primary`), KEY `idx_create_time` (`create`)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4", "CREATE TABLE IF NOT EXISTS `version` (`id` INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, `info` VARCHAR(255) NOT NULL) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4", }, DialectPostgres: { @@ -36,8 +38,10 @@ func coreSchemaStatements() map[Dialect][]string { `CREATE TABLE IF NOT EXISTS "rule" ("id" BIGSERIAL PRIMARY KEY, "user_id" BIGINT NOT NULL DEFAULT 0, "name" VARCHAR(255) NOT NULL DEFAULT '', "value" TEXT NULL, "action" INTEGER NOT NULL DEFAULT 0, "params" VARCHAR(255) NOT NULL DEFAULT '', "sort" INTEGER NOT NULL DEFAULT 0)`, `CREATE TABLE IF NOT EXISTS "sessions" ("token" VARCHAR(43) PRIMARY KEY, "data" BYTEA NOT NULL, "expiry" TIMESTAMPTZ NOT NULL)`, `CREATE INDEX IF NOT EXISTS "idx_sessions_expiry" ON "sessions"("expiry")`, - `CREATE TABLE IF NOT EXISTS "user_email" ("id" BIGSERIAL PRIMARY KEY, "user_id" BIGINT NOT NULL, "email_id" BIGINT NOT NULL, "is_read" SMALLINT NOT NULL DEFAULT 0, "group_id" BIGINT NOT NULL DEFAULT 0, "status" SMALLINT NOT NULL DEFAULT 0, "create" TIMESTAMPTZ NULL)`, + `CREATE TABLE IF NOT EXISTS "user_email" ("id" BIGSERIAL PRIMARY KEY, "user_id" BIGINT NOT NULL, "email_id" BIGINT NOT NULL, "address" VARCHAR(254) NULL, "address_canonical" VARCHAR(254) NULL, "is_primary" SMALLINT NOT NULL DEFAULT 0, "verified_at" TIMESTAMPTZ NULL, "created_at" TIMESTAMPTZ NULL, "updated_at" TIMESTAMPTZ NULL, "is_read" SMALLINT NOT NULL DEFAULT 0, "group_id" BIGINT NOT NULL DEFAULT 0, "status" SMALLINT NOT NULL DEFAULT 0, "create" TIMESTAMPTZ NULL)`, `CREATE INDEX IF NOT EXISTS "idx_eid" ON "user_email"("user_id", "email_id")`, + `CREATE UNIQUE INDEX IF NOT EXISTS "uq_user_email_address_canonical" ON "user_email"("address_canonical")`, + `CREATE INDEX IF NOT EXISTS "idx_user_email_user_primary" ON "user_email"("user_id", "is_primary")`, `CREATE INDEX IF NOT EXISTS "idx_create_time" ON "user_email"("create")`, `CREATE TABLE IF NOT EXISTS "version" ("id" BIGSERIAL PRIMARY KEY, "info" VARCHAR(255) NOT NULL)`, }, diff --git a/server/db/migration/user_email.go b/server/db/migration/user_email.go new file mode 100644 index 0000000..d15d3ea --- /dev/null +++ b/server/db/migration/user_email.go @@ -0,0 +1,69 @@ +package migration + +import ( + "context" + "fmt" +) + +func applyUserEmailAddressSchema(ctx context.Context, exec executor, dialect Dialect) error { + columns := []struct { + name string + definition map[Dialect]string + }{ + {name: "address", definition: map[Dialect]string{ + DialectSQLite: `TEXT NULL`, + DialectMySQL: `VARCHAR(254) NULL`, + DialectPostgres: `VARCHAR(254) NULL`, + }}, + {name: "address_canonical", definition: map[Dialect]string{ + DialectSQLite: `TEXT NULL`, + DialectMySQL: `VARCHAR(254) NULL`, + DialectPostgres: `VARCHAR(254) NULL`, + }}, + {name: "is_primary", definition: map[Dialect]string{ + DialectSQLite: `INTEGER NOT NULL DEFAULT 0`, + DialectMySQL: `TINYINT NOT NULL DEFAULT 0`, + DialectPostgres: `SMALLINT NOT NULL DEFAULT 0`, + }}, + {name: "verified_at", definition: map[Dialect]string{ + DialectSQLite: `DATETIME NULL`, + DialectMySQL: `DATETIME(6) NULL`, + DialectPostgres: `TIMESTAMPTZ NULL`, + }}, + {name: "created_at", definition: map[Dialect]string{ + DialectSQLite: `DATETIME NULL`, + DialectMySQL: `DATETIME(6) NULL`, + DialectPostgres: `TIMESTAMPTZ NULL`, + }}, + {name: "updated_at", definition: map[Dialect]string{ + DialectSQLite: `DATETIME NULL`, + DialectMySQL: `DATETIME(6) NULL`, + DialectPostgres: `TIMESTAMPTZ NULL`, + }}, + } + for _, column := range columns { + if err := ensureColumn(ctx, exec, dialect, "user_email", column.name, column.definition[dialect]); err != nil { + return err + } + } + + var conflicts int + conflictQuery := `SELECT COUNT(*) FROM (SELECT "address_canonical" FROM "user_email" WHERE "address_canonical" IS NOT NULL AND TRIM("address_canonical") <> '' GROUP BY "address_canonical" HAVING COUNT(*) > 1) AS conflicts` + if dialect == DialectMySQL { + conflictQuery = "SELECT COUNT(*) FROM (SELECT `address_canonical` FROM `user_email` WHERE `address_canonical` IS NOT NULL AND TRIM(`address_canonical`) <> '' GROUP BY `address_canonical` HAVING COUNT(*) > 1) AS conflicts" + } + if dialect == DialectPostgres { + conflictQuery = `SELECT COUNT(*) FROM (SELECT "address_canonical" FROM "user_email" WHERE "address_canonical" IS NOT NULL AND BTRIM("address_canonical") <> '' GROUP BY "address_canonical" HAVING COUNT(*) > 1) AS conflicts` + } + if err := exec.QueryRowContext(ctx, conflictQuery).Scan(&conflicts); err != nil { + return fmt.Errorf("check user email address conflicts: %w", err) + } + if conflicts > 0 { + return fmt.Errorf("user email address conflict count: %d", conflicts) + } + + if err := ensureIndex(ctx, exec, dialect, "user_email", "uq_user_email_address_canonical", true, []string{"address_canonical"}); err != nil { + return err + } + return ensureIndex(ctx, exec, dialect, "user_email", "idx_user_email_user_primary", false, []string{"user_id", "is_primary"}) +} diff --git a/server/models/user_email.go b/server/models/user_email.go index 819d5c0..2d0fe19 100644 --- a/server/models/user_email.go +++ b/server/models/user_email.go @@ -3,13 +3,19 @@ package models import "time" type UserEmail struct { - ID int `xorm:"id int unsigned not null pk autoincr"` - UserID int `xorm:"user_id int not null index('idx_eid') index comment('用户id')"` - EmailID int `xorm:"email_id not null index('idx_eid') index comment('信件id')"` - IsRead int8 `xorm:"is_read tinyint(1) comment('是否已读')" json:"is_read"` - GroupId int `xorm:"group_id int notnull default(0) comment('分组id')'" json:"group_id"` - Status int8 `xorm:"status tinyint(4) notnull default(0) comment('0未发送或收件,1已发送,2发送失败,3删除 4草稿 5广告')" json:"status"` // 0未发送或收件,1已发送,2发送失败 3删除 4草稿箱(Drafts) 5骚扰邮件(Junk) - Created time.Time `xorm:"create datetime created index('idx_create_time')"` + ID int `xorm:"id int unsigned not null pk autoincr"` + UserID int `xorm:"user_id int not null index('idx_eid') index('idx_user_email_user_primary')"` + EmailID int `xorm:"email_id not null index('idx_eid') index"` + Address string `xorm:"address varchar(254) null" json:"address,omitempty"` + AddressCanonical string `xorm:"address_canonical varchar(254) null unique" json:"-"` + IsPrimary int8 `xorm:"is_primary tinyint(1) notnull default(0)" json:"is_primary"` + VerifiedAt *time.Time `xorm:"verified_at datetime null" json:"verified_at,omitempty"` + CreatedAt time.Time `xorm:"created_at datetime created" json:"created_at"` + UpdatedAt time.Time `xorm:"updated_at datetime updated" json:"updated_at"` + IsRead int8 `xorm:"is_read tinyint(1) comment('是否已读')" json:"is_read"` + GroupId int `xorm:"group_id int notnull default(0) comment('分组id')'" json:"group_id"` + Status int8 `xorm:"status tinyint(4) notnull default(0) comment('0未发送或收件,1已发送,2发送失败,3删除 4草稿 5广告')" json:"status"` // 0未发送或收件,1已发送,2发送失败 3删除 4草稿箱(Drafts) 5骚扰邮件(Junk) + Created time.Time `xorm:"create datetime created index('idx_create_time')"` } func (p UserEmail) TableName() string { diff --git a/server/services/auth/auth.go b/server/services/auth/auth.go index 1bca8e1..f2523dd 100644 --- a/server/services/auth/auth.go +++ b/server/services/auth/auth.go @@ -4,7 +4,6 @@ import ( "database/sql" "errors" "fmt" - "net/mail" "path/filepath" "strings" @@ -23,66 +22,19 @@ func NormalizeAccount(account string) string { return strings.TrimSpace(account) } -// AuthenticateIdentifier 支持账号和受信主域名邮箱两种登录标识,最终统一到同一账号密码校验。 +// AuthenticateIdentifier 支持账号和已验证邮箱两种登录标识,最终统一到同一账号密码校验。 func AuthenticateIdentifier(identifier, plain string) (*models.User, error) { - account, err := NormalizeIdentifier(identifier) + identity, err := ResolveIdentifier(identifier) if err != nil { if plain != "" { password.VerifyDummy(plain) } return nil, errors.New("invalid credentials") } - return Authenticate(account, plain) -} - -// NormalizeIdentifier 将完整邮箱解析为本地账号,并拒绝未配置的域名。 -func NormalizeIdentifier(identifier string) (string, error) { - identifier = strings.TrimSpace(identifier) - if identifier == "" || strings.ContainsAny(identifier, "\r\n") { - return "", errors.New("invalid identifier") + if identity.Kind == IdentifierEmail { + return authenticateUserByID(identity.UserID, plain) } - if !strings.Contains(identifier, "@") { - return validation.NormalizeAccount(identifier) - } - identifier = strings.ToLower(identifier) - parsed, err := mail.ParseAddress(identifier) - if err != nil || parsed.Address != identifier { - return "", errors.New("invalid identifier") - } - parts := strings.Split(identifier, "@") - if len(parts) != 2 || parts[0] == "" || !isConfiguredLoginDomain(parts[1]) { - return "", errors.New("invalid identifier") - } - return validation.NormalizeAccount(parts[0]) -} - -func isConfiguredLoginDomain(domain string) bool { - domain = strings.TrimSuffix(strings.ToLower(strings.TrimSpace(domain)), ".") - if manager := config.CurrentManager(); manager != nil { - runtime := manager.Snapshot() - if strings.TrimSuffix(strings.ToLower(strings.TrimSpace(runtime.Root.Domains.Primary)), ".") == domain { - return true - } - for _, item := range runtime.Root.Domains.Accepted { - if strings.TrimSuffix(strings.ToLower(strings.TrimSpace(item)), ".") == domain { - return true - } - } - return false - } - current := config.CurrentConfig() - if current == nil { - return false - } - if strings.TrimSuffix(strings.ToLower(strings.TrimSpace(current.Domain)), ".") == domain { - return true - } - for _, item := range current.Domains { - if strings.TrimSuffix(strings.ToLower(strings.TrimSpace(item)), ".") == domain { - return true - } - } - return false + return Authenticate(identity.Account, plain) } func Authenticate(account, plain string) (*models.User, error) { @@ -93,6 +45,25 @@ func Authenticate(account, plain string) (*models.User, error) { } var user models.User has, err := db.Instance.Where("account = ? and disabled = 0", account).Get(&user) + return authenticateLoadedUser(&user, has, err, plain) +} + +func authenticateUserByID(userID int, plain string) (*models.User, error) { + if userID <= 0 { + if plain != "" { + password.VerifyDummy(plain) + } + return nil, errors.New("invalid credentials") + } + if plain == "" || len([]byte(plain)) > 256 { + return nil, errors.New("invalid credentials") + } + var user models.User + has, err := db.Instance.ID(userID).Where("disabled = 0").Get(&user) + return authenticateLoadedUser(&user, has, err, plain) +} + +func authenticateLoadedUser(user *models.User, has bool, err error, plain string) (*models.User, error) { if err != nil { if errors.Is(err, sql.ErrNoRows) { password.VerifyDummy(plain) @@ -107,7 +78,7 @@ func Authenticate(account, plain string) (*models.User, error) { if !password.Verify(user.Password, plain) { return nil, errors.New("invalid credentials") } - return &user, nil + return user, nil } // HashPassword 统一生成新密码哈希。 diff --git a/server/services/auth/auth_test.go b/server/services/auth/auth_test.go index 9e0d9f1..b83e380 100644 --- a/server/services/auth/auth_test.go +++ b/server/services/auth/auth_test.go @@ -4,6 +4,7 @@ import ( "path/filepath" "strings" "testing" + "time" "git.pcux.cn/xuans/Smail/config" "git.pcux.cn/xuans/Smail/db" @@ -34,7 +35,6 @@ func TestAuthenticateUsesArgon2idOnly(t *testing.T) { if _, err := db.Instance.Insert(&user); err != nil { t.Fatal(err) } - var loaded models.User if _, err := db.Instance.Where("account = ?", "test-user").Get(&loaded); err != nil { t.Fatal(err) @@ -134,6 +134,15 @@ func TestProtocolAuthenticationAcceptsConfiguredFullEmailIdentifier(t *testing.T if _, err := db.Instance.Insert(&user); err != nil { t.Fatal(err) } + verifiedAt := time.Now().UTC() + if _, err := db.Instance.Insert(&models.UserEmail{ + UserID: user.ID, + Address: "alice@example.com", + AddressCanonical: "alice@example.com", + VerifiedAt: &verifiedAt, + }); err != nil { + t.Fatal(err) + } for _, protocol := range []string{"smtp", "imap", "pop3"} { got, err := AuthenticateProtocol(protocol, "192.0.2.10:12345", " Alice@Example.com ", "CorrectP12") diff --git a/server/services/auth/identifier.go b/server/services/auth/identifier.go new file mode 100644 index 0000000..b1edba6 --- /dev/null +++ b/server/services/auth/identifier.go @@ -0,0 +1,158 @@ +package auth + +import ( + "errors" + "fmt" + "net/mail" + "strings" + "unicode" + + "git.pcux.cn/xuans/Smail/config" + "git.pcux.cn/xuans/Smail/db" + "git.pcux.cn/xuans/Smail/models" + "git.pcux.cn/xuans/Smail/utils/validation" +) + +const maxLoginIdentifierBytes = 254 + +type IdentifierKind string + +const ( + IdentifierAccount IdentifierKind = "account" + IdentifierEmail IdentifierKind = "email" +) + +type ResolvedIdentity struct { + UserID int + Account string + LoginAddress string + Kind IdentifierKind +} + +// NormalizeIdentifier 保留旧调用方的账号规范化行为。 +// 完整邮箱的认证解析由 ResolveIdentifier 负责,避免在这里截断域名。 +func NormalizeIdentifier(identifier string) (string, error) { + normalized, kind, err := parseLoginIdentifier(identifier) + if err != nil { + return "", err + } + if kind == IdentifierAccount { + return validation.NormalizeAccount(normalized) + } + parts := strings.SplitN(normalized, "@", 2) + if len(parts) != 2 || !isConfiguredLoginDomain(parts[1]) { + return "", errors.New("invalid identifier") + } + return validation.NormalizeAccount(parts[0]) +} + +// NormalizeIdentifierKey 返回限流和审计使用的稳定标识,不泄露原始输入格式。 +func NormalizeIdentifierKey(identifier string) (string, error) { + normalized, _, err := parseLoginIdentifier(identifier) + return normalized, err +} + +// ResolveIdentifier 将账号或已验证邮箱绑定解析为唯一用户身份。 +func ResolveIdentifier(identifier string) (ResolvedIdentity, error) { + normalized, kind, err := parseLoginIdentifier(identifier) + if err != nil { + return ResolvedIdentity{}, err + } + if kind == IdentifierAccount { + return ResolvedIdentity{Account: normalized, Kind: IdentifierAccount}, nil + } + if db.Instance == nil { + return ResolvedIdentity{}, errors.New("database is unavailable") + } + + var bindings []models.UserEmail + if err := db.Instance.Table(&models.UserEmail{}). + Where("address_canonical = ? AND verified_at IS NOT NULL AND status != ?", normalized, 3). + Limit(2). + Find(&bindings); err != nil { + return ResolvedIdentity{}, fmt.Errorf("load login email binding: %w", err) + } + if len(bindings) != 1 || bindings[0].UserID <= 0 { + return ResolvedIdentity{}, errors.New("invalid identifier") + } + + var user models.User + has, err := db.Instance.ID(bindings[0].UserID).Get(&user) + if err != nil { + return ResolvedIdentity{}, fmt.Errorf("load login user: %w", err) + } + if !has || user.ID <= 0 || user.Account == "" { + return ResolvedIdentity{}, errors.New("invalid identifier") + } + return ResolvedIdentity{ + UserID: user.ID, + Account: user.Account, + LoginAddress: normalized, + Kind: IdentifierEmail, + }, nil +} + +func parseLoginIdentifier(identifier string) (string, IdentifierKind, error) { + identifier = strings.TrimSpace(identifier) + if identifier == "" || len([]byte(identifier)) > maxLoginIdentifierBytes || strings.ContainsAny(identifier, "\r\n") { + return "", "", errors.New("invalid identifier") + } + if !strings.Contains(identifier, "@") { + account, err := validation.NormalizeAccount(identifier) + if err != nil { + return "", "", errors.New("invalid identifier") + } + return account, IdentifierAccount, nil + } + if strings.Count(identifier, "@") != 1 { + return "", "", errors.New("invalid identifier") + } + + normalized := strings.ToLower(identifier) + parsed, err := mail.ParseAddress(normalized) + if err != nil || parsed.Address != normalized { + return "", "", errors.New("invalid identifier") + } + parts := strings.SplitN(normalized, "@", 2) + if len(parts) != 2 || parts[0] == "" { + return "", "", errors.New("invalid identifier") + } + if _, err := validation.NormalizeDomain(parts[1]); err != nil { + return "", "", errors.New("invalid identifier") + } + for _, char := range parts[0] { + if unicode.IsSpace(char) || unicode.IsControl(char) || strings.ContainsRune(`"(),:;<>[\\]`, char) { + return "", "", errors.New("invalid identifier") + } + } + return normalized, IdentifierEmail, nil +} + +func isConfiguredLoginDomain(domain string) bool { + domain = strings.TrimSuffix(strings.ToLower(strings.TrimSpace(domain)), ".") + if manager := config.CurrentManager(); manager != nil { + runtime := manager.Snapshot() + if strings.TrimSuffix(strings.ToLower(strings.TrimSpace(runtime.Root.Domains.Primary)), ".") == domain { + return true + } + for _, item := range runtime.Root.Domains.Accepted { + if strings.TrimSuffix(strings.ToLower(strings.TrimSpace(item)), ".") == domain { + return true + } + } + return false + } + current := config.CurrentConfig() + if current == nil { + return false + } + if strings.TrimSuffix(strings.ToLower(strings.TrimSpace(current.Domain)), ".") == domain { + return true + } + for _, item := range current.Domains { + if strings.TrimSuffix(strings.ToLower(strings.TrimSpace(item)), ".") == domain { + return true + } + } + return false +} diff --git a/server/services/auth/identifier_test.go b/server/services/auth/identifier_test.go new file mode 100644 index 0000000..9693ba7 --- /dev/null +++ b/server/services/auth/identifier_test.go @@ -0,0 +1,92 @@ +package auth + +import ( + "path/filepath" + "strings" + "testing" + "time" + + "git.pcux.cn/xuans/Smail/config" + "git.pcux.cn/xuans/Smail/db" + "git.pcux.cn/xuans/Smail/models" +) + +func TestResolveIdentifierUsesVerifiedFullEmailBinding(t *testing.T) { + oldConfig := config.Instance + oldDB := db.Instance + config.Instance = &config.Config{ + DbType: config.DBTypeSQLite, + DbDSN: filepath.Join(t.TempDir(), "identifier.db"), + } + if err := db.Init(""); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + if db.Instance != nil && db.Instance != oldDB { + _ = db.Instance.Close() + } + db.Instance = oldDB + config.Instance = oldConfig + }) + + user := models.User{Account: "alice", Name: "Alice"} + if err := SetPassword(&user, "CorrectP12"); err != nil { + t.Fatal(err) + } + if _, err := db.Instance.Insert(&user); err != nil { + t.Fatal(err) + } + verifiedAt := time.Now().UTC() + if _, err := db.Instance.Insert(&models.UserEmail{ + UserID: user.ID, + Address: "Alice@Example.com", + AddressCanonical: "alice@example.com", + VerifiedAt: &verifiedAt, + }); err != nil { + t.Fatal(err) + } + if _, err := db.Instance.Insert(&models.UserEmail{ + UserID: user.ID, + Address: "pending@example.com", + AddressCanonical: "pending@example.com", + }); err != nil { + t.Fatal(err) + } + + identity, err := ResolveIdentifier(" Alice@EXAMPLE.COM ") + if err != nil { + t.Fatalf("resolve verified email: %v", err) + } + if identity.Kind != IdentifierEmail || identity.UserID != user.ID || identity.Account != user.Account || identity.LoginAddress != "alice@example.com" { + t.Fatalf("resolved identity = %+v", identity) + } + if _, err := AuthenticateIdentifier("Alice@Example.com", "CorrectP12"); err != nil { + t.Fatalf("authenticate verified email: %v", err) + } + if _, err := AuthenticateIdentifier("pending@example.com", "CorrectP12"); err == nil { + t.Fatal("unverified email binding was accepted") + } +} + +func TestResolveIdentifierRejectsMalformedOrOversizedEmail(t *testing.T) { + for _, identifier := range []string{ + "alice@@example.com", + "Alice ", + "alice@example.com\r\nX-Header: value", + strings.Repeat("a", maxLoginIdentifierBytes+1), + "alice@-invalid.example", + } { + if _, err := NormalizeIdentifierKey(identifier); err == nil { + t.Fatalf("identifier %q was accepted", identifier) + } + } +} + +func TestNormalizeIdentifierKeepsLegacyAccountCompatibility(t *testing.T) { + if got, err := NormalizeIdentifier("alice"); err != nil || got != "alice" { + t.Fatalf("normalized account = %q, err = %v", got, err) + } + if got, err := NormalizeIdentifierKey(" Alice@Example.com "); err != nil || got != "alice@example.com" { + t.Fatalf("normalized email key = %q, err = %v", got, err) + } +} diff --git a/server/services/auth/protocol_limiter.go b/server/services/auth/protocol_limiter.go index 4cc05e5..ad7245c 100644 --- a/server/services/auth/protocol_limiter.go +++ b/server/services/auth/protocol_limiter.go @@ -43,8 +43,8 @@ var ( func AuthenticateProtocol(protocol string, remoteAddress string, account string, plain string) (*models.User, error) { normalizedAccount := strings.ToLower(strings.TrimSpace(account)) - if resolvedAccount, err := NormalizeIdentifier(account); err == nil { - normalizedAccount = resolvedAccount + if normalizedIdentifier, err := NormalizeIdentifierKey(account); err == nil { + normalizedAccount = normalizedIdentifier } normalizedRemote := protocolRemoteHost(remoteAddress) pairKey := hashedProtocolLoginKey("pair", protocol, normalizedRemote, normalizedAccount)