feat(19.3-02): Cloudflare Turnstile verification as turnstile:open|closed middleware

- bouncer: Turnstile.Verify posts secret/response/remoteip to siteverify with
  a 5 s bound and PHP's open and closed modes (503 unconfigured in production,
  422 on a missing, rejected or overlong token, an error or a timeout); errors
  never carry the secret or the token
- bouncer: Turnstile.Middleware reads the token from the query, a JSON body or
  a form body, restores the body and writes refusals through a published
  TurnstileWriter or neutral JSON
- surf: factory turnstile:open|closed from turnstile.secret/turnstile.field
  (a published *bouncer.Turnstile wins), client IP with trusted proxies; any
  other param fails boot
- docs: Turnstile section, middleware table, README rows
This commit is contained in:
Jakub Zych
2026-10-15 17:54:58 +02:00
parent 5055e8bb61
commit 597c79bae7
7 changed files with 469 additions and 5 deletions

View File

@@ -1,6 +1,6 @@
---
title: Authentication
description: Mint and verify JWTs with bouncer, turn guards into route middleware, revoke tokens through a jti blacklist and hash passwords with bcrypt.
description: JWTs with bouncer, cookie transport, guards as route middleware, a jti blacklist, Cloudflare Turnstile checks and bcrypt passwords.
section: services
order: 50
---
@@ -166,6 +166,30 @@ fmt.Println(rec.Code, rec.Body.String())
// 401 {"error":"Authentication required"}
```
## Turnstile
[Cloudflare Turnstile](https://developers.cloudflare.com/turnstile/) keeps bots off guest and registration endpoints. The frontend widget produces a token, the request carries it, and the server checks it with Cloudflare's siteverify endpoint. surf registers the check as named middleware with two modes:
| Middleware | Without a secret | With a secret |
|------------|------------------|---------------|
| `turnstile:open` | passes (a development no-op) | the token must verify |
| `turnstile:closed` | 503 in the production environment, passes elsewhere | the token must verify |
Use `turnstile:closed` where an unprotected endpoint would be a hole, such as guest entry: a production deployment that forgot the secret answers 503 instead of letting every bot in. Use `turnstile:open` where the endpoint has other protection and must keep working without Turnstile, such as registration. With a secret set both modes behave the same: a missing token, a token Cloudflare rejects, a token longer than 2048 characters, a failed siteverify call or one slower than 5 seconds answers 422.
The middleware reads the token from the `turnstile_token` field (`turnstile.field` changes the name): the query string first, then a JSON object body or a form body. It reads the body and restores it, so the handler sees the same bytes. A multipart body is not searched; put the token in the query string there.
| Key | Default | Controls |
|-----|---------|----------|
| `turnstile.secret` | empty | The site's secret key. Set it through `SUMMER_TURNSTILE__SECRET`; a PHP application's `TURNSTILE_SECRET` value moves to that variable. |
| `turnstile.field` | `turnstile_token` | The request field carrying the token. |
Refusals are written with neutral bodies, `{"error":"turnstile_unavailable"}` (503) and `{"error":"turnstile_failed"}` (422). To keep an existing response envelope, publish a `bouncer.TurnstileWriter` on the app in your plugin's `Register`; it receives the status and the `bouncer.TurnstileVerdict`. A published `*bouncer.Turnstile` replaces the verifier built from config, for example to give it its own `http.Client`.
Cloudflare's [testing page](https://developers.cloudflare.com/turnstile/troubleshooting/testing/) lists secrets that always pass (`1x0000000000000000000000000000000AA`) or always fail (`2x0000000000000000000000000000000AA`), for staging environments and manual checks. A token is valid for 300 seconds and only once: verify it before any write and never retry a verification with the same token.
The middleware runs before the handler, so a request with both a bad token and invalid fields gets the Turnstile answer, not the validation errors. When a ported endpoint must keep the order it had, such as validating a name first, call `bouncer.Turnstile.Verify` from the handler instead of using the middleware: it takes the context, token, client IP and mode and returns the verdict, plus an error for logging that never contains the secret or the token.
## Passwords
`bouncer.HashPassword` hashes with bcrypt at the cost you give it, `bouncer.CheckPassword` compares in constant time, and `bouncer.NeedsRehash` reports a hash made below your configured cost. WinterCMS stores bcrypt hashes too, so existing passwords keep working:

View File

@@ -99,6 +99,7 @@ The framework registers these names:
| `throttle:<bucket>` or `throttle:<max>,<minutes>` | Rate limiting; see [Rate limiting](rate-limiting.md). |
| `body.limit:<bytes>` | Replaces the default request body limit for the route. |
| `cors:<profile>` | CORS headers from `http.cors_profiles.<profile>`; see [CORS profiles](#cors-profiles). |
| `turnstile:open`, `turnstile:closed` | Cloudflare Turnstile verification; see [Turnstile](authentication.md#turnstile). |
| `locale.from-principal` | Switches the request locale to the signed-in user's preferred locale. |
| `backend` | The admin guard, when the admin is enabled. |