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:
Jakub Zych
2026-09-22 14:40:18 +02:00
parent 00e6c51abd
commit 6bceb8167c
8 changed files with 1187 additions and 19 deletions

View File

@@ -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{