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