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:
@@ -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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user