feat(09-01): implement separate-admin genre list tracer

- Audience-aware mint, verify, refresh, and backend guard keep frontend tokens compatible
- Cabana mounts raw admin login, list schema, and record list behind admin.jwt.secret
- Framework migration seeds Winter backend users and developer/publisher roles
This commit is contained in:
Jakub Zych
2026-09-24 17:17:19 +02:00
parent 01d3871510
commit dfa00f7e3a
13 changed files with 1178 additions and 13 deletions

View File

@@ -9,11 +9,15 @@ type userKey struct{}
// Principal is the authenticated identity stored on the request context.
// PreferredLocale empty means no override. TokensValidAfter zero means no cutoff.
// IsSuperuser and PermissionGrants are set only for backend-admin principals.
// A grant ending in ".*" matches permission codes by prefix.
type Principal struct {
ID uint
MustChangePassword bool
PreferredLocale string
TokensValidAfter time.Time
IsSuperuser bool `json:"-"`
PermissionGrants map[string]bool `json:"-"`
}
// WithUser stores the verified principal on ctx.

View File

@@ -66,10 +66,13 @@ func Middleware(secret string, users UserProvider) func(http.Handler) http.Handl
}
type jwtGuard struct {
secret string
users UserProvider
bl BlacklistStore
cookieNames []string
secret string
users UserProvider
bl BlacklistStore
cookieNames []string
audience string
requireAudience bool
writeFn func(http.ResponseWriter, error)
}
var (
@@ -84,12 +87,31 @@ func NewJWTGuard(secret string, users UserProvider, bl BlacklistStore, cookieNam
return &jwtGuard{secret: secret, users: users, bl: bl, cookieNames: cookieNames}
}
// NewBackendJWTGuard is bearer-only and requires AudienceBackend.
// write may replace the PHP-shaped 401 body; nil keeps write401.
func NewBackendJWTGuard(secret string, users UserProvider, bl BlacklistStore, write func(http.ResponseWriter, error)) Guard {
return &jwtGuard{
secret: secret,
users: users,
bl: bl,
audience: AudienceBackend,
requireAudience: true,
writeFn: write,
}
}
func (g *jwtGuard) Authenticate(r *http.Request) (*Principal, error) {
raw, err := extractToken(r, g.cookieNames)
if err != nil {
return nil, err
}
sub, iat, _, jti, err := VerifyClaims(raw, g.secret)
var sub, jti string
var iat time.Time
if g.requireAudience {
sub, iat, _, jti, err = VerifyClaimsAudience(raw, g.secret, g.audience)
} else {
sub, iat, _, jti, err = VerifyClaims(raw, g.secret)
}
if err != nil {
return nil, err
}
@@ -123,6 +145,10 @@ func (g *jwtGuard) Authenticate(r *http.Request) (*Principal, error) {
}
func (g *jwtGuard) WriteUnauthorized(w http.ResponseWriter, err error) {
if g.writeFn != nil {
g.writeFn(w, err)
return
}
write401(w, err.Error())
}
@@ -147,7 +173,20 @@ func Verify(tokenString, secret string) (string, error) {
}
// VerifyClaims parses a token the same way Verify does and also returns iat, exp, and jti.
// A missing audience stays valid so PHP-issued frontend tokens keep working.
func VerifyClaims(tokenString, secret string) (sub string, iat, exp time.Time, jti string, err error) {
return verifyClaims(tokenString, secret, "")
}
// VerifyClaimsAudience is VerifyClaims plus a required audience claim.
func VerifyClaimsAudience(tokenString, secret, audience string) (sub string, iat, exp time.Time, jti string, err error) {
if strings.TrimSpace(audience) == "" {
return "", time.Time{}, time.Time{}, "", fmt.Errorf("bouncer: jwt audience is empty")
}
return verifyClaims(tokenString, secret, audience)
}
func verifyClaims(tokenString, secret, audience string) (sub string, iat, exp time.Time, jti string, err error) {
if strings.TrimSpace(secret) == "" {
return "", time.Time{}, time.Time{}, "", fmt.Errorf("bouncer: jwt secret is empty")
}
@@ -159,6 +198,9 @@ func VerifyClaims(tokenString, secret string) (sub string, iat, exp time.Time, j
if err != nil {
return "", time.Time{}, time.Time{}, "", mapJWTError(err)
}
if audience != "" && !audienceMatches(claims, audience) {
return "", time.Time{}, time.Time{}, "", errors.New(msgBadSignature)
}
sub = subject(claims)
if sub == "" {
return "", time.Time{}, time.Time{}, "", errors.New(msgRequiredClaims)
@@ -169,6 +211,38 @@ func VerifyClaims(tokenString, secret string) (sub string, iat, exp time.Time, j
return sub, iat, exp, jti, nil
}
func audienceMatches(claims jwt.MapClaims, expected string) bool {
for _, aud := range claimAudiences(claims) {
if aud == expected {
return true
}
}
return false
}
func claimAudiences(claims jwt.MapClaims) []string {
switch v := claims["aud"].(type) {
case string:
if strings.TrimSpace(v) == "" {
return nil
}
return []string{v}
case []string:
return v
case []any:
out := make([]string, 0, len(v))
for _, item := range v {
s, ok := item.(string)
if ok && s != "" {
out = append(out, s)
}
}
return out
default:
return nil
}
}
func extractToken(r *http.Request, cookieNames []string) (string, error) {
raw, err := bearerToken(r)
if err == nil {

View File

@@ -11,35 +11,56 @@ import (
)
// prvHash is sha1("Golem15\User\Models\User"), the lock-subject PHP jwt-auth
// stamps on every user token.
// stamps on every frontend user token. Backend tokens do not carry it.
const prvHash = "a867434cbc213adfbe78a02bed7082a6bd99c883"
const (
// AudienceUser is the frontend jwt-guard audience.
AudienceUser = "user"
// AudienceBackend is the admin jwt-guard audience.
AudienceBackend = "backend"
)
type registeredClaims struct {
jwt.RegisteredClaims
Prv string `json:"prv,omitempty"`
}
// Mint signs an HS256 token whose iss is the full URL of the minting endpoint.
// Mint signs a frontend-audience HS256 token. iss is the minting endpoint URL.
// The returned jti is the token's own jti claim.
func Mint(secret, sub, issuerURL string, ttl time.Duration) (string, string, error) {
return MintAudience(secret, sub, issuerURL, ttl, AudienceUser)
}
// MintAudience signs an HS256 token for audience. Frontend tokens keep the PHP
// prv lock-subject; backend tokens omit it.
func MintAudience(secret, sub, issuerURL string, ttl time.Duration, audience string) (string, string, error) {
if strings.TrimSpace(secret) == "" {
return "", "", fmt.Errorf("bouncer: jwt secret is empty")
}
if strings.TrimSpace(audience) == "" {
return "", "", fmt.Errorf("bouncer: jwt audience is empty")
}
jti, err := newJTI()
if err != nil {
return "", "", err
}
now := time.Now()
prv := ""
if audience == AudienceUser {
prv = prvHash
}
claims := registeredClaims{
RegisteredClaims: jwt.RegisteredClaims{
Issuer: issuerURL,
Subject: sub,
Audience: jwt.ClaimStrings{audience},
ExpiresAt: jwt.NewNumericDate(now.Add(ttl)),
NotBefore: jwt.NewNumericDate(now),
IssuedAt: jwt.NewNumericDate(now),
ID: jti,
},
Prv: prvHash,
Prv: prv,
}
signed, err := jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString([]byte(secret))
if err != nil {

View File

@@ -15,6 +15,18 @@ import (
// jwt-auth: the later of the old exp and iat+refreshTTL, plus one minute, so
// a logged-out token cannot be refreshed again for the rest of its refresh window.
func Refresh(secret, tokenString string, refreshTTL time.Duration, bl BlacklistStore, grace time.Duration, issuerURL string) (string, error) {
return refreshAudience(secret, tokenString, AudienceUser, true, refreshTTL, bl, grace, issuerURL)
}
// RefreshAudience reissues a token that already carries audience. Missing aud is rejected.
func RefreshAudience(secret, tokenString, audience string, refreshTTL time.Duration, bl BlacklistStore, grace time.Duration, issuerURL string) (string, error) {
if strings.TrimSpace(audience) == "" {
return "", errors.New("bouncer: jwt audience is empty")
}
return refreshAudience(secret, tokenString, audience, false, refreshTTL, bl, grace, issuerURL)
}
func refreshAudience(secret, tokenString, audience string, allowMissing bool, refreshTTL time.Duration, bl BlacklistStore, grace time.Duration, issuerURL string) (string, error) {
if strings.TrimSpace(secret) == "" {
return "", errors.New("bouncer: jwt secret is empty")
}
@@ -29,6 +41,14 @@ func Refresh(secret, tokenString string, refreshTTL time.Duration, bl BlacklistS
if sub == "" {
return "", errors.New(msgRequiredClaims)
}
auds := claimAudiences(claims)
if len(auds) == 0 {
if !allowMissing {
return "", errors.New(msgBadSignature)
}
} else if !audienceMatches(claims, audience) {
return "", errors.New(msgBadSignature)
}
iat, ok := claimTime(claims, "iat")
if !ok || time.Now().After(iat.Add(refreshTTL)) {
return "", errors.New("Token has expired and can no longer be refreshed")
@@ -48,7 +68,7 @@ func Refresh(secret, tokenString string, refreshTTL time.Duration, bl BlacklistS
if !expOK || ttl <= 0 {
return "", errors.New(msgRequiredClaims)
}
next, _, err := Mint(secret, sub, issuerURL, ttl)
next, _, err := MintAudience(secret, sub, issuerURL, ttl, audience)
if err != nil {
return "", err
}