feat(07-02): serve login, logout, fetch, and refresh
Wire the user API session loop to the JWT blacklist and a pre-password throttle gate so a logged-out token cannot be fetched again. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
22
classes/events.go
Normal file
22
classes/events.go
Normal file
@@ -0,0 +1,22 @@
|
||||
package classes
|
||||
|
||||
import "git.golem15.com/golem15/fonoteka/plugins/golem15/user/models"
|
||||
|
||||
// GetApiArrayEvent is the fire-and-collect payload other plugins extend.
|
||||
type GetApiArrayEvent struct {
|
||||
User *models.User
|
||||
data map[string]any
|
||||
}
|
||||
|
||||
// Collected returns the map listeners merge into the user payload.
|
||||
func (e *GetApiArrayEvent) Collected() map[string]any {
|
||||
if e.data == nil {
|
||||
e.data = map[string]any{}
|
||||
}
|
||||
return e.data
|
||||
}
|
||||
|
||||
// RegisterEvent is fired after a user row is created. No listener consumes it yet.
|
||||
type RegisterEvent struct {
|
||||
User *models.User
|
||||
}
|
||||
@@ -7,17 +7,17 @@ import (
|
||||
"git.golem15.com/golem15/summercms/towel"
|
||||
)
|
||||
|
||||
// mailTemplate appends -en for English and leaves every other locale,
|
||||
// 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 {
|
||||
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 {
|
||||
// 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
|
||||
}
|
||||
|
||||
@@ -4,13 +4,13 @@ import "testing"
|
||||
|
||||
func TestMailTemplate(t *testing.T) {
|
||||
base := "golem15.user::mail.reactivate"
|
||||
if got := mailTemplate(base, "pl"); got != base {
|
||||
if got := MailTemplate(base, "pl"); got != base {
|
||||
t.Fatalf("pl = %s", got)
|
||||
}
|
||||
if got := mailTemplate(base, ""); got != base {
|
||||
if got := MailTemplate(base, ""); got != base {
|
||||
t.Fatalf("empty = %s", got)
|
||||
}
|
||||
if got := mailTemplate(base, "en"); got != base+"-en" {
|
||||
if got := MailTemplate(base, "en"); got != base+"-en" {
|
||||
t.Fatalf("en = %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"time"
|
||||
|
||||
"git.golem15.com/golem15/fonoteka/plugins/golem15/user/models"
|
||||
"git.golem15.com/golem15/summercms/backpack"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
@@ -29,6 +30,23 @@ func withThrottle(ctx context.Context, s throttleSettings) context.Context {
|
||||
return context.WithValue(ctx, throttleSettingsKey{}, s)
|
||||
}
|
||||
|
||||
// ContextWithThrottle copies golem15.user.throttle.* onto ctx for CheckAndRecordLogin.
|
||||
func ContextWithThrottle(ctx context.Context, app *backpack.App) context.Context {
|
||||
s := throttleSettings{use: true, limit: 5, minutes: 15}
|
||||
if app != nil && app.Config != nil {
|
||||
if app.Config.Has("golem15.user.throttle.use_throttle") {
|
||||
s.use = app.Config.Bool("golem15.user.throttle.use_throttle")
|
||||
}
|
||||
if n := app.Config.Int("golem15.user.throttle.attempt_limit"); n > 0 {
|
||||
s.limit = n
|
||||
}
|
||||
if n := app.Config.Int("golem15.user.throttle.suspension_minutes"); n > 0 {
|
||||
s.minutes = n
|
||||
}
|
||||
}
|
||||
return withThrottle(ctx, s)
|
||||
}
|
||||
|
||||
func throttleFrom(ctx context.Context) throttleSettings {
|
||||
if ctx != nil {
|
||||
if s, ok := ctx.Value(throttleSettingsKey{}).(throttleSettings); ok {
|
||||
@@ -38,10 +56,54 @@ func throttleFrom(ctx context.Context) throttleSettings {
|
||||
return throttleSettings{use: true, limit: 5, minutes: 15}
|
||||
}
|
||||
|
||||
// IsLoginRejected reports a ban or an active suspension. Callers map it to the
|
||||
// generic invalid-login response so the cause is not distinguishable.
|
||||
func IsLoginRejected(err error) bool {
|
||||
return errors.Is(err, errBanned) || errors.Is(err, errSuspended)
|
||||
}
|
||||
|
||||
// RejectIfThrottled enforces a ban or an active suspension without recording
|
||||
// an attempt. Login calls it before the password check; a missing row is allowed.
|
||||
func RejectIfThrottled(ctx context.Context, db *gorm.DB, user *models.User, ip string) error {
|
||||
if user == nil || user.ID == 0 || db == nil {
|
||||
return nil
|
||||
}
|
||||
settings := throttleFrom(ctx)
|
||||
if !settings.use {
|
||||
return nil
|
||||
}
|
||||
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) {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return throttleBlocked(row, settings)
|
||||
}
|
||||
|
||||
func throttleBlocked(row models.Throttle, settings throttleSettings) error {
|
||||
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
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// 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.
|
||||
// Login calls RejectIfThrottled before the password check, then this function
|
||||
// once with the outcome. 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
|
||||
@@ -69,12 +131,8 @@ func CheckAndRecordLogin(ctx context.Context, db *gorm.DB, user *models.User, ip
|
||||
} 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 err := throttleBlocked(row, settings); err != nil {
|
||||
return err
|
||||
}
|
||||
if ok {
|
||||
return db.WithContext(ctx).Model(&row).Updates(map[string]any{
|
||||
|
||||
Reference in New Issue
Block a user