From 00e6c51abde8784d40d08462766b2f685de0e0b6 Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Tue, 22 Sep 2026 14:03:31 +0200 Subject: [PATCH] feat(07-02): throttle failed logins and pick mail template locale Co-authored-by: Cursor --- classes/mail.go | 28 ++++++ classes/mail_test.go | 16 ++++ classes/throttle.go | 93 +++++++++++++++++++ classes/throttle_test.go | 131 +++++++++++++++++++++++++++ classes/user_lookup.go | 26 +++++- models/user.go | 49 +++++----- updates/202609220005_extend_users.go | 2 + 7 files changed, 322 insertions(+), 23 deletions(-) create mode 100644 classes/mail.go create mode 100644 classes/mail_test.go create mode 100644 classes/throttle.go create mode 100644 classes/throttle_test.go diff --git a/classes/mail.go b/classes/mail.go new file mode 100644 index 0000000..96cd29c --- /dev/null +++ b/classes/mail.go @@ -0,0 +1,28 @@ +package classes + +import ( + "context" + + "git.golem15.com/golem15/fonoteka/plugins/golem15/user/models" + "git.golem15.com/golem15/summercms/towel" +) + +// mailTemplate appends -en for English and leaves every other locale, +// including empty and pl, on the unsuffixed Polish template name. +func mailTemplate(base, locale string) string { + if locale == "en" { + return base + "-en" + } + return base +} + +// resolveMailLocale prefers the user's saved locale, then the request locale. +func resolveMailLocale(ctx context.Context, user *models.User) string { + if user != nil && user.PreferredLocale != nil && *user.PreferredLocale != "" { + return *user.PreferredLocale + } + if loc, ok := towel.Locale(ctx); ok { + return loc + } + return "" +} diff --git a/classes/mail_test.go b/classes/mail_test.go new file mode 100644 index 0000000..a81947e --- /dev/null +++ b/classes/mail_test.go @@ -0,0 +1,16 @@ +package classes + +import "testing" + +func TestMailTemplate(t *testing.T) { + base := "golem15.user::mail.reactivate" + if got := mailTemplate(base, "pl"); got != base { + t.Fatalf("pl = %s", got) + } + if got := mailTemplate(base, ""); got != base { + t.Fatalf("empty = %s", got) + } + if got := mailTemplate(base, "en"); got != base+"-en" { + t.Fatalf("en = %s", got) + } +} diff --git a/classes/throttle.go b/classes/throttle.go new file mode 100644 index 0000000..6840482 --- /dev/null +++ b/classes/throttle.go @@ -0,0 +1,93 @@ +package classes + +import ( + "context" + "errors" + "time" + + "git.golem15.com/golem15/fonoteka/plugins/golem15/user/models" + "gorm.io/gorm" +) + +var ( + errBanned = errors.New("user is banned") + errSuspended = errors.New("user is suspended") +) + +type throttleSettings struct { + use bool + limit int + minutes int +} + +type throttleSettingsKey struct{} + +func withThrottle(ctx context.Context, s throttleSettings) context.Context { + if ctx == nil { + ctx = context.Background() + } + return context.WithValue(ctx, throttleSettingsKey{}, s) +} + +func throttleFrom(ctx context.Context) throttleSettings { + if ctx != nil { + if s, ok := ctx.Value(throttleSettingsKey{}).(throttleSettings); ok { + return s + } + } + return throttleSettings{use: true, limit: 5, minutes: 15} +} + +// CheckAndRecordLogin applies Winter's failed-login throttle. +// Call it once before the password check (ok=false) to enforce a ban or +// suspension, and again after the outcome is known. A nil user is ignored +// so an unknown email never creates a row. +func CheckAndRecordLogin(ctx context.Context, db *gorm.DB, user *models.User, ip string, ok bool) error { + if user == nil || user.ID == 0 || db == nil { + return nil + } + settings := throttleFrom(ctx) + if !settings.use { + return nil + } + if settings.limit <= 0 { + settings.limit = 5 + } + if settings.minutes <= 0 { + settings.minutes = 15 + } + var row models.Throttle + err := db.WithContext(ctx). + Where("user_id = ? AND (ip_address = ? OR ip_address IS NULL)", user.ID, ip). + Order("ip_address NULLS LAST"). + Take(&row).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + row = models.Throttle{UserID: user.ID, IPAddress: &ip} + if err := db.WithContext(ctx).Create(&row).Error; err != nil { + return err + } + } else if err != nil { + return err + } + if row.IsBanned { + return errBanned + } + window := time.Duration(settings.minutes) * time.Minute + if row.IsSuspended && row.SuspendedAt != nil && time.Now().Before(row.SuspendedAt.Add(window)) { + return errSuspended + } + if ok { + return db.WithContext(ctx).Model(&row).Updates(map[string]any{ + "attempts": 0, + "is_suspended": false, + "suspended_at": nil, + }).Error + } + attempts := row.Attempts + 1 + updates := map[string]any{"attempts": attempts} + if attempts >= settings.limit { + updates["is_suspended"] = true + updates["suspended_at"] = time.Now() + } + return db.WithContext(ctx).Model(&row).Updates(updates).Error +} diff --git a/classes/throttle_test.go b/classes/throttle_test.go new file mode 100644 index 0000000..141292c --- /dev/null +++ b/classes/throttle_test.go @@ -0,0 +1,131 @@ +package classes + +import ( + "context" + "database/sql" + "testing" + "time" + + "git.golem15.com/golem15/fonoteka/plugins/golem15/user/models" + "git.golem15.com/golem15/fonoteka/plugins/golem15/user/updates" + "git.golem15.com/golem15/summercms/lagoon" + "github.com/go-gormigrate/gormigrate/v2" + _ "github.com/jackc/pgx/v5/stdlib" + "github.com/testcontainers/testcontainers-go" + "github.com/testcontainers/testcontainers-go/modules/postgres" + "gorm.io/gorm" +) + +func TestCheckAndRecordLogin(t *testing.T) { + db := throttleDB(t) + user := &models.User{Email: "ada@example.test", Password: "x"} + if err := db.Create(user).Error; err != nil { + t.Fatal(err) + } + ctx := t.Context() + for i := 1; i <= 5; i++ { + if err := CheckAndRecordLogin(ctx, db, user, "10.0.0.1", false); err != nil { + t.Fatalf("attempt %d: %v", i, err) + } + } + var row models.Throttle + if err := db.Where("user_id = ?", user.ID).Take(&row).Error; err != nil { + t.Fatal(err) + } + if row.Attempts != 5 || !row.IsSuspended { + t.Fatalf("after 5: %+v", row) + } + if err := CheckAndRecordLogin(ctx, db, user, "10.0.0.1", false); err == nil { + t.Fatal("6th attempt must fail") + } + + past := time.Now().Add(-16 * time.Minute) + if err := db.Model(&row).Updates(map[string]any{"is_suspended": true, "suspended_at": past, "attempts": 5}).Error; err != nil { + t.Fatal(err) + } + if err := CheckAndRecordLogin(ctx, db, user, "10.0.0.1", false); err != nil { + t.Fatalf("after window: %v", err) + } + + if err := db.Model(&models.Throttle{}).Where("user_id = ?", user.ID).Update("is_banned", true).Error; err != nil { + t.Fatal(err) + } + if err := CheckAndRecordLogin(ctx, db, user, "10.0.0.1", false); err == nil { + t.Fatal("banned user must fail") + } + + other := &models.User{Email: "bea@example.test", Password: "x"} + if err := db.Create(other).Error; err != nil { + t.Fatal(err) + } + if err := CheckAndRecordLogin(ctx, db, other, "10.0.0.2", false); err != nil { + t.Fatal(err) + } + if err := CheckAndRecordLogin(ctx, db, other, "10.0.0.2", true); err != nil { + t.Fatal(err) + } + var cleared models.Throttle + if err := db.Where("user_id = ?", other.ID).Take(&cleared).Error; err != nil { + t.Fatal(err) + } + if cleared.Attempts != 0 || cleared.IsSuspended { + t.Fatalf("reset = %+v", cleared) + } + + before := countThrottle(t, db) + if err := CheckAndRecordLogin(ctx, db, nil, "10.0.0.9", false); err != nil { + t.Fatal(err) + } + if countThrottle(t, db) != before { + t.Fatal("nil user created a throttle row") + } +} + +func countThrottle(t *testing.T, db *gorm.DB) int64 { + t.Helper() + var n int64 + if err := db.Model(&models.Throttle{}).Count(&n).Error; err != nil { + t.Fatal(err) + } + return n +} + +func throttleDB(t *testing.T) *gorm.DB { + t.Helper() + ctx, cancel := context.WithTimeout(t.Context(), 2*time.Minute) + t.Cleanup(cancel) + ctr, err := postgres.Run(ctx, "postgres:16-alpine", + postgres.WithDatabase("throttle"), + postgres.WithUsername("throttle"), + postgres.WithPassword("throttle"), + postgres.BasicWaitStrategies(), + testcontainers.WithEnv(map[string]string{ + "POSTGRES_INITDB_ARGS": "--locale-provider=icu --icu-locale=pl-PL --encoding=UTF8", + }), + ) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = testcontainers.TerminateContainer(ctr) }) + dsn, err := ctr.ConnectionString(ctx, "sslmode=disable") + if err != nil { + t.Fatal(err) + } + sqlDB, err := sql.Open("pgx", dsn) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = sqlDB.Close() }) + gdb, err := lagoon.Use(ctx, sqlDB) + if err != nil { + t.Fatal(err) + } + m := gormigrate.New(gdb, &gormigrate.Options{ + TableName: "summer_migrations_golem15_user", + UseTransaction: true, + }, updates.All()) + if err := m.Migrate(); err != nil { + t.Fatal(err) + } + return gdb +} diff --git a/classes/user_lookup.go b/classes/user_lookup.go index 1f57bf9..2d607bb 100644 --- a/classes/user_lookup.go +++ b/classes/user_lookup.go @@ -35,7 +35,31 @@ func (g GormUsers) FindByID(ctx context.Context, id uint) (*bouncer.Principal, e if err != nil { return nil, err } - return &bouncer.Principal{ID: row.ID, MustChangePassword: row.MustChangePassword}, nil + principal := &bouncer.Principal{ID: row.ID, MustChangePassword: row.MustChangePassword} + if row.PreferredLocale != nil { + principal.PreferredLocale = *row.PreferredLocale + } + if row.TokensValidAfter != nil { + principal.TokensValidAfter = *row.TokensValidAfter + } + return principal, nil +} + +// LookupByEmail returns the user row, including a soft-deleted one. +// A missing email is (nil, nil). +func LookupByEmail(ctx context.Context, db *gorm.DB, email string) (*models.User, error) { + if db == nil { + return nil, fmt.Errorf("golem15.user: gorm handle is missing") + } + var row models.User + err := db.WithContext(ctx).Unscoped().Where("email = ?", email).Take(&row).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, nil + } + if err != nil { + return nil, err + } + return &row, nil } // JWTSecret reads golem15.user.jwt.secret and fails loudly when it is empty. diff --git a/models/user.go b/models/user.go index ca62cb6..4d62b20 100644 --- a/models/user.go +++ b/models/user.go @@ -1,30 +1,35 @@ package models -import "time" +import ( + "time" + + "gorm.io/gorm" +) // User is the persisted account the JWT guard and user API load. type User struct { - ID uint `gorm:"column:id;primaryKey"` - Email string `gorm:"column:email"` - Password string `gorm:"column:password" json:"-"` - MustChangePassword bool `gorm:"column:must_change_password"` - Name *string `gorm:"column:name"` - Surname *string `gorm:"column:surname"` - IsActivated bool `gorm:"column:is_activated"` - ActivatedAt *time.Time `gorm:"column:activated_at"` - ActivationCode *string `gorm:"column:activation_code" json:"-"` - ActivationCodeIssuedAt *time.Time `gorm:"column:activation_code_issued_at"` - ResetPasswordCode *string `gorm:"column:reset_password_code" json:"-"` - ResetPasswordCodeIssuedAt *time.Time `gorm:"column:reset_password_code_issued_at"` - HasSelfSetPassword bool `gorm:"column:has_self_set_password"` - MarketingConsent bool `gorm:"column:marketing_consent"` - IsOnboarded bool `gorm:"column:is_onboarded"` - OrganisationID *uint `gorm:"column:organisation_id"` - OrganisationRole *string `gorm:"column:organisation_role"` - PreferredLocale *string `gorm:"column:preferred_locale"` - TokensValidAfter *time.Time `gorm:"column:tokens_valid_after"` - CreatedIPAddress *string `gorm:"column:created_ip_address"` - LastIPAddress *string `gorm:"column:last_ip_address"` + ID uint `gorm:"column:id;primaryKey"` + Email string `gorm:"column:email"` + Password string `gorm:"column:password" json:"-"` + MustChangePassword bool `gorm:"column:must_change_password"` + Name *string `gorm:"column:name"` + Surname *string `gorm:"column:surname"` + IsActivated bool `gorm:"column:is_activated"` + ActivatedAt *time.Time `gorm:"column:activated_at"` + ActivationCode *string `gorm:"column:activation_code" json:"-"` + ActivationCodeIssuedAt *time.Time `gorm:"column:activation_code_issued_at"` + ResetPasswordCode *string `gorm:"column:reset_password_code" json:"-"` + ResetPasswordCodeIssuedAt *time.Time `gorm:"column:reset_password_code_issued_at"` + HasSelfSetPassword bool `gorm:"column:has_self_set_password"` + MarketingConsent bool `gorm:"column:marketing_consent"` + IsOnboarded bool `gorm:"column:is_onboarded"` + OrganisationID *uint `gorm:"column:organisation_id"` + OrganisationRole *string `gorm:"column:organisation_role"` + PreferredLocale *string `gorm:"column:preferred_locale"` + TokensValidAfter *time.Time `gorm:"column:tokens_valid_after"` + CreatedIPAddress *string `gorm:"column:created_ip_address"` + LastIPAddress *string `gorm:"column:last_ip_address"` + DeletedAt gorm.DeletedAt `gorm:"column:deleted_at"` } func (User) TableName() string { return "users" } diff --git a/updates/202609220005_extend_users.go b/updates/202609220005_extend_users.go index 0ec7a53..df4c503 100644 --- a/updates/202609220005_extend_users.go +++ b/updates/202609220005_extend_users.go @@ -27,10 +27,12 @@ var extendUsersMigrations = []*gormigrate.Migration{ `ALTER TABLE users ADD COLUMN tokens_valid_after TIMESTAMPTZ`, `ALTER TABLE users ADD COLUMN created_ip_address TEXT`, `ALTER TABLE users ADD COLUMN last_ip_address TEXT`, + `ALTER TABLE users ADD COLUMN deleted_at TIMESTAMPTZ`, }) }, Rollback: func(tx *gorm.DB) error { return execStmts(tx, []string{ + `ALTER TABLE users DROP COLUMN IF EXISTS deleted_at`, `ALTER TABLE users DROP COLUMN IF EXISTS last_ip_address`, `ALTER TABLE users DROP COLUMN IF EXISTS created_ip_address`, `ALTER TABLE users DROP COLUMN IF EXISTS tokens_valid_after`,