diff --git a/README.md b/README.md index 8a90e4bc22e7fcb6ae362cba0d95c81829542ccd..b7b26d44f696a63a3b726bd0422e6ac6cb0918b5 100644 --- a/README.md +++ b/README.md @@ -26,32 +26,32 @@ COMMANDS: help, h Shows a list of commands or help for one command GLOBAL OPTIONS: - --config the path to the configuration file for the keycloak proxy + --config the path to the configuration file for the keycloak proxy --listen "127.0.0.1:8080" the interface the service should be listening on - --secret the client secret used to authenticate to the oauth server - --client-id the client id used to authenticate to the oauth serves - --discovery-url the discovery url to retrieve the openid configuration - --upstream-url "http://127.0.0.1:8080" the url for the upstream endpoint you wish to proxy to - --encryption-key the encryption key used to encrpytion the session state - --redirection-url the redirection url, namely the site url, note: /oauth will be added to it + --secret the client secret used to authenticate to the oauth server + --client-id the client id used to authenticate to the oauth serves + --discovery-url the discovery url to retrieve the openid configuration + --upstream-url "http://127.0.0.1:8080" the url for the upstream endpoint you wish to proxy to + --encryption-key the encryption key used to encrpytion the session state + --redirection-url the redirection url, namely the site url, note: /oauth will be added to it --hostname [--hostname option --hostname option] a list of hostname which the service will respond to, defaults to all - --tls-cert the path to a certificate file used for TLS - --tls-private-key the path to the private key for TLS support - --scope [--scope option --scope option] a variable list of scopes requested when authenticating the user - --claim [--claim option --claim option] a series of key pair values which must match the claims in the token present e.g. aud=myapp, iss=http://example.com etcd - --resource [--resource option --resource option] a list of resources 'uri=/admin|methods=GET|roles=role1,role2' - --signin-page a custom template displayed for signin - --forbidden-page a custom template used for access forbidden - --tag [--tag option --tag option] a keypair tag which is passed to the templates when render, i.e. title='My Page',site='my name' etc - --max-session "1h0m0s" if refresh sessions are enabled we can limit their duration via this + --tls-cert the path to a certificate file used for TLS + --tls-private-key the path to the private key for TLS support + --scope [--scope option --scope option] a variable list of scopes requested when authenticating the user + --claim [--claim option --claim option] a series of key pair values which must match the claims in the token present e.g. aud=myapp, iss=http://example.com etcd + --resource [--resource option --resource option] a list of resources 'uri=/admin|methods=GET|roles=role1,role2|whitelisted=(true|false)' + --signin-page a custom template displayed for signin + --forbidden-page a custom template used for access forbidden + --tag [--tag option --tag option] a keypair tag which is passed to the templates when render, i.e. title='My Page',site='my name' etc + --max-session "1h0m0s" if refresh sessions are enabled we can limit their duration via this --skip-token-verification testing purposes ONLY, the option allows you to bypass the token verification, expiration and roles are still enforced - --proxy-protocol switches on proxy protocol support on the listen (not supported yet) - --refresh-sessions enables the refreshing of tokens via offline access - --json-logging switch on json logging rather than text (defaults true) - --log-requests switch on logging of all incoming requests (defaults true) - --verbose switch on debug / verbose logging - --help, -h show help - --version, -v print the version + --proxy-protocol switches on proxy protocol support on the listen (not supported yet) + --refresh-sessions enables the refreshing of tokens via offline access + --json-logging switch on json logging rather than text (defaults true) + --log-requests switch on logging of all incoming requests (defaults true) + --verbose switch on debug / verbose logging + --help, -h show help + --version, -v print the version ``` #### **Configuration** @@ -179,5 +179,26 @@ passed into the scope hold the oauth redirection url. If you wish pass additiona </body> </html> +#### **White-listed URL's** -``` \ No newline at end of file +Depending on how the application urls are laid out, you might want protect the root / url but have exceptions on a list of paths, i.e. /health etc. Although you should probably +fix this by fixing up the paths, you can add excepts to the protected resources. (Note: it's an array, so the order is important) + +```YAML + resources: + - url: /some_white_listed_url + white-listed: true + - url: / + methods: + - GET + roles_allowed: + - <CLIENT_APP_NAME>:<ROLE_NAME> + - <CLIENT_APP_NAME>:<ROLE_NAME> +``` + +Or on the command line + +```shell + --resource "uri=/some_white_listed_url,white-listed=true" + --resource "uri=/" # requires authentication on the rest +``` diff --git a/doc.go b/doc.go index 479ab8e3dd8d826bccc5455bcfdc5a6b7a2f5812..9238e04b1168453383e4784e14172369eb586ac2 100644 --- a/doc.go +++ b/doc.go @@ -17,17 +17,12 @@ package main import ( "errors" - "net/http/httputil" - "net/url" "time" - - "github.com/gambol99/go-oidc/oidc" - "github.com/gin-gonic/gin" ) const ( prog = "keycloak-proxy" - version = "v0.0.7" + version = "v0.0.8" author = "Rohith" email = "gambol99@gmail.com" description = "is a proxy using the keycloak service for auth and authorization" @@ -66,6 +61,8 @@ type Resource struct { URL string `json:"url" yaml:"url"` // Methods the method type Methods []string `json:"methods" yaml:"methods"` + // WhiteListed permits the prefix through + WhiteListed bool `json:"white-listed" yaml:"white-listed"` // RolesAllowed the roles required to access this url RolesAllowed []string `json:"roles_allowed" yaml:"roles_allowed"` } @@ -119,26 +116,3 @@ type Config struct { // Hostname is a list of hostnames the service should response to Hostnames []string `json:"hostnames" yaml:"hostnames"` } - -// KeycloakProxy is the server component -type KeycloakProxy struct { - config *Config - // the gin service - router *gin.Engine - // the oidc provider config - openIDConfig oidc.ClientConfig - // the oidc client - openIDClient *oidc.Client - // the proxy client - proxy *httputil.ReverseProxy - // the upstream endpoint - upstreamURL *url.URL -} - -// sessionState holds the state related data -type sessionState struct { - // the max time the session is permitted - expireOn time.Time - // the refresh token if any - refreshToken string -} diff --git a/handlers.go b/handlers.go index 750985eeff4129c98345a7749aeee6937bba367e..2c9e812e99a4467539e3397c10529ad719205dec 100644 --- a/handlers.go +++ b/handlers.go @@ -95,6 +95,10 @@ func (r *KeycloakProxy) entrypointHandler() gin.HandlerFunc { // step: check if authentication is required - gin doesn't support wildcard url, so we have have to use prefixes for _, resource := range r.config.Resources { if strings.HasPrefix(cx.Request.RequestURI, resource.URL) { + // step: has the resource been white listed? + if resource.WhiteListed { + break + } // step: inject the resource into the context, saves us from doing this again if containedIn(cx.Request.Method, resource.Methods) || containedIn("ANY", resource.Methods) { cx.Set(cxEnforce, resource) diff --git a/handlers_test.go b/handlers_test.go index b619853339359759cdeb3b855c891162be742cc2..51bb04e77e696b4aae333c7838e3fd982c0d4750 100644 --- a/handlers_test.go +++ b/handlers_test.go @@ -43,16 +43,26 @@ func TestEntrypointHandler(t *testing.T) { { Context: newFakeGinContext("GET", "/not_secure"), }, + { + Context: newFakeGinContext("GET", fakeTestWhitelistedURL), + }, { Context: newFakeGinContext("GET", oauthURL), }, + { + Context: newFakeGinContext("GET", faketestListenOrdered), Secure: true, + }, } for i, c := range tests { handler(c.Context) - if _, found := c.Context.Get(cxEnforce); c.Secure && !found { + _, found := c.Context.Get(cxEnforce) + if c.Secure && !found { t.Errorf("test case %d should have been set secure", i) } + if !c.Secure && found { + t.Errorf("test case %d should not have been set secure", i) + } } } @@ -132,6 +142,18 @@ func TestAdmissionHandler(t *testing.T) { roles: []string{fakeTestRole, fakeAdminRole}, }, }, + { + Context: newFakeGinContext("POST", fakeAdminRoleURL), + HTTPCode: http.StatusForbidden, + Resource: &Resource{ + URL: fakeAdminRoleURL, + Methods: []string{"POST"}, + RolesAllowed: []string{fakeTestRole, "test"}, + }, + UserContext: &userContext{ + roles: []string{fakeTestRole, fakeAdminRole}, + }, + }, } for i, c := range tests { @@ -147,3 +169,12 @@ func TestAdmissionHandler(t *testing.T) { } } } + +func TestHealthHandler(t *testing.T) { + proxy := newFakeKeycloakProxy(t) + context := newFakeGinContext("GET", healthURL) + proxy.healthHandler(context) + if context.Writer.Status() != http.StatusOK { + t.Errorf("we should have recieved a 200 response") + } +} diff --git a/resource.go b/resource.go index f9fe6e2cf26f2200798920b0f794471228400840..55f440484d4160e17666dd62b8148319696f5860 100644 --- a/resource.go +++ b/resource.go @@ -59,6 +59,10 @@ func (r Resource) String() string { var roles string var methods string + if r.WhiteListed { + return fmt.Sprintf("uri: %s, white-listed", r.URL) + } + if len(r.RolesAllowed) <= 0 { roles = "authentication only" } else { diff --git a/server.go b/server.go index d8fa0c6a7cbdf451f9d3c6df8c397f375fb58075..991ff3807cdaaa4c44d158879540d18290d4949f 100644 --- a/server.go +++ b/server.go @@ -18,14 +18,31 @@ package main import ( "fmt" "net/http" + "net/http/httputil" "net/url" "os" "sync" log "github.com/Sirupsen/logrus" + "github.com/gambol99/go-oidc/oidc" "github.com/gin-gonic/gin" ) +// KeycloakProxy is the server component +type KeycloakProxy struct { + config *Config + // the gin service + router *gin.Engine + // the oidc provider config + openIDConfig oidc.ClientConfig + // the oidc client + openIDClient *oidc.Client + // the proxy client + proxy *httputil.ReverseProxy + // the upstream endpoint + upstreamURL *url.URL +} + // newKeycloakProxy create's a new keycloak proxy from configuration func newKeycloakProxy(cfg *Config) (*KeycloakProxy, error) { // step: set the logging level diff --git a/server_test.go b/server_test.go index 87fea54025aae15f214d27c5d2438b27583702f2..82798d2e87f4c13fdcbbb8029cc1fce8a71d1053 100644 --- a/server_test.go +++ b/server_test.go @@ -31,9 +31,12 @@ const ( fakeClientID = "test" fakeSecret = fakeClientID - fakeAdminRoleURL = "/admin" - fakeTestRoleURL = "/test_role" - fakeTestAdminRolesURL = "/test_admin_roles" + fakeAdminRoleURL = "/admin" + fakeTestRoleURL = "/test_role" + fakeTestAdminRolesURL = "/test_admin_roles" + fakeAuthAllURL = "/auth_all" + fakeTestWhitelistedURL = fakeAuthAllURL + "/white_listed" + faketestListenOrdered = fakeAuthAllURL + "/bad_order" fakeAdminRole = "role:admin" fakeTestRole = "role:test" @@ -69,6 +72,23 @@ func newFakeKeycloakProxy(t *testing.T) *KeycloakProxy { Methods: []string{"GET"}, RolesAllowed: []string{fakeAdminRole, fakeTestRole}, }, + { + URL: fakeTestWhitelistedURL, + WhiteListed: true, + Methods: []string{}, + RolesAllowed: []string{}, + }, + { + URL: fakeAuthAllURL, + Methods: []string{"ANY"}, + RolesAllowed: []string{}, + }, + { + URL: fakeTestWhitelistedURL, + WhiteListed: true, + Methods: []string{}, + RolesAllowed: []string{}, + }, }, }, } diff --git a/session.go b/session.go index 38ecc60a7ffa4aedd7da43e560c6f4b1af006275..867cedc3c0a47d505981da6d9391380d8e8aad7f 100644 --- a/session.go +++ b/session.go @@ -35,6 +35,14 @@ const ( claimResourceRoles = "roles" ) +// sessionState holds the state related data +type sessionState struct { + // the max time the session is permitted + expireOn time.Time + // the refresh token if any + refreshToken string +} + // refreshUserSessionToken is responsible for retrieving the session state cookie and attempting to // refresh the access token for the user func (r *KeycloakProxy) refreshUserSessionToken(cx *gin.Context) (jose.JWT, error) { diff --git a/util_test.go b/util_test.go index a5f2f82b9fb99678bb600782e9bab2a49d787f56..cda969bc3b38c377a168b230848c2631a2a8801c 100644 --- a/util_test.go +++ b/util_test.go @@ -235,6 +235,14 @@ func TestDecodeResource(t *testing.T) { Methods: []string{"GET", "POST"}, }, }, + { + Option: "uri=/allow_me|white-listed=true", + Ok: true, + Resource: &Resource{ + URL: "/allow_me", + WhiteListed: true, + }, + }, { Option: "", }, diff --git a/utils.go b/utils.go index cbd78228f3db68c5bd510a7a7778230886f8d66f..ef435697f9658cf970764193d930b690b95a5c06 100644 --- a/utils.go +++ b/utils.go @@ -285,7 +285,7 @@ func decodeResource(v string) (*Resource, error) { // step: split up the keypair kp := strings.Split(x, "=") if len(kp) != 2 { - return nil, fmt.Errorf("invalid resource keypair, should be (uri|roles|method)=comma_values") + return nil, fmt.Errorf("invalid resource keypair, should be (uri|roles|method|white-listed)=comma_values") } switch kp[0] { case "uri": @@ -294,6 +294,12 @@ func decodeResource(v string) (*Resource, error) { resource.Methods = strings.Split(kp[1], ",") case "roles": resource.RolesAllowed = strings.Split(kp[1], ",") + case "white-listed": + value, err := strconv.ParseBool(kp[1]) + if err != nil { + return nil, fmt.Errorf("the value of whitelisted must be true|TRUE|T or it's false equivilant") + } + resource.WhiteListed = value default: return nil, fmt.Errorf("invalid identifier, should be roles, uri or methods") }