mirror of
https://github.com/portainer/portainer.git
synced 2025-07-25 08:19:40 +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
|
@ -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)
|
||||
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue