- bouncer: CookieConfig from auth.cookie.* (LoadCookieConfig validates domain, same_site none without Secure, secure false in production), TokenCookie with Max-Age = refresh window, ForgetTokenCookie, CookieRefreshHandler and CookieLogoutHandler with an app-supplied writer - surf: http.cors refuses credentials with a * origin and bad or PHP-delimited patterns at boot, turns wildcard origins into anchored patterns, echoes the exact origin and sends Vary: Origin on every dynamic CORS answer; the non-credentialed * answer is unchanged - smoke tests for the www -> api refresh and the refused wildcard config
202 lines
13 KiB
Markdown
202 lines
13 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.
|
|
- 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.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. 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: `context`, `crypto/rand`, `database/sql`, `encoding/hex`, `encoding/json`, `errors`, `fmt`, `math`, `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.
|