From 9dbcf62c4e43993fec8e3401e283a824092829f3 Mon Sep 17 00:00:00 2001 From: dencoded <33698537+dencoded@users.noreply.github.com> Date: Tue, 23 Oct 2018 00:37:24 -0400 Subject: [PATCH 1/3] JWT scope-policy mapping support added openID mw changed to use scope-policy mapping from claim --- apidef/api_definitions.go | 7 +- middleware.go | 3 + mw_jwt.go | 57 ++++++++++- mw_jwt_test.go | 192 ++++++++++++++++++++++++++++++++++++++ mw_openid.go | 53 ++++++++--- test/http.go | 5 + 6 files changed, 302 insertions(+), 15 deletions(-) diff --git a/apidef/api_definitions.go b/apidef/api_definitions.go index 3d65339b971..9285eb309ce 100644 --- a/apidef/api_definitions.go +++ b/apidef/api_definitions.go @@ -305,8 +305,10 @@ type ServiceDiscoveryConfiguration struct { } type OIDProviderConfig struct { - Issuer string `bson:"issuer" json:"issuer"` - ClientIDs map[string]string `bson:"client_ids" json:"client_ids"` + Issuer string `bson:"issuer" json:"issuer"` + ClientIDs map[string]string `bson:"client_ids" json:"client_ids"` + ScopeFieldName string `bson:"scope_field_name" json:"scope_field_name"` + ScopeToPolicyMapping map[string]string `bson:"scope_to_policy_mapping" json:"scope_to_policy_mapping"` } type OpenIDOptions struct { @@ -352,6 +354,7 @@ type APIDefinition struct { JWTExpiresAtValidationSkew uint64 `bson:"jwt_expires_at_validation_skew" json:"jwt_expires_at_validation_skew"` JWTNotBeforeValidationSkew uint64 `bson:"jwt_not_before_validation_skew" json:"jwt_not_before_validation_skew"` JWTSkipKid bool `bson:"jwt_skip_kid" json:"jwt_skip_kid"` + JWTScopeToPolicyMapping map[string]string `bson:"jwt_scope_to_policy_mapping" json:"jwt_scope_to_policy_mapping"` NotificationsDetails NotificationsManager `bson:"notifications" json:"notifications"` EnableSignatureChecking bool `bson:"enable_signature_checking" json:"enable_signature_checking"` HmacAllowedClockSkew float64 `bson:"hmac_allowed_clock_skew" json:"hmac_allowed_clock_skew"` diff --git a/middleware.go b/middleware.go index df185ba82fa..89779ac8a74 100644 --- a/middleware.go +++ b/middleware.go @@ -425,6 +425,7 @@ func (t BaseMiddleware) CheckSessionAndIdentityForValidKey(key string, r *http.R session := cachedVal.(user.SessionState) if err := t.ApplyPolicies(key, &session); err != nil { t.Logger().Error(err) + return session, false } return session, true } @@ -444,6 +445,7 @@ func (t BaseMiddleware) CheckSessionAndIdentityForValidKey(key string, r *http.R // Check for a policy, if there is a policy, pull it and overwrite the session values if err := t.ApplyPolicies(key, &session); err != nil { t.Logger().Error(err) + return session, false } t.Logger().Debug("Got key") return session, true @@ -465,6 +467,7 @@ func (t BaseMiddleware) CheckSessionAndIdentityForValidKey(key string, r *http.R // Check for a policy, if there is a policy, pull it and overwrite the session values if err := t.ApplyPolicies(key, &session); err != nil { t.Logger().Error(err) + return session, false } t.Logger().Debug("Lifetime is: ", session.Lifetime(t.Spec.SessionLifetime)) diff --git a/mw_jwt.go b/mw_jwt.go index 4998d2e0f8c..c180f770928 100644 --- a/mw_jwt.go +++ b/mw_jwt.go @@ -11,7 +11,7 @@ import ( "time" "github.com/dgrijalva/jwt-go" - cache "github.com/pmylund/go-cache" + "github.com/pmylund/go-cache" "github.com/TykTechnologies/tyk/apidef" "github.com/TykTechnologies/tyk/user" @@ -244,6 +244,33 @@ func (k *JWTMiddleware) getUserIdFromClaim(claims jwt.MapClaims) (string, error) return "", errors.New(message) } +func getScopeFromClaim(claims jwt.MapClaims, scopeClaimName string) []string { + // get claim with scopes and turn it into slice of strings + if scope, found := claims[scopeClaimName].(string); found { + return strings.Split(scope, " ") // by standard is space separated list of values + } + + // claim with scopes is optional so return nothing if it is not present + return nil +} + +func mapScopeToPolicies(mapping map[string]string, scope []string) []string { + polIDs := []string{} + + // add all policies matched from scope-policy mapping + policiesToApply := map[string]bool{} + for _, scopeItem := range scope { + if policyID, ok := mapping[scopeItem]; ok { + policiesToApply[policyID] = true + } + } + for id := range policiesToApply { + polIDs = append(polIDs, id) + } + + return polIDs +} + // processCentralisedJWT Will check a JWT token centrally against the secret stored in the API Definition. func (k *JWTMiddleware) processCentralisedJWT(r *http.Request, token *jwt.Token) (error, int) { k.Logger().Debug("JWT authority is centralised") @@ -278,6 +305,29 @@ func (k *JWTMiddleware) processCentralisedJWT(r *http.Request, token *jwt.Token) newSession, err := generateSessionFromPolicy(basePolicyID, k.Spec.OrgID, true) + + // apply policies from scope if scope-to-policy mapping is specified for this API + if k.Spec.JWTScopeToPolicyMapping != nil { + if scope := getScopeFromClaim(claims, "scope"); scope != nil { + polIDs := []string{ + basePolicyID, // add base policy as a first one + } + + // add all policies matched from scope-policy mapping + mappedPolIDs := mapScopeToPolicies(k.Spec.JWTScopeToPolicyMapping, scope) + + polIDs = append(polIDs, mappedPolIDs...) + newSession.SetPolicies(polIDs...) + + // multiple policies assigned to a key, check if it is applicable + if err := k.ApplyPolicies(sessionID, &newSession); err != nil { + k.reportLoginFailure(baseFieldData, r) + k.Logger().WithError(err).Error("Could not several policies from scope-claim mapping to JWT to session") + return errors.New("Key not authorized: could not apply several policies"), http.StatusForbidden + } + } + } + if err != nil { k.reportLoginFailure(baseFieldData, r) k.Logger().Error("Could not find a valid policy to apply to this token!") @@ -575,7 +625,10 @@ func generateSessionFromPolicy(policyID, orgID string, enforceOrg bool) (user.Se session.Per = policy.Per session.QuotaMax = policy.QuotaMax session.QuotaRenewalRate = policy.QuotaRenewalRate - session.AccessRights = policy.AccessRights + session.AccessRights = make(map[string]user.AccessDefinition) + for apiID, access := range policy.AccessRights { + session.AccessRights[apiID] = access + } session.HMACEnabled = policy.HMACEnabled session.IsInactive = policy.IsInactive session.Tags = policy.Tags diff --git a/mw_jwt_test.go b/mw_jwt_test.go index c787b691d68..a888f4b0966 100644 --- a/mw_jwt_test.go +++ b/mw_jwt_test.go @@ -3,8 +3,10 @@ package main import ( "crypto/md5" "encoding/base64" + "encoding/json" "fmt" "net/http" + "reflect" "testing" "time" @@ -905,6 +907,196 @@ func TestJWTExistingSessionRSAWithRawSourceInvalidPolicyID(t *testing.T) { }) } +func TestJWTScopeToPolicyMapping(t *testing.T) { + ts := newTykTestServer() + defer ts.Close() + + basePolicyID := createPolicy(func(p *user.Policy) { + p.AccessRights = map[string]user.AccessDefinition{ + "base-api": { + Limit: &user.APILimit{ + Rate: 111, + Per: 3600, + QuotaMax: -1, + }, + }, + } + p.Partitions = user.PolicyPartitions{ + PerAPI: true, + } + }) + + spec1 := buildAPI(func(spec *APISpec) { + spec.APIID = "api1" + spec.UseKeylessAccess = false + spec.EnableJWT = true + spec.JWTSigningMethod = RSASign + spec.JWTSource = base64.StdEncoding.EncodeToString([]byte(jwtRSAPubKey)) + spec.JWTIdentityBaseField = "user_id" + spec.JWTPolicyFieldName = "policy_id" + spec.Proxy.ListenPath = "/api1" + })[0] + + p1ID := createPolicy(func(p *user.Policy) { + p.AccessRights = map[string]user.AccessDefinition{ + spec1.APIID: { + Limit: &user.APILimit{ + Rate: 100, + Per: 60, + QuotaMax: -1, + }, + }, + } + p.Partitions = user.PolicyPartitions{ + PerAPI: true, + } + }) + + spec2 := buildAPI(func(spec *APISpec) { + spec.APIID = "api2" + spec.UseKeylessAccess = false + spec.EnableJWT = true + spec.JWTSigningMethod = RSASign + spec.JWTSource = base64.StdEncoding.EncodeToString([]byte(jwtRSAPubKey)) + spec.JWTIdentityBaseField = "user_id" + spec.JWTPolicyFieldName = "policy_id" + spec.Proxy.ListenPath = "/api2" + })[0] + + p2ID := createPolicy(func(p *user.Policy) { + p.AccessRights = map[string]user.AccessDefinition{ + spec2.APIID: { + Limit: &user.APILimit{ + Rate: 500, + Per: 30, + QuotaMax: -1, + }, + }, + } + p.Partitions = user.PolicyPartitions{ + PerAPI: true, + } + }) + + spec3 := buildAPI(func(spec *APISpec) { + spec.APIID = "api3" + spec.UseKeylessAccess = false + spec.EnableJWT = true + spec.JWTSigningMethod = RSASign + spec.JWTSource = base64.StdEncoding.EncodeToString([]byte(jwtRSAPubKey)) + spec.JWTIdentityBaseField = "user_id" + spec.JWTPolicyFieldName = "policy_id" + spec.Proxy.ListenPath = "/api3" + })[0] + + spec := buildAPI(func(spec *APISpec) { + spec.APIID = "base-api" + spec.UseKeylessAccess = false + spec.EnableJWT = true + spec.JWTSigningMethod = RSASign + spec.JWTSource = base64.StdEncoding.EncodeToString([]byte(jwtRSAPubKey)) + spec.JWTIdentityBaseField = "user_id" + spec.JWTPolicyFieldName = "policy_id" + spec.Proxy.ListenPath = "/base" + spec.JWTScopeToPolicyMapping = map[string]string{ + "user:read": p1ID, + "user:write": p2ID, + } + })[0] + + loadAPI(spec, spec1, spec2, spec3) + + userID := "user-" + uuid.New() + + jwtToken := createJWKToken(func(t *jwt.Token) { + t.Header["kid"] = "12345" + t.Claims.(jwt.MapClaims)["foo"] = "bar" + t.Claims.(jwt.MapClaims)["user_id"] = userID + t.Claims.(jwt.MapClaims)["policy_id"] = basePolicyID + t.Claims.(jwt.MapClaims)["exp"] = time.Now().Add(time.Hour * 72).Unix() + t.Claims.(jwt.MapClaims)["scope"] = "user:read user:write" + }) + + authHeaders := map[string]string{"authorization": jwtToken} + t.Run("Request with scope in JWT to create a key session", func(t *testing.T) { + ts.Run(t, + test.TestCase{ + Headers: authHeaders, + Path: "/base", + Code: http.StatusOK, + }) + }) + + // check that key has right set of policies assigned - there should be all three - base one and two from scope + sessionID := generateToken("", fmt.Sprintf("%x", md5.Sum([]byte(userID)))) + t.Run("Request to check that session has got correct apply_policies value", func(t *testing.T) { + ts.Run( + t, + test.TestCase{ + Method: http.MethodGet, + Path: "/tyk/keys/" + sessionID, + AdminAuth: true, + Code: http.StatusOK, + BodyMatchFunc: func(body []byte) bool { + expectedResp := map[interface{}]bool{ + basePolicyID: true, + p1ID: true, + p2ID: true, + } + + resp := map[string]interface{}{} + json.Unmarshal(body, &resp) + realResp := map[interface{}]bool{} + for _, val := range resp["apply_policies"].([]interface{}) { + realResp[val] = true + } + + return reflect.DeepEqual(realResp, expectedResp) + }, + }, + ) + }) + + // try to access api1 using JWT issued via base-api + t.Run("Request to api1", func(t *testing.T) { + ts.Run( + t, + test.TestCase{ + Headers: authHeaders, + Method: http.MethodGet, + Path: "/api1", + Code: http.StatusOK, + }, + ) + }) + + // try to access api2 using JWT issued via base-api + t.Run("Request to api2", func(t *testing.T) { + ts.Run( + t, + test.TestCase{ + Headers: authHeaders, + Method: http.MethodGet, + Path: "/api2", + Code: http.StatusOK, + }, + ) + }) + + // try to access api3 (which is not granted via base policy nor scope-policy mapping) using JWT issued via base-api + t.Run("Request to api3", func(t *testing.T) { + ts.Run( + t, + test.TestCase{ + Headers: authHeaders, + Method: http.MethodGet, + Path: "/api3", + Code: http.StatusForbidden, + }, + ) + }) +} + func TestJWTExistingSessionRSAWithRawSourcePolicyIDChanged(t *testing.T) { ts := newTykTestServer() defer ts.Close() diff --git a/mw_openid.go b/mw_openid.go index e7384d2badd..bfb39bb93e6 100644 --- a/mw_openid.go +++ b/mw_openid.go @@ -23,6 +23,7 @@ type OpenIDMW struct { providerConfiguration *openid.Configuration provider_client_policymap map[string]map[string]string lock sync.RWMutex + providerConfigs map[string]apidef.OIDProviderConfig } func (k *OpenIDMW) Name() string { @@ -43,6 +44,12 @@ func (k *OpenIDMW) Init() { if err != nil { k.Logger().WithError(err).Error("OpenID configuration error") } + + // prepare map issuer->config to lookup configs when processing requests + k.providerConfigs = make(map[string]apidef.OIDProviderConfig) + for _, providerConf := range k.Spec.OpenIDOptions.Providers { + k.providerConfigs[providerConf.Issuer] = providerConf + } } func (k *OpenIDMW) getProviders() ([]openid.Provider, error) { @@ -113,6 +120,16 @@ func (k *OpenIDMW) ProcessRequest(w http.ResponseWriter, r *http.Request, _ inte return errors.New("Key not authorised"), http.StatusUnauthorized } + providerConf, ok := k.providerConfigs[iss.(string)] + if !ok { + logger.Error("No issuer or audiences found!") + k.reportLoginFailure("[NOT GENERATED]", r) + return errors.New("Key not authorised"), http.StatusUnauthorized + } + + // decide if we use policy ID from provider client settings or list of policies from scope-policy mapping + useScope := providerConf.ScopeFieldName != "" && providerConf.ScopeToPolicyMapping != nil + k.lock.RLock() clientSet, foundIssuer := k.provider_client_policymap[iss.(string)] k.lock.RUnlock() @@ -143,7 +160,7 @@ func (k *OpenIDMW) ProcessRequest(w http.ResponseWriter, r *http.Request, _ inte } } - if policyID == "" { + if !useScope && policyID == "" { logger.Error("No matching policy found!") k.reportLoginFailure("[NOT GENERATED]", r) return errors.New("Key not authorised"), http.StatusUnauthorized @@ -161,24 +178,38 @@ func (k *OpenIDMW) ProcessRequest(w http.ResponseWriter, r *http.Request, _ inte logger.Debug("Generated Session ID: ", sessionID) + var policiesToApply []string + if !useScope { + policiesToApply = append(policiesToApply, policyID) + } else { + if scope := getScopeFromClaim(token.Claims.(jwt.MapClaims), providerConf.ScopeFieldName); scope != nil { + // add all policies matched from scope-policy mapping + policiesToApply = mapScopeToPolicies(providerConf.ScopeToPolicyMapping, scope) + } + } + session, exists := k.CheckSessionAndIdentityForValidKey(sessionID, r) if !exists { // Create it logger.Debug("Key does not exist, creating") session = user.SessionState{} - // We need a base policy as a template, either get it from the token itself OR a proxy client ID within Tyk - newSession, err := generateSessionFromPolicy(policyID, - k.Spec.OrgID, - true) + if !useScope { + // We need a base policy as a template, either get it from the token itself OR a proxy client ID within Tyk + newSession, err := generateSessionFromPolicy(policyID, + k.Spec.OrgID, + true) - if err != nil { - k.reportLoginFailure(sessionID, r) - logger.Error("Could not find a valid policy to apply to this token!") - return errors.New("Key not authorized: no matching policy"), http.StatusForbidden + if err != nil { + k.reportLoginFailure(sessionID, r) + logger.Error("Could not find a valid policy to apply to this token!") + return errors.New("Key not authorized: no matching policy"), http.StatusForbidden + } + + session = newSession } - session = newSession + session.OrgID = k.Spec.OrgID session.MetaData = map[string]interface{}{"TykJWTSessionID": sessionID, "ClientID": clientID} session.Alias = clientID + ":" + ouser.ID @@ -186,7 +217,7 @@ func (k *OpenIDMW) ProcessRequest(w http.ResponseWriter, r *http.Request, _ inte logger.Debug("Policy applied to key") } // apply new policy to session if any and update session - session.SetPolicies(policyID) + session.SetPolicies(policiesToApply...) if err := k.ApplyPolicies(sessionID, &session); err != nil { k.Logger().WithError(err).Error("Could not apply new policy from OIDC client to session") return errors.New("Key not authorized: could not apply new policy"), http.StatusForbidden diff --git a/test/http.go b/test/http.go index ee0cc63be89..eb4e7a912ad 100644 --- a/test/http.go +++ b/test/http.go @@ -21,6 +21,7 @@ type TestCase struct { Cookies []*http.Cookie `json:",omitempty"` Delay time.Duration `json:",omitempty"` BodyMatch string `json:",omitempty"` + BodyMatchFunc func([]byte) bool `json:",omitempty"` BodyNotMatch string `json:",omitempty"` HeadersMatch map[string]string `json:",omitempty"` HeadersNotMatch map[string]string `json:",omitempty"` @@ -49,6 +50,10 @@ func AssertResponse(resp *http.Response, tc TestCase) error { return fmt.Errorf("Response body should not contain `%s`. %s", tc.BodyNotMatch, string(body)) } + if tc.BodyMatchFunc != nil && !tc.BodyMatchFunc(body) { + return fmt.Errorf("Response body did not pass BodyMatchFunc: %s", string(body)) + } + for k, v := range tc.HeadersMatch { if resp.Header.Get(k) != v { return fmt.Errorf("Response header `%s` expected `%s` instead `%s`. %v", k, v, resp.Header.Get(k), resp.Header) From 7002d7c832470f7895ecaf4996db600e17ab9163 Mon Sep 17 00:00:00 2001 From: Leonid Bugaev Date: Thu, 6 Dec 2018 10:11:18 +0300 Subject: [PATCH 2/3] Fix schema --- apidef/schema.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/apidef/schema.go b/apidef/schema.go index 940cc14648c..936fbe71dc3 100644 --- a/apidef/schema.go +++ b/apidef/schema.go @@ -102,6 +102,9 @@ const Schema = `{ "jwt_not_before_validation_skew": { "type": "number" }, + "jwt_scope_to_policy_mapping": { + "type": "object" + }, "use_keyless": { "type": "boolean" }, From 7febb9737a820f1f35a33be0ad1845aef2f5cf50 Mon Sep 17 00:00:00 2001 From: Leonid Bugaev Date: Thu, 6 Dec 2018 14:16:39 +0300 Subject: [PATCH 3/3] Additional Schema fix --- apidef/api_definitions.go | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/apidef/api_definitions.go b/apidef/api_definitions.go index 9285eb309ce..91b0c85af7e 100644 --- a/apidef/api_definitions.go +++ b/apidef/api_definitions.go @@ -668,15 +668,16 @@ func DummyAPI() APIDefinition { } return APIDefinition{ - VersionData: versionData, - ConfigData: map[string]interface{}{}, - AllowedIPs: []string{}, - PinnedPublicKeys: map[string]string{}, - ResponseProcessors: []ResponseProcessor{}, - ClientCertificates: []string{}, - BlacklistedIPs: []string{}, - TagHeaders: []string{}, - UpstreamCertificates: map[string]string{}, + VersionData: versionData, + ConfigData: map[string]interface{}{}, + AllowedIPs: []string{}, + PinnedPublicKeys: map[string]string{}, + ResponseProcessors: []ResponseProcessor{}, + ClientCertificates: []string{}, + BlacklistedIPs: []string{}, + TagHeaders: []string{}, + UpstreamCertificates: map[string]string{}, + JWTScopeToPolicyMapping: map[string]string{}, CustomMiddleware: MiddlewareSection{ Post: []MiddlewareDefinition{}, Pre: []MiddlewareDefinition{},