diff --git a/README.md b/README.md index 7e93baffd..74e25bdf1 100644 --- a/README.md +++ b/README.md @@ -27,7 +27,7 @@ Keycloak-proxy is a proxy service which at the risk of stating the obvious integrates with the [Keycloak](https://github.com/keycloak/keycloak) authentication service. Although technically the service has no dependency on Keycloak itself and would quite happily work with any OpenID provider. The service supports both access tokens in browser cookie or bearer tokens. ```shell -$ bin/keycloak-proxy help +$ bin/keycloak-proxy --help NAME: keycloak-proxy - is a proxy using the keycloak service for auth and authorization @@ -35,7 +35,7 @@ USAGE: keycloak-proxy [options] VERSION: - v2.2.0 (git+sha: 72a3646-dirty, built: 25-05-2018) + v2.2.2 (git+sha: c4d677a-dirty, built: 12-07-2018) AUTHOR: Rohith @@ -55,6 +55,7 @@ GLOBAL OPTIONS: --skip-openid-provider-tls-verify skip the verification of any TLS communication with the openid provider (default: false) --openid-provider-proxy value proxy for communication with the openid provider --openid-provider-timeout value timeout for openid configuration on .well-known/openid-configuration (default: 30s) + --base-uri value common prefix for all URIs [$PROXY_BASE_URI] --oauth-uri value the uri for proxy oauth endpoints (default: "/oauth") [$PROXY_OAUTH_URI] --scopes value list of scopes requested when authenticating the user --upstream-url value url for the upstream endpoint you wish to proxy [$PROXY_UPSTREAM_URL] @@ -62,15 +63,21 @@ GLOBAL OPTIONS: --resources value list of resources 'uri=/admin*|methods=GET,PUT|roles=role1,role2' --headers value custom headers to the upstream request, key=value --preserve-host preserve the host header of the proxied request in the upstream request (default: false) + --request-id-header value the http header name for request id (default: "X-Request-ID") [$PROXY_REQUEST_ID_HEADER] + --response-headers value custom headers to added to the http response key=value + --enable-self-signed-tls create self signed certificates for the proxy (default: false) [$PROXY_ENABLE_SELF_SIGNED_TLS] + --self-signed-tls-hostnames value a list of hostnames to place on the self-signed certificate + --self-signed-tls-expiration value the expiration of the certificate before rotation (default: 3h0m0s) + --enable-request-id indicates we should add a request id if none found (default: false) [$PROXY_ENABLE_REQUEST_ID] --enable-logout-redirect indicates we should redirect to the identity provider for logging out (default: false) - --enable-default-deny enables a default denial on all requests, you have to explicitly say what is permitted (recommended) (default: false) + --enable-default-deny enables a default denial on all requests, you have to explicitly say what is permitted (recommended) (default: true) --enable-encrypted-token enable encryption for the access tokens (default: false) --enable-logging enable http logging of the requests (default: false) --enable-json-logging switch on json logging rather than text (default: false) --enable-forwarding enables the forwarding proxy mode, signing outbound request (default: false) --enable-security-filter enables the security filter handler (default: false) [$PROXY_ENABLE_SECURITY_FILTER] --enable-refresh-tokens enables the handling of the refresh tokens (default: false) [$PROXY_ENABLE_REFRESH_TOKEN] - --enable-session-cookies access and refresh tokens are session only i.e. removed browser close (default: false) + --enable-session-cookies access and refresh tokens are session only i.e. removed browser close (default: true) --enable-login-handler enables the handling of the refresh tokens (default: false) [$PROXY_ENABLE_LOGIN_HANDLER] --enable-token-header enables the token authentication header X-Auth-Token to upstream (default: true) --enable-authorization-header adds the authorization header to the proxy request (default: true) [$PROXY_ENABLE_AUTHORIZATION_HEADER] @@ -107,6 +114,7 @@ GLOBAL OPTIONS: --hostnames value list of hostnames the service will respond to --store-url value url for the storage subsystem, e.g redis://127.0.0.1:6379, file:///etc/tokens.file --encryption-key value encryption key used to encryption the session state [$PROXY_ENCRYPTION_KEY] + --invalid-auth-redirects-with-303 use HTTP 303 redirects instead of 307 for invalid auth tokens (default: false) --no-redirects do not have back redirects when no authentication is present, 401 them (default: false) --skip-token-verification TESTING ONLY; bypass token verification, only expiration and roles enforced (default: false) --upstream-keepalives enables or disables the keepalive connections for upstream endpoint (default: true) @@ -138,8 +146,8 @@ GLOBAL OPTIONS: Assuming you have make + go, simply run make (or 'make static' for static linking). You can also build via docker container: make docker-build #### **Docker image** -Docker image is available at [https://quay.io/repository/gambol99/keycloak-proxy](https://quay.io/repository/gambol99/keycloak-proxy) +Docker image is available at [https://quay.io/repository/gambol99/keycloak-proxy](https://quay.io/repository/gambol99/keycloak-proxy) #### **Configuration** @@ -269,6 +277,23 @@ By default all requests will be proxyed on to the upstream, if you wish to ensur Note the HTTP routing rules following the guidelines from [chi](https://github.com/go-chi/chi#router-design). Its also worth nothing the ordering of the resource do not matter, the router will handle that for you. +#### **Resources** + +The resources defined either on the command line as `--resources` or via a configuration file defines a collection of enrtypoints and the requirement for access. + +```YAML +resources: +- uri: /admin/* + roles: + - admin + - superuser + # will work with either 'admin' or 'superuser' the default is false and requires both roles present + require-any-role: true +- uri: /public/* + # indicates we permit access regardless + white-listed: true +``` + #### **Google OAuth** Although the role extensions do require a Keycloak IDP or at the very least a IDP that produces a token which contains roles, there's nothing stopping you from using it against any OpenID providers, such as Google. Go to the Google Developers Console/Google Cloud Console and create a new OAuth 2.0 client ID *(via "API Manager-> Credentials)*. Once you've created the OAuth 2.0 client ID, take the client ID, secret and make sure you've added the callback url to the application scope *(using the default this would be http://127.0.0.1:3000/oauth/callback)* @@ -430,6 +455,10 @@ X-Auth-Given-Name: Rohith X-Auth-Name: Rohith Jayawardene ``` +#### **Self Signed Certificate** + +The proxy can be instructed to generate it's on self-signed certificate which are rotated on a user-defined expiration. Add the `--enable-self-signed-tls=true` option to the config or command line and if required you can configure the hostnames and expiration via the `--self-signed-tls-hostnames` and `--self-signed-tls-expiration` + #### **Encryption Key** In order to remain stateless and not have to rely on a central cache to persist the 'refresh_tokens', the refresh token is encrypted and added as a cookie using *crypto/aes*. Naturally the key must be the same if your running behind a load balancer etc. The key length should either 16 or 32 bytes depending or whether you want AES-128 or AES-256. diff --git a/config.go b/config.go index 239109f30..c43bb4b04 100644 --- a/config.go +++ b/config.go @@ -19,6 +19,7 @@ import ( "errors" "fmt" "net/url" + "os" "regexp" "strings" "time" @@ -26,6 +27,12 @@ import ( // newDefaultConfig returns a initialized config func newDefaultConfig() *Config { + var hostnames []string + if name, err := os.Hostname(); err == nil { + hostnames = append(hostnames, name) + } + hostnames = append(hostnames, []string{"localhost", "127.0.0.1"}...) + return &Config{ AccessTokenDuration: time.Duration(720) * time.Hour, CookieAccessName: "kc-access", @@ -35,6 +42,8 @@ func newDefaultConfig() *Config { EnableDefaultDeny: true, EnableSessionCookies: true, EnableTokenHeader: true, + SelfSignedTLSHostnames: hostnames, + SelfSignedTLSExpiration: 3 * time.Hour, Headers: make(map[string]string), LetsEncryptCacheDir: "./cache/", MatchClaims: make(map[string]string), diff --git a/doc.go b/doc.go index 75fc6cb69..cb939d3f0 100644 --- a/doc.go +++ b/doc.go @@ -35,7 +35,7 @@ var ( const ( prog = "keycloak-proxy" - author = "Rohith" + author = "Rohith Jayawardene" email = "gambol99@gmail.com" description = "is a proxy using the keycloak service for auth and authorization" @@ -183,6 +183,13 @@ type Config struct { // ResponseHeader is a map of response headers to add to the response ResponseHeaders map[string]string `json:"response-headers" yaml:"response-headers" usage:"custom headers to added to the http response key=value"` + // EnableSelfSignedTLS indicates we should create a self-signed ceritificate for the service + EnabledSelfSignedTLS bool `json:"enable-self-signed-tls" yaml:"enable-self-signed-tls" usage:"create self signed certificates for the proxy" env:"ENABLE_SELF_SIGNED_TLS"` + // SelfSignedTLSHostnames is the list of hostnames to place on the certificate + SelfSignedTLSHostnames []string `json:"self-signed-tls-hostnames" yaml:"self-signed-tls-hostnames" usage:"a list of hostnames to place on the self-signed certificate"` + // SelfSignedTLSExpiration is the expiration time of the tls certificate before rotation occurs + SelfSignedTLSExpiration time.Duration `json:"self-signed-tls-expiration" yaml:"self-signed-tls-expiration" usage:"the expiration of the certificate before rotation"` + // EnableRequestID indicates the proxy should add request id if none if found EnableRequestID bool `json:"enable-request-id" yaml:"enable-request-id" usage:"indicates we should add a request id if none found" env:"ENABLE_REQUEST_ID"` // EnableLogoutRedirect indicates we should redirect to the identity provider for logging out diff --git a/rotation.go b/rotation.go index 33c535867..91a2c7a29 100644 --- a/rotation.go +++ b/rotation.go @@ -44,7 +44,7 @@ func newCertificateRotator(cert, key string, log *zap.Logger) (*certificationRot if err != nil { return nil, err } - // step: are we watching the files for changes? + // @step: are we watching the files for changes? return &certificationRotation{ certificate: certificate, certificateFile: cert, diff --git a/self_signed.go b/self_signed.go new file mode 100644 index 000000000..6bc409769 --- /dev/null +++ b/self_signed.go @@ -0,0 +1,140 @@ +/* +Copyright 2018 All rights reserved. + +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. +*/ + +package main + +import ( + "context" + "crypto/rand" + "crypto/rsa" + "crypto/tls" + "errors" + "sync" + "time" + + "go.uber.org/zap" +) + +type selfSignedCertificate struct { + sync.RWMutex + // certificate holds the current issuing certificate + certificate tls.Certificate + // expiration is the certificate expiration + expiration time.Duration + // hostnames is the list of host names on the certificate + hostnames []string + // privateKey is the rsa private key + privateKey *rsa.PrivateKey + // the logger for this service + log *zap.Logger + // stopCh is a channel to close off the rotation + cancel context.CancelFunc +} + +// newSelfSignedCertificate creates and returns a self signed certificate manager +func newSelfSignedCertificate(hostnames []string, expiry time.Duration, log *zap.Logger) (*selfSignedCertificate, error) { + if len(hostnames) <= 0 { + return nil, errors.New("no hostnames specified") + } + if expiry < 5*time.Minute { + return nil, errors.New("expiration must be greater then 5 minutes") + } + + // @step: generate a certificate pair + log.Info("generating a private key for self-signed certificate", zap.String("common_name", hostnames[0])) + + key, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + return nil, err + } + + // @step: create an initial certificate + certificate, err := createCertificate(key, hostnames, expiry) + if err != nil { + return nil, err + } + + // @step: create a context to run under + ctx, cancel := context.WithCancel(context.Background()) + + svc := &selfSignedCertificate{ + certificate: certificate, + expiration: expiry, + hostnames: hostnames, + log: log, + privateKey: key, + cancel: cancel, + } + + if err := svc.rotate(ctx); err != nil { + return nil, err + } + + return svc, nil +} + +// rotate is responsible for rotation the certificate +func (c *selfSignedCertificate) rotate(ctx context.Context) error { + go func() { + c.log.Info("starting the self-signed certificate rotation", + zap.Duration("expiration", c.expiration)) + + for { + expires := time.Now().Add(c.expiration).Add(-5 * time.Minute) + ticker := expires.Sub(time.Now()) + + select { + case <-ctx.Done(): + return + case <-time.After(ticker): + } + c.log.Info("going to sleep until required for rotation", zap.Time("expires", expires), zap.Duration("duration", expires.Sub(time.Now()))) + + // @step: got to sleep until we need to rotate + time.Sleep(expires.Sub(time.Now())) + + // @step: create a new certificate for us + cert, _ := createCertificate(c.privateKey, c.hostnames, c.expiration) + c.log.Info("updating the certificate for server") + + // @step: update the current certificate + c.updateCertificate(cert) + } + }() + + return nil +} + +// close is used to shutdown resources +func (c *selfSignedCertificate) close() { + c.cancel() +} + +// updateCertificate is responsible for update the certificate +func (c *selfSignedCertificate) updateCertificate(cert tls.Certificate) { + c.Lock() + defer c.Unlock() + + c.certificate = cert +} + +// GetCertificate is responsible for retrieving +func (c *selfSignedCertificate) GetCertificate(hello *tls.ClientHelloInfo) (*tls.Certificate, error) { + c.RLock() + defer c.RUnlock() + + return &c.certificate, nil +} diff --git a/self_signed_test.go b/self_signed_test.go new file mode 100644 index 000000000..ba4ce5b43 --- /dev/null +++ b/self_signed_test.go @@ -0,0 +1,56 @@ +/* +Copyright 2018 All rights reserved. + +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. +*/ + +package main + +import ( + "crypto/tls" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/zap" +) + +func TestNewSelfSignedCertificate(t *testing.T) { + c, err := newSelfSignedCertificate([]string{"localhost"}, 5*time.Minute, zap.NewNop()) + assert.NoError(t, err) + assert.NotNil(t, c) + c.close() +} + +func TestSelfSignedNoHostnames(t *testing.T) { + c, err := newSelfSignedCertificate([]string{}, 5*time.Minute, zap.NewNop()) + assert.Error(t, err) + assert.Nil(t, c) +} + +func TestSelfSignedExpirationBad(t *testing.T) { + c, err := newSelfSignedCertificate([]string{"localhost"}, 1*time.Minute, zap.NewNop()) + assert.Error(t, err) + assert.Nil(t, c) +} + +func TestSelfSignedGetCertificate(t *testing.T) { + c, err := newSelfSignedCertificate([]string{"localhost"}, 5*time.Minute, zap.NewNop()) + require.NoError(t, err) + require.NotNil(t, c) + defer c.close() + cert, err := c.GetCertificate(&tls.ClientHelloInfo{}) + assert.NoError(t, err) + assert.NotNil(t, cert) +} diff --git a/server.go b/server.go index 2c710dc56..7f0c1739d 100644 --- a/server.go +++ b/server.go @@ -65,7 +65,6 @@ type oauthProxy struct { func init() { time.LoadLocation("UTC") // ensure all time is in UTC runtime.GOMAXPROCS(runtime.NumCPU()) // set the core - // @step: register the instrumentation prometheus.MustRegister(certificateRotationMetric) prometheus.MustRegister(latencyMetric) prometheus.MustRegister(oauthLatencyMetric) @@ -352,16 +351,18 @@ func (r *oauthProxy) createForwardingProxy() error { // Run starts the proxy service func (r *oauthProxy) Run() error { listener, err := r.createHTTPListener(listenerConfig{ - listen: r.config.Listen, - certificate: r.config.TLSCertificate, - privateKey: r.config.TLSPrivateKey, ca: r.config.TLSCaCertificate, + certificate: r.config.TLSCertificate, clientCert: r.config.TLSClientCertificate, - proxyProtocol: r.config.EnableProxyProtocol, - useLetsEncrypt: r.config.UseLetsEncrypt, - letsEncryptCacheDir: r.config.LetsEncryptCacheDir, hostnames: r.config.Hostnames, + letsEncryptCacheDir: r.config.LetsEncryptCacheDir, + listen: r.config.Listen, + privateKey: r.config.TLSPrivateKey, + proxyProtocol: r.config.EnableProxyProtocol, redirectionURL: r.config.RedirectionURL, + useFileTLS: r.config.TLSPrivateKey != "" && r.config.TLSCertificate != "", + useLetsEncryptTLS: r.config.UseLetsEncrypt, + useSelfSignedTLS: r.config.EnabledSelfSignedTLS, }) if err != nil { @@ -416,16 +417,18 @@ func (r *oauthProxy) Run() error { // listenerConfig encapsulate listener options type listenerConfig struct { - listen string // the interface to bind the listener to - certificate string // the path to the certificate if any - privateKey string // the path to the private key if any ca string // the path to a certificate authority + certificate string // the path to the certificate if any clientCert string // the path to a client certificate to use for mutual tls - proxyProtocol bool // whether to enable proxy protocol on the listen hostnames []string // list of hostnames the service will respond to - redirectionURL string // url to redirect to - useLetsEncrypt bool // whether to use lets encrypt for retrieving ssl certificates letsEncryptCacheDir string // the path to cache letsencrypt certificates + listen string // the interface to bind the listener to + privateKey string // the path to the private key if any + proxyProtocol bool // whether to enable proxy protocol on the listen + redirectionURL string // url to redirect to + useFileTLS bool // indicates we are using certificates from files + useLetsEncryptTLS bool // indicates we are using letsencrypt + useSelfSignedTLS bool // indicates we are using the self-signed tls } // ErrHostNotConfigured indicates the hostname was not configured @@ -460,13 +463,15 @@ func (r *oauthProxy) createHTTPListener(config listenerConfig) (net.Listener, er listener = &proxyproto.Listener{Listener: listener} } - // does the socket require TLS? - if (config.certificate != "" && config.privateKey != "") || config.useLetsEncrypt { + // @check if the socket requires TLS + if config.useSelfSignedTLS || config.useLetsEncryptTLS || config.useFileTLS { getCertificate := func(hello *tls.ClientHelloInfo) (*tls.Certificate, error) { return nil, errors.New("Not configured") } - if config.useLetsEncrypt { + if config.useLetsEncryptTLS { + r.log.Info("enabling letsencrypt tls support") + m := autocert.Manager{ Prompt: autocert.AcceptTOS, Cache: autocert.DirCache(config.letsEncryptCacheDir), @@ -494,9 +499,21 @@ func (r *oauthProxy) createHTTPListener(config listenerConfig) (net.Listener, er } getCertificate = m.GetCertificate - } else { + } + + if config.useSelfSignedTLS { + r.log.Info("enabling self-signed tls support", zap.Duration("expiration", r.config.SelfSignedTLSExpiration)) + + rotate, err := newSelfSignedCertificate(r.config.SelfSignedTLSHostnames, r.config.SelfSignedTLSExpiration, r.log) + if err != nil { + return nil, err + } + getCertificate = rotate.GetCertificate + + } + + if config.useFileTLS { r.log.Info("tls support enabled", zap.String("certificate", config.certificate), zap.String("private_key", config.privateKey)) - // creating a certificate rotation rotate, err := newCertificateRotator(config.certificate, config.privateKey, r.log) if err != nil { return nil, err @@ -510,13 +527,13 @@ func (r *oauthProxy) createHTTPListener(config listenerConfig) (net.Listener, er } tlsConfig := &tls.Config{ - PreferServerCipherSuites: true, GetCertificate: getCertificate, + PreferServerCipherSuites: true, } listener = tls.NewListener(listener, tlsConfig) - // are we doing mutual tls? + // @check if we doing mutual tls if config.clientCert != "" { caCert, err := ioutil.ReadFile(config.clientCert) if err != nil { diff --git a/utils.go b/utils.go index a66e61693..b02b00614 100644 --- a/utils.go +++ b/utils.go @@ -19,15 +19,19 @@ import ( "crypto/aes" "crypto/cipher" "crypto/rand" + "crypto/rsa" sha "crypto/sha256" "crypto/tls" "crypto/x509" + "crypto/x509/pkix" "encoding/base64" "encoding/json" + "encoding/pem" "errors" "fmt" "io" "io/ioutil" + "math/big" "net" "net/http" "net/url" @@ -62,6 +66,52 @@ var ( symbolsFilter = regexp.MustCompilePOSIX("[_$><\\[\\].,\\+-/'%^&*()!\\\\]+") ) +// createCertificate is responsible for creating a certificate +func createCertificate(key *rsa.PrivateKey, hostnames []string, expire time.Duration) (tls.Certificate, error) { + // @step: create a serial for the certificate + serial, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128)) + if err != nil { + return tls.Certificate{}, err + } + + template := x509.Certificate{ + BasicConstraintsValid: true, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + IsCA: false, + KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature, + NotAfter: time.Now().Add(expire), + NotBefore: time.Now().Add(-30 * time.Second), + PublicKeyAlgorithm: x509.ECDSA, + SerialNumber: serial, + SignatureAlgorithm: x509.SHA512WithRSA, + Subject: pkix.Name{ + CommonName: hostnames[0], + Organization: []string{"Keycloak Proxy"}, + }, + } + + // @step: add the hostnames to the certificate template + if len(hostnames) > 1 { + for _, x := range hostnames[1:] { + if ip := net.ParseIP(x); ip != nil { + template.IPAddresses = append(template.IPAddresses, ip) + } else { + template.DNSNames = append(template.DNSNames, x) + } + } + } + + // @step: create the certificate + cert, err := x509.CreateCertificate(rand.Reader, &template, &template, &key.PublicKey, key) + if err != nil { + return tls.Certificate{}, err + } + certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: cert}) + keyPEM := pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(key)}) + + return tls.X509KeyPair(certPEM, keyPEM) +} + // getRequestHostURL returns the hostname from the request func getRequestHostURL(r *http.Request) string { hostname := r.Host diff --git a/utils_test.go b/utils_test.go index f133532b1..a55917ff1 100644 --- a/utils_test.go +++ b/utils_test.go @@ -17,6 +17,7 @@ package main import ( "bytes" + "crypto/tls" "fmt" "io/ioutil" "net/http" @@ -65,6 +66,51 @@ func TestDecodeKeyPairs(t *testing.T) { } } +func TestGetRequestHostURL(t *testing.T) { + cs := []struct { + Expected string + HostHeader string + Hostname string + TLS *tls.ConnectionState + }{ + { + Expected: "http://www.test.com", + Hostname: "www.test.com", + }, + { + Expected: "http://", + }, + { + Expected: "http://www.override.com", + HostHeader: "www.override.com", + Hostname: "www.test.com", + }, + { + Expected: "https://www.test.com", + Hostname: "www.test.com", + TLS: &tls.ConnectionState{}, + }, + { + Expected: "https://www.override.com", + HostHeader: "www.override.com", + Hostname: "www.test.com", + TLS: &tls.ConnectionState{}, + }, + } + for i, c := range cs { + request := &http.Request{ + Method: http.MethodGet, + Host: c.Hostname, + TLS: c.TLS, + } + if c.HostHeader != "" { + request.Header = make(http.Header, 0) + request.Header.Set("X-Forwarded-Host", c.HostHeader) + } + assert.Equal(t, c.Expected, getRequestHostURL(request), "case %d, expected: %s, got: %s", i, c.Expected, getRequestHostURL(request)) + } +} + func BenchmarkUUID(b *testing.B) { for n := 0; n < b.N; n++ { s := uuid.NewV1()