Files
summercms/modules/bouncer/README.md
Jakub Zych 597c79bae7 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
2026-10-15 17:54:58 +02:00

213 lines
15 KiB
Markdown

# bouncer
Authentication for SummerCMS: HS256 JWT minting, verification and refresh, request guards, a revoked-token blacklist and bcrypt password helpers.
`import "git.golem15.com/golem15/summercms/modules/bouncer"`
**Name:** Checks IDs at the beach club door; it authenticates requests and turns away the ones without a valid token.
## Overview
bouncer decides who is making a request. Guards (`bouncer.Guard`, `bouncer.CredentialGuard`) turn an `*http.Request` into a `bouncer.Principal`; a `bouncer.Registry` holds named guards that plugins register and turns each one into HTTP middleware that stores the principal on the request context. The JWT side issues and checks HS256 tokens for two audiences, frontend users (`bouncer.AudienceUser`) and admin users (`bouncer.AudienceBackend`), with a refresh flow and a jti blacklist compatible with tokens issued by the PHP jwt-auth library. It is the counterpart of WinterCMS's Auth and BackendAuth facades and the JWT auth layer used by API plugins.
## Role in the framework
A dependency-free security primitive. `surf` uses it for request guards, `cabana` for admin sign-in, and `conga` and `lighthouse` for authenticated workers and realtime tokens. It is separate so authentication code can be reviewed and tested on its own, without the HTTP or admin layers around it.
Use cases:
- Issue and refresh JWTs for a single-page or mobile frontend.
- Protect API routes with a guard and read the authenticated user downstream.
- Revoke tokens on sign-out and hash and check passwords with bcrypt.
## Features
- Token minting with `bouncer.Mint` (frontend audience) and `bouncer.MintAudience` (any audience), each returning the signed token and its random jti.
- Verification with HS256 pinned and `exp` and `sub` required: `bouncer.Verify` and `bouncer.VerifyClaims` accept frontend tokens, including legacy tokens with no audience claim; `bouncer.VerifyClaimsAudience` requires an explicit audience, so a backend token cannot pass a frontend check and the other way round.
- Refresh with `bouncer.Refresh`, `bouncer.RefreshAudience` and `bouncer.RefreshAudienceFor`: an expired token can be reissued while its `iat` is inside the refresh window; the old jti is blacklisted after a grace period. `bouncer.RefreshAudienceFor` also reloads the user and refuses deleted users and tokens issued before `bouncer.Principal.TokensValidAfter`, reporting `bouncer.ErrSubjectRejected`.
- JWT guards: `bouncer.NewJWTGuard` (frontend) and `bouncer.NewBackendJWTGuard` (admin audience, optional custom 401 writer) read the bearer token first and then any configured cookies, load the user through a `bouncer.UserProvider`, check the blacklist and the `bouncer.Principal.TokensValidAfter` cutoff, and write a JSON 401 body (`{"error":true,"message":...}`, with `Cache-Control: no-cache, private`) on failure.
- Named guard registry: `bouncer.Registry.Register` accepts any `bouncer.Guard` or `bouncer.CredentialGuard`; `bouncer.Registry.Middleware` derives middleware that stores the principal (and credential, if any) on the context. Guards that do not implement `bouncer.UnauthorizedWriter` let unauthenticated requests through so later middleware can decide.
- Standalone bearer middleware: `bouncer.Middleware`.
- Context helpers: `bouncer.WithUser` and `bouncer.User` for the principal, `bouncer.WithCredential` and `bouncer.Credential` for the credential behind it (for example an API token record).
- Scope limits for personal API tokens: `bouncer.ScopeLimit` on `bouncer.Principal.Scope` (nil means unrestricted, as for a JWT session). A token guard sets it after filtering the token's scopes by the request method; `Full` means the token acts as the owner on every method (`*`) or on GET/HEAD (`admin:read`).
- Blacklist stores behind `bouncer.BlacklistStore`: `bouncer.MemoryBlacklist` for tests and `bouncer.PostgresBlacklist` for production, which works on a caller-supplied table with `jti`, `expires_at` and `valid_until` columns and rejects unsafe table names.
- Cookie transport: `bouncer.LoadCookieConfig` reads the `auth.cookie.*` block into a `bouncer.CookieConfig` and refuses an invalid domain, `same_site: none` without Secure and `secure: false` in the production environment; `bouncer.CookieConfig.TokenCookie` builds the httpOnly token cookie whose Max-Age is the refresh window, and `bouncer.CookieConfig.ForgetTokenCookie` the matching cookie that deletes it. `bouncer.CookieRefreshHandler` and `bouncer.CookieLogoutHandler` are ready-made handlers for a cookie-based refresh and logout; the application supplies the refresh or revoke call and a writer for its own response envelope.
- Cloudflare Turnstile: `bouncer.Turnstile.Verify` checks a token against siteverify with a 5 second timeout, in PHP's open mode (an empty secret passes) or closed mode (an empty secret is `bouncer.TurnstileUnavailable` in production); `bouncer.Turnstile.Middleware` reads the token from the query, a JSON body or a form body, restores the body and writes refusals through a `bouncer.TurnstileWriter`. surf registers it as `turnstile:open` and `turnstile:closed`.
- Passwords: `bouncer.HashPassword`, `bouncer.CheckPassword` and `bouncer.NeedsRehash` (bcrypt, cost chosen by the caller).
## Usage
```go
package blog
import (
"context"
"fmt"
"net/http"
"time"
"git.golem15.com/golem15/summercms/modules/bouncer"
)
type users struct{}
// FindByID loads the user behind a token subject; nil means "not found".
func (users) FindByID(ctx context.Context, id uint) (*bouncer.Principal, error) {
return &bouncer.Principal{ID: id, PreferredLocale: "en"}, nil
}
func Routes(secret string) (http.Handler, error) {
guards := bouncer.NewRegistry()
guard := bouncer.NewJWTGuard(secret, users{}, bouncer.NewMemoryBlacklist(), "token")
if err := guards.Register("acme.blog", "jwt", guard); err != nil {
return nil, err
}
auth, err := guards.Middleware("jwt")
if err != nil {
return nil, err
}
mux := http.NewServeMux()
mux.Handle("GET /api/me", auth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
user, _ := bouncer.User(r.Context())
fmt.Fprintf(w, "user %d", user.ID)
})))
return mux, nil
}
func Login(secret string) (string, error) {
token, _, err := bouncer.Mint(secret, "42", "https://example.com/api/login", time.Hour)
return token, err
}
```
An application that keeps the token in an httpOnly cookie, for a frontend on `www.example.com` calling the API on `api.example.com`, loads the cookie config at boot and mounts the refresh handler on a POST route:
```go
package acme
import (
"context"
"encoding/json"
"net/http"
"time"
"git.golem15.com/golem15/summercms/modules/bouncer"
)
// RefreshRoute rotates the cookie's token. cfg is the app config
// (app.Config); users, secret and bl are the guard's; refreshTTL is its
// refresh_ttl.
func RefreshRoute(cfg bouncer.CookieConfigSource, users bouncer.UserProvider, secret string, bl bouncer.BlacklistStore, refreshTTL time.Duration) (http.Handler, error) {
cookie, err := bouncer.LoadCookieConfig(cfg) // a bad auth.cookie block fails boot
if err != nil {
return nil, err
}
return bouncer.CookieRefreshHandler(bouncer.CookieRefreshOptions{
Cookie: cookie,
RefreshTTL: refreshTTL,
Refresh: func(ctx context.Context, token string) (string, error) {
return bouncer.RefreshAudienceFor(ctx, users, secret, token, bouncer.AudienceUser, refreshTTL, bl, 0, "https://api.example.com/api/auth/refresh")
},
Write: func(w http.ResponseWriter, r *http.Request, res bouncer.CookieResult) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(res.Status)
if res.Err != nil {
_ = json.NewEncoder(w).Encode(map[string]any{"success": false, "error": "UNAUTHENTICATED"})
return
}
_ = json.NewEncoder(w).Encode(map[string]any{"success": true})
},
})
}
```
## API reference
| Identifier | Description |
|------------|-------------|
| `bouncer.Principal` | The authenticated identity: user ID, locale override, token cutoff, admin permission grants and optional `Scope`. |
| `bouncer.ScopeLimit` | A personal API token's method-filtered scope: `Full` or one permission-pattern slice per allowed scope. |
| `bouncer.Principal.Scope` | Set only for personal API token principals; nil for JWT sessions. |
| `bouncer.Guard` | Resolves the `bouncer.Principal` for a request. |
| `bouncer.CredentialGuard` | Resolves the principal and its underlying credential in one pass. |
| `bouncer.UnauthorizedWriter` | Optional guard interface for writing its own 401 response. |
| `bouncer.UserProvider` | Loads a user by numeric token subject. |
| `bouncer.Registry` | Named guard registry; `bouncer.NewRegistry` creates one. |
| `bouncer.Registry.Register` | Registers a guard under a name on behalf of a plugin; duplicate names fail. |
| `bouncer.Registry.Middleware` | Returns HTTP middleware for a registered guard; unknown names fail. |
| `bouncer.Registry.Owner` | The plugin ID that registered a named guard. |
| `bouncer.NewJWTGuard` | Frontend JWT guard reading the bearer header and optional cookies. |
| `bouncer.NewBackendJWTGuard` | Admin JWT guard that requires the backend audience. |
| `bouncer.Middleware` | Standalone middleware that validates a bearer token and loads the user. |
| `bouncer.Mint` | Signs a frontend-audience token; returns the token and its jti. |
| `bouncer.MintAudience` | Signs a token for a given audience. |
| `bouncer.Verify` | Verifies a frontend token and returns its subject. |
| `bouncer.VerifyClaims` | `bouncer.Verify` plus `iat`, `exp` and `jti`. |
| `bouncer.VerifyClaimsAudience` | `bouncer.VerifyClaims` with a required audience. |
| `bouncer.Refresh` | Reissues a frontend token inside the refresh window and blacklists the old jti. |
| `bouncer.RefreshAudience` | Refresh for a token that carries the given audience. |
| `bouncer.VerifyRefreshableClaimsAudience` | Verifies signature and audience without checking `exp`, while the refresh window is open; for revoking a refreshable token on logout. |
| `bouncer.RefreshAudienceFor` | `bouncer.RefreshAudience` plus the guard's user checks. |
| `bouncer.ErrSubjectRejected` | The token subject is not a loadable user, or the token predates the user's cutoff. |
| `bouncer.AudienceUser`, `bouncer.AudienceBackend` | The frontend and admin audience values. |
| `bouncer.WithUser`, `bouncer.User` | Store and read the principal on a context. |
| `bouncer.WithCredential`, `bouncer.Credential` | Store and read the resolved credential on a context. |
| `bouncer.BlacklistStore` | Revoked-jti store with a grace window and sweeping. |
| `bouncer.NewMemoryBlacklist` | In-process blacklist for tests. |
| `bouncer.NewPostgresBlacklist` | Blacklist over a `*sql.DB` and a table name. |
| `bouncer.CookieConfig` | Token cookie attributes: `Name`, `Domain`, `Path`, `Secure`, `SameSite`. |
| `bouncer.CookieConfigSource` | The config methods `bouncer.LoadCookieConfig` reads (`Lookup`, `String`, `Environment`); `*compass.Config` satisfies it. |
| `bouncer.LoadCookieConfig` | Reads and validates `auth.cookie.*`; every error names the key. |
| `bouncer.DefaultCookieName` | `token`, the cookie name when `auth.cookie.name` is unset. |
| `bouncer.CookieConfig.TokenCookie` | The httpOnly cookie carrying a token, Max-Age the refresh window passed in. |
| `bouncer.CookieConfig.ForgetTokenCookie` | The same cookie with an empty value and a past expiry, which deletes it. |
| `bouncer.CookieRefreshHandler` | Reads the token cookie, calls the application's refresh func, sets the rotated cookie and hands a `bouncer.CookieResult` to the writer. |
| `bouncer.CookieRefreshOptions` | Cookie config, refresh window, refresh func and writer of `bouncer.CookieRefreshHandler`. |
| `bouncer.CookieLogoutHandler` | Revokes the cookie's token through the application's func, clears the cookie and writes 200. |
| `bouncer.CookieLogoutOptions` | Cookie config, revoke func and writer of `bouncer.CookieLogoutHandler`. |
| `bouncer.CookieResult` | What the cookie handlers pass to the writer: status, rotated token and error. |
| `bouncer.ErrNoTokenCookie` | The refresh request carried no token cookie (401). |
| `bouncer.Turnstile` | Cloudflare Turnstile verifier: `Secret`, `Production`, `Endpoint`, `Client`. |
| `bouncer.NewTurnstile` | A verifier with the default endpoint and a client with `bouncer.TurnstileTimeout`. |
| `bouncer.Turnstile.Verify` | Checks a token for a client IP in open or closed mode; returns a `bouncer.TurnstileVerdict` and an error for logging. |
| `bouncer.Turnstile.Middleware` | Middleware that reads the token field, verifies it and answers 503 or 422 through a `bouncer.TurnstileWriter`. |
| `bouncer.TurnstileVerdict` | `bouncer.TurnstilePassed`, `bouncer.TurnstileFailed` or `bouncer.TurnstileUnavailable`. |
| `bouncer.TurnstileWriter` | Writes a Turnstile refusal in the application's envelope; publish one on the app to replace the neutral default. |
| `bouncer.TurnstileSiteverifyURL` | Cloudflare's siteverify endpoint. |
| `bouncer.TurnstileTimeout` | 5 seconds, the bound on one siteverify call. |
| `bouncer.TurnstileMaxTokenLength` | 2048, Cloudflare's token limit; longer tokens fail without a call. |
| `bouncer.DefaultTurnstileField` | `turnstile_token`, the default request field. |
| `bouncer.HashPassword` | Returns a bcrypt hash at the given cost. |
| `bouncer.CheckPassword` | Reports whether a password matches a hash. |
| `bouncer.NeedsRehash` | Reports whether a hash was made below the configured cost. |
## Configuration
`bouncer.LoadCookieConfig` reads these keys; nothing else in bouncer reads config (surf builds the Turnstile verifier from `turnstile.secret` and `turnstile.field`, see the [surf README](../surf/README.md#configuration)). The per-guard token lifetimes (`ttl`, `refresh_ttl`) stay with the guard's own config, such as the user plugin's `jwt` block or `admin.jwt.*`.
| Key | Default | Controls |
|-----|---------|----------|
| `auth.cookie.name` | `token` | Cookie name; must be a valid cookie name. |
| `auth.cookie.domain` | empty (host-only) | Cookie domain. Set a parent domain such as `example.com` to share the cookie between `api.example.com` and `www.example.com`; every subdomain then receives it. An invalid domain fails boot. |
| `auth.cookie.path` | `/` | Cookie path; must start with `/`. |
| `auth.cookie.secure` | true when `app.url` is https | Sends the cookie over https only. `false` is refused in the production environment. |
| `auth.cookie.same_site` | `lax` | `lax`, `strict` or `none`; `none` requires Secure. |
Environment variables follow the compass mapping, for example `SUMMER_AUTH__COOKIE__DOMAIN`.
## Dependencies
- SummerCMS modules: none.
- Third-party: `github.com/golang-jwt/jwt/v5`, `golang.org/x/crypto/bcrypt`.
- Standard library: `bytes`, `context`, `crypto/rand`, `database/sql`, `encoding/hex`, `encoding/json`, `errors`, `fmt`, `io`, `log/slog`, `math`, `mime`, `net`, `net/http`, `net/url`, `reflect`, `regexp`, `strconv`, `strings`, `sync`, `time`.
- Tests additionally use `github.com/testcontainers/testcontainers-go` with its `modules/postgres` package, and `github.com/jackc/pgx/v5/stdlib`.
## Testing
```sh
go test ./modules/bouncer/...
```
The Postgres blacklist concurrency test starts a PostgreSQL container through testcontainers-go and needs Docker. Run `go test -short ./modules/bouncer/...` to skip it; the remaining tests need no external services (Turnstile tests use an `httptest` siteverify fake through `Endpoint`).