From 6bceb8167cc57f6313336356498d94edb04151bd Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Tue, 22 Sep 2026 14:40:18 +0200 Subject: [PATCH] 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 --- classes/events.go | 22 ++ classes/mail.go | 8 +- classes/mail_test.go | 6 +- classes/throttle.go | 76 ++++- controllers/api_controller.go | 414 +++++++++++++++++++++++++ plugin.go | 96 +++++- routes.go | 19 ++ session_test.go | 565 ++++++++++++++++++++++++++++++++++ 8 files changed, 1187 insertions(+), 19 deletions(-) create mode 100644 classes/events.go create mode 100644 controllers/api_controller.go create mode 100644 routes.go create mode 100644 session_test.go diff --git a/classes/events.go b/classes/events.go new file mode 100644 index 0000000..195e8fd --- /dev/null +++ b/classes/events.go @@ -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 +} diff --git a/classes/mail.go b/classes/mail.go index 96cd29c..f4249a7 100644 --- a/classes/mail.go +++ b/classes/mail.go @@ -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 } diff --git a/classes/mail_test.go b/classes/mail_test.go index a81947e..b609f63 100644 --- a/classes/mail_test.go +++ b/classes/mail_test.go @@ -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) } } diff --git a/classes/throttle.go b/classes/throttle.go index 6840482..f71702a 100644 --- a/classes/throttle.go +++ b/classes/throttle.go @@ -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{ diff --git a/controllers/api_controller.go b/controllers/api_controller.go new file mode 100644 index 0000000..adb7a32 --- /dev/null +++ b/controllers/api_controller.go @@ -0,0 +1,414 @@ +package controllers + +import ( + "context" + "encoding/json" + "errors" + "io" + "net" + "net/http" + "strconv" + "strings" + "time" + + "git.golem15.com/golem15/fonoteka/plugins/golem15/user/classes" + "git.golem15.com/golem15/fonoteka/plugins/golem15/user/models" + "git.golem15.com/golem15/summercms/backpack" + "git.golem15.com/golem15/summercms/bouncer" + "git.golem15.com/golem15/summercms/postcard" + "git.golem15.com/golem15/summercms/wire" + "gorm.io/gorm" +) + +const invalidLoginBody = "Invalid email or password" + +// Login authenticates email and password and returns a JWT plus the user payload. +func Login(app *backpack.App) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + gdb, secret, ok := sessionDeps(w, app) + if !ok { + return + } + fields, err := readFields(r) + if err != nil { + writeOpaque500(w) + return + } + email := strField(fields, "email") + password := strField(fields, "password") + ctx := classes.ContextWithThrottle(r.Context(), app) + user, err := classes.LookupByEmail(ctx, gdb, email) + if err != nil { + writeOpaque500(w) + return + } + ip := remoteIP(r) + if user != nil { + if err := classes.RejectIfThrottled(ctx, gdb, user, ip); err != nil { + if classes.IsLoginRejected(err) { + writeInvalidLogin(w) + return + } + writeOpaque500(w) + return + } + } + if user == nil || !bouncer.CheckPassword(user.Password, password) { + if user != nil { + if err := classes.CheckAndRecordLogin(ctx, gdb, user, ip, false); err != nil && !classes.IsLoginRejected(err) { + writeOpaque500(w) + return + } + } + writeInvalidLogin(w) + return + } + if user.DeletedAt.Valid { + if err := gdb.WithContext(ctx).Unscoped().Model(user).Update("deleted_at", nil).Error; err != nil { + writeOpaque500(w) + return + } + sendTemplate(ctx, app, mailName(ctx, user, "golem15.user::mail.reactivate"), user.Email) + } + if err := classes.CheckAndRecordLogin(ctx, gdb, user, ip, true); err != nil { + if classes.IsLoginRejected(err) { + writeInvalidLogin(w) + return + } + writeOpaque500(w) + return + } + cost := 10 + if app.Config != nil && app.Config.Int("golem15.user.password.bcrypt_cost") > 0 { + cost = app.Config.Int("golem15.user.password.bcrypt_cost") + } + if bouncer.NeedsRehash(user.Password, cost) { + if next, err := bouncer.HashPassword(cost, password); err == nil { + _ = gdb.WithContext(ctx).Model(user).Update("password", next).Error + } + } + token, err := mintFor(app, secret, user, requestURL(r)) + if err != nil { + writeOpaque500(w) + return + } + payload, err := apiArray(ctx, app, user) + if err != nil { + writeOpaque500(w) + return + } + wire.WriteJSON(w, http.StatusOK, map[string]any{"token": token, "user": payload}) + } +} + +// Logout forever-blacklists the presented bearer token. +func Logout(app *backpack.App) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + _, secret, ok := sessionDeps(w, app) + if !ok { + return + } + raw := bearerToken(r) + sub, _, exp, jti, err := bouncer.VerifyClaims(raw, secret) + if err != nil || jti == "" { + writeUnauthorized(w) + return + } + id, err := strconv.ParseUint(sub, 10, 64) + if err != nil || id == 0 { + writeUnauthorized(w) + return + } + principal, err := (classes.GormUsers{App: app}).FindByID(r.Context(), uint(id)) + if err != nil || principal == nil { + writeUnauthorized(w) + return + } + if bl, ok := app.Lookup[bouncer.BlacklistStore](); ok && bl != nil { + if err := bl.Add(r.Context(), jti, exp, time.Now()); err != nil { + writeOpaque500(w) + return + } + } + wire.WriteJSON(w, http.StatusOK, map[string]any{"message": "Logged out"}) + } +} + +// Fetch returns the authenticated user's payload. +func Fetch(app *backpack.App) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + user, ok := authenticate(w, r, app) + if !ok { + return + } + payload, err := apiArray(r.Context(), app, user) + if err != nil { + writeOpaque500(w) + return + } + wire.WriteJSON(w, http.StatusOK, map[string]any{"user": payload}) + } +} + +// Refresh slides a still-refreshable bearer token. +func Refresh(app *backpack.App) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + _, secret, ok := sessionDeps(w, app) + if !ok { + return + } + raw := bearerToken(r) + if raw == "" { + wire.WriteJSON(w, http.StatusUnauthorized, map[string]any{"error": "Token not found"}) + return + } + var bl bouncer.BlacklistStore + if store, ok := app.Lookup[bouncer.BlacklistStore](); ok { + bl = store + } + next, err := bouncer.Refresh(secret, raw, refreshTTL(app), bl, blacklistGrace(app), requestURL(r)) + if err != nil { + wire.WriteJSON(w, http.StatusUnauthorized, map[string]any{ + "error": "Could not refresh token", + "msg": err.Error(), + }) + return + } + wire.WriteJSON(w, http.StatusOK, map[string]any{"token": next}) + } +} + +// OAuthProviders reports that this app configures no social providers. +func OAuthProviders(app *backpack.App) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + wire.WriteJSON(w, http.StatusOK, map[string]any{ + "success": true, + "providers": wire.Slice([]any{}), + }) + } +} + +// Register is filled in by the registration task. The route is declared now. +func Register(app *backpack.App) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + writeOpaque500(w) + } +} + +func authenticate(w http.ResponseWriter, r *http.Request, app *backpack.App) (*models.User, bool) { + gdb, secret, ok := sessionDeps(w, app) + if !ok { + return nil, false + } + raw := bearerToken(r) + sub, _, _, _, err := bouncer.VerifyClaims(raw, secret) + if err != nil { + writeUnauthorized(w) + return nil, false + } + id, err := strconv.ParseUint(sub, 10, 64) + if err != nil || id == 0 { + writeUnauthorized(w) + return nil, false + } + var bl bouncer.BlacklistStore + if store, ok := app.Lookup[bouncer.BlacklistStore](); ok { + bl = store + } + if _, err := bouncer.NewJWTGuard(secret, classes.GormUsers{App: app}, bl).Authenticate(r); err != nil { + writeUnauthorized(w) + return nil, false + } + var user models.User + if err := gdb.WithContext(r.Context()).Where("id = ?", id).Take(&user).Error; err != nil { + writeUnauthorized(w) + return nil, false + } + return &user, true +} + +func sessionDeps(w http.ResponseWriter, app *backpack.App) (*gorm.DB, string, bool) { + if app == nil { + writeOpaque500(w) + return nil, "", false + } + gdb, ok := app.Lookup[*gorm.DB]() + if !ok || gdb == nil { + writeOpaque500(w) + return nil, "", false + } + secret, err := classes.JWTSecret(app) + if err != nil { + writeOpaque500(w) + return nil, "", false + } + return gdb, secret, true +} + +func apiArray(ctx context.Context, app *backpack.App, user *models.User) (map[string]any, error) { + name, surname := "", "" + if user.Name != nil { + name = *user.Name + } + if user.Surname != nil { + surname = *user.Surname + } + payload := map[string]any{ + "id": user.ID, + "name": name, + "surname": surname, + "email": user.Email, + "is_activated": user.IsActivated, + "permissions": []string{}, + "avatar": nil, + "avatar_url": nil, + "has_avatar": false, + "marketing_consent": user.MarketingConsent, + "groups": map[string]string{}, + "role": nil, + "is_onboarded": user.IsOnboarded, + "has_self_set_password": user.HasSelfSetPassword, + // feedback_widget_hidden stays false until a feedback plugin listens. + "feedback_widget_hidden": false, + } + if app != nil && app.Events != nil { + extra, err := app.Events.Collect(ctx, &classes.GetApiArrayEvent{User: user}) + if err != nil { + return nil, err + } + for k, v := range extra { + payload[k] = v + } + } + return payload, nil +} + +func mintFor(app *backpack.App, secret string, user *models.User, issuer string) (string, error) { + ttl := 60 * time.Minute + if app != nil && app.Config != nil && app.Config.Int("golem15.user.jwt.ttl") > 0 { + ttl = time.Duration(app.Config.Int("golem15.user.jwt.ttl")) * time.Minute + } + token, _, err := bouncer.Mint(secret, strconv.FormatUint(uint64(user.ID), 10), issuer, ttl) + return token, err +} + +func refreshTTL(app *backpack.App) time.Duration { + mins := 20160 + if app != nil && app.Config != nil && app.Config.Int("golem15.user.jwt.refresh_ttl") > 0 { + mins = app.Config.Int("golem15.user.jwt.refresh_ttl") + } + return time.Duration(mins) * time.Minute +} + +func blacklistGrace(app *backpack.App) time.Duration { + secs := 0 + if app != nil && app.Config != nil && app.Config.Has("golem15.user.jwt.blacklist_grace") { + secs = app.Config.Int("golem15.user.jwt.blacklist_grace") + } + return time.Duration(secs) * time.Second +} + +func mailName(ctx context.Context, user *models.User, base string) string { + return classes.MailTemplate(base, classes.ResolveMailLocale(ctx, user)) +} + +func sendTemplate(ctx context.Context, app *backpack.App, template, to string) { + if app == nil || to == "" { + return + } + mailer, ok := app.Lookup[postcard.Mailer]() + if !ok || mailer == nil { + return + } + _ = mailer.Send(ctx, postcard.Message{Template: template, To: []string{to}}) +} + +func readFields(r *http.Request) (map[string]any, error) { + ct := r.Header.Get("Content-Type") + if strings.Contains(ct, "application/json") { + var fields map[string]any + dec := json.NewDecoder(r.Body) + if err := dec.Decode(&fields); err != nil && !errors.Is(err, io.EOF) { + return nil, err + } + if fields == nil { + fields = map[string]any{} + } + return fields, nil + } + if err := r.ParseForm(); err != nil { + return nil, err + } + fields := map[string]any{} + for k, v := range r.Form { + if len(v) > 0 { + fields[k] = v[0] + } + } + return fields, nil +} + +func strField(fields map[string]any, key string) string { + v, ok := fields[key] + if !ok || v == nil { + return "" + } + s, ok := v.(string) + if !ok { + return strings.TrimSpace(jsonNumber(v)) + } + return strings.TrimSpace(s) +} + +func jsonNumber(v any) string { + switch n := v.(type) { + case float64: + return strconv.FormatFloat(n, 'f', -1, 64) + default: + return "" + } +} + +func bearerToken(r *http.Request) string { + h := strings.TrimSpace(r.Header.Get("Authorization")) + token, ok := strings.CutPrefix(h, "Bearer ") + token = strings.TrimSpace(token) + if !ok || token == "" { + return "" + } + return token +} + +func requestURL(r *http.Request) string { + scheme := "http" + if r.TLS != nil { + scheme = "https" + } + if proto := strings.TrimSpace(r.Header.Get("X-Forwarded-Proto")); proto != "" { + scheme = proto + } + return scheme + "://" + r.Host + r.URL.Path +} + +func remoteIP(r *http.Request) string { + if r.RemoteAddr == "" { + return "" + } + host, _, err := net.SplitHostPort(r.RemoteAddr) + if err != nil { + return r.RemoteAddr + } + return host +} + +func writeInvalidLogin(w http.ResponseWriter) { + wire.WriteJSON(w, http.StatusUnauthorized, map[string]any{"error": true, "message": invalidLoginBody}) +} + +func writeUnauthorized(w http.ResponseWriter) { + wire.WriteJSON(w, http.StatusUnauthorized, map[string]any{"error": true, "message": "Unauthorized"}) +} + +func writeOpaque500(w http.ResponseWriter) { + wire.WriteOpaque500(w) +} diff --git a/plugin.go b/plugin.go index cc9b440..71a3c56 100644 --- a/plugin.go +++ b/plugin.go @@ -1,8 +1,14 @@ package user import ( + "context" + "database/sql" "embed" "io/fs" + "log/slog" + "net/http" + "strings" + "time" "git.golem15.com/golem15/fonoteka/plugins/golem15/user/classes" "git.golem15.com/golem15/fonoteka/plugins/golem15/user/models" @@ -11,6 +17,7 @@ import ( "git.golem15.com/golem15/summercms/bouncer" "git.golem15.com/golem15/summercms/pact" "git.golem15.com/golem15/summercms/party" + "git.golem15.com/golem15/summercms/surf" "github.com/go-gormigrate/gormigrate/v2" "gorm.io/gorm" ) @@ -21,15 +28,18 @@ var ( _ pact.HasMigrations = (*Plugin)(nil) _ pact.HasMiddleware = (*Plugin)(nil) _ pact.HasModels = (*Plugin)(nil) + _ pact.HasRoutes = (*Plugin)(nil) + _ surf.BucketProvider = (*Plugin)(nil) _ bouncer.UserProvider = classes.GormUsers{} ) //go:embed config var configFS embed.FS -// Plugin is the stub golem15.user plugin. +// Plugin is the golem15.user plugin. type Plugin struct { - app *backpack.App + app *backpack.App + sweepStop chan struct{} } func (p *Plugin) ID() string { return "golem15.user" } @@ -58,7 +68,87 @@ func (p *Plugin) Boot(app *backpack.App) error { return err } } - return reg.Register(p.ID(), "jwt", bouncer.NewJWTGuard(secret, classes.GormUsers{App: app}, nil)) + var bl bouncer.BlacklistStore + if sqlDB, ok := app.Lookup[*sql.DB](); ok && sqlDB != nil { + bl = bouncer.NewPostgresBlacklist(sqlDB, "jwt_blacklist") + } else if gdb, ok := app.Lookup[*gorm.DB](); ok && gdb != nil { + if sqlDB, err := gdb.DB(); err == nil && sqlDB != nil { + bl = bouncer.NewPostgresBlacklist(sqlDB, "jwt_blacklist") + } + } + if bl != nil { + if err := app.Publish[bouncer.BlacklistStore](bl); err != nil { + return err + } + p.startBlacklistSweep(bl) + } + return reg.Register(p.ID(), "jwt", bouncer.NewJWTGuard(secret, classes.GormUsers{App: app}, bl, "token", "auth_token")) +} + +func (p *Plugin) startBlacklistSweep(bl bouncer.BlacklistStore) { + interval := 10 * time.Minute + if p.app != nil && p.app.Config != nil { + raw := p.app.Config.String("golem15.user.jwt.blacklist_sweep_interval") + if raw != "" { + parsed, err := time.ParseDuration(raw) + if err != nil || parsed <= 0 { + return + } + interval = parsed + } + } + if p.sweepStop != nil { + return + } + p.sweepStop = make(chan struct{}) + stop := p.sweepStop + go func() { + ticker := time.NewTicker(interval) + defer ticker.Stop() + for { + select { + case <-ticker.C: + if err := bl.Sweep(context.Background(), time.Now()); err != nil { + slog.Default().Error("golem15.user: jwt blacklist sweep", "err", err) + } + case <-stop: + return + } + } + }() +} + +func (p *Plugin) Buckets() map[string]surf.Bucket { + trusted := surf.TrustedProxies(nil) + if p.app != nil { + trusted = surf.TrustedProxies(p.app.Config) + } + secret := "" + if p.app != nil { + secret, _ = classes.JWTSecret(p.app) + } + return map[string]surf.Bucket{ + "user-api": { + Max: 120, + Decay: time.Minute, + Key: func(r *http.Request) string { + if sub, err := bouncer.Verify(bearerFrom(r), secret); err == nil { + return "u:" + sub + } + return surf.ClientIP(r, trusted) + }, + }, + } +} + +func bearerFrom(r *http.Request) string { + h := strings.TrimSpace(r.Header.Get("Authorization")) + token, ok := strings.CutPrefix(h, "Bearer ") + token = strings.TrimSpace(token) + if !ok || token == "" { + return "" + } + return token } func (p *Plugin) ConfigFS() fs.FS { return configFS } diff --git a/routes.go b/routes.go new file mode 100644 index 0000000..39f83f9 --- /dev/null +++ b/routes.go @@ -0,0 +1,19 @@ +package user + +import ( + "git.golem15.com/golem15/fonoteka/plugins/golem15/user/controllers" + "git.golem15.com/golem15/summercms/pact" + "git.golem15.com/golem15/summercms/surf" +) + +func (p *Plugin) Routes(r pact.Router) error { + r.Group("/_user/api/v1", surf.Use("throttle:user-api"), func(g pact.Router) { + g.Post("/login", controllers.Login(p.app)) + g.Post("/logout", controllers.Logout(p.app)) + g.Get("/fetch", controllers.Fetch(p.app)) + g.Post("/refresh", controllers.Refresh(p.app)) + g.Post("/register", controllers.Register(p.app)) + g.Get("/oauth-providers", controllers.OAuthProviders(p.app)) + }) + return nil +} diff --git a/session_test.go b/session_test.go new file mode 100644 index 0000000..a700b61 --- /dev/null +++ b/session_test.go @@ -0,0 +1,565 @@ +package user + +import ( + "context" + "database/sql" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strconv" + "strings" + "sync" + "testing" + "time" + + "git.golem15.com/golem15/fonoteka/plugins/golem15/user/controllers" + "git.golem15.com/golem15/fonoteka/plugins/golem15/user/models" + "git.golem15.com/golem15/fonoteka/plugins/golem15/user/updates" + "git.golem15.com/golem15/summercms/backpack" + "git.golem15.com/golem15/summercms/bouncer" + "git.golem15.com/golem15/summercms/compass" + "git.golem15.com/golem15/summercms/lagoon" + "git.golem15.com/golem15/summercms/party" + "git.golem15.com/golem15/summercms/postcard" + "git.golem15.com/golem15/summercms/surf" + "github.com/go-gormigrate/gormigrate/v2" + "github.com/golang-jwt/jwt/v5" + _ "github.com/jackc/pgx/v5/stdlib" + "github.com/testcontainers/testcontainers-go" + "github.com/testcontainers/testcontainers-go/modules/postgres" + "gorm.io/gorm" +) + +const ( + sessionSecret = "summercms-test-only-hs256-secret" + invalidLoginBody = `{"error":true,"message":"Invalid email or password"}` + prvHash = "a867434cbc213adfbe78a02bed7082a6bd99c883" +) + +var ( + sessionOnce sync.Once + sessionGDB *gorm.DB + sessionSQL *sql.DB + sessionErr error +) + +func TestLogin(t *testing.T) { + app, gdb := sessionApp(t, "0s") + hash, err := bouncer.HashPassword(10, "correct-horse") + if err != nil { + t.Fatal(err) + } + user := &models.User{Email: "ada-login@example.test", Password: hash} + if err := gdb.Create(user).Error; err != nil { + t.Fatal(err) + } + + rec := postJSON(t, controllers.Login(app), "/_user/api/v1/login", `{"email":"ada-login@example.test","password":"correct-horse"}`) + if rec.Code != http.StatusOK { + t.Fatalf("status %d body %s", rec.Code, rec.Body.String()) + } + var body map[string]any + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { + t.Fatal(err) + } + token, _ := body["token"].(string) + claims := parseToken(t, token) + if claims["sub"] != strconv.FormatUint(uint64(user.ID), 10) { + t.Fatalf("sub = %v", claims["sub"]) + } + if claims["prv"] != prvHash { + t.Fatalf("prv = %v", claims["prv"]) + } + if claims["iss"] != "http://example.com/_user/api/v1/login" { + t.Fatalf("iss = %v", claims["iss"]) + } + userBody, _ := body["user"].(map[string]any) + if userBody["email"] != user.Email || userBody["feedback_widget_hidden"] != false { + t.Fatalf("user payload = %#v", userBody) + } + if _, ok := userBody["permissions"].([]any); !ok { + t.Fatalf("permissions = %#v", userBody["permissions"]) + } + + wrong := postJSON(t, controllers.Login(app), "/_user/api/v1/login", `{"email":"ada-login@example.test","password":"nope"}`) + unknown := postJSON(t, controllers.Login(app), "/_user/api/v1/login", `{"email":"nobody@example.test","password":"nope"}`) + if wrong.Code != http.StatusUnauthorized || wrong.Body.String() != invalidLoginBody { + t.Fatalf("wrong = %d %s", wrong.Code, wrong.Body.String()) + } + if unknown.Body.String() != wrong.Body.String() { + t.Fatalf("unknown body %s", unknown.Body.String()) + } + + var before int64 + if err := gdb.Model(&models.Throttle{}).Count(&before).Error; err != nil { + t.Fatal(err) + } + again := postJSON(t, controllers.Login(app), "/_user/api/v1/login", `{"email":"ghost@example.test","password":"nope"}`) + if again.Body.String() != invalidLoginBody { + t.Fatal(again.Body.String()) + } + var after int64 + if err := gdb.Model(&models.Throttle{}).Count(&after).Error; err != nil { + t.Fatal(err) + } + if after != before { + t.Fatalf("unknown email created a throttle row (%d -> %d)", before, after) + } + + now := time.Now() + ip := "192.0.2.1" + throttled := &models.User{Email: "throttled@example.test", Password: hash} + if err := gdb.Create(throttled).Error; err != nil { + t.Fatal(err) + } + row := models.Throttle{UserID: throttled.ID, IPAddress: &ip, Attempts: 5, IsSuspended: true, SuspendedAt: &now} + if err := gdb.Create(&row).Error; err != nil { + t.Fatal(err) + } + blocked := postJSON(t, controllers.Login(app), "/_user/api/v1/login", `{"email":"throttled@example.test","password":"correct-horse"}`) + if blocked.Code != http.StatusUnauthorized || blocked.Body.String() != invalidLoginBody { + t.Fatalf("throttled = %d %s", blocked.Code, blocked.Body.String()) + } + + loc := "en" + soft := &models.User{Email: "soft@example.test", Password: hash, PreferredLocale: &loc} + if err := gdb.Create(soft).Error; err != nil { + t.Fatal(err) + } + if err := gdb.Model(soft).Update("deleted_at", time.Now()).Error; err != nil { + t.Fatal(err) + } + mail := &captureMail{} + if err := app.Publish[postcard.Mailer](mail); err != nil { + t.Fatal(err) + } + restored := postJSON(t, controllers.Login(app), "/_user/api/v1/login", `{"email":"soft@example.test","password":"correct-horse"}`) + if restored.Code != http.StatusOK { + t.Fatalf("restore %d %s", restored.Code, restored.Body.String()) + } + var againUser models.User + if err := gdb.Unscoped().First(&againUser, soft.ID).Error; err != nil { + t.Fatal(err) + } + if againUser.DeletedAt.Valid { + t.Fatal("soft-deleted user was not restored") + } + if len(mail.got) != 1 || mail.got[0] != "golem15.user::mail.reactivate-en" { + t.Fatalf("reactivate template = %#v", mail.got) + } +} + +func TestLoginSixthAttempt(t *testing.T) { + app, gdb := sessionApp(t, "0s") + hash, err := bouncer.HashPassword(10, "correct-horse") + if err != nil { + t.Fatal(err) + } + user := &models.User{Email: "sixth@example.test", Password: hash} + if err := gdb.Create(user).Error; err != nil { + t.Fatal(err) + } + for i := 1; i <= 5; i++ { + rec := postJSON(t, controllers.Login(app), "/_user/api/v1/login", `{"email":"sixth@example.test","password":"wrong"}`) + if rec.Code != http.StatusUnauthorized || rec.Body.String() != invalidLoginBody { + t.Fatalf("attempt %d: %d %s", i, rec.Code, rec.Body.String()) + } + } + var row models.Throttle + if err := gdb.Where("user_id = ?", user.ID).Take(&row).Error; err != nil { + t.Fatal(err) + } + if row.Attempts != 5 || !row.IsSuspended { + t.Fatalf("after 5 logins: %+v", row) + } + blocked := postJSON(t, controllers.Login(app), "/_user/api/v1/login", `{"email":"sixth@example.test","password":"correct-horse"}`) + if blocked.Code != http.StatusUnauthorized || blocked.Body.String() != invalidLoginBody { + t.Fatalf("6th = %d %s", blocked.Code, blocked.Body.String()) + } + if err := gdb.Where("user_id = ?", user.ID).Take(&row).Error; err != nil { + t.Fatal(err) + } + if row.Attempts != 5 { + t.Fatalf("6th login recorded another attempt: %+v", row) + } +} + +func TestLogout(t *testing.T) { + app, gdb := sessionApp(t, "0s") + token := loginToken(t, app, gdb, "logout@example.test") + rec := bearer(t, controllers.Logout(app), http.MethodPost, "/_user/api/v1/logout", token) + if rec.Code != http.StatusOK || rec.Body.String() != `{"message":"Logged out"}` { + t.Fatalf("logout %d %s", rec.Code, rec.Body.String()) + } + fetch := bearer(t, controllers.Fetch(app), http.MethodGet, "/_user/api/v1/fetch", token) + if fetch.Code != http.StatusUnauthorized || fetch.Body.String() != `{"error":true,"message":"Unauthorized"}` { + t.Fatalf("fetch after logout %d %s", fetch.Code, fetch.Body.String()) + } +} + +func TestFetch(t *testing.T) { + app, gdb := sessionApp(t, "0s") + token := loginToken(t, app, gdb, "fetch@example.test") + ok := bearer(t, controllers.Fetch(app), http.MethodGet, "/_user/api/v1/fetch", token) + if ok.Code != http.StatusOK || !strings.Contains(ok.Body.String(), `"email":"fetch@example.test"`) { + t.Fatalf("fetch %d %s", ok.Code, ok.Body.String()) + } + missing := bearer(t, controllers.Fetch(app), http.MethodGet, "/_user/api/v1/fetch", "") + if missing.Code != http.StatusUnauthorized || missing.Body.String() != `{"error":true,"message":"Unauthorized"}` { + t.Fatalf("missing %d %s", missing.Code, missing.Body.String()) + } + bad := bearer(t, controllers.Fetch(app), http.MethodGet, "/_user/api/v1/fetch", "not-a-token") + if bad.Body.String() != missing.Body.String() { + t.Fatalf("invalid %s", bad.Body.String()) + } +} + +func TestRefresh(t *testing.T) { + app, gdb := sessionApp(t, "0s") + user := insertUser(t, gdb, "refresh@example.test", "correct-horse") + sub := strconv.FormatUint(uint64(user.ID), 10) + + missing := bearer(t, controllers.Refresh(app), http.MethodPost, "/_user/api/v1/refresh", "") + if missing.Code != http.StatusUnauthorized || missing.Body.String() != `{"error":"Token not found"}` { + t.Fatalf("missing %d %s", missing.Code, missing.Body.String()) + } + + iat := time.Now().Add(-2 * time.Hour) + old := craftToken(t, sub, "refresh-old", iat, iat.Add(time.Minute)) + rec := bearer(t, controllers.Refresh(app), http.MethodPost, "/_user/api/v1/refresh", old) + if rec.Code != http.StatusOK { + t.Fatalf("refresh %d %s", rec.Code, rec.Body.String()) + } + var body map[string]any + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { + t.Fatal(err) + } + next, _ := body["token"].(string) + if next == "" || next == old { + t.Fatalf("new token = %q", next) + } + if parseToken(t, next)["jti"] == "refresh-old" { + t.Fatal("refresh reused jti") + } + bl, ok := app.Lookup[bouncer.BlacklistStore]() + if !ok || bl == nil { + t.Fatal("blacklist was not published") + } + blocked, err := bl.IsBlacklisted(t.Context(), "refresh-old") + if err != nil || !blocked { + t.Fatalf("old jti blacklisted=%v err=%v", blocked, err) + } + fetch := bearer(t, controllers.Fetch(app), http.MethodGet, "/_user/api/v1/fetch", old) + if fetch.Code != http.StatusUnauthorized { + t.Fatalf("old token still fetches: %d %s", fetch.Code, fetch.Body.String()) + } + + stale := craftToken(t, sub, "refresh-stale", time.Now().Add(-40*24*time.Hour), time.Now().Add(-39*24*time.Hour)) + denied := bearer(t, controllers.Refresh(app), http.MethodPost, "/_user/api/v1/refresh", stale) + if denied.Code != http.StatusUnauthorized || !strings.Contains(denied.Body.String(), `"error":"Could not refresh token"`) || !strings.Contains(denied.Body.String(), `"msg":`) { + t.Fatalf("stale %d %s", denied.Code, denied.Body.String()) + } +} + +func TestLoginRouteGroup(t *testing.T) { + cfg := sessionConfig(t) + if err := cfg.Set("http.body_limits.default_bytes", 1048576); err != nil { + t.Fatal(err) + } + if err := cfg.Set("http.body_limits.upload_bytes", 1048576); err != nil { + t.Fatal(err) + } + app := backpack.New(cfg) + p := &Plugin{} + if err := p.Boot(app); err != nil { + t.Fatal(err) + } + rt, err := surf.BuildRouter(app, []party.Plugin{p}) + if err != nil { + t.Fatal(err) + } + want := map[string]bool{ + "POST /_user/api/v1/login": false, + "POST /_user/api/v1/logout": false, + "GET /_user/api/v1/fetch": false, + "POST /_user/api/v1/refresh": false, + "POST /_user/api/v1/register": false, + "GET /_user/api/v1/oauth-providers": false, + } + for _, info := range rt.Routes() { + key := info.Method + " " + info.Pattern + if _, ok := want[key]; !ok { + continue + } + want[key] = true + if len(info.Middleware) != 1 || info.Middleware[0] != "throttle:user-api" { + t.Fatalf("%s middleware = %#v", key, info.Middleware) + } + } + for key, seen := range want { + if !seen { + t.Fatalf("missing route %s", key) + } + } +} + +func TestLoginBlacklistSweep(t *testing.T) { + gdb, sqlDB := sessionDB(t) + app := newSessionApp(t, gdb, sqlDB) + _, stop := bootPlugin(t, app, "50ms") + past := time.Now().Add(-time.Minute) + if err := gdb.Exec(`INSERT INTO jwt_blacklist (jti, expires_at, valid_until) VALUES (?, ?, ?)`, "sweep-me", past, past).Error; err != nil { + t.Fatal(err) + } + deadline := time.Now().Add(2 * time.Second) + for { + var n int64 + if err := gdb.Raw(`SELECT COUNT(*) FROM jwt_blacklist WHERE jti = ?`, "sweep-me").Scan(&n).Error; err != nil { + t.Fatal(err) + } + if n == 0 { + break + } + if time.Now().After(deadline) { + t.Fatal("expired jwt_blacklist row was not swept") + } + time.Sleep(20 * time.Millisecond) + } + stop() + time.Sleep(150 * time.Millisecond) + + quiet := newSessionApp(t, gdb, sqlDB) + bootPlugin(t, quiet, "0s") + later := time.Now().Add(-time.Minute) + if err := gdb.Exec(`INSERT INTO jwt_blacklist (jti, expires_at, valid_until) VALUES (?, ?, ?)`, "sweep-keep", later, later).Error; err != nil { + t.Fatal(err) + } + time.Sleep(200 * time.Millisecond) + var n int64 + if err := gdb.Raw(`SELECT COUNT(*) FROM jwt_blacklist WHERE jti = ?`, "sweep-keep").Scan(&n).Error; err != nil { + t.Fatal(err) + } + if n != 1 { + t.Fatal("non-positive sweep interval removed a row") + } +} + +type captureMail struct { + got []string +} + +func (c *captureMail) Send(_ context.Context, msg postcard.Message) error { + c.got = append(c.got, msg.Template) + return nil +} + +func loginToken(t *testing.T, app *backpack.App, gdb *gorm.DB, email string) string { + t.Helper() + insertUser(t, gdb, email, "correct-horse") + rec := postJSON(t, controllers.Login(app), "/_user/api/v1/login", `{"email":"`+email+`","password":"correct-horse"}`) + if rec.Code != http.StatusOK { + t.Fatalf("login %s: %d %s", email, rec.Code, rec.Body.String()) + } + var body map[string]any + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { + t.Fatal(err) + } + token, _ := body["token"].(string) + if token == "" { + t.Fatal("empty token") + } + return token +} + +func insertUser(t *testing.T, gdb *gorm.DB, email, password string) *models.User { + t.Helper() + hash, err := bouncer.HashPassword(10, password) + if err != nil { + t.Fatal(err) + } + user := &models.User{Email: email, Password: hash} + if err := gdb.Create(user).Error; err != nil { + t.Fatal(err) + } + return user +} + +func postJSON(t *testing.T, h http.HandlerFunc, path, body string) *httptest.ResponseRecorder { + t.Helper() + req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(body)) + req.Host = "example.com" + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + h(rec, req) + return rec +} + +func bearer(t *testing.T, h http.HandlerFunc, method, path, token string) *httptest.ResponseRecorder { + t.Helper() + req := httptest.NewRequest(method, path, nil) + req.Host = "example.com" + if token != "" { + req.Header.Set("Authorization", "Bearer "+token) + } + rec := httptest.NewRecorder() + h(rec, req) + return rec +} + +func parseToken(t *testing.T, raw string) jwt.MapClaims { + t.Helper() + claims := jwt.MapClaims{} + if _, err := jwt.ParseWithClaims(raw, claims, func(*jwt.Token) (any, error) { + return []byte(sessionSecret), nil + }); err != nil { + t.Fatal(err) + } + return claims +} + +func craftToken(t *testing.T, sub, jti string, iat, exp time.Time) string { + t.Helper() + claims := jwt.MapClaims{ + "sub": sub, + "iss": "http://example.com/_user/api/v1/login", + "iat": iat.Unix(), + "nbf": iat.Unix(), + "exp": exp.Unix(), + "jti": jti, + "prv": prvHash, + } + signed, err := jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString([]byte(sessionSecret)) + if err != nil { + t.Fatal(err) + } + return signed +} + +func sessionApp(t *testing.T, sweep string) (*backpack.App, *gorm.DB) { + t.Helper() + gdb, sqlDB := sessionDB(t) + app := backpack.New(sessionConfig(t)) + if err := app.Config.Set("http.body_limits.default_bytes", 1048576); err != nil { + t.Fatal(err) + } + if err := app.Config.Set("http.body_limits.upload_bytes", 1048576); err != nil { + t.Fatal(err) + } + if sweep != "" { + if err := app.Config.Set("golem15.user.jwt.blacklist_sweep_interval", sweep); err != nil { + t.Fatal(err) + } + } + if err := lagoon.Publish(app, sqlDB, gdb); err != nil { + t.Fatal(err) + } + bootPlugin(t, app, sweep) + return app, gdb +} + +func newSessionApp(t *testing.T, gdb *gorm.DB, sqlDB *sql.DB) *backpack.App { + t.Helper() + app := backpack.New(sessionConfig(t)) + if err := app.Config.Set("http.body_limits.default_bytes", 1048576); err != nil { + t.Fatal(err) + } + if err := app.Config.Set("http.body_limits.upload_bytes", 1048576); err != nil { + t.Fatal(err) + } + if err := lagoon.Publish(app, sqlDB, gdb); err != nil { + t.Fatal(err) + } + return app +} + +func bootPlugin(t *testing.T, app *backpack.App, sweep string) (*Plugin, func()) { + t.Helper() + if sweep != "" { + if err := app.Config.Set("golem15.user.jwt.blacklist_sweep_interval", sweep); err != nil { + t.Fatal(err) + } + } + p := &Plugin{} + if err := p.Boot(app); err != nil { + t.Fatal(err) + } + stop := func() {} + if p.sweepStop != nil { + ch := p.sweepStop + var once sync.Once + stop = func() { once.Do(func() { close(ch) }) } + t.Cleanup(stop) + } + return p, stop +} + +func sessionConfig(t *testing.T) *compass.Config { + t.Helper() + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "app.yaml"), []byte("name: user-session\n"), 0o644); err != nil { + t.Fatal(err) + } + cfg, err := compass.Open(compass.Options{ + Dir: dir, + Environ: []string{ + "SUMMER_ENV=development", + "SUMMER_GOLEM15__USER__JWT__SECRET=" + sessionSecret, + }, + }) + if err != nil { + t.Fatal(err) + } + return cfg +} + +func sessionDB(t *testing.T) (*gorm.DB, *sql.DB) { + t.Helper() + sessionOnce.Do(func() { + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() + ctr, err := postgres.Run(ctx, "postgres:16-alpine", + postgres.WithDatabase("user_session"), + postgres.WithUsername("user_session"), + postgres.WithPassword("user_session"), + postgres.BasicWaitStrategies(), + testcontainers.WithEnv(map[string]string{ + "POSTGRES_INITDB_ARGS": "--locale-provider=icu --icu-locale=pl-PL --encoding=UTF8", + }), + ) + if err != nil { + sessionErr = err + return + } + dsn, err := ctr.ConnectionString(ctx, "sslmode=disable") + if err != nil { + sessionErr = err + return + } + sqlDB, err := sql.Open("pgx", dsn) + if err != nil { + sessionErr = err + return + } + gdb, err := lagoon.Use(ctx, sqlDB) + if err != nil { + sessionErr = err + return + } + m := gormigrate.New(gdb, &gormigrate.Options{ + TableName: "summer_migrations_golem15_user", + UseTransaction: true, + }, updates.All()) + if err := m.Migrate(); err != nil { + sessionErr = err + return + } + sessionGDB = gdb + sessionSQL = sqlDB + }) + if sessionErr != nil { + t.Fatal(sessionErr) + } + return sessionGDB, sessionSQL +}