1 Commits

Author SHA1 Message Date
Jakub Zych
7b9faa3419 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
2026-10-20 14:45:22 +02:00
4 changed files with 180 additions and 2 deletions

View File

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

View File

@@ -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: ""

View File

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

View File

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