1
0
Fork 0
mirror of https://github.com/portainer/portainer.git synced 2025-08-05 05:45:22 +02:00

feat(auth): save jwt in cookie [EE-5864] (#10527)

This commit is contained in:
Chaim Lev-Ari 2023-11-20 09:35:03 +02:00 committed by GitHub
parent ecce501cf3
commit 436da01bce
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
51 changed files with 679 additions and 312 deletions

View file

@ -26,13 +26,13 @@ type (
AuthorizedEndpointOperation(*http.Request, *portainer.Endpoint) error
AuthorizedEdgeEndpointOperation(*http.Request, *portainer.Endpoint) error
TrustedEdgeEnvironmentAccess(dataservices.DataStoreTx, *portainer.Endpoint) error
JWTAuthLookup(*http.Request) *portainer.TokenData
CookieAuthLookup(*http.Request) (*portainer.TokenData, error)
}
// RequestBouncer represents an entity that manages API request accesses
RequestBouncer struct {
dataStore dataservices.DataStore
jwtService dataservices.JWTService
jwtService portainer.JWTService
apiKeyService apikey.APIKeyService
}
@ -46,13 +46,14 @@ type (
}
// tokenLookup looks up a token in the request
tokenLookup func(*http.Request) *portainer.TokenData
tokenLookup func(*http.Request) (*portainer.TokenData, error)
)
const apiKeyHeader = "X-API-KEY"
const jwtTokenHeader = "Authorization"
// NewRequestBouncer initializes a new RequestBouncer
func NewRequestBouncer(dataStore dataservices.DataStore, jwtService dataservices.JWTService, apiKeyService apikey.APIKeyService) *RequestBouncer {
func NewRequestBouncer(dataStore dataservices.DataStore, jwtService portainer.JWTService, apiKeyService apikey.APIKeyService) *RequestBouncer {
return &RequestBouncer{
dataStore: dataStore,
jwtService: jwtService,
@ -188,8 +189,9 @@ func (bouncer *RequestBouncer) TrustedEdgeEnvironmentAccess(tx dataservices.Data
// - authenticating the request with a valid token
func (bouncer *RequestBouncer) mwAuthenticatedUser(h http.Handler) http.Handler {
h = bouncer.mwAuthenticateFirst([]tokenLookup{
bouncer.JWTAuthLookup,
bouncer.apiKeyLookup,
bouncer.CookieAuthLookup,
bouncer.JWTAuthLookup,
}, h)
h = mwSecureHeaders(h)
return h
@ -276,24 +278,26 @@ func (bouncer *RequestBouncer) mwAuthenticateFirst(tokenLookups []tokenLookup, n
var token *portainer.TokenData
for _, lookup := range tokenLookups {
token = lookup(r)
resultToken, err := lookup(r)
if err != nil {
httperror.WriteError(w, http.StatusUnauthorized, "Invalid API key", httperrors.ErrUnauthorized)
return
}
if token != nil {
if resultToken != nil {
token = resultToken
break
}
}
if token == nil {
httperror.WriteError(w, http.StatusUnauthorized, "A valid authorisation token is missing", httperrors.ErrUnauthorized)
httperror.WriteError(w, http.StatusUnauthorized, "A valid authorization token is missing", httperrors.ErrUnauthorized)
return
}
_, err := bouncer.dataStore.User().Read(token.ID)
if err != nil && bouncer.dataStore.IsErrObjectNotFound(err) {
httperror.WriteError(w, http.StatusUnauthorized, "Unauthorized", httperrors.ErrUnauthorized)
return
} else if err != nil {
httperror.WriteError(w, http.StatusInternalServerError, "Unable to retrieve user details from the database", err)
user, _ := bouncer.dataStore.User().Read(token.ID)
if user == nil {
httperror.WriteError(w, http.StatusUnauthorized, "An authorization token is invalid", httperrors.ErrUnauthorized)
return
}
@ -303,21 +307,39 @@ func (bouncer *RequestBouncer) mwAuthenticateFirst(tokenLookups []tokenLookup, n
}
// JWTAuthLookup looks up a valid bearer in the request.
func (bouncer *RequestBouncer) JWTAuthLookup(r *http.Request) *portainer.TokenData {
func (bouncer *RequestBouncer) CookieAuthLookup(r *http.Request) (*portainer.TokenData, error) {
// get token from the Authorization header or query parameter
token, err := extractBearerToken(r)
token, err := extractKeyFromCookie(r)
if err != nil {
return nil
return nil, nil
}
tokenData, err := bouncer.jwtService.ParseAndVerifyToken(token)
if err != nil {
return nil
return nil, ErrInvalidKey
}
return tokenData
return tokenData, nil
}
// JWTAuthLookup looks up a valid bearer in the request.
func (bouncer *RequestBouncer) JWTAuthLookup(r *http.Request) (*portainer.TokenData, error) {
// get token from the Authorization header or query parameter
token, ok := extractBearerToken(r)
if !ok {
return nil, nil
}
tokenData, err := bouncer.jwtService.ParseAndVerifyToken(token)
if err != nil {
return nil, ErrInvalidKey
}
return tokenData, nil
}
var ErrInvalidKey = errors.New("Invalid API key")
// apiKeyLookup looks up an verifies an api-key by:
// - computing the digest of the raw api-key
// - verifying it exists in cache/database
@ -325,17 +347,17 @@ func (bouncer *RequestBouncer) JWTAuthLookup(r *http.Request) *portainer.TokenDa
// If the key is valid/verified, the last updated time of the key is updated.
// Successful verification of the key will return a TokenData object - since the downstream handlers
// utilise the token injected in the request context.
func (bouncer *RequestBouncer) apiKeyLookup(r *http.Request) *portainer.TokenData {
func (bouncer *RequestBouncer) apiKeyLookup(r *http.Request) (*portainer.TokenData, error) {
rawAPIKey, ok := extractAPIKey(r)
if !ok {
return nil
return nil, nil
}
digest := bouncer.apiKeyService.HashRaw(rawAPIKey)
user, apiKey, err := bouncer.apiKeyService.GetDigestUserAndKey(digest)
if err != nil {
return nil
return nil, ErrInvalidKey
}
tokenData := &portainer.TokenData{
@ -343,8 +365,8 @@ func (bouncer *RequestBouncer) apiKeyLookup(r *http.Request) *portainer.TokenDat
Username: user.Username,
Role: user.Role,
}
if _, err := bouncer.jwtService.GenerateToken(tokenData); err != nil {
return nil
if _, _, err := bouncer.jwtService.GenerateToken(tokenData); err != nil {
return nil, ErrInvalidKey
}
if now := time.Now().UTC().Unix(); now-apiKey.LastUsed > 60 { // [seconds]
@ -353,32 +375,74 @@ func (bouncer *RequestBouncer) apiKeyLookup(r *http.Request) *portainer.TokenDat
bouncer.apiKeyService.UpdateAPIKey(&apiKey)
}
return tokenData
return tokenData, nil
}
// extractBearerToken extracts the Bearer token from the request header or query parameter and returns the token.
func extractBearerToken(r *http.Request) (string, error) {
// Optionally, token might be set via the "token" query parameter.
func extractBearerToken(r *http.Request) (string, bool) {
// Token might be set via the "token" query parameter.
// For example, in websocket requests
token := r.URL.Query().Get("token")
// For these cases, hide the token from the query
query := r.URL.Query()
token := query.Get("token")
if token != "" {
query.Del("token")
r.URL.RawQuery = query.Encode()
return token, true
}
tokens, ok := r.Header["Authorization"]
if ok && len(tokens) >= 1 {
token = tokens[0]
token = strings.TrimPrefix(token, "Bearer ")
tokens, ok := r.Header[jwtTokenHeader]
if !ok || len(tokens) == 0 {
return "", false
}
if token == "" {
return "", httperrors.ErrUnauthorized
token = tokens[0]
token = strings.TrimPrefix(token, "Bearer ")
return token, true
}
// AddAuthCookie adds the jwt token to the response cookie.
func AddAuthCookie(w http.ResponseWriter, token string, expirationTime time.Time) {
http.SetCookie(w, &http.Cookie{
Name: portainer.AuthCookieKey,
Value: token,
Path: "/",
Expires: expirationTime,
HttpOnly: true,
SameSite: http.SameSiteStrictMode,
})
}
// RemoveAuthCookie removes the jwt token from the response cookie.
func RemoveAuthCookie(w http.ResponseWriter) {
http.SetCookie(w, &http.Cookie{
Name: portainer.AuthCookieKey,
Value: "",
Path: "/",
Expires: time.Unix(0, 0),
HttpOnly: true,
MaxAge: -1,
SameSite: http.SameSiteStrictMode,
})
}
// extractKeyFromCookie extracts the jwt token from the cookie.
func extractKeyFromCookie(r *http.Request) (string, error) {
cookie, err := r.Cookie(portainer.AuthCookieKey)
if err != nil {
return "", err
}
return token, nil
return cookie.Value, nil
}
// extractAPIKey extracts the api key from the api key request header or query params.
func extractAPIKey(r *http.Request) (apikey string, ok bool) {
func extractAPIKey(r *http.Request) (string, bool) {
// extract the API key from the request header
apikey = r.Header.Get(apiKeyHeader)
if apikey != "" {
return apikey, true
apiKey := r.Header.Get(apiKeyHeader)
if apiKey != "" {
return apiKey, true
}
// extract the API key from query params.
@ -448,3 +512,35 @@ func (bouncer *RequestBouncer) EdgeComputeOperation(next http.Handler) http.Hand
next.ServeHTTP(w, r)
})
}
// ShouldSkipCSRFCheck checks if the CSRF check should be skipped
//
// It returns true if the request has no cookie token and has either (but not both):
// - an api key header
// - an auth header
// if it has both headers, an error is returned
//
// we allow CSRF check to be skipped for the following reasons:
// - public routes
// - kubectl - a bearer token is needed, and no csrf token can be sent
// - api token
func ShouldSkipCSRFCheck(r *http.Request) (bool, error) {
cookie, _ := r.Cookie(portainer.AuthCookieKey)
hasCookie := cookie != nil && cookie.Value != ""
if hasCookie {
return false, nil
}
apiKey := r.Header.Get(apiKeyHeader)
hasApiKey := apiKey != ""
authHeader := r.Header.Get(jwtTokenHeader)
hasAuthHeader := authHeader != ""
if hasApiKey && hasAuthHeader {
return false, errors.New("api key and auth header are not allowed at the same time")
}
return true, nil
}

View file

@ -10,7 +10,7 @@ import (
"github.com/portainer/portainer/api/apikey"
"github.com/portainer/portainer/api/dataservices"
"github.com/portainer/portainer/api/datastore"
httperrors "github.com/portainer/portainer/api/http/errors"
"github.com/portainer/portainer/api/internal/testhelpers"
"github.com/portainer/portainer/api/jwt"
"github.com/stretchr/testify/assert"
@ -21,21 +21,24 @@ var testHandler200 = http.HandlerFunc(func(w http.ResponseWriter, r *http.Reques
w.WriteHeader(http.StatusOK)
})
func tokenLookupSucceed(dataStore dataservices.DataStore, jwtService dataservices.JWTService) tokenLookup {
return func(r *http.Request) *portainer.TokenData {
func tokenLookupSucceed(dataStore dataservices.DataStore, jwtService portainer.JWTService) tokenLookup {
return func(r *http.Request) (*portainer.TokenData, error) {
uid := portainer.UserID(1)
dataStore.User().Create(&portainer.User{ID: uid})
jwtService.GenerateToken(&portainer.TokenData{ID: uid})
return &portainer.TokenData{ID: 1}
return &portainer.TokenData{ID: 1}, nil
}
}
func tokenLookupFail(r *http.Request) *portainer.TokenData {
return nil
func tokenLookupFail(r *http.Request) (*portainer.TokenData, error) {
return nil, ErrInvalidKey
}
func tokenLookupEmpty(r *http.Request) (*portainer.TokenData, error) {
return nil, nil
}
func Test_mwAuthenticateFirst(t *testing.T) {
is := assert.New(t)
_, store := datastore.MustNewTestStore(t, true, true)
@ -79,17 +82,28 @@ func Test_mwAuthenticateFirst(t *testing.T) {
wantStatusCode: http.StatusOK,
},
{
name: "mwAuthenticateFirst succeeds if last middleware successfully handles request",
name: "mwAuthenticateFirst fails if first middleware fails",
verificationMiddlwares: []tokenLookup{
tokenLookupFail,
tokenLookupSucceed(store, jwtService),
},
wantStatusCode: http.StatusOK,
wantStatusCode: http.StatusUnauthorized,
},
{
name: "mwAuthenticateFirst fails if first middleware has no token, but second middleware fails",
verificationMiddlwares: []tokenLookup{
tokenLookupEmpty,
tokenLookupFail,
tokenLookupSucceed(store, jwtService),
},
wantStatusCode: http.StatusUnauthorized,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
is := assert.New(t)
req := httptest.NewRequest(http.MethodGet, "/", nil)
rr := httptest.NewRecorder()
@ -101,9 +115,46 @@ func Test_mwAuthenticateFirst(t *testing.T) {
}
}
func Test_extractBearerToken(t *testing.T) {
func Test_extractKeyFromCookie(t *testing.T) {
is := assert.New(t)
tt := []struct {
name string
token string
succeeds bool
}{
{
name: "missing cookie",
token: "",
succeeds: false,
},
{
name: "valid cookie",
token: "abc",
succeeds: true,
},
}
for _, test := range tt {
req := httptest.NewRequest(http.MethodGet, "/", nil)
if test.token != "" {
testhelpers.AddTestSecurityCookie(req, test.token)
}
apiKey, err := extractKeyFromCookie(req)
is.Equal(test.token, apiKey)
if !test.succeeds {
is.Error(err, "Should return error")
is.ErrorIs(err, http.ErrNoCookie)
} else {
is.NoError(err)
}
}
}
func Test_extractBearerToken(t *testing.T) {
tt := []struct {
name string
requestHeader string
@ -142,16 +193,14 @@ func Test_extractBearerToken(t *testing.T) {
}
for _, test := range tt {
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.Header.Set(test.requestHeader, test.requestHeaderValue)
apiKey, err := extractBearerToken(req)
is.Equal(test.wantToken, apiKey)
if !test.succeeds {
is.Error(err, "Should return error")
is.ErrorIs(err, httperrors.ErrUnauthorized)
} else {
is.NoError(err)
}
t.Run(test.name, func(t *testing.T) {
is := assert.New(t)
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.Header.Set(test.requestHeader, test.requestHeaderValue)
apiKey, ok := extractBearerToken(req)
is.Equal(test.wantToken, apiKey)
is.Equal(test.succeeds, ok)
})
}
}
@ -274,16 +323,17 @@ func Test_apiKeyLookup(t *testing.T) {
t.Run("missing x-api-key header fails api-key lookup", func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/", nil)
// req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", jwt))
token := bouncer.apiKeyLookup(req)
// testhelpers.AddTestSecurityCookie(req, jwt)
token, _ := bouncer.apiKeyLookup(req)
is.Nil(token)
})
t.Run("invalid x-api-key header fails api-key lookup", func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.Header.Add("x-api-key", "random-failing-api-key")
token := bouncer.apiKeyLookup(req)
token, err := bouncer.apiKeyLookup(req)
is.Nil(token)
is.Error(err)
})
t.Run("valid x-api-key header succeeds api-key lookup", func(t *testing.T) {
@ -293,7 +343,7 @@ func Test_apiKeyLookup(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.Header.Add("x-api-key", rawAPIKey)
token := bouncer.apiKeyLookup(req)
token, err := bouncer.apiKeyLookup(req)
expectedToken := &portainer.TokenData{ID: user.ID, Username: user.Username, Role: portainer.StandardUserRole}
is.Equal(expectedToken, token)
@ -307,7 +357,7 @@ func Test_apiKeyLookup(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.Header.Add("x-api-key", rawAPIKey)
token := bouncer.apiKeyLookup(req)
token, err := bouncer.apiKeyLookup(req)
expectedToken := &portainer.TokenData{ID: user.ID, Username: user.Username, Role: portainer.StandardUserRole}
is.Equal(expectedToken, token)
@ -321,7 +371,7 @@ func Test_apiKeyLookup(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.Header.Add("x-api-key", rawAPIKey)
token := bouncer.apiKeyLookup(req)
token, err := bouncer.apiKeyLookup(req)
expectedToken := &portainer.TokenData{ID: user.ID, Username: user.Username, Role: portainer.StandardUserRole}
is.Equal(expectedToken, token)
@ -332,3 +382,68 @@ func Test_apiKeyLookup(t *testing.T) {
is.True(apiKeyUpdated.LastUsed > apiKey.LastUsed)
})
}
func Test_ShouldSkipCSRFCheck(t *testing.T) {
tt := []struct {
name string
cookieValue string
apiKey string
authHeader string
expectedResult bool
expectedError bool
}{
{
name: "Should return false when cookie is present",
cookieValue: "test-cookie",
},
{
name: "Should return true when cookie is not present",
cookieValue: "",
expectedResult: true,
},
{
name: "Should return true when api key is present",
cookieValue: "",
apiKey: "test-api-key",
expectedResult: true,
},
{
name: "Should return true when auth header is present",
cookieValue: "",
authHeader: "test-auth-header",
expectedResult: true,
},
{
name: "Should return false and error when both api key and auth header are present",
cookieValue: "",
apiKey: "test-api-key",
authHeader: "test-auth-header",
expectedError: true,
},
}
for _, test := range tt {
t.Run(test.name, func(t *testing.T) {
is := assert.New(t)
req := httptest.NewRequest(http.MethodGet, "/", nil)
if test.cookieValue != "" {
req.AddCookie(&http.Cookie{Name: portainer.AuthCookieKey, Value: test.cookieValue})
}
if test.apiKey != "" {
req.Header.Set(apiKeyHeader, test.apiKey)
}
if test.authHeader != "" {
req.Header.Set(jwtTokenHeader, test.authHeader)
}
result, err := ShouldSkipCSRFCheck(req)
is.Equal(test.expectedResult, result)
if test.expectedError {
is.Error(err)
} else {
is.NoError(err)
}
})
}
}