mirror of
https://github.com/portainer/portainer.git
synced 2025-08-02 20:35:25 +02:00
fix(bouncer): add support for JWT revocation BE-11179 (#12164)
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
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:
parent
a5e246cc16
commit
e4e55157e8
8 changed files with 254 additions and 86 deletions
|
@ -4,6 +4,7 @@ import (
|
|||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
|
@ -16,6 +17,9 @@ import (
|
|||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
const apiKeyHeader = "X-API-KEY"
|
||||
const jwtTokenHeader = "Authorization"
|
||||
|
||||
type (
|
||||
BouncerService interface {
|
||||
PublicAccess(http.Handler) http.Handler
|
||||
|
@ -27,9 +31,10 @@ type (
|
|||
|
||||
AuthorizedEndpointOperation(*http.Request, *portainer.Endpoint) error
|
||||
AuthorizedEdgeEndpointOperation(*http.Request, *portainer.Endpoint) error
|
||||
TrustedEdgeEnvironmentAccess(dataservices.DataStoreTx, *portainer.Endpoint) error
|
||||
CookieAuthLookup(*http.Request) (*portainer.TokenData, error)
|
||||
JWTAuthLookup(*http.Request) (*portainer.TokenData, error)
|
||||
TrustedEdgeEnvironmentAccess(dataservices.DataStoreTx, *portainer.Endpoint) 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.
|
||||
|
@ -80,6 +92,7 @@ func (bouncer *RequestBouncer) AdminAccess(h http.Handler) http.Handler {
|
|||
h = bouncer.mwUpgradeToRestrictedRequest(h)
|
||||
h = bouncer.mwCheckPortainerAuthorizations(h, true)
|
||||
h = bouncer.mwAuthenticatedUser(h)
|
||||
|
||||
return h
|
||||
}
|
||||
|
||||
|
@ -92,6 +105,7 @@ func (bouncer *RequestBouncer) RestrictedAccess(h http.Handler) http.Handler {
|
|||
h = bouncer.mwUpgradeToRestrictedRequest(h)
|
||||
h = bouncer.mwCheckPortainerAuthorizations(h, false)
|
||||
h = bouncer.mwAuthenticatedUser(h)
|
||||
|
||||
return h
|
||||
}
|
||||
|
||||
|
@ -105,6 +119,7 @@ func (bouncer *RequestBouncer) TeamLeaderAccess(h http.Handler) http.Handler {
|
|||
h = bouncer.mwIsTeamLeader(h)
|
||||
h = bouncer.mwUpgradeToRestrictedRequest(h)
|
||||
h = bouncer.mwAuthenticatedUser(h)
|
||||
|
||||
return h
|
||||
}
|
||||
|
||||
|
@ -116,6 +131,7 @@ func (bouncer *RequestBouncer) TeamLeaderAccess(h http.Handler) http.Handler {
|
|||
func (bouncer *RequestBouncer) AuthenticatedAccess(h http.Handler) http.Handler {
|
||||
h = bouncer.mwUpgradeToRestrictedRequest(h)
|
||||
h = bouncer.mwAuthenticatedUser(h)
|
||||
|
||||
return h
|
||||
}
|
||||
|
||||
|
@ -197,6 +213,7 @@ func (bouncer *RequestBouncer) mwAuthenticatedUser(h http.Handler) http.Handler
|
|||
bouncer.JWTAuthLookup,
|
||||
}, h)
|
||||
h = mwSecureHeaders(h)
|
||||
|
||||
return h
|
||||
}
|
||||
|
||||
|
@ -284,23 +301,27 @@ func (bouncer *RequestBouncer) mwAuthenticateFirst(tokenLookups []tokenLookup, n
|
|||
resultToken, err := lookup(r)
|
||||
if err != nil {
|
||||
httperror.WriteError(w, http.StatusUnauthorized, "Invalid JWT token", httperrors.ErrUnauthorized)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if resultToken != nil {
|
||||
token = resultToken
|
||||
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if token == nil {
|
||||
httperror.WriteError(w, http.StatusUnauthorized, "A valid authorization token is missing", httperrors.ErrUnauthorized)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
user, _ := bouncer.dataStore.User().Read(token.ID)
|
||||
if user == nil {
|
||||
httperror.WriteError(w, http.StatusUnauthorized, "An authorization token is invalid", httperrors.ErrUnauthorized)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
|
@ -317,11 +338,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 +358,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
|
||||
|
@ -392,6 +446,7 @@ func extractBearerToken(r *http.Request) (string, bool) {
|
|||
if token != "" {
|
||||
query.Del("token")
|
||||
r.URL.RawQuery = query.Encode()
|
||||
|
||||
return token, true
|
||||
}
|
||||
|
||||
|
@ -505,11 +560,13 @@ func (bouncer *RequestBouncer) EdgeComputeOperation(next http.Handler) http.Hand
|
|||
settings, err := bouncer.dataStore.Settings().Settings()
|
||||
if err != nil {
|
||||
httperror.WriteError(w, http.StatusServiceUnavailable, "Unable to retrieve settings", err)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if !settings.EnableEdgeComputeFeatures {
|
||||
httperror.WriteError(w, http.StatusServiceUnavailable, "Edge compute features are disabled", errors.New("Edge compute features are disabled"))
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue