From d2116410edf1f5089427858727f155bc0aa4313c Mon Sep 17 00:00:00 2001 From: arekkas Date: Sun, 22 Jul 2018 10:10:11 +0200 Subject: [PATCH] judge: Add endpoint for answering access requests directly This patch adds endpoint `/judge` to `oathkeeper serve api`. The `/judge` endpoint mimics the behavior of `oathkeeper serve proxy` but instead of forwarding the request to the upstream server, the endpoint answers directly with a HTTP response. The HTTP response returns status code 200 if the request should be allowed and any other status code (e.g. 401, 403) if not. Assuming you are making the following request: ``` PUT /judge/my-service/whatever HTTP/1.1 Host: oathkeeper-api:4456 User-Agent: curl/7.54.0 Authorization: bearer some-token Accept: */* Content-Type: application/json Content-Length: 0 ``` And you have a rule which allows token `some-bearer` to access `PUT /my-service/whatever` and you have a credentials issuer which does not modify the Authorization header, the response will be: ``` HTTP/1.1 200 OK Authorization: bearer-sometoken Content-Length: 0 Connection: Closed ``` If the rule denies the request, the response will be, for example: ``` HTTP/1.1 401 OK Content-Length: 0 Connection: Closed ``` Close #42 Signed-off-by: arekkas --- cmd/serve_api.go | 12 +- docs/api.swagger.json | 31 ++++ judge/handler.go | 118 +++++++++++++ judge/handler_test.go | 196 +++++++++++++++++++++ proxy/proxy.go | 6 +- sdk/go/oathkeeper/swagger/README.md | 1 + sdk/go/oathkeeper/swagger/docs/JudgeApi.md | 35 ++++ sdk/go/oathkeeper/swagger/judge_api.go | 93 ++++++++++ 8 files changed, 487 insertions(+), 5 deletions(-) create mode 100644 judge/handler.go create mode 100644 judge/handler_test.go create mode 100644 sdk/go/oathkeeper/swagger/docs/JudgeApi.md create mode 100644 sdk/go/oathkeeper/swagger/judge_api.go diff --git a/cmd/serve_api.go b/cmd/serve_api.go index 57202a4f06..61c8da22ea 100644 --- a/cmd/serve_api.go +++ b/cmd/serve_api.go @@ -30,6 +30,8 @@ import ( "github.com/ory/graceful" "github.com/ory/herodot" "github.com/ory/metrics-middleware" + "github.com/ory/oathkeeper/judge" + "github.com/ory/oathkeeper/proxy" "github.com/ory/oathkeeper/rsakey" "github.com/ory/oathkeeper/rule" "github.com/rs/cors" @@ -78,17 +80,23 @@ HTTP CONTROLS logger.WithError(err).Fatalln("Unable to initialize the ID Token signing algorithm") } + matcher := rule.NewCachedMatcher(rules) + enabledAuthenticators, enabledAuthorizers, enabledCredentialIssuers := enabledHandlerNames() availableAuthenticators, availableAuthorizers, availableCredentialIssuers := availableHandlerNames() + authenticators, authorizers, credentialIssuers := handlerFactories(keyManager) + eval := proxy.NewRequestHandler(logger, authenticators, authorizers, credentialIssuers) + + router := httprouter.New() writer := herodot.NewJSONWriter(logger) ruleHandler := rule.NewHandler(writer, rules, rule.ValidateRule( enabledAuthenticators, availableAuthenticators, enabledAuthorizers, availableAuthorizers, enabledCredentialIssuers, availableCredentialIssuers, )) + judgeHandler := judge.NewHandler(eval, logger, matcher, router) keyHandler := rsakey.NewHandler(writer, keyManager) - router := httprouter.New() health := newHealthHandler(db, writer, router) ruleHandler.SetRoutes(router) keyHandler.SetRoutes(router) @@ -113,7 +121,7 @@ HTTP CONTROLS n.Use(segmentMiddleware) } - n.UseHandler(router) + n.UseHandler(judgeHandler) ch := cors.New(corsx.ParseOptions()).Handler(n) go refreshKeys(keyManager) diff --git a/docs/api.swagger.json b/docs/api.swagger.json index afe88307e7..12a00b2f33 100644 --- a/docs/api.swagger.json +++ b/docs/api.swagger.json @@ -95,6 +95,37 @@ } } }, + "/judge": { + "get": { + "description": "This endpoint mirrors the proxy capability of ORY Oathkeeper's proxy functionality but instead of forwarding the\nrequest to the upstream server, returns 200 (request should be allowed), 401 (unauthorized), or 403 (forbidden)\nstatus codes. This endpoint can be used to integrate with other API Proxies like Ambassador, Kong, Envoy, and many more.", + "schemes": [ + "http", + "https" + ], + "tags": [ + "judge" + ], + "summary": "Judge if a request should be allowed or not", + "operationId": "judge", + "responses": { + "200": { + "$ref": "#/responses/emptyResponse" + }, + "401": { + "$ref": "#/responses/genericError" + }, + "403": { + "$ref": "#/responses/genericError" + }, + "404": { + "$ref": "#/responses/genericError" + }, + "500": { + "$ref": "#/responses/genericError" + } + } + } + }, "/rules": { "get": { "description": "This method returns an array of all rules that are stored in the backend. This is useful if you want to get a full\nview of what rules you have currently in place.", diff --git a/judge/handler.go b/judge/handler.go new file mode 100644 index 0000000000..f900f65fdb --- /dev/null +++ b/judge/handler.go @@ -0,0 +1,118 @@ +/* + * Copyright © 2017-2018 Aeneas Rekkas + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * @author Aeneas Rekkas + * @copyright 2017-2018 Aeneas Rekkas + * @license Apache-2.0 + */ + +package judge + +import ( + "net/http" + + "github.com/julienschmidt/httprouter" + "github.com/ory/herodot" + "github.com/ory/oathkeeper/proxy" + "github.com/ory/oathkeeper/rsakey" + "github.com/ory/oathkeeper/rule" + "github.com/sirupsen/logrus" +) + +const ( + JudgePath = "/judge" +) + +func NewHandler(handler *proxy.RequestHandler, logger logrus.FieldLogger, matcher rule.Matcher, router *httprouter.Router) *Handler { + if logger == nil { + logger = logrus.New() + } + return &Handler{ + Logger: logger, + Matcher: matcher, + RequestHandler: handler, + H: herodot.NewNegotiationHandler(logger), + Router: router, + } +} + +type Handler struct { + Logger logrus.FieldLogger + RequestHandler *proxy.RequestHandler + KeyManager rsakey.Manager + Matcher rule.Matcher + H herodot.Writer + Router *httprouter.Router +} + +func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + if len(r.URL.Path) >= len(JudgePath) && r.URL.Path[:len(JudgePath)] == JudgePath { + r.URL.Scheme = "http" + r.URL.Host = r.Host + if r.TLS != nil { + r.URL.Scheme = "https" + } + r.URL.Path = r.URL.Path[len(JudgePath):] + + h.judge(w, r) + } else { + h.Router.ServeHTTP(w, r) + } +} + +// swagger:route GET /judge judge judge +// +// Judge if a request should be allowed or not +// +// This endpoint mirrors the proxy capability of ORY Oathkeeper's proxy functionality but instead of forwarding the +// request to the upstream server, returns 200 (request should be allowed), 401 (unauthorized), or 403 (forbidden) +// status codes. This endpoint can be used to integrate with other API Proxies like Ambassador, Kong, Envoy, and many more. +// +// Schemes: http, https +// +// Responses: +// 200: emptyResponse +// 401: genericError +// 403: genericError +// 404: genericError +// 500: genericError +func (h *Handler) judge(w http.ResponseWriter, r *http.Request) { + rl, err := h.Matcher.MatchRule(r.Method, r.URL) + if err != nil { + h.Logger.WithError(err). + WithField("granted", false). + WithField("access_url", r.URL.String()). + Warn("Access request denied") + h.H.WriteError(w, r, err) + return + } + + if err := h.RequestHandler.HandleRequest(r, rl); err != nil { + h.Logger.WithError(err). + WithField("granted", false). + WithField("access_url", r.URL.String()). + Warn("Access request denied") + h.H.WriteError(w, r, err) + return + } + + h.Logger. + WithField("granted", true). + WithField("access_url", r.URL.String()). + Warn("Access request granted") + + w.Header().Set("Authorization", r.Header.Get("Authorization")) + w.WriteHeader(http.StatusOK) +} diff --git a/judge/handler_test.go b/judge/handler_test.go new file mode 100644 index 0000000000..ddf6a04615 --- /dev/null +++ b/judge/handler_test.go @@ -0,0 +1,196 @@ +/* + * Copyright © 2017-2018 Aeneas Rekkas + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * @author Aeneas Rekkas + * @copyright 2017-2018 Aeneas Rekkas + * @license Apache-2.0 + */ + +package judge + +import ( + "fmt" + "net/http" + "net/http/httptest" + "strconv" + "testing" + + "github.com/julienschmidt/httprouter" + "github.com/ory/oathkeeper/proxy" + "github.com/ory/oathkeeper/rule" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestProxy(t *testing.T) { + matcher := &rule.CachedMatcher{Rules: map[string]rule.Rule{}} + rh := proxy.NewRequestHandler( + nil, + []proxy.Authenticator{proxy.NewAuthenticatorNoOp(), proxy.NewAuthenticatorAnonymous("anonymous"), proxy.NewAuthenticatorBroken()}, + []proxy.Authorizer{proxy.NewAuthorizerAllow(), proxy.NewAuthorizerDeny()}, + []proxy.CredentialsIssuer{proxy.NewCredentialsIssuerNoOp(), proxy.NewCredentialsIssuerBroken()}, + ) + + router := httprouter.New() + d := NewHandler(rh, nil, matcher, router) + + ts := httptest.NewServer(d) + defer ts.Close() + + ruleNoOpAuthenticator := rule.Rule{ + Match: rule.RuleMatch{Methods: []string{"GET"}, URL: ts.URL + "/authn-noop/<[0-9]+>"}, + Authenticators: []rule.RuleHandler{{Handler: "noop"}}, + Upstream: rule.Upstream{URL: ""}, + } + ruleNoOpAuthenticatorModifyUpstream := rule.Rule{ + Match: rule.RuleMatch{Methods: []string{"GET"}, URL: ts.URL + "/strip-path/authn-noop/<[0-9]+>"}, + Authenticators: []rule.RuleHandler{{Handler: "noop"}}, + Upstream: rule.Upstream{URL: "", StripPath: "/strip-path/", PreserveHost: true}, + } + + for k, tc := range []struct { + url string + code int + messages []string + rules []rule.Rule + transform func(r *http.Request) + authz string + d string + }{ + { + d: "should fail because url does not exist in rule set", + url: ts.URL + "/judge" + "/invalid", + rules: []rule.Rule{}, + code: http.StatusNotFound, + }, + { + d: "should fail because url does exist but is matched by two rules", + url: ts.URL + "/judge" + "/authn-noop/1234", + rules: []rule.Rule{ruleNoOpAuthenticator, ruleNoOpAuthenticator}, + code: http.StatusInternalServerError, + }, + { + d: "should pass", + url: ts.URL + "/judge" + "/authn-noop/1234", + rules: []rule.Rule{ruleNoOpAuthenticator}, + code: http.StatusOK, + transform: func(r *http.Request) { + r.Header.Add("Authorization", "bearer token") + }, + authz: "bearer token", + }, + { + d: "should pass", + url: ts.URL + "/judge" + "/strip-path/authn-noop/1234", + rules: []rule.Rule{ruleNoOpAuthenticatorModifyUpstream}, + code: http.StatusOK, + transform: func(r *http.Request) { + r.Header.Add("Authorization", "bearer token") + }, + authz: "bearer token", + }, + { + d: "should fail because no authorizer was configured", + url: ts.URL + "/judge" + "/authn-anon/authz-none/cred-none/1234", + rules: []rule.Rule{{ + Match: rule.RuleMatch{Methods: []string{"GET"}, URL: ts.URL + "/authn-anon/authz-none/cred-none/<[0-9]+>"}, + Authenticators: []rule.RuleHandler{{Handler: "anonymous"}}, + Upstream: rule.Upstream{URL: ""}, + }}, + transform: func(r *http.Request) { + r.Header.Add("Authorization", "bearer token") + }, + code: http.StatusUnauthorized, + }, + { + d: "should fail because no credentials issuer was configured", + url: ts.URL + "/judge" + "/authn-anon/authz-allow/cred-none/1234", + rules: []rule.Rule{{ + Match: rule.RuleMatch{Methods: []string{"GET"}, URL: ts.URL + "/authn-anon/authz-allow/cred-none/<[0-9]+>"}, + Authenticators: []rule.RuleHandler{{Handler: "anonymous"}}, + Authorizer: rule.RuleHandler{Handler: "allow"}, + Upstream: rule.Upstream{URL: ""}, + }}, + code: http.StatusInternalServerError, + }, + { + d: "should pass with anonymous and everything else set to noop", + url: ts.URL + "/judge" + "/authn-anon/authz-allow/cred-noop/1234", + rules: []rule.Rule{{ + Match: rule.RuleMatch{Methods: []string{"GET"}, URL: ts.URL + "/authn-anon/authz-allow/cred-noop/<[0-9]+>"}, + Authenticators: []rule.RuleHandler{{Handler: "anonymous"}}, + Authorizer: rule.RuleHandler{Handler: "allow"}, + CredentialsIssuer: rule.RuleHandler{Handler: "noop"}, + Upstream: rule.Upstream{URL: ""}, + }}, + code: http.StatusOK, + authz: "", + }, + { + d: "should fail when authorizer fails", + url: ts.URL + "/judge" + "/authn-anon/authz-deny/cred-noop/1234", + rules: []rule.Rule{{ + Match: rule.RuleMatch{Methods: []string{"GET"}, URL: ts.URL + "/authn-anon/authz-deny/cred-noop/<[0-9]+>"}, + Authenticators: []rule.RuleHandler{{Handler: "anonymous"}}, + Authorizer: rule.RuleHandler{Handler: "deny"}, + CredentialsIssuer: rule.RuleHandler{Handler: "noop"}, + Upstream: rule.Upstream{URL: ""}, + }}, + code: http.StatusForbidden, + }, + { + d: "should fail when authenticator fails", + url: ts.URL + "/judge" + "/authn-broken/authz-none/cred-none/1234", + rules: []rule.Rule{{ + Match: rule.RuleMatch{Methods: []string{"GET"}, URL: ts.URL + "/authn-broken/authz-none/cred-none/<[0-9]+>"}, + Authenticators: []rule.RuleHandler{{Handler: "broken"}}, + Upstream: rule.Upstream{URL: ""}, + }}, + code: http.StatusUnauthorized, + }, + { + d: "should fail when credentials issuer fails", + url: ts.URL + "/judge" + "/authn-anonymous/authz-allow/cred-broken/1234", + rules: []rule.Rule{{ + Match: rule.RuleMatch{Methods: []string{"GET"}, URL: ts.URL + "/authn-anonymous/authz-allow/cred-broken/<[0-9]+>"}, + Authenticators: []rule.RuleHandler{{Handler: "anonymous"}}, + Authorizer: rule.RuleHandler{Handler: "allow"}, + CredentialsIssuer: rule.RuleHandler{Handler: "broken"}, + Upstream: rule.Upstream{URL: ""}, + }}, + code: http.StatusInternalServerError, + }, + } { + t.Run(fmt.Sprintf("case=%d", k), func(t *testing.T) { + matcher.Rules = map[string]rule.Rule{} + for k, r := range tc.rules { + matcher.Rules[strconv.Itoa(k)] = r + } + + req, err := http.NewRequest("GET", tc.url, nil) + require.NoError(t, err) + if tc.transform != nil { + tc.transform(req) + } + + res, err := http.DefaultClient.Do(req) + require.NoError(t, err) + defer res.Body.Close() + + assert.Equal(t, res.Header.Get("Authorization"), tc.authz) + assert.Equal(t, tc.code, res.StatusCode) + }) + } +} diff --git a/proxy/proxy.go b/proxy/proxy.go index 962e492530..6567fd0cfb 100644 --- a/proxy/proxy.go +++ b/proxy/proxy.go @@ -111,7 +111,7 @@ func (d *Proxy) RoundTrip(r *http.Request) (*http.Response, error) { } func (d *Proxy) Director(r *http.Request) { - enrichRequestedURL(r) + EnrichRequestedURL(r) rl, err := d.Matcher.MatchRule(r.Method, r.URL) if err != nil { *r = *r.WithContext(context.WithValue(r.Context(), director, err)) @@ -132,9 +132,9 @@ func (d *Proxy) Director(r *http.Request) { *r = *r.WithContext(context.WithValue(r.Context(), director, en)) } -// enrichRequestedURL sets Scheme and Host values in a URL passed down by a http server. Per default, the URL +// EnrichRequestedURL sets Scheme and Host values in a URL passed down by a http server. Per default, the URL // does not contain host nor scheme values. -func enrichRequestedURL(r *http.Request) { +func EnrichRequestedURL(r *http.Request) { r.URL.Scheme = "http" r.URL.Host = r.Host if r.TLS != nil { diff --git a/sdk/go/oathkeeper/swagger/README.md b/sdk/go/oathkeeper/swagger/README.md index 85409b0407..81ae7f925b 100644 --- a/sdk/go/oathkeeper/swagger/README.md +++ b/sdk/go/oathkeeper/swagger/README.md @@ -25,6 +25,7 @@ Class | Method | HTTP request | Description *DefaultApi* | [**GetWellKnown**](docs/DefaultApi.md#getwellknown) | **Get** /.well-known/jwks.json | Returns well known keys *HealthApi* | [**IsInstanceAlive**](docs/HealthApi.md#isinstancealive) | **Get** /health/alive | Check the Alive Status *HealthApi* | [**IsInstanceReady**](docs/HealthApi.md#isinstanceready) | **Get** /health/ready | Check the Readiness Status +*JudgeApi* | [**Judge**](docs/JudgeApi.md#judge) | **Get** /judge | Judge if a request should be allowed or not *RuleApi* | [**CreateRule**](docs/RuleApi.md#createrule) | **Post** /rules | Create a rule *RuleApi* | [**DeleteRule**](docs/RuleApi.md#deleterule) | **Delete** /rules/{id} | Delete a rule *RuleApi* | [**GetRule**](docs/RuleApi.md#getrule) | **Get** /rules/{id} | Retrieve a rule diff --git a/sdk/go/oathkeeper/swagger/docs/JudgeApi.md b/sdk/go/oathkeeper/swagger/docs/JudgeApi.md new file mode 100644 index 0000000000..3dd8b0addf --- /dev/null +++ b/sdk/go/oathkeeper/swagger/docs/JudgeApi.md @@ -0,0 +1,35 @@ +# \JudgeApi + +All URIs are relative to *http://localhost* + +Method | HTTP request | Description +------------- | ------------- | ------------- +[**Judge**](JudgeApi.md#Judge) | **Get** /judge | Judge if a request should be allowed or not + + +# **Judge** +> Judge() + +Judge if a request should be allowed or not + +This endpoint mirrors the proxy capability of ORY Oathkeeper's proxy functionality but instead of forwarding the request to the upstream server, returns 200 (request should be allowed), 401 (unauthorized), or 403 (forbidden) status codes. This endpoint can be used to integrate with other API Proxies like Ambassador, Kong, Envoy, and many more. + + +### Parameters +This endpoint does not need any parameter. + +### Return type + +void (empty response body) + +### Authorization + +No authorization required + +### HTTP request headers + + - **Content-Type**: application/json + - **Accept**: application/json + +[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md) + diff --git a/sdk/go/oathkeeper/swagger/judge_api.go b/sdk/go/oathkeeper/swagger/judge_api.go new file mode 100644 index 0000000000..1933f286a9 --- /dev/null +++ b/sdk/go/oathkeeper/swagger/judge_api.go @@ -0,0 +1,93 @@ +/* + * ORY Oathkeeper + * + * ORY Oathkeeper is a reverse proxy that checks the HTTP Authorization for validity against a set of rules. This service uses Hydra to validate access tokens and policies. + * + * OpenAPI spec version: Latest + * Contact: hi@ory.am + * Generated by: https://github.com/swagger-api/swagger-codegen.git + */ + +package swagger + +import ( + "net/url" + "strings" +) + +type JudgeApi struct { + Configuration *Configuration +} + +func NewJudgeApi() *JudgeApi { + configuration := NewConfiguration() + return &JudgeApi{ + Configuration: configuration, + } +} + +func NewJudgeApiWithBasePath(basePath string) *JudgeApi { + configuration := NewConfiguration() + configuration.BasePath = basePath + + return &JudgeApi{ + Configuration: configuration, + } +} + +/** + * Judge if a request should be allowed or not + * This endpoint mirrors the proxy capability of ORY Oathkeeper's proxy functionality but instead of forwarding the request to the upstream server, returns 200 (request should be allowed), 401 (unauthorized), or 403 (forbidden) status codes. This endpoint can be used to integrate with other API Proxies like Ambassador, Kong, Envoy, and many more. + * + * @return void + */ +func (a JudgeApi) Judge() (*APIResponse, error) { + + var localVarHttpMethod = strings.ToUpper("Get") + // create path and map variables + localVarPath := a.Configuration.BasePath + "/judge" + + localVarHeaderParams := make(map[string]string) + localVarQueryParams := url.Values{} + localVarFormParams := make(map[string]string) + var localVarPostBody interface{} + var localVarFileName string + var localVarFileBytes []byte + // add default headers if any + for key := range a.Configuration.DefaultHeader { + localVarHeaderParams[key] = a.Configuration.DefaultHeader[key] + } + + // to determine the Content-Type header + localVarHttpContentTypes := []string{"application/json"} + + // set Content-Type header + localVarHttpContentType := a.Configuration.APIClient.SelectHeaderContentType(localVarHttpContentTypes) + if localVarHttpContentType != "" { + localVarHeaderParams["Content-Type"] = localVarHttpContentType + } + // to determine the Accept header + localVarHttpHeaderAccepts := []string{ + "application/json", + } + + // set Accept header + localVarHttpHeaderAccept := a.Configuration.APIClient.SelectHeaderAccept(localVarHttpHeaderAccepts) + if localVarHttpHeaderAccept != "" { + localVarHeaderParams["Accept"] = localVarHttpHeaderAccept + } + localVarHttpResponse, err := a.Configuration.APIClient.CallAPI(localVarPath, localVarHttpMethod, localVarPostBody, localVarHeaderParams, localVarQueryParams, localVarFormParams, localVarFileName, localVarFileBytes) + + var localVarURL, _ = url.Parse(localVarPath) + localVarURL.RawQuery = localVarQueryParams.Encode() + var localVarAPIResponse = &APIResponse{Operation: "Judge", Method: localVarHttpMethod, RequestURL: localVarURL.String()} + if localVarHttpResponse != nil { + localVarAPIResponse.Response = localVarHttpResponse.RawResponse + localVarAPIResponse.Payload = localVarHttpResponse.Body() + } + + if err != nil { + return localVarAPIResponse, err + } + return localVarAPIResponse, err +}