From 7b9faa34192e99b9a6236627d5f4a421f635fd4b Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Tue, 20 Oct 2026 14:45:22 +0200 Subject: [PATCH] feat: opt-in Cloudflare Turnstile on login and register (golem15.user.turnstile) - empty (default) adds nothing; open or closed appends turnstile:{mode} to POST /login and /register only; any other value fails boot naming the key - TestTurnstileRoutesSmoke records the declared routes with a recording router --- README.md | 1 + config/config.yaml | 5 ++ routes.go | 30 ++++++- turnstile_routes_smoke_test.go | 146 +++++++++++++++++++++++++++++++++ 4 files changed, 180 insertions(+), 2 deletions(-) create mode 100644 turnstile_routes_smoke_test.go diff --git a/README.md b/README.md index 50003df..ace65a9 100644 --- a/README.md +++ b/README.md @@ -205,6 +205,7 @@ Defaults live in `config/config.yaml` and are merged under `golem15.user.*`. Any | `golem15.user.password.min_length` | `8` | Minimum password length. | | `golem15.user.api_tokens.prefix` | `usr_` | Prefix for newly minted and accepted shared API-token secrets. Override an existing application's legacy prefix without creating a plugin-specific token table. | | `golem15.user.privileged_groups` | `[admin]` | Codes of the privileged user groups. Membership of such a group, the group's code and its delete, and the email, password and permanent delete of its members need `golem15.users.manage_privileged_groups`. Read on every request. An empty list protects no group. | +| `golem15.user.turnstile` | `""` | Cloudflare Turnstile on `POST /login` and `POST /register`. Empty adds nothing; `open` or `closed` appends the framework's `turnstile:open` or `turnstile:closed` middleware to those two routes only (summercms v0.3.0 and later; secret in `turnstile.secret`, token in the `turnstile_token` field). `open` lets requests pass when Cloudflare is unreachable, `closed` refuses them. Any other value fails boot. | ## CLI commands diff --git a/config/config.yaml b/config/config.yaml index 7f97486..d2f627d 100644 --- a/config/config.yaml +++ b/config/config.yaml @@ -24,3 +24,8 @@ api_tokens: prefix: usr_ privileged_groups: - admin +# Cloudflare Turnstile on POST /login and /register. Empty (the default) adds +# nothing. "open" lets requests pass when Cloudflare is unreachable, "closed" +# refuses them. The turnstile:* middleware comes from the framework (summercms +# v0.3.0 and later); its secret is turnstile.secret (SUMMER_TURNSTILE__SECRET). +turnstile: "" diff --git a/routes.go b/routes.go index b41310b..a89d712 100644 --- a/routes.go +++ b/routes.go @@ -1,18 +1,25 @@ package user import ( + "fmt" + "strings" + "git.golem15.com/golem15/sm-user-plugin/controllers" "git.golem15.com/golem15/summercms/modules/pact" "git.golem15.com/golem15/summercms/modules/surf" ) func (p *Plugin) Routes(r pact.Router) error { + guard, err := p.turnstileGuard() + if err != nil { + return err + } r.Group("/_user/api/v1", surf.Use("throttle:user-api"), func(g pact.Router) { - g.Post("/login", controllers.Login(p.app)) + g.Post("/login", controllers.Login(p.app), guard...) 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.Post("/register", controllers.Register(p.app), guard...) g.Get("/oauth-providers", controllers.OAuthProviders(p.app)) g.Post("/forgot-password", controllers.ForgotPassword(p.app)) g.Post("/reset-password", controllers.ResetPassword(p.app)) @@ -30,3 +37,22 @@ func (p *Plugin) Routes(r pact.Router) error { }) return nil } + +// turnstileGuard returns the middleware that golem15.user.turnstile adds to +// POST /login and /register. The key is opt-in: empty (the default) adds +// nothing, "open" and "closed" select the framework's turnstile:{mode} +// middleware (summercms v0.3.0 and later), and any other value is refused so +// a typo cannot silently leave the forms unprotected. +func (p *Plugin) turnstileGuard() ([]string, error) { + if p.app == nil || p.app.Config == nil { + return nil, nil + } + switch mode := strings.TrimSpace(p.app.Config.String("golem15.user.turnstile")); mode { + case "": + return nil, nil + case "open", "closed": + return []string{"turnstile:" + mode}, nil + default: + return nil, fmt.Errorf("golem15.user: golem15.user.turnstile must be empty, open or closed, got %q", mode) + } +} diff --git a/turnstile_routes_smoke_test.go b/turnstile_routes_smoke_test.go new file mode 100644 index 0000000..80f6feb --- /dev/null +++ b/turnstile_routes_smoke_test.go @@ -0,0 +1,146 @@ +package user + +import ( + "net/http" + "strings" + "testing" + + "git.golem15.com/golem15/summercms/modules/pact" +) + +// recordedRoute is one registration seen by recordingRouter. +type recordedRoute struct { + method string + path string + middleware []string +} + +// recordingRouter is a pact.Router that records each registration with its +// full path and the group plus route middleware. The plugin requires a +// framework whose surf does not know the turnstile:* names, so the smoke test +// checks the declared middleware instead of assembling a real router. +type recordingRouter struct { + prefix string + middleware []string + routes *[]recordedRoute +} + +func newRecordingRouter() *recordingRouter { + return &recordingRouter{routes: &[]recordedRoute{}} +} + +func (r *recordingRouter) Group(prefix string, middleware []string, fn func(pact.Router)) { + fn(&recordingRouter{ + prefix: r.prefix + prefix, + middleware: append(append([]string{}, r.middleware...), middleware...), + routes: r.routes, + }) +} + +func (r *recordingRouter) GroupRaw(prefix string, middleware []string, fn func(pact.Router)) { + r.Group(prefix, middleware, fn) +} + +func (r *recordingRouter) add(method, path string, middleware []string) { + *r.routes = append(*r.routes, recordedRoute{ + method: method, + path: r.prefix + path, + middleware: append(append([]string{}, r.middleware...), middleware...), + }) +} + +func (r *recordingRouter) Get(path string, _ http.HandlerFunc, mw ...string) { + r.add(http.MethodGet, path, mw) +} +func (r *recordingRouter) Post(path string, _ http.HandlerFunc, mw ...string) { + r.add(http.MethodPost, path, mw) +} +func (r *recordingRouter) Put(path string, _ http.HandlerFunc, mw ...string) { + r.add(http.MethodPut, path, mw) +} +func (r *recordingRouter) Patch(path string, _ http.HandlerFunc, mw ...string) { + r.add(http.MethodPatch, path, mw) +} +func (r *recordingRouter) Delete(path string, _ http.HandlerFunc, mw ...string) { + r.add(http.MethodDelete, path, mw) +} +func (r *recordingRouter) Where(string, string) {} +func (r *recordingRouter) WhereIn(string, ...string) {} + +func recordUserRoutes(t *testing.T, mode string) ([]recordedRoute, error) { + t.Helper() + app := bareApp(t) + if err := app.Config.Set("golem15.user.turnstile", mode); err != nil { + t.Fatal(err) + } + rec := newRecordingRouter() + err := (&Plugin{app: app}).Routes(rec) + return *rec.routes, err +} + +func turnstileOf(route recordedRoute) []string { + var out []string + for _, name := range route.middleware { + if strings.HasPrefix(name, "turnstile") { + out = append(out, name) + } + } + return out +} + +func TestTurnstileRoutesSmoke(t *testing.T) { + off, err := recordUserRoutes(t, "") + if err != nil { + t.Fatalf("empty turnstile: Routes error %v", err) + } + if len(off) == 0 { + t.Fatal("no routes recorded") + } + for _, route := range off { + if got := turnstileOf(route); len(got) > 0 { + t.Errorf("empty turnstile: %s %s carries %v", route.method, route.path, got) + } + } + + closed, err := recordUserRoutes(t, "closed") + if err != nil { + t.Fatalf("closed turnstile: Routes error %v", err) + } + if len(closed) != len(off) { + t.Fatalf("closed turnstile: %d routes, want %d", len(closed), len(off)) + } + guarded := map[string]bool{"/_user/api/v1/login": true, "/_user/api/v1/register": true} + for i, route := range closed { + got := turnstileOf(route) + if guarded[route.path] && route.method == http.MethodPost { + if len(got) != 1 || got[0] != "turnstile:closed" { + t.Errorf("closed turnstile: POST %s middleware %v, want one turnstile:closed", route.path, route.middleware) + } + if strings.Join(route.middleware[:len(route.middleware)-1], ",") != strings.Join(off[i].middleware, ",") { + t.Errorf("closed turnstile: POST %s middleware %v, want %v plus turnstile:closed", route.path, route.middleware, off[i].middleware) + } + continue + } + if route.method != off[i].method || route.path != off[i].path || + strings.Join(route.middleware, ",") != strings.Join(off[i].middleware, ",") { + t.Errorf("closed turnstile: %s %s %v changed from %s %s %v", + route.method, route.path, route.middleware, off[i].method, off[i].path, off[i].middleware) + } + } + + open, err := recordUserRoutes(t, "open") + if err != nil { + t.Fatalf("open turnstile: Routes error %v", err) + } + for _, route := range open { + if guarded[route.path] { + if got := turnstileOf(route); len(got) != 1 || got[0] != "turnstile:open" { + t.Errorf("open turnstile: %s middleware %v", route.path, route.middleware) + } + } + } + + if _, err := recordUserRoutes(t, "bogus"); err == nil || !strings.Contains(err.Error(), "golem15.user.turnstile") { + t.Fatalf("bogus turnstile: error %v, want one naming golem15.user.turnstile", err) + } +}