feat(07-02): throttle failed logins and pick mail template locale
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
28
classes/mail.go
Normal file
28
classes/mail.go
Normal 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
16
classes/mail_test.go
Normal 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
93
classes/throttle.go
Normal 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
131
classes/throttle_test.go
Normal 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
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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" }
|
||||
|
||||
@@ -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`,
|
||||
|
||||
Reference in New Issue
Block a user