fix(cabana): enforce tokens_valid_after and is_activated on admin refresh
Fixes review finding CR-01 (quick 260927-q23): POST {prefix}/api/v1/auth/refresh
minted a new token without loading the admin, so a session kept alive by the
SPA's refresh-on-401 survived admin:reset-password, deactivation and deletion.
This broke Phase 9 truth T-09-04.
- bouncer: extract the JWT guard's subject lookup into subjectPrincipal and
issuedBeforeCutoff (same order and messages), add ErrSubjectRejected
- bouncer: add RefreshAudienceFor, which runs the guard's subject checks
after the token-only checks and before minting; Refresh and
RefreshAudience are unchanged (nil hook)
- cabana: share one lazyBackendUsers provider between the backend guard and
refresh; a cookie refresh refused for its subject expires summer_admin
- test: TestAdminRefreshRevocation (Postgres, real admin:reset-password)
This commit is contained in:
@@ -118,19 +118,9 @@ func (g *jwtGuard) Authenticate(r *http.Request) (*Principal, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
id, err := strconv.ParseUint(sub, 10, 64)
|
||||
if err != nil || id == 0 {
|
||||
return nil, errors.New(msgUserNotFound)
|
||||
}
|
||||
if g.users == nil {
|
||||
return nil, errors.New(msgUserNotFound)
|
||||
}
|
||||
user, err := g.users.FindByID(r.Context(), uint(id))
|
||||
user, err := subjectPrincipal(r.Context(), g.users, sub)
|
||||
if err != nil {
|
||||
return nil, errors.New("Authentication error")
|
||||
}
|
||||
if user == nil {
|
||||
return nil, errors.New(msgUserNotFound)
|
||||
return nil, err
|
||||
}
|
||||
if g.bl != nil {
|
||||
blocked, err := g.bl.IsBlacklisted(r.Context(), jti)
|
||||
@@ -141,12 +131,48 @@ func (g *jwtGuard) Authenticate(r *http.Request) (*Principal, error) {
|
||||
return nil, errors.New(msgBadSignature)
|
||||
}
|
||||
}
|
||||
if !user.TokensValidAfter.IsZero() && iat.Before(user.TokensValidAfter) {
|
||||
return nil, errors.New(msgUserNotFound)
|
||||
if issuedBeforeCutoff(user, iat) {
|
||||
return nil, ErrSubjectRejected
|
||||
}
|
||||
return user, nil
|
||||
}
|
||||
|
||||
// ErrSubjectRejected reports that a token's subject is not a loadable user
|
||||
// (a non-numeric or zero sub, a nil provider, or a provider that returned no
|
||||
// principal for a missing, deleted or not-activated user), or that the token
|
||||
// was issued before Principal.TokensValidAfter. Its message is "User not found".
|
||||
var ErrSubjectRejected = errors.New(msgUserNotFound)
|
||||
|
||||
// errAuthentication reports a provider failure while loading the subject.
|
||||
var errAuthentication = errors.New("Authentication error")
|
||||
|
||||
// subjectPrincipal loads the token subject through users, shared by the JWT
|
||||
// guard and RefreshAudienceFor. A provider error is errAuthentication; every
|
||||
// other refusal is ErrSubjectRejected.
|
||||
func subjectPrincipal(ctx context.Context, users UserProvider, sub string) (*Principal, error) {
|
||||
id, err := strconv.ParseUint(sub, 10, 64)
|
||||
if err != nil || id == 0 {
|
||||
return nil, ErrSubjectRejected
|
||||
}
|
||||
if users == nil {
|
||||
return nil, ErrSubjectRejected
|
||||
}
|
||||
user, err := users.FindByID(ctx, uint(id))
|
||||
if err != nil {
|
||||
return nil, errAuthentication
|
||||
}
|
||||
if user == nil {
|
||||
return nil, ErrSubjectRejected
|
||||
}
|
||||
return user, nil
|
||||
}
|
||||
|
||||
// issuedBeforeCutoff reports whether iat predates the user's
|
||||
// tokens_valid_after cutoff (set by a password reset). A zero cutoff never cuts.
|
||||
func issuedBeforeCutoff(user *Principal, iat time.Time) bool {
|
||||
return !user.TokensValidAfter.IsZero() && iat.Before(user.TokensValidAfter)
|
||||
}
|
||||
|
||||
func (g *jwtGuard) WriteUnauthorized(w http.ResponseWriter, err error) {
|
||||
if g.writeFn != nil {
|
||||
g.writeFn(w, err)
|
||||
|
||||
Reference in New Issue
Block a user