mirror of
https://github.com/portainer/portainer.git
synced 2025-07-24 15:59:41 +02:00
feat(auth): save jwt in cookie [EE-5864] (#10527)
This commit is contained in:
parent
ecce501cf3
commit
436da01bce
51 changed files with 679 additions and 312 deletions
62
api/http/csrf/csrf.go
Normal file
62
api/http/csrf/csrf.go
Normal file
|
@ -0,0 +1,62 @@
|
|||
package csrf
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
httperror "github.com/portainer/portainer/pkg/libhttp/error"
|
||||
|
||||
gorillacsrf "github.com/gorilla/csrf"
|
||||
"github.com/portainer/portainer/api/http/security"
|
||||
"github.com/urfave/negroni"
|
||||
)
|
||||
|
||||
func WithProtect(handler http.Handler) (http.Handler, error) {
|
||||
handler = withSendCSRFToken(handler)
|
||||
|
||||
token := make([]byte, 32)
|
||||
_, err := rand.Read(token)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to generate CSRF token: %w", err)
|
||||
}
|
||||
|
||||
handler = gorillacsrf.Protect([]byte(token), gorillacsrf.Path("/"))(handler)
|
||||
|
||||
return withSkipCSRF(handler), nil
|
||||
}
|
||||
|
||||
func withSendCSRFToken(handler http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
sw := negroni.NewResponseWriter(w)
|
||||
|
||||
sw.Before(func(sw negroni.ResponseWriter) {
|
||||
statusCode := sw.Status()
|
||||
if statusCode >= 200 && statusCode < 300 {
|
||||
csrfToken := gorillacsrf.Token(r)
|
||||
sw.Header().Set("X-CSRF-Token", csrfToken)
|
||||
}
|
||||
})
|
||||
|
||||
handler.ServeHTTP(sw, r)
|
||||
|
||||
})
|
||||
}
|
||||
|
||||
func withSkipCSRF(handler http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
skip, err := security.ShouldSkipCSRFCheck(r)
|
||||
if err != nil {
|
||||
httperror.WriteError(w, http.StatusForbidden, err.Error(), err)
|
||||
return
|
||||
}
|
||||
|
||||
if skip {
|
||||
r = gorillacsrf.UnsafeSkipCheck(r)
|
||||
}
|
||||
|
||||
handler.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
|
@ -6,6 +6,7 @@ import (
|
|||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
httperrors "github.com/portainer/portainer/api/http/errors"
|
||||
"github.com/portainer/portainer/api/http/security"
|
||||
"github.com/portainer/portainer/api/internal/authorization"
|
||||
httperror "github.com/portainer/portainer/pkg/libhttp/error"
|
||||
"github.com/portainer/portainer/pkg/libhttp/request"
|
||||
|
@ -142,12 +143,15 @@ func (handler *Handler) writeToken(w http.ResponseWriter, user *portainer.User,
|
|||
}
|
||||
|
||||
func (handler *Handler) persistAndWriteToken(w http.ResponseWriter, tokenData *portainer.TokenData) *httperror.HandlerError {
|
||||
token, err := handler.JWTService.GenerateToken(tokenData)
|
||||
token, expirationTime, err := handler.JWTService.GenerateToken(tokenData)
|
||||
if err != nil {
|
||||
return httperror.InternalServerError("Unable to generate JWT token", err)
|
||||
}
|
||||
|
||||
security.AddAuthCookie(w, token, expirationTime)
|
||||
|
||||
return response.JSON(w, &authenticateResponse{JWT: token})
|
||||
|
||||
}
|
||||
|
||||
func (handler *Handler) syncUserTeamsWithLDAPGroups(user *portainer.User, settings *portainer.LDAPSettings) error {
|
||||
|
|
|
@ -18,7 +18,7 @@ type Handler struct {
|
|||
*mux.Router
|
||||
DataStore dataservices.DataStore
|
||||
CryptoService portainer.CryptoService
|
||||
JWTService dataservices.JWTService
|
||||
JWTService portainer.JWTService
|
||||
LDAPService portainer.LDAPService
|
||||
OAuthService portainer.OAuthService
|
||||
ProxyManager *proxy.Manager
|
||||
|
|
|
@ -3,6 +3,7 @@ package auth
|
|||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/portainer/portainer/api/http/security"
|
||||
"github.com/portainer/portainer/api/internal/logoutcontext"
|
||||
httperror "github.com/portainer/portainer/pkg/libhttp/error"
|
||||
"github.com/portainer/portainer/pkg/libhttp/response"
|
||||
|
@ -18,12 +19,14 @@ import (
|
|||
// @failure 500 "Server error"
|
||||
// @router /auth/logout [post]
|
||||
func (handler *Handler) logout(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
|
||||
tokenData := handler.bouncer.JWTAuthLookup(r)
|
||||
tokenData, _ := handler.bouncer.CookieAuthLookup(r)
|
||||
|
||||
if tokenData != nil {
|
||||
handler.KubernetesTokenCacheManager.RemoveUserFromCache(tokenData.ID)
|
||||
logoutcontext.Cancel(tokenData.Token)
|
||||
}
|
||||
|
||||
security.RemoveAuthCookie(w)
|
||||
|
||||
return response.Empty(w)
|
||||
}
|
||||
|
|
|
@ -2,7 +2,6 @@ package customtemplates
|
|||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
|
@ -18,6 +17,7 @@ import (
|
|||
gittypes "github.com/portainer/portainer/api/git/types"
|
||||
"github.com/portainer/portainer/api/http/security"
|
||||
"github.com/portainer/portainer/api/internal/authorization"
|
||||
"github.com/portainer/portainer/api/internal/testhelpers"
|
||||
"github.com/portainer/portainer/api/jwt"
|
||||
|
||||
"github.com/segmentio/encoding/json"
|
||||
|
@ -76,7 +76,7 @@ func singleAPIRequest(h *Handler, jwt string, is *assert.Assertions, expect stri
|
|||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodPut, "/custom_templates/1/git_fetch", bytes.NewBuffer([]byte("{}")))
|
||||
req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", jwt))
|
||||
testhelpers.AddTestSecurityCookie(req, jwt)
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
h.ServeHTTP(rr, req)
|
||||
|
@ -132,8 +132,8 @@ func Test_customTemplateGitFetch(t *testing.T) {
|
|||
h := NewHandler(requestBouncer, store, fileService, gitService)
|
||||
|
||||
// generate two standard users' tokens
|
||||
jwt1, _ := jwtService.GenerateToken(&portainer.TokenData{ID: user1.ID, Username: user1.Username, Role: user1.Role})
|
||||
jwt2, _ := jwtService.GenerateToken(&portainer.TokenData{ID: user2.ID, Username: user2.Username, Role: user2.Role})
|
||||
jwt1, _, _ := jwtService.GenerateToken(&portainer.TokenData{ID: user1.ID, Username: user1.Username, Role: user1.Role})
|
||||
jwt2, _, _ := jwtService.GenerateToken(&portainer.TokenData{ID: user2.ID, Username: user2.Username, Role: user2.Role})
|
||||
|
||||
t.Run("can return the expected file content by a single call from one user", func(t *testing.T) {
|
||||
singleAPIRequest(h, jwt1, is, "abcdefg")
|
||||
|
|
|
@ -211,7 +211,7 @@ func buildEndpointListRequest(query string) *http.Request {
|
|||
restrictedCtx := security.StoreRestrictedRequestContext(req, &security.RestrictedRequestContext{UserID: 1, IsAdmin: true})
|
||||
req = req.WithContext(restrictedCtx)
|
||||
|
||||
req.Header.Add("Authorization", "Bearer dummytoken")
|
||||
testhelpers.AddTestSecurityCookie(req, "Bearer dummytoken")
|
||||
|
||||
return req
|
||||
}
|
||||
|
|
|
@ -20,14 +20,14 @@ type Handler struct {
|
|||
*mux.Router
|
||||
requestBouncer security.BouncerService
|
||||
dataStore dataservices.DataStore
|
||||
jwtService dataservices.JWTService
|
||||
jwtService portainer.JWTService
|
||||
kubeClusterAccessService kubernetes.KubeClusterAccessService
|
||||
kubernetesDeployer portainer.KubernetesDeployer
|
||||
helmPackageManager libhelm.HelmPackageManager
|
||||
}
|
||||
|
||||
// NewHandler creates a handler to manage endpoint group operations.
|
||||
func NewHandler(bouncer security.BouncerService, dataStore dataservices.DataStore, jwtService dataservices.JWTService, kubernetesDeployer portainer.KubernetesDeployer, helmPackageManager libhelm.HelmPackageManager, kubeClusterAccessService kubernetes.KubeClusterAccessService) *Handler {
|
||||
func NewHandler(bouncer security.BouncerService, dataStore dataservices.DataStore, jwtService portainer.JWTService, kubernetesDeployer portainer.KubernetesDeployer, helmPackageManager libhelm.HelmPackageManager, kubeClusterAccessService kubernetes.KubeClusterAccessService) *Handler {
|
||||
h := &Handler{
|
||||
Router: mux.NewRouter(),
|
||||
requestBouncer: bouncer,
|
||||
|
@ -93,7 +93,7 @@ func (handler *Handler) getHelmClusterAccess(r *http.Request) (*options.Kubernet
|
|||
return nil, httperror.InternalServerError("Unable to retrieve user authentication token", err)
|
||||
}
|
||||
|
||||
bearerToken, err := handler.jwtService.GenerateToken(tokenData)
|
||||
bearerToken, _, err := handler.jwtService.GenerateToken(tokenData)
|
||||
if err != nil {
|
||||
return nil, httperror.Unauthorized("Unauthorized", err)
|
||||
}
|
||||
|
|
|
@ -16,6 +16,7 @@ import (
|
|||
"github.com/portainer/portainer/pkg/libhelm/options"
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"github.com/portainer/portainer/api/internal/testhelpers"
|
||||
helper "github.com/portainer/portainer/api/internal/testhelpers"
|
||||
)
|
||||
|
||||
|
@ -48,7 +49,7 @@ func Test_helmDelete(t *testing.T) {
|
|||
req := httptest.NewRequest(http.MethodDelete, fmt.Sprintf("/1/kubernetes/helm/%s", options.Name), nil)
|
||||
ctx := security.StoreTokenData(req, &portainer.TokenData{ID: 1, Username: "admin", Role: 1})
|
||||
req = req.WithContext(ctx)
|
||||
req.Header.Add("Authorization", "Bearer dummytoken")
|
||||
testhelpers.AddTestSecurityCookie(req, "Bearer dummytoken")
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
h.ServeHTTP(rr, req)
|
||||
|
|
|
@ -11,6 +11,7 @@ import (
|
|||
"github.com/portainer/portainer/api/datastore"
|
||||
"github.com/portainer/portainer/api/exec/exectest"
|
||||
"github.com/portainer/portainer/api/http/security"
|
||||
"github.com/portainer/portainer/api/internal/testhelpers"
|
||||
helper "github.com/portainer/portainer/api/internal/testhelpers"
|
||||
"github.com/portainer/portainer/api/jwt"
|
||||
"github.com/portainer/portainer/api/kubernetes"
|
||||
|
@ -52,7 +53,7 @@ func Test_helmInstall(t *testing.T) {
|
|||
req := httptest.NewRequest(http.MethodPost, "/1/kubernetes/helm", bytes.NewBuffer(optdata))
|
||||
ctx := security.StoreTokenData(req, &portainer.TokenData{ID: 1, Username: "admin", Role: 1})
|
||||
req = req.WithContext(ctx)
|
||||
req.Header.Add("Authorization", "Bearer dummytoken")
|
||||
testhelpers.AddTestSecurityCookie(req, "Bearer dummytoken")
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
h.ServeHTTP(rr, req)
|
||||
|
|
|
@ -10,6 +10,7 @@ import (
|
|||
"github.com/portainer/portainer/api/datastore"
|
||||
"github.com/portainer/portainer/api/exec/exectest"
|
||||
"github.com/portainer/portainer/api/http/security"
|
||||
"github.com/portainer/portainer/api/internal/testhelpers"
|
||||
helper "github.com/portainer/portainer/api/internal/testhelpers"
|
||||
"github.com/portainer/portainer/api/jwt"
|
||||
"github.com/portainer/portainer/api/kubernetes"
|
||||
|
@ -48,7 +49,7 @@ func Test_helmList(t *testing.T) {
|
|||
req := httptest.NewRequest(http.MethodGet, "/1/kubernetes/helm", nil)
|
||||
ctx := security.StoreTokenData(req, &portainer.TokenData{ID: 1, Username: "admin", Role: 1})
|
||||
req = req.WithContext(ctx)
|
||||
req.Header.Add("Authorization", "Bearer dummytoken")
|
||||
testhelpers.AddTestSecurityCookie(req, "Bearer dummytoken")
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
h.ServeHTTP(rr, req)
|
||||
|
|
|
@ -26,12 +26,12 @@ type Handler struct {
|
|||
authorizationService *authorization.Service
|
||||
DataStore dataservices.DataStore
|
||||
KubernetesClientFactory *cli.ClientFactory
|
||||
JwtService dataservices.JWTService
|
||||
JwtService portainer.JWTService
|
||||
kubeClusterAccessService kubernetes.KubeClusterAccessService
|
||||
}
|
||||
|
||||
// NewHandler creates a handler to process pre-proxied requests to external APIs.
|
||||
func NewHandler(bouncer security.BouncerService, authorizationService *authorization.Service, dataStore dataservices.DataStore, jwtService dataservices.JWTService, kubeClusterAccessService kubernetes.KubeClusterAccessService, kubernetesClientFactory *cli.ClientFactory, kubernetesClient portainer.KubeClient) *Handler {
|
||||
func NewHandler(bouncer security.BouncerService, authorizationService *authorization.Service, dataStore dataservices.DataStore, jwtService portainer.JWTService, kubeClusterAccessService kubernetes.KubeClusterAccessService, kubernetesClientFactory *cli.ClientFactory, kubernetesClient portainer.KubeClient) *Handler {
|
||||
h := &Handler{
|
||||
Router: mux.NewRouter(),
|
||||
authorizationService: authorizationService,
|
||||
|
@ -120,7 +120,12 @@ func (h *Handler) getProxyKubeClient(r *http.Request) (*cli.KubeClient, *httperr
|
|||
return nil, httperror.BadRequest("Invalid environment identifier route variable", err)
|
||||
}
|
||||
|
||||
cli, ok := h.KubernetesClientFactory.GetProxyKubeClient(strconv.Itoa(endpointID), r.Header.Get("Authorization"))
|
||||
tokenData, err := security.RetrieveTokenData(r)
|
||||
if err != nil {
|
||||
return nil, httperror.Forbidden("Permission denied to access environment", err)
|
||||
}
|
||||
|
||||
cli, ok := h.KubernetesClientFactory.GetProxyKubeClient(strconv.Itoa(endpointID), tokenData.Username)
|
||||
if !ok {
|
||||
return nil, httperror.InternalServerError("Failed to lookup KubeClient", nil)
|
||||
}
|
||||
|
@ -141,8 +146,13 @@ func (handler *Handler) kubeClientMiddleware(next http.Handler) http.Handler {
|
|||
return
|
||||
}
|
||||
|
||||
tokenData, err := security.RetrieveTokenData(r)
|
||||
if err != nil {
|
||||
httperror.WriteError(w, http.StatusForbidden, "Permission denied to access environment", err)
|
||||
}
|
||||
|
||||
// Check if we have a kubeclient against this auth token already, otherwise generate a new one
|
||||
_, ok := handler.KubernetesClientFactory.GetProxyKubeClient(strconv.Itoa(endpointID), r.Header.Get("Authorization"))
|
||||
_, ok := handler.KubernetesClientFactory.GetProxyKubeClient(strconv.Itoa(endpointID), tokenData.Username)
|
||||
if ok {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
|
@ -164,12 +174,6 @@ func (handler *Handler) kubeClientMiddleware(next http.Handler) http.Handler {
|
|||
return
|
||||
}
|
||||
|
||||
// Generate a proxied kubeconfig, then create a kubeclient using it.
|
||||
tokenData, err := security.RetrieveTokenData(r)
|
||||
if err != nil {
|
||||
httperror.WriteError(w, http.StatusForbidden, "Permission denied to access environment", err)
|
||||
return
|
||||
}
|
||||
bearerToken, err := handler.JwtService.GenerateTokenForKubeconfig(tokenData)
|
||||
if err != nil {
|
||||
httperror.WriteError(w, http.StatusInternalServerError, "Unable to create JWT token", err)
|
||||
|
@ -208,7 +212,7 @@ func (handler *Handler) kubeClientMiddleware(next http.Handler) http.Handler {
|
|||
return
|
||||
}
|
||||
|
||||
handler.KubernetesClientFactory.SetProxyKubeClient(strconv.Itoa(int(endpoint.ID)), r.Header.Get("Authorization"), kubeCli)
|
||||
handler.KubernetesClientFactory.SetProxyKubeClient(strconv.Itoa(int(endpoint.ID)), tokenData.Username, kubeCli)
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
|
|
@ -23,7 +23,7 @@ type Handler struct {
|
|||
*mux.Router
|
||||
DataStore dataservices.DataStore
|
||||
FileService portainer.FileService
|
||||
JWTService dataservices.JWTService
|
||||
JWTService portainer.JWTService
|
||||
LDAPService portainer.LDAPService
|
||||
SnapshotService portainer.SnapshotService
|
||||
demoService *demo.Service
|
||||
|
|
|
@ -1,7 +1,6 @@
|
|||
package system
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
|
@ -13,6 +12,7 @@ import (
|
|||
"github.com/portainer/portainer/api/datastore"
|
||||
"github.com/portainer/portainer/api/demo"
|
||||
"github.com/portainer/portainer/api/http/security"
|
||||
"github.com/portainer/portainer/api/internal/testhelpers"
|
||||
"github.com/portainer/portainer/api/jwt"
|
||||
|
||||
"github.com/segmentio/encoding/json"
|
||||
|
@ -43,12 +43,12 @@ func Test_getSystemVersion(t *testing.T) {
|
|||
h := NewHandler(requestBouncer, &portainer.Status{}, &demo.Service{}, store, nil)
|
||||
|
||||
// generate standard and admin user tokens
|
||||
jwt, _ := jwtService.GenerateToken(&portainer.TokenData{ID: adminUser.ID, Username: adminUser.Username, Role: adminUser.Role})
|
||||
jwt, _, _ := jwtService.GenerateToken(&portainer.TokenData{ID: adminUser.ID, Username: adminUser.Username, Role: adminUser.Role})
|
||||
|
||||
t.Run("Display Edition", func(t *testing.T) {
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/system/version", nil)
|
||||
req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", jwt))
|
||||
testhelpers.AddTestSecurityCookie(req, jwt)
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
h.ServeHTTP(rr, req)
|
||||
|
|
|
@ -13,6 +13,7 @@ import (
|
|||
"github.com/portainer/portainer/api/datastore"
|
||||
"github.com/portainer/portainer/api/http/security"
|
||||
"github.com/portainer/portainer/api/internal/authorization"
|
||||
"github.com/portainer/portainer/api/internal/testhelpers"
|
||||
"github.com/portainer/portainer/api/jwt"
|
||||
|
||||
"github.com/segmentio/encoding/json"
|
||||
|
@ -39,7 +40,7 @@ func Test_teamList(t *testing.T) {
|
|||
h.DataStore = store
|
||||
|
||||
// generate admin user tokens
|
||||
adminJWT, _ := jwtService.GenerateToken(&portainer.TokenData{ID: adminUser.ID, Username: adminUser.Username, Role: adminUser.Role})
|
||||
adminJWT, _, _ := jwtService.GenerateToken(&portainer.TokenData{ID: adminUser.ID, Username: adminUser.Username, Role: adminUser.Role})
|
||||
|
||||
// Case 1: the team is given the endpoint access directly
|
||||
// create teams
|
||||
|
@ -77,11 +78,11 @@ func Test_teamList(t *testing.T) {
|
|||
err = store.Endpoint().Create(endpointWithTeamAccessPolicy)
|
||||
is.NoError(err, "error creating endpoint")
|
||||
|
||||
jwt, _ := jwtService.GenerateToken(&portainer.TokenData{ID: userWithEndpointAccessByTeam.ID, Username: userWithEndpointAccessByTeam.Username, Role: userWithEndpointAccessByTeam.Role})
|
||||
jwt, _, _ := jwtService.GenerateToken(&portainer.TokenData{ID: userWithEndpointAccessByTeam.ID, Username: userWithEndpointAccessByTeam.Username, Role: userWithEndpointAccessByTeam.Role})
|
||||
|
||||
t.Run("admin user can successfully list all teams", func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodGet, "/teams", nil)
|
||||
req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", adminJWT))
|
||||
testhelpers.AddTestSecurityCookie(req, adminJWT)
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
h.ServeHTTP(rr, req)
|
||||
|
@ -102,7 +103,7 @@ func Test_teamList(t *testing.T) {
|
|||
params := url.Values{}
|
||||
params.Add("environmentId", fmt.Sprintf("%d", endpointWithTeamAccessPolicy.ID))
|
||||
req := httptest.NewRequest(http.MethodGet, "/teams?"+params.Encode(), nil)
|
||||
req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", adminJWT))
|
||||
testhelpers.AddTestSecurityCookie(req, adminJWT)
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
h.ServeHTTP(rr, req)
|
||||
|
@ -124,7 +125,7 @@ func Test_teamList(t *testing.T) {
|
|||
|
||||
t.Run("standard user only can list team where he belongs to", func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodGet, "/teams", nil)
|
||||
req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", jwt))
|
||||
testhelpers.AddTestSecurityCookie(req, jwt)
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
h.ServeHTTP(rr, req)
|
||||
|
@ -168,7 +169,7 @@ func Test_teamList(t *testing.T) {
|
|||
params := url.Values{}
|
||||
params.Add("environmentId", fmt.Sprintf("%d", endpointUnderGroupWithTeam.ID))
|
||||
req := httptest.NewRequest(http.MethodGet, "/teams?"+params.Encode(), nil)
|
||||
req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", adminJWT))
|
||||
testhelpers.AddTestSecurityCookie(req, adminJWT)
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
h.ServeHTTP(rr, req)
|
||||
|
|
|
@ -67,6 +67,9 @@ func NewHandler(bouncer security.BouncerService, rateLimiter *security.RateLimit
|
|||
|
||||
adminRouter.Handle("/users", httperror.LoggerHandler(h.userCreate)).Methods(http.MethodPost)
|
||||
restrictedRouter.Handle("/users", httperror.LoggerHandler(h.userList)).Methods(http.MethodGet)
|
||||
|
||||
authenticatedRouter.Handle("/users/me", httperror.LoggerHandler(h.userInspectMe)).Methods(http.MethodGet)
|
||||
restrictedRouter.Handle("/users/me", httperror.LoggerHandler(h.userInspectMe)).Methods(http.MethodGet)
|
||||
restrictedRouter.Handle("/users/{id}", httperror.LoggerHandler(h.userInspect)).Methods(http.MethodGet)
|
||||
authenticatedRouter.Handle("/users/{id}", httperror.LoggerHandler(h.userUpdate)).Methods(http.MethodPut)
|
||||
adminRouter.Handle("/users/{id}", httperror.LoggerHandler(h.userDelete)).Methods(http.MethodDelete)
|
||||
|
@ -75,6 +78,7 @@ func NewHandler(bouncer security.BouncerService, rateLimiter *security.RateLimit
|
|||
restrictedRouter.Handle("/users/{id}/tokens/{keyID}", httperror.LoggerHandler(h.userRemoveAccessToken)).Methods(http.MethodDelete)
|
||||
restrictedRouter.Handle("/users/{id}/memberships", httperror.LoggerHandler(h.userMemberships)).Methods(http.MethodGet)
|
||||
authenticatedRouter.Handle("/users/{id}/passwd", rateLimiter.LimitAccess(httperror.LoggerHandler(h.userUpdatePassword))).Methods(http.MethodPut)
|
||||
|
||||
publicRouter.Handle("/users/admin/check", httperror.LoggerHandler(h.adminCheck)).Methods(http.MethodGet)
|
||||
publicRouter.Handle("/users/admin/init", httperror.LoggerHandler(h.adminInit)).Methods(http.MethodPost)
|
||||
|
||||
|
|
|
@ -55,9 +55,9 @@ type accessTokenResponse struct {
|
|||
// @failure 500 "Server error"
|
||||
// @router /users/{id}/tokens [post]
|
||||
func (handler *Handler) userCreateAccessToken(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
|
||||
// specifically require JWT auth for this endpoint since API-Key based auth is not supported
|
||||
if jwt := handler.bouncer.JWTAuthLookup(r); jwt == nil {
|
||||
return httperror.Unauthorized("Auth not supported", errors.New("JWT Authentication required"))
|
||||
// specifically require Cookie auth for this endpoint since API-Key based auth is not supported
|
||||
if jwt, _ := handler.bouncer.CookieAuthLookup(r); jwt == nil {
|
||||
return httperror.Unauthorized("Auth not supported", errors.New("Cookie Authentication required"))
|
||||
}
|
||||
|
||||
var payload userAccessTokenCreatePayload
|
||||
|
|
|
@ -2,7 +2,6 @@ package users
|
|||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
|
@ -13,6 +12,7 @@ import (
|
|||
"github.com/portainer/portainer/api/apikey"
|
||||
"github.com/portainer/portainer/api/datastore"
|
||||
"github.com/portainer/portainer/api/http/security"
|
||||
"github.com/portainer/portainer/api/internal/testhelpers"
|
||||
"github.com/portainer/portainer/api/jwt"
|
||||
|
||||
"github.com/segmentio/encoding/json"
|
||||
|
@ -45,8 +45,8 @@ func Test_userCreateAccessToken(t *testing.T) {
|
|||
h.DataStore = store
|
||||
|
||||
// generate standard and admin user tokens
|
||||
adminJWT, _ := jwtService.GenerateToken(&portainer.TokenData{ID: adminUser.ID, Username: adminUser.Username, Role: adminUser.Role})
|
||||
jwt, _ := jwtService.GenerateToken(&portainer.TokenData{ID: user.ID, Username: user.Username, Role: user.Role})
|
||||
adminJWT, _, _ := jwtService.GenerateToken(&portainer.TokenData{ID: adminUser.ID, Username: adminUser.Username, Role: adminUser.Role})
|
||||
jwt, _, _ := jwtService.GenerateToken(&portainer.TokenData{ID: user.ID, Username: user.Username, Role: user.Role})
|
||||
|
||||
t.Run("standard user successfully generates API key", func(t *testing.T) {
|
||||
data := userAccessTokenCreatePayload{Description: "test-token"}
|
||||
|
@ -54,7 +54,7 @@ func Test_userCreateAccessToken(t *testing.T) {
|
|||
is.NoError(err)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/users/2/tokens", bytes.NewBuffer(payload))
|
||||
req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", jwt))
|
||||
testhelpers.AddTestSecurityCookie(req, jwt)
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
h.ServeHTTP(rr, req)
|
||||
|
@ -77,7 +77,7 @@ func Test_userCreateAccessToken(t *testing.T) {
|
|||
is.NoError(err)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/users/2/tokens", bytes.NewBuffer(payload))
|
||||
req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", adminJWT))
|
||||
testhelpers.AddTestSecurityCookie(req, adminJWT)
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
h.ServeHTTP(rr, req)
|
||||
|
@ -106,7 +106,7 @@ func Test_userCreateAccessToken(t *testing.T) {
|
|||
|
||||
body, err := io.ReadAll(rr.Body)
|
||||
is.NoError(err, "ReadAll should not return error")
|
||||
is.Equal(`{"message":"Auth not supported","details":"JWT Authentication required"}`, string(body))
|
||||
is.Equal(`{"message":"Auth not supported","details":"Cookie Authentication required"}`, string(body))
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
@ -1,7 +1,6 @@
|
|||
package users
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
|
@ -12,6 +11,7 @@ import (
|
|||
"github.com/portainer/portainer/api/apikey"
|
||||
"github.com/portainer/portainer/api/datastore"
|
||||
"github.com/portainer/portainer/api/http/security"
|
||||
"github.com/portainer/portainer/api/internal/testhelpers"
|
||||
"github.com/portainer/portainer/api/jwt"
|
||||
|
||||
"github.com/segmentio/encoding/json"
|
||||
|
@ -44,15 +44,15 @@ func Test_userGetAccessTokens(t *testing.T) {
|
|||
h.DataStore = store
|
||||
|
||||
// generate standard and admin user tokens
|
||||
adminJWT, _ := jwtService.GenerateToken(&portainer.TokenData{ID: adminUser.ID, Username: adminUser.Username, Role: adminUser.Role})
|
||||
jwt, _ := jwtService.GenerateToken(&portainer.TokenData{ID: user.ID, Username: user.Username, Role: user.Role})
|
||||
adminJWT, _, _ := jwtService.GenerateToken(&portainer.TokenData{ID: adminUser.ID, Username: adminUser.Username, Role: adminUser.Role})
|
||||
jwt, _, _ := jwtService.GenerateToken(&portainer.TokenData{ID: user.ID, Username: user.Username, Role: user.Role})
|
||||
|
||||
t.Run("standard user can successfully retrieve API key", func(t *testing.T) {
|
||||
_, apiKey, err := apiKeyService.GenerateApiKey(*user, "test-get-token")
|
||||
is.NoError(err)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/users/2/tokens", nil)
|
||||
req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", jwt))
|
||||
testhelpers.AddTestSecurityCookie(req, jwt)
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
h.ServeHTTP(rr, req)
|
||||
|
@ -81,7 +81,7 @@ func Test_userGetAccessTokens(t *testing.T) {
|
|||
is.NoError(err)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/users/2/tokens", nil)
|
||||
req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", adminJWT))
|
||||
testhelpers.AddTestSecurityCookie(req, adminJWT)
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
h.ServeHTTP(rr, req)
|
||||
|
|
49
api/http/handler/users/user_inspect_me.go
Normal file
49
api/http/handler/users/user_inspect_me.go
Normal file
|
@ -0,0 +1,49 @@
|
|||
package users
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/http/security"
|
||||
httperror "github.com/portainer/portainer/pkg/libhttp/error"
|
||||
"github.com/portainer/portainer/pkg/libhttp/response"
|
||||
)
|
||||
|
||||
type CurrentUserInspectResponse struct {
|
||||
*portainer.User
|
||||
ForceChangePassword bool `json:"forceChangePassword"`
|
||||
}
|
||||
|
||||
// @id CurrentUserInspect
|
||||
// @summary Inspect the current user user
|
||||
// @description Retrieve details about the current user.
|
||||
// @description User passwords are filtered out, and should never be accessible.
|
||||
// @description **Access policy**: authenticated
|
||||
// @tags users
|
||||
// @security ApiKeyAuth
|
||||
// @security jwt
|
||||
// @produce json
|
||||
// @success 200 {object} portainer.User "Success"
|
||||
// @failure 400 "Invalid request"
|
||||
// @failure 403 "Permission denied"
|
||||
// @failure 404 "User not found"
|
||||
// @failure 500 "Server error"
|
||||
// @router /users/me [get]
|
||||
func (handler *Handler) userInspectMe(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
|
||||
securityContext, err := security.RetrieveRestrictedRequestContext(r)
|
||||
if err != nil {
|
||||
return httperror.InternalServerError("Unable to retrieve info from request context", err)
|
||||
}
|
||||
|
||||
user, err := handler.DataStore.User().Read(securityContext.UserID)
|
||||
if handler.DataStore.IsErrObjectNotFound(err) {
|
||||
return httperror.NotFound("Unable to find a user with the specified identifier inside the database", err)
|
||||
} else if err != nil {
|
||||
return httperror.InternalServerError("Unable to find a user with the specified identifier inside the database", err)
|
||||
}
|
||||
|
||||
forceChangePassword := !handler.passwordStrengthChecker.Check(user.Password)
|
||||
|
||||
hideFields(user)
|
||||
return response.JSON(w, &CurrentUserInspectResponse{User: user, ForceChangePassword: forceChangePassword})
|
||||
}
|
|
@ -15,6 +15,7 @@ import (
|
|||
"github.com/portainer/portainer/api/demo"
|
||||
"github.com/portainer/portainer/api/http/security"
|
||||
"github.com/portainer/portainer/api/internal/authorization"
|
||||
"github.com/portainer/portainer/api/internal/testhelpers"
|
||||
"github.com/portainer/portainer/api/jwt"
|
||||
|
||||
"github.com/segmentio/encoding/json"
|
||||
|
@ -43,7 +44,7 @@ func Test_userList(t *testing.T) {
|
|||
h.DataStore = store
|
||||
|
||||
// generate admin user tokens
|
||||
adminJWT, _ := jwtService.GenerateToken(&portainer.TokenData{ID: adminUser.ID, Username: adminUser.Username, Role: adminUser.Role})
|
||||
adminJWT, _, _ := jwtService.GenerateToken(&portainer.TokenData{ID: adminUser.ID, Username: adminUser.Username, Role: adminUser.Role})
|
||||
|
||||
// Case 1: the user is given the endpoint access directly
|
||||
userWithEndpointAccess := &portainer.User{ID: 2, Username: "standard-user-with-endpoint-access", Role: portainer.StandardUserRole, PortainerAuthorizations: authorization.DefaultPortainerAuthorizations()}
|
||||
|
@ -67,11 +68,11 @@ func Test_userList(t *testing.T) {
|
|||
err = store.Endpoint().Create(endpointWithUserAccessPolicy)
|
||||
is.NoError(err, "error creating endpoint")
|
||||
|
||||
jwt, _ := jwtService.GenerateToken(&portainer.TokenData{ID: userWithEndpointAccess.ID, Username: userWithEndpointAccess.Username, Role: userWithEndpointAccess.Role})
|
||||
jwt, _, _ := jwtService.GenerateToken(&portainer.TokenData{ID: userWithEndpointAccess.ID, Username: userWithEndpointAccess.Username, Role: userWithEndpointAccess.Role})
|
||||
|
||||
t.Run("admin user can successfully list all users", func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodGet, "/users", nil)
|
||||
req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", adminJWT))
|
||||
testhelpers.AddTestSecurityCookie(req, adminJWT)
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
h.ServeHTTP(rr, req)
|
||||
|
@ -92,7 +93,7 @@ func Test_userList(t *testing.T) {
|
|||
params := url.Values{}
|
||||
params.Add("environmentId", fmt.Sprintf("%d", endpointWithUserAccessPolicy.ID))
|
||||
req := httptest.NewRequest(http.MethodGet, "/users?"+params.Encode(), nil)
|
||||
req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", adminJWT))
|
||||
testhelpers.AddTestSecurityCookie(req, adminJWT)
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
h.ServeHTTP(rr, req)
|
||||
|
@ -114,7 +115,7 @@ func Test_userList(t *testing.T) {
|
|||
|
||||
t.Run("standard user cannot list users", func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodGet, "/users", nil)
|
||||
req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", jwt))
|
||||
testhelpers.AddTestSecurityCookie(req, jwt)
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
h.ServeHTTP(rr, req)
|
||||
|
@ -146,7 +147,7 @@ func Test_userList(t *testing.T) {
|
|||
params := url.Values{}
|
||||
params.Add("environmentId", fmt.Sprintf("%d", endpointUnderGroupWithUser.ID))
|
||||
req := httptest.NewRequest(http.MethodGet, "/users?"+params.Encode(), nil)
|
||||
req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", adminJWT))
|
||||
testhelpers.AddTestSecurityCookie(req, adminJWT)
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
h.ServeHTTP(rr, req)
|
||||
|
@ -198,7 +199,7 @@ func Test_userList(t *testing.T) {
|
|||
params := url.Values{}
|
||||
params.Add("environmentId", fmt.Sprintf("%d", endpointUnderGroupWithTeam.ID))
|
||||
req := httptest.NewRequest(http.MethodGet, "/users?"+params.Encode(), nil)
|
||||
req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", adminJWT))
|
||||
testhelpers.AddTestSecurityCookie(req, adminJWT)
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
h.ServeHTTP(rr, req)
|
||||
|
@ -249,7 +250,7 @@ func Test_userList(t *testing.T) {
|
|||
params := url.Values{}
|
||||
params.Add("environmentId", fmt.Sprintf("%d", endpointWithTeamAccessPolicy.ID))
|
||||
req := httptest.NewRequest(http.MethodGet, "/users?"+params.Encode(), nil)
|
||||
req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", adminJWT))
|
||||
testhelpers.AddTestSecurityCookie(req, adminJWT)
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
h.ServeHTTP(rr, req)
|
||||
|
|
|
@ -11,6 +11,7 @@ import (
|
|||
"github.com/portainer/portainer/api/apikey"
|
||||
"github.com/portainer/portainer/api/datastore"
|
||||
"github.com/portainer/portainer/api/http/security"
|
||||
"github.com/portainer/portainer/api/internal/testhelpers"
|
||||
"github.com/portainer/portainer/api/jwt"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
@ -41,15 +42,16 @@ func Test_userRemoveAccessToken(t *testing.T) {
|
|||
h.DataStore = store
|
||||
|
||||
// generate standard and admin user tokens
|
||||
adminJWT, _ := jwtService.GenerateToken(&portainer.TokenData{ID: adminUser.ID, Username: adminUser.Username, Role: adminUser.Role})
|
||||
jwt, _ := jwtService.GenerateToken(&portainer.TokenData{ID: user.ID, Username: user.Username, Role: user.Role})
|
||||
adminJWT, _, _ := jwtService.GenerateToken(&portainer.TokenData{ID: adminUser.ID, Username: adminUser.Username, Role: adminUser.Role})
|
||||
jwt, _, _ := jwtService.GenerateToken(&portainer.TokenData{ID: user.ID, Username: user.Username, Role: user.Role})
|
||||
|
||||
t.Run("standard user can successfully delete API key", func(t *testing.T) {
|
||||
is := assert.New(t)
|
||||
_, apiKey, err := apiKeyService.GenerateApiKey(*user, "test-delete-token")
|
||||
is.NoError(err)
|
||||
|
||||
req := httptest.NewRequest(http.MethodDelete, fmt.Sprintf("%s/%d", "/users/2/tokens", apiKey.ID), nil)
|
||||
req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", jwt))
|
||||
testhelpers.AddTestSecurityCookie(req, jwt)
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
h.ServeHTTP(rr, req)
|
||||
|
@ -63,11 +65,12 @@ func Test_userRemoveAccessToken(t *testing.T) {
|
|||
})
|
||||
|
||||
t.Run("admin can delete a standard user API Key", func(t *testing.T) {
|
||||
is := assert.New(t)
|
||||
_, apiKey, err := apiKeyService.GenerateApiKey(*user, "test-admin-delete-token")
|
||||
is.NoError(err)
|
||||
|
||||
req := httptest.NewRequest(http.MethodDelete, fmt.Sprintf("%s/%d", "/users/2/tokens", apiKey.ID), nil)
|
||||
req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", adminJWT))
|
||||
testhelpers.AddTestSecurityCookie(req, adminJWT)
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
h.ServeHTTP(rr, req)
|
||||
|
@ -81,6 +84,7 @@ func Test_userRemoveAccessToken(t *testing.T) {
|
|||
})
|
||||
|
||||
t.Run("user can delete API Key using api-key auth", func(t *testing.T) {
|
||||
is := assert.New(t)
|
||||
rawAPIKey, apiKey, err := apiKeyService.GenerateApiKey(*user, "test-api-key-auth-deletion")
|
||||
is.NoError(err)
|
||||
|
||||
|
|
|
@ -2,10 +2,13 @@ package azure
|
|||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/http/proxy/factory/utils"
|
||||
"github.com/portainer/portainer/api/http/security"
|
||||
httperror "github.com/portainer/portainer/pkg/libhttp/error"
|
||||
)
|
||||
|
||||
// proxy for /subscriptions/*/resourceGroups/*/providers/Microsoft.ContainerInstance/containerGroups/*
|
||||
|
@ -23,6 +26,12 @@ func (transport *Transport) proxyContainerGroupRequest(request *http.Request) (*
|
|||
}
|
||||
|
||||
func (transport *Transport) proxyContainerGroupPutRequest(request *http.Request) (*http.Response, error) {
|
||||
|
||||
tokenData, err := security.RetrieveTokenData(request)
|
||||
if err != nil {
|
||||
return nil, httperror.Forbidden("Permission denied to access environment", err)
|
||||
}
|
||||
|
||||
//add a lock before processing existence check
|
||||
transport.mutex.Lock()
|
||||
defer transport.mutex.Unlock()
|
||||
|
@ -32,7 +41,7 @@ func (transport *Transport) proxyContainerGroupPutRequest(request *http.Request)
|
|||
Method: http.MethodGet,
|
||||
URL: request.URL,
|
||||
Header: http.Header{
|
||||
"Authorization": []string{request.Header.Get("Authorization")},
|
||||
"Authorization": []string{fmt.Sprintf("Bearer %s", tokenData.Token)},
|
||||
},
|
||||
}
|
||||
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
@ -7,6 +7,7 @@ import (
|
|||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/adminmonitor"
|
||||
"github.com/portainer/portainer/api/apikey"
|
||||
|
@ -15,6 +16,7 @@ import (
|
|||
"github.com/portainer/portainer/api/demo"
|
||||
"github.com/portainer/portainer/api/docker"
|
||||
dockerclient "github.com/portainer/portainer/api/docker/client"
|
||||
"github.com/portainer/portainer/api/http/csrf"
|
||||
"github.com/portainer/portainer/api/http/handler"
|
||||
"github.com/portainer/portainer/api/http/handler/auth"
|
||||
"github.com/portainer/portainer/api/http/handler/backup"
|
||||
|
@ -91,7 +93,7 @@ type Server struct {
|
|||
GitService portainer.GitService
|
||||
OpenAMTService portainer.OpenAMTService
|
||||
APIKeyService apikey.APIKeyService
|
||||
JWTService dataservices.JWTService
|
||||
JWTService portainer.JWTService
|
||||
LDAPService portainer.LDAPService
|
||||
OAuthService portainer.OAuthService
|
||||
SwarmStackManager portainer.SwarmStackManager
|
||||
|
@ -342,6 +344,11 @@ func (server *Server) Start() error {
|
|||
|
||||
handler = middlewares.WithSlowRequestsLogger(handler)
|
||||
|
||||
handler, err := csrf.WithProtect(handler)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "failed to create CSRF middleware")
|
||||
}
|
||||
|
||||
if server.HTTPEnabled {
|
||||
go func() {
|
||||
log.Info().Str("bind_address", server.BindAddress).Msg("starting HTTP server")
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue