From 1311bd6cadf3531a69e41b819b90c824f5acfb27 Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Tue, 22 Sep 2026 13:53:16 +0200 Subject: [PATCH] feat(07-02): add user session schema, throttle table, and jwt config Co-authored-by: Cursor --- config/config.yaml | 20 ++++++ models/throttle.go | 22 +++++++ models/user.go | 53 ++++++++++++++-- plugin.go | 2 +- updates/202609220005_extend_users.go | 67 ++++++++++++++++++++ updates/202609220006_create_user_throttle.go | 35 ++++++++++ updates/202609220007_create_jwt_blacklist.go | 26 ++++++++ updates/organisations_test.go | 21 +++++- 8 files changed, 235 insertions(+), 11 deletions(-) create mode 100644 models/throttle.go create mode 100644 updates/202609220005_extend_users.go create mode 100644 updates/202609220006_create_user_throttle.go create mode 100644 updates/202609220007_create_jwt_blacklist.go diff --git a/config/config.yaml b/config/config.yaml index af503c1..772f07a 100644 --- a/config/config.yaml +++ b/config/config.yaml @@ -1,2 +1,22 @@ jwt: secret: "" + ttl: 60 + refresh_ttl: 20160 + blacklist_grace: 0 + leeway: 0 + blacklist_sweep_interval: 10m +activation: + require_activation: true + activate_mode: auto + reset_ttl_minutes: 60 + activation_ttl_hours: 72 +registration: + allow_registration: true + use_register_throttle: true +throttle: + attempt_limit: 5 + suspension_minutes: 15 + use_throttle: true +password: + bcrypt_cost: 10 + min_length: 8 diff --git a/models/throttle.go b/models/throttle.go new file mode 100644 index 0000000..2090b8e --- /dev/null +++ b/models/throttle.go @@ -0,0 +1,22 @@ +package models + +import "time" + +// Throttle is one failed-login counter, keyed by user and optional IP. +type Throttle struct { + ID uint `gorm:"column:id;primaryKey"` + UserID uint `gorm:"column:user_id"` + IPAddress *string `gorm:"column:ip_address"` + Attempts int `gorm:"column:attempts"` + IsSuspended bool `gorm:"column:is_suspended"` + SuspendedAt *time.Time `gorm:"column:suspended_at"` + IsBanned bool `gorm:"column:is_banned"` + CreatedAt time.Time `gorm:"column:created_at"` + UpdatedAt time.Time `gorm:"column:updated_at"` +} + +func (Throttle) TableName() string { return "user_throttle" } + +func init() { + Register(Throttle{}) +} diff --git a/models/user.go b/models/user.go index d19833b..ca62cb6 100644 --- a/models/user.go +++ b/models/user.go @@ -1,17 +1,56 @@ package models -// User is the minimal users row the JWT guard loads. +import "time" + +// 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"` + 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"` } func (User) TableName() string { return "users" } -// Hidden mirrors PHP User::$hidden for the columns this stub carries (D-08). -func (User) Hidden() []string { return []string{"password"} } +// Fillable is the register/update field list. Username, PIN, and GDPR columns +// stay out of Phase 7 because no ported handler reads them. +func (User) Fillable() []string { + return []string{ + "name", "surname", "email", "password", "created_ip_address", "last_ip_address", + "is_onboarded", "preferred_locale", "marketing_consent", "organisation_id", + "organisation_role", "must_change_password", + } +} + +// Hidden mirrors PHP User::$hidden for the secrets this model stores. +func (User) Hidden() []string { + return []string{"password", "reset_password_code", "activation_code"} +} + +// Rules is the register-time rule set. confirmed covers password_confirmation. +func (User) Rules() map[string]string { + return map[string]string{ + "email": "required|between:6,255|email|unique:users", + "password": "required|between:8,255|confirmed", + } +} func init() { Register(User{}) diff --git a/plugin.go b/plugin.go index 62fc6a0..cc9b440 100644 --- a/plugin.go +++ b/plugin.go @@ -58,7 +58,7 @@ func (p *Plugin) Boot(app *backpack.App) error { return err } } - return reg.Register(p.ID(), "jwt", bouncer.NewJWTGuard(secret, classes.GormUsers{App: app})) + return reg.Register(p.ID(), "jwt", bouncer.NewJWTGuard(secret, classes.GormUsers{App: app}, nil)) } func (p *Plugin) ConfigFS() fs.FS { return configFS } diff --git a/updates/202609220005_extend_users.go b/updates/202609220005_extend_users.go new file mode 100644 index 0000000..0ec7a53 --- /dev/null +++ b/updates/202609220005_extend_users.go @@ -0,0 +1,67 @@ +package updates + +import ( + "github.com/go-gormigrate/gormigrate/v2" + "gorm.io/gorm" +) + +var extendUsersMigrations = []*gormigrate.Migration{ + { + ID: "202609220005_extend_users", + Migrate: func(tx *gorm.DB) error { + return execStmts(tx, []string{ + `ALTER TABLE users ADD COLUMN name TEXT`, + `ALTER TABLE users ADD COLUMN surname TEXT`, + `ALTER TABLE users ADD COLUMN is_activated BOOLEAN NOT NULL DEFAULT FALSE`, + `ALTER TABLE users ADD COLUMN activated_at TIMESTAMPTZ`, + `ALTER TABLE users ADD COLUMN activation_code TEXT`, + `ALTER TABLE users ADD COLUMN activation_code_issued_at TIMESTAMPTZ`, + `ALTER TABLE users ADD COLUMN reset_password_code TEXT`, + `ALTER TABLE users ADD COLUMN reset_password_code_issued_at TIMESTAMPTZ`, + `ALTER TABLE users ADD COLUMN has_self_set_password BOOLEAN NOT NULL DEFAULT TRUE`, + `ALTER TABLE users ADD COLUMN marketing_consent BOOLEAN NOT NULL DEFAULT FALSE`, + `ALTER TABLE users ADD COLUMN is_onboarded BOOLEAN NOT NULL DEFAULT FALSE`, + `ALTER TABLE users ADD COLUMN organisation_id INTEGER REFERENCES golem15_user_organisations(id)`, + `ALTER TABLE users ADD COLUMN organisation_role TEXT`, + `ALTER TABLE users ADD COLUMN preferred_locale TEXT`, + `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`, + }) + }, + Rollback: func(tx *gorm.DB) error { + return execStmts(tx, []string{ + `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`, + `ALTER TABLE users DROP COLUMN IF EXISTS preferred_locale`, + `ALTER TABLE users DROP COLUMN IF EXISTS organisation_role`, + `ALTER TABLE users DROP COLUMN IF EXISTS organisation_id`, + `ALTER TABLE users DROP COLUMN IF EXISTS is_onboarded`, + `ALTER TABLE users DROP COLUMN IF EXISTS marketing_consent`, + `ALTER TABLE users DROP COLUMN IF EXISTS has_self_set_password`, + `ALTER TABLE users DROP COLUMN IF EXISTS reset_password_code_issued_at`, + `ALTER TABLE users DROP COLUMN IF EXISTS reset_password_code`, + `ALTER TABLE users DROP COLUMN IF EXISTS activation_code_issued_at`, + `ALTER TABLE users DROP COLUMN IF EXISTS activation_code`, + `ALTER TABLE users DROP COLUMN IF EXISTS activated_at`, + `ALTER TABLE users DROP COLUMN IF EXISTS is_activated`, + `ALTER TABLE users DROP COLUMN IF EXISTS surname`, + `ALTER TABLE users DROP COLUMN IF EXISTS name`, + }) + }, + }, +} + +func execStmts(tx *gorm.DB, stmts []string) error { + for _, stmt := range stmts { + if err := tx.Exec(stmt).Error; err != nil { + return err + } + } + return nil +} + +func init() { + Register(extendUsersMigrations...) +} diff --git a/updates/202609220006_create_user_throttle.go b/updates/202609220006_create_user_throttle.go new file mode 100644 index 0000000..99c8c19 --- /dev/null +++ b/updates/202609220006_create_user_throttle.go @@ -0,0 +1,35 @@ +package updates + +import ( + "github.com/go-gormigrate/gormigrate/v2" + "gorm.io/gorm" +) + +var userThrottleMigrations = []*gormigrate.Migration{ + { + ID: "202609220006_create_user_throttle", + Migrate: func(tx *gorm.DB) error { + return execStmts(tx, []string{ + `CREATE TABLE user_throttle ( + id SERIAL PRIMARY KEY, + user_id INTEGER NOT NULL REFERENCES users(id), + ip_address TEXT, + attempts INTEGER NOT NULL DEFAULT 0, + is_suspended BOOLEAN NOT NULL DEFAULT FALSE, + suspended_at TIMESTAMPTZ, + is_banned BOOLEAN NOT NULL DEFAULT FALSE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +)`, + `CREATE INDEX user_throttle_user_ip ON user_throttle (user_id, ip_address)`, + }) + }, + Rollback: func(tx *gorm.DB) error { + return tx.Exec(`DROP TABLE IF EXISTS user_throttle`).Error + }, + }, +} + +func init() { + Register(userThrottleMigrations...) +} diff --git a/updates/202609220007_create_jwt_blacklist.go b/updates/202609220007_create_jwt_blacklist.go new file mode 100644 index 0000000..3fbc1ac --- /dev/null +++ b/updates/202609220007_create_jwt_blacklist.go @@ -0,0 +1,26 @@ +package updates + +import ( + "github.com/go-gormigrate/gormigrate/v2" + "gorm.io/gorm" +) + +var jwtBlacklistMigrations = []*gormigrate.Migration{ + { + ID: "202609220007_create_jwt_blacklist", + Migrate: func(tx *gorm.DB) error { + return tx.Exec(`CREATE TABLE jwt_blacklist ( + jti TEXT PRIMARY KEY, + expires_at TIMESTAMPTZ NOT NULL, + valid_until TIMESTAMPTZ NOT NULL +)`).Error + }, + Rollback: func(tx *gorm.DB) error { + return tx.Exec(`DROP TABLE IF EXISTS jwt_blacklist`).Error + }, + }, +} + +func init() { + Register(jwtBlacklistMigrations...) +} diff --git a/updates/organisations_test.go b/updates/organisations_test.go index 124af25..58bd0d3 100644 --- a/updates/organisations_test.go +++ b/updates/organisations_test.go @@ -38,12 +38,27 @@ func TestCreateOrganisationsMigration(t *testing.T) { if err := gdb.Raw(`SELECT id FROM summer_migrations_golem15_user ORDER BY id`).Scan(&ids).Error; err != nil { t.Fatal(err) } - if len(ids) != 2 || ids[1] != "202609180002_create_organisations" { + want := []string{ + "202609170001_create_users", + "202609180002_create_organisations", + "202609220005_extend_users", + "202609220006_create_user_throttle", + "202609220007_create_jwt_blacklist", + } + if len(ids) != len(want) { t.Fatalf("history = %v", ids) } + for i := range want { + if ids[i] != want[i] { + t.Fatalf("history = %v", ids) + } + } - if err := m.RollbackLast(); err != nil { - t.Fatal(err) + // jwt blacklist, throttle, extend-users, then organisations. + for range 4 { + if err := m.RollbackLast(); err != nil { + t.Fatal(err) + } } if gdb.Migrator().HasTable("golem15_user_organisations") { t.Fatal("rollback must drop golem15_user_organisations")