1
0
Fork 0
mirror of https://github.com/portainer/portainer.git synced 2025-07-31 03:09:44 +02:00

fix(bouncer): add support for JWT revocation BE-11179 (#12165)
Some checks failed
ci / build_images (map[arch:amd64 platform:linux version:]) (push) Has been cancelled
ci / build_images (map[arch:amd64 platform:windows version:1809]) (push) Has been cancelled
ci / build_images (map[arch:amd64 platform:windows version:ltsc2022]) (push) Has been cancelled
ci / build_images (map[arch:arm platform:linux version:]) (push) Has been cancelled
ci / build_images (map[arch:arm64 platform:linux version:]) (push) Has been cancelled
ci / build_images (map[arch:ppc64le platform:linux version:]) (push) Has been cancelled
ci / build_images (map[arch:s390x platform:linux version:]) (push) Has been cancelled
/ triage (push) Has been cancelled
Lint / Run linters (push) Has been cancelled
Test / test-client (push) Has been cancelled
Test / test-server (map[arch:amd64 platform:linux]) (push) Has been cancelled
Test / test-server (map[arch:amd64 platform:windows version:1809]) (push) Has been cancelled
Test / test-server (map[arch:amd64 platform:windows version:ltsc2022]) (push) Has been cancelled
Test / test-server (map[arch:arm64 platform:linux]) (push) Has been cancelled
ci / build_manifests (push) Has been cancelled

This commit is contained in:
andres-portainer 2024-08-30 20:24:14 -03:00 committed by GitHub
parent 9133cbf544
commit 6cc95e11ae
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 176 additions and 12 deletions

View file

@ -28,5 +28,7 @@ func (handler *Handler) logout(w http.ResponseWriter, r *http.Request) *httperro
security.RemoveAuthCookie(w)
handler.bouncer.RevokeJWT(tokenData.Token)
return response.Empty(w)
}

View file

@ -4,6 +4,7 @@ import (
"fmt"
"net/http"
"strings"
"sync"
"time"
portainer "github.com/portainer/portainer/api"
@ -16,6 +17,9 @@ import (
"github.com/pkg/errors"
)
const apiKeyHeader = "X-API-KEY"
const jwtTokenHeader = "Authorization"
type (
BouncerService interface {
PublicAccess(http.Handler) http.Handler
@ -30,6 +34,7 @@ type (
TrustedEdgeEnvironmentAccess(dataservices.DataStoreTx, *portainer.Endpoint) error
CookieAuthLookup(*http.Request) (*portainer.TokenData, error)
JWTAuthLookup(*http.Request) (*portainer.TokenData, error)
RevokeJWT(string)
}
// RequestBouncer represents an entity that manages API request accesses
@ -37,6 +42,7 @@ type (
dataStore dataservices.DataStore
jwtService portainer.JWTService
apiKeyService apikey.APIKeyService
revokedJWT sync.Map
}
// RestrictedRequestContext is a data structure containing information
@ -52,16 +58,22 @@ type (
tokenLookup func(*http.Request) (*portainer.TokenData, error)
)
const apiKeyHeader = "X-API-KEY"
const jwtTokenHeader = "Authorization"
var (
ErrInvalidKey = errors.New("Invalid API key")
ErrRevokedJWT = errors.New("the JWT has been revoked")
)
// NewRequestBouncer initializes a new RequestBouncer
func NewRequestBouncer(dataStore dataservices.DataStore, jwtService portainer.JWTService, apiKeyService apikey.APIKeyService) *RequestBouncer {
return &RequestBouncer{
b := &RequestBouncer{
dataStore: dataStore,
jwtService: jwtService,
apiKeyService: apiKeyService,
}
go b.cleanUpExpiredJWT()
return b
}
// PublicAccess defines a security check for public API endpoints.
@ -317,11 +329,15 @@ func (bouncer *RequestBouncer) CookieAuthLookup(r *http.Request) (*portainer.Tok
return nil, nil
}
tokenData, err := bouncer.jwtService.ParseAndVerifyToken(token)
tokenData, jti, _, err := bouncer.jwtService.ParseAndVerifyToken(token)
if err != nil {
return nil, err
}
if _, ok := bouncer.revokedJWT.Load(jti); ok {
return nil, ErrRevokedJWT
}
return tokenData, nil
}
@ -333,15 +349,44 @@ func (bouncer *RequestBouncer) JWTAuthLookup(r *http.Request) (*portainer.TokenD
return nil, nil
}
tokenData, err := bouncer.jwtService.ParseAndVerifyToken(token)
tokenData, jti, _, err := bouncer.jwtService.ParseAndVerifyToken(token)
if err != nil {
return nil, err
}
if _, ok := bouncer.revokedJWT.Load(jti); ok {
return nil, ErrRevokedJWT
}
return tokenData, nil
}
var ErrInvalidKey = errors.New("Invalid API key")
func (bouncer *RequestBouncer) RevokeJWT(token string) {
_, jti, exp, err := bouncer.jwtService.ParseAndVerifyToken(token)
if err != nil {
return
}
bouncer.revokedJWT.Store(jti, exp)
}
func (bouncer *RequestBouncer) cleanUpExpiredJWTPass() {
bouncer.revokedJWT.Range(func(key, value any) bool {
if time.Now().After(value.(time.Time)) {
bouncer.revokedJWT.Delete(key)
}
return true
})
}
func (bouncer *RequestBouncer) cleanUpExpiredJWT() {
ticker := time.NewTicker(time.Hour)
for range ticker.C {
bouncer.cleanUpExpiredJWTPass()
}
}
// apiKeyLookup looks up an verifies an api-key by:
// - computing the digest of the raw api-key

View file

@ -5,6 +5,7 @@ import (
"net/http"
"net/http/httptest"
"testing"
"time"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/apikey"
@ -14,6 +15,7 @@ import (
"github.com/portainer/portainer/api/jwt"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// testHandler200 is a simple handler which returns HTTP status 200 OK
@ -459,3 +461,60 @@ func Test_ShouldSkipCSRFCheck(t *testing.T) {
})
}
}
func TestJWTRevocation(t *testing.T) {
_, store := datastore.MustNewTestStore(t, true, true)
jwtService, err := jwt.NewService("1h", store)
require.NoError(t, err)
err = store.User().Create(&portainer.User{ID: 1})
require.NoError(t, err)
jwtService.SetUserSessionDuration(time.Second)
token, _, err := jwtService.GenerateToken(&portainer.TokenData{ID: 1})
require.NoError(t, err)
apiKeyService := apikey.NewAPIKeyService(nil, nil)
bouncer := NewRequestBouncer(store, jwtService, apiKeyService)
r, err := http.NewRequest(http.MethodGet, "url", nil)
require.NoError(t, err)
r.Header.Add(jwtTokenHeader, "Bearer "+token)
r.AddCookie(&http.Cookie{Name: portainer.AuthCookieKey, Value: token})
_, err = bouncer.JWTAuthLookup(r)
require.NoError(t, err)
_, err = bouncer.CookieAuthLookup(r)
require.NoError(t, err)
bouncer.RevokeJWT(token)
revokeLen := func() (l int) {
bouncer.revokedJWT.Range(func(key, value any) bool {
l++
return true
})
return l
}
require.Equal(t, 1, revokeLen())
_, err = bouncer.JWTAuthLookup(r)
require.Error(t, err)
_, err = bouncer.CookieAuthLookup(r)
require.Error(t, err)
time.Sleep(time.Second)
bouncer.cleanUpExpiredJWTPass()
require.Equal(t, 0, revokeLen())
}