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"
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.
Features
- Token minting with
bouncer.Mint(frontend audience) andbouncer.MintAudience(any audience), each returning the signed token and its random jti. - Verification with HS256 pinned and
expandsubrequired:bouncer.Verifyandbouncer.VerifyClaimsaccept frontend tokens, including legacy tokens with no audience claim;bouncer.VerifyClaimsAudiencerequires an explicit audience, so a backend token cannot pass a frontend check and the other way round. - Refresh with
bouncer.Refresh,bouncer.RefreshAudienceandbouncer.RefreshAudienceFor: an expired token can be reissued while itsiatis inside the refresh window; the old jti is blacklisted after a grace period.bouncer.RefreshAudienceForalso reloads the user and refuses deleted users and tokens issued beforebouncer.Principal.TokensValidAfter, reportingbouncer.ErrSubjectRejected. - JWT guards:
bouncer.NewJWTGuard(frontend) andbouncer.NewBackendJWTGuard(admin audience, optional custom 401 writer) read the bearer token first and then any configured cookies, load the user through abouncer.UserProvider, check the blacklist and thebouncer.Principal.TokensValidAftercutoff, and write a JSON 401 body ({"error":true,"message":...}, withCache-Control: no-cache, private) on failure. - Named guard registry:
bouncer.Registry.Registeraccepts anybouncer.Guardorbouncer.CredentialGuard;bouncer.Registry.Middlewarederives middleware that stores the principal (and credential, if any) on the context. Guards that do not implementbouncer.UnauthorizedWriterlet unauthenticated requests through so later middleware can decide. - Standalone bearer middleware:
bouncer.Middleware. - Context helpers:
bouncer.WithUserandbouncer.Userfor the principal,bouncer.WithCredentialandbouncer.Credentialfor the credential behind it (for example an API token record). - Blacklist stores behind
bouncer.BlacklistStore:bouncer.MemoryBlacklistfor tests andbouncer.PostgresBlacklistfor production, which works on a caller-supplied table withjti,expires_atandvalid_untilcolumns and rejects unsafe table names. - Passwords:
bouncer.HashPassword,bouncer.CheckPasswordandbouncer.NeedsRehash(bcrypt, cost chosen by the caller).
Usage
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
}
API reference
| Identifier | Description |
|---|---|
bouncer.Principal |
The authenticated identity: user ID, locale override, token cutoff and admin permission grants. |
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.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.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. |
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,reflect,regexp,strconv,strings,sync,time. - Tests additionally use
github.com/testcontainers/testcontainers-gowith itsmodules/postgrespackage, andgithub.com/jackc/pgx/v5/stdlib.
Testing
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.