diff --git a/cmd/serve_api.go b/cmd/serve_api.go index 57202a4f0..61c8da22e 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 afe88307e..12a00b2f3 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 000000000..f900f65fd --- /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 000000000..ddf6a0461 --- /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 962e49253..6567fd0cf 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 85409b040..81ae7f925 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 000000000..3dd8b0add --- /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 000000000..1933f286a --- /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 +}