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.
|
||||
|
||||
Reference in New Issue
Block a user