feat(07-02): throttle failed logins and pick mail template locale

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Jakub Zych
2026-09-22 14:03:31 +02:00
parent 1311bd6cad
commit 00e6c51abd
7 changed files with 322 additions and 23 deletions

28
classes/mail.go Normal file
View File

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

16
classes/mail_test.go Normal file
View File

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

93
classes/throttle.go Normal file
View File

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

131
classes/throttle_test.go Normal file
View File

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

View File

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

View File

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

View File

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