Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7b9faa3419 |
@@ -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
|
||||
|
||||
|
||||
@@ -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: ""
|
||||
|
||||
30
routes.go
30
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)
|
||||
}
|
||||
}
|
||||
|
||||
146
turnstile_routes_smoke_test.go
Normal file
146
turnstile_routes_smoke_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user