Files
sm-user-plugin/classes/throttle.go
2026-09-22 14:03:31 +02:00

94 lines
2.3 KiB
Go

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
}