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

22
classes/events.go Normal file
View 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
}

View File

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

View File

@@ -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)
}
}

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{

View File

@@ -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)
}

View File

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

19
routes.go Normal file
View File

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

565
session_test.go Normal file
View File

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