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
|
@ -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
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue