From e4626c1360980e97770948126944dddb13a6b0dc Mon Sep 17 00:00:00 2001 From: Mike Bland Date: Sun, 15 Nov 2015 22:08:30 -0500 Subject: [PATCH] Sign Upstream requests with HMAC. closes #147 --- Godeps | 1 + README.md | 29 ++++-- main.go | 2 + oauthproxy.go | 31 +++++- oauthproxy_test.go | 246 ++++++++++++++++++++++++++++++++++----------- options.go | 33 ++++++ options_test.go | 25 +++++ 7 files changed, 298 insertions(+), 69 deletions(-) diff --git a/Godeps b/Godeps index ad3cf0d..b6bc08d 100644 --- a/Godeps +++ b/Godeps @@ -1,3 +1,4 @@ +github.com/18F/hmacauth 1.0.1 github.com/BurntSushi/toml 3883ac1ce943878302255f538fce319d23226223 github.com/bitly/go-simplejson 3378bdcb5cebedcbf8b5750edee28010f128fe24 github.com/mreiferson/go-options ee94b57f2fbf116075426f853e5abbcdfeca8b3d diff --git a/README.md b/README.md index ca248d1..3284ce0 100644 --- a/README.md +++ b/README.md @@ -113,15 +113,16 @@ An example [oauth2_proxy.cfg](contrib/oauth2_proxy.cfg.example) config file is i ``` Usage of oauth2_proxy: - -approval_prompt="force": Oauth approval_prompt + -approval-prompt="force": Oauth approval_prompt -authenticated-emails-file="": authenticate against emails via file (one per line) + -basic-auth-password="": the password to set when passing the HTTP Basic Auth header -client-id="": the OAuth Client ID: ie: "123456.apps.googleusercontent.com" -client-secret="": the OAuth Client Secret -config="": path to config file -cookie-domain="": an optional cookie domain to force cookies to (ie: .yourcompany.com)* -cookie-expire=168h0m0s: expire timeframe for cookie -cookie-httponly=true: set HttpOnly cookie flag - -cookie-key="_oauth2_proxy": the name of the cookie that the oauth_proxy creates + -cookie-name="_oauth2_proxy": the name of the cookie that the oauth_proxy creates -cookie-refresh=0: refresh the cookie after this duration; 0 to disable -cookie-secret="": the seed string for secure cookies -cookie-secure=true: set secure (HTTPS) cookie flag @@ -130,17 +131,15 @@ Usage of oauth2_proxy: -email-domain=: authenticate emails with the specified domain (may be given multiple times). Use * to authenticate any email -github-org="": restrict logins to members of this organisation -github-team="": restrict logins to members of this team - -google-group="": restrict logins to members of this google group -google-admin-email="": the google admin to impersonate for api calls + -google-group=: restrict logins to members of this google group (may be given multiple times). -google-service-account-json="": the path to the service account json credentials - -htpasswd-file="": additionally authenticate against a htpasswd file. Entries must be created with "htpasswd -s" for SHA encryption -http-address="127.0.0.1:4180": [http://]: or unix:// to listen on for HTTP clients -https-address=":443": : to listen on for HTTPS clients -login-url="": Authentication endpoint -pass-access-token=false: pass OAuth access_token to upstream via X-Forwarded-Access-Token header -pass-basic-auth=true: pass HTTP Basic Auth, X-Forwarded-User and X-Forwarded-Email information to upstream - -basic-auth-password="": the password to set when passing the HTTP Basic Auth header -pass-host-header=true: pass the request Host Header to upstream -profile-url="": Profile access endpoint -provider="google": OAuth provider @@ -149,6 +148,7 @@ Usage of oauth2_proxy: -redirect-url="": the OAuth Redirect URL. ie: "https://internalapp.yourcompany.com/oauth2/callback" -request-logging=true: Log requests to stdout -scope="": Oauth scope specification + -signature-key="": GAP-Signature request signature key (algorithm:secretkey) -skip-auth-regex=: bypass authentication for requests path's that match (may be given multiple times) -tls-cert="": path to certificate file -tls-key="": path to private key file @@ -250,6 +250,24 @@ OAuth2 Proxy responds directly to the following endpoints. All other endpoints w * /oauth2/callback - the URL used at the end of the OAuth cycle. The oauth app will be configured with this as the callback url. * /oauth2/auth - only returns a 202 Accepted response or a 401 Unauthorized response; for use with the [Nginx `auth_request` directive](#nginx-auth-request) +## Request signatures + +If `signature_key` is defined, proxied requests will be signed with the +`GAP-Signature` header, which is a [Hash-based Message Authentication Code +(HMAC)](https://en.wikipedia.org/wiki/Hash-based_message_authentication_code) +of selected request information and the request body [see `SIGNATURE_HEADERS` +in `oauthproxy.go`](./oauthproxy.go). + +`signature_key` must be of the form `algorithm:secretkey`, (ie: `signature_key = "sha1:secret0"`) + +For more information about HMAC request signature validation, read the +following: + +* [Amazon Web Services: Signing and Authenticating REST + Requests](https://docs.aws.amazon.com/AmazonS3/latest/dev/RESTAuthentication.html) +* [rc3.org: Using HMAC to authenticate Web service + requests](http://rc3.org/2011/12/02/using-hmac-to-authenticate-web-service-requests/) + ## Logging Format OAuth2 Proxy logs requests to stdout in a format similar to Apache Combined Log. @@ -258,7 +276,6 @@ OAuth2 Proxy logs requests to stdout in a format similar to Apache Combined Log. - [19/Mar/2015:17:20:19 -0400] GET "/path/" HTTP/1.1 "" ``` - ## Adding a new Provider Follow the examples in the [`providers` package](providers/) to define a new diff --git a/main.go b/main.go index 93098dd..a8d3f1b 100644 --- a/main.go +++ b/main.go @@ -69,6 +69,8 @@ func main() { flagSet.String("scope", "", "OAuth scope specification") flagSet.String("approval-prompt", "force", "OAuth approval_prompt") + flagSet.String("signature-key", "", "GAP-Signature request signature key (algorithm:secretkey)") + flagSet.Parse(os.Args[1:]) if *showVersion { diff --git a/oauthproxy.go b/oauthproxy.go index 7e6d31f..dd69d6a 100644 --- a/oauthproxy.go +++ b/oauthproxy.go @@ -14,10 +14,26 @@ import ( "strings" "time" + "github.com/18F/hmacauth" "github.com/bitly/oauth2_proxy/cookie" "github.com/bitly/oauth2_proxy/providers" ) +const SignatureHeader = "GAP-Signature" + +var SignatureHeaders []string = []string{ + "Content-Length", + "Content-Md5", + "Content-Type", + "Date", + "Authorization", + "X-Forwarded-User", + "X-Forwarded-Email", + "X-Forwarded-Access-Token", + "Cookie", + "Gap-Auth", +} + type OAuthProxy struct { CookieSeed string CookieName string @@ -54,10 +70,15 @@ type OAuthProxy struct { type UpstreamProxy struct { upstream string handler http.Handler + auth hmacauth.HmacAuth } func (u *UpstreamProxy) ServeHTTP(w http.ResponseWriter, r *http.Request) { w.Header().Set("GAP-Upstream-Address", u.upstream) + if u.auth != nil { + r.Header.Set("GAP-Auth", w.Header().Get("GAP-Auth")) + u.auth.SignRequest(r) + } u.handler.ServeHTTP(w, r) } @@ -89,6 +110,11 @@ func NewFileServer(path string, filesystemPath string) (proxy http.Handler) { func NewOAuthProxy(opts *Options, validator func(string) bool) *OAuthProxy { serveMux := http.NewServeMux() + var auth hmacauth.HmacAuth + if sigData := opts.signatureData; sigData != nil { + auth = hmacauth.NewHmacAuth(sigData.hash, []byte(sigData.key), + SignatureHeader, SignatureHeaders) + } for _, u := range opts.proxyURLs { path := u.Path switch u.Scheme { @@ -101,14 +127,15 @@ func NewOAuthProxy(opts *Options, validator func(string) bool) *OAuthProxy { } else { setProxyDirector(proxy) } - serveMux.Handle(path, &UpstreamProxy{u.Host, proxy}) + serveMux.Handle(path, + &UpstreamProxy{u.Host, proxy, auth}) case "file": if u.Fragment != "" { path = u.Fragment } log.Printf("mapping path %q => file system %q", path, u.Path) proxy := NewFileServer(path, u.Path) - serveMux.Handle(path, &UpstreamProxy{path, proxy}) + serveMux.Handle(path, &UpstreamProxy{path, proxy, nil}) default: panic(fmt.Sprintf("unknown upstream protocol %s", u.Scheme)) } diff --git a/oauthproxy_test.go b/oauthproxy_test.go index a89f041..7af1de1 100644 --- a/oauthproxy_test.go +++ b/oauthproxy_test.go @@ -1,9 +1,12 @@ package main import ( + "crypto" "encoding/base64" + "github.com/18F/hmacauth" "github.com/bitly/oauth2_proxy/providers" "github.com/bmizerany/assert" + "io" "io/ioutil" "log" "net" @@ -88,6 +91,45 @@ func TestRobotsTxt(t *testing.T) { assert.Equal(t, "User-agent: *\nDisallow: /", rw.Body.String()) } +type TestProvider struct { + *providers.ProviderData + EmailAddress string + ValidToken bool +} + +func NewTestProvider(provider_url *url.URL, email_address string) *TestProvider { + return &TestProvider{ + ProviderData: &providers.ProviderData{ + ProviderName: "Test Provider", + LoginURL: &url.URL{ + Scheme: "http", + Host: provider_url.Host, + Path: "/oauth/authorize", + }, + RedeemURL: &url.URL{ + Scheme: "http", + Host: provider_url.Host, + Path: "/oauth/token", + }, + ProfileURL: &url.URL{ + Scheme: "http", + Host: provider_url.Host, + Path: "/api/v1/profile", + }, + Scope: "profile.email", + }, + EmailAddress: email_address, + } +} + +func (tp *TestProvider) GetEmailAddress(session *providers.SessionState) (string, error) { + return tp.EmailAddress, nil +} + +func (tp *TestProvider) ValidateSessionState(session *providers.SessionState) bool { + return tp.ValidToken +} + func TestBasicAuthPassword(t *testing.T) { provider_server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { log.Printf("%#v", r) @@ -121,29 +163,7 @@ func TestBasicAuthPassword(t *testing.T) { const email_address = "michael.bland@gsa.gov" const user_name = "michael.bland" - opts.provider = &TestProvider{ - ProviderData: &providers.ProviderData{ - ProviderName: "Test Provider", - LoginURL: &url.URL{ - Scheme: "http", - Host: provider_url.Host, - Path: "/oauth/authorize", - }, - RedeemURL: &url.URL{ - Scheme: "http", - Host: provider_url.Host, - Path: "/oauth/token", - }, - ProfileURL: &url.URL{ - Scheme: "http", - Host: provider_url.Host, - Path: "/api/v1/profile", - }, - Scope: "profile.email", - }, - EmailAddress: email_address, - } - + opts.provider = NewTestProvider(provider_url, email_address) proxy := NewOAuthProxy(opts, func(email string) bool { return email == email_address }) @@ -183,20 +203,6 @@ func TestBasicAuthPassword(t *testing.T) { provider_server.Close() } -type TestProvider struct { - *providers.ProviderData - EmailAddress string - ValidToken bool -} - -func (tp *TestProvider) GetEmailAddress(session *providers.SessionState) (string, error) { - return tp.EmailAddress, nil -} - -func (tp *TestProvider) ValidateSessionState(session *providers.SessionState) bool { - return tp.ValidToken -} - type PassAccessTokenTest struct { provider_server *httptest.Server proxy *OAuthProxy @@ -242,29 +248,7 @@ func NewPassAccessTokenTest(opts PassAccessTokenTestOptions) *PassAccessTokenTes provider_url, _ := url.Parse(t.provider_server.URL) const email_address = "michael.bland@gsa.gov" - t.opts.provider = &TestProvider{ - ProviderData: &providers.ProviderData{ - ProviderName: "Test Provider", - LoginURL: &url.URL{ - Scheme: "http", - Host: provider_url.Host, - Path: "/oauth/authorize", - }, - RedeemURL: &url.URL{ - Scheme: "http", - Host: provider_url.Host, - Path: "/oauth/token", - }, - ProfileURL: &url.URL{ - Scheme: "http", - Host: provider_url.Host, - Path: "/api/v1/profile", - }, - Scope: "profile.email", - }, - EmailAddress: email_address, - } - + t.opts.provider = NewTestProvider(provider_url, email_address) t.proxy = NewOAuthProxy(t.opts, func(email string) bool { return email == email_address }) @@ -559,7 +543,7 @@ func TestProcessCookieFailIfRefreshSetAndCookieExpired(t *testing.T) { func NewAuthOnlyEndpointTest() *ProcessCookieTest { pc_test := NewProcessCookieTestWithDefaults() pc_test.req, _ = http.NewRequest("GET", - pc_test.opts.ProxyPrefix + "/auth", nil) + pc_test.opts.ProxyPrefix+"/auth", nil) return pc_test } @@ -610,3 +594,143 @@ func TestAuthOnlyEndpointUnauthorizedOnEmailValidationFailure(t *testing.T) { bodyBytes, _ := ioutil.ReadAll(test.rw.Body) assert.Equal(t, "unauthorized request\n", string(bodyBytes)) } + +type SignatureAuthenticator struct { + auth hmacauth.HmacAuth +} + +func (v *SignatureAuthenticator) Authenticate( + w http.ResponseWriter, r *http.Request) { + result, headerSig, computedSig := v.auth.AuthenticateRequest(r) + if result == hmacauth.ResultNoSignature { + w.Write([]byte("no signature received")) + } else if result == hmacauth.ResultMatch { + w.Write([]byte("signatures match")) + } else if result == hmacauth.ResultMismatch { + w.Write([]byte("signatures do not match:" + + "\n received: " + headerSig + + "\n computed: " + computedSig)) + } else { + panic("Unknown result value: " + result.String()) + } +} + +type SignatureTest struct { + opts *Options + upstream *httptest.Server + upstream_host string + provider *httptest.Server + header http.Header + rw *httptest.ResponseRecorder + authenticator *SignatureAuthenticator +} + +func NewSignatureTest() *SignatureTest { + opts := NewOptions() + opts.CookieSecret = "cookie secret" + opts.ClientID = "client ID" + opts.ClientSecret = "client secret" + opts.EmailDomains = []string{"acm.org"} + + authenticator := &SignatureAuthenticator{} + upstream := httptest.NewServer( + http.HandlerFunc(authenticator.Authenticate)) + upstream_url, _ := url.Parse(upstream.URL) + opts.Upstreams = append(opts.Upstreams, upstream.URL) + + providerHandler := func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte(`{"access_token": "my_auth_token"}`)) + } + provider := httptest.NewServer(http.HandlerFunc(providerHandler)) + provider_url, _ := url.Parse(provider.URL) + opts.provider = NewTestProvider(provider_url, "mbland@acm.org") + + return &SignatureTest{ + opts, + upstream, + upstream_url.Host, + provider, + make(http.Header), + httptest.NewRecorder(), + authenticator, + } +} + +func (st *SignatureTest) Close() { + st.provider.Close() + st.upstream.Close() +} + +// fakeNetConn simulates an http.Request.Body buffer that will be consumed +// when it is read by the hmacauth.HmacAuth if not handled properly. See: +// https://github.com/18F/hmacauth/pull/4 +type fakeNetConn struct { + reqBody string +} + +func (fnc *fakeNetConn) Read(p []byte) (n int, err error) { + if bodyLen := len(fnc.reqBody); bodyLen != 0 { + copy(p, fnc.reqBody) + fnc.reqBody = "" + return bodyLen, io.EOF + } + return 0, io.EOF +} + +func (st *SignatureTest) MakeRequestWithExpectedKey(method, body, key string) { + err := st.opts.Validate() + if err != nil { + panic(err) + } + proxy := NewOAuthProxy(st.opts, func(email string) bool { return true }) + + var bodyBuf io.ReadCloser + if body != "" { + bodyBuf = ioutil.NopCloser(&fakeNetConn{reqBody: body}) + } + req, err := http.NewRequest(method, "/foo/bar", bodyBuf) + if err != nil { + panic(err) + } + req.Header = st.header + + state := &providers.SessionState{ + Email: "mbland@acm.org", AccessToken: "my_access_token"} + value, err := proxy.provider.CookieForSession(state, proxy.CookieCipher) + if err != nil { + panic(err) + } + cookie := proxy.MakeCookie(req, value, proxy.CookieExpire, time.Now()) + req.AddCookie(cookie) + // This is used by the upstream to validate the signature. + st.authenticator.auth = hmacauth.NewHmacAuth( + crypto.SHA1, []byte(key), SignatureHeader, SignatureHeaders) + proxy.ServeHTTP(st.rw, req) +} + +func TestNoRequestSignature(t *testing.T) { + st := NewSignatureTest() + defer st.Close() + st.MakeRequestWithExpectedKey("GET", "", "") + assert.Equal(t, 200, st.rw.Code) + assert.Equal(t, st.rw.Body.String(), "no signature received") +} + +func TestRequestSignatureGetRequest(t *testing.T) { + st := NewSignatureTest() + defer st.Close() + st.opts.SignatureKey = "sha1:foobar" + st.MakeRequestWithExpectedKey("GET", "", "foobar") + assert.Equal(t, 200, st.rw.Code) + assert.Equal(t, st.rw.Body.String(), "signatures match") +} + +func TestRequestSignaturePostRequest(t *testing.T) { + st := NewSignatureTest() + defer st.Close() + st.opts.SignatureKey = "sha1:foobar" + payload := `{ "hello": "world!" }` + st.MakeRequestWithExpectedKey("POST", payload, "foobar") + assert.Equal(t, 200, st.rw.Code) + assert.Equal(t, st.rw.Body.String(), "signatures match") +} diff --git a/options.go b/options.go index 945125a..b64396c 100644 --- a/options.go +++ b/options.go @@ -1,6 +1,7 @@ package main import ( + "crypto" "fmt" "net/url" "os" @@ -8,6 +9,7 @@ import ( "strings" "time" + "github.com/18F/hmacauth" "github.com/bitly/oauth2_proxy/providers" ) @@ -60,11 +62,19 @@ type Options struct { RequestLogging bool `flag:"request-logging" cfg:"request_logging"` + SignatureKey string `flag:"signature-key" cfg:"signature_key"` + // internal values that are set after config validation redirectURL *url.URL proxyURLs []*url.URL CompiledRegex []*regexp.Regexp provider providers.Provider + signatureData *SignatureData +} + +type SignatureData struct { + hash crypto.Hash + key string } func NewOptions() *Options { @@ -175,6 +185,8 @@ func (o *Options) Validate() error { } } + msgs = parseSignatureKey(o, msgs) + if len(msgs) != 0 { return fmt.Errorf("Invalid configuration:\n %s", strings.Join(msgs, "\n ")) @@ -210,3 +222,24 @@ func parseProviderInfo(o *Options, msgs []string) []string { } return msgs } + +func parseSignatureKey(o *Options, msgs []string) []string { + if o.SignatureKey == "" { + return msgs + } + + components := strings.Split(o.SignatureKey, ":") + if len(components) != 2 { + return append(msgs, "invalid signature hash:key spec: "+ + o.SignatureKey) + } + + algorithm, secretKey := components[0], components[1] + if hash, err := hmacauth.DigestNameToCryptoHash(algorithm); err != nil { + return append(msgs, "unsupported signature hash algorithm: "+ + o.SignatureKey) + } else { + o.signatureData = &SignatureData{hash, secretKey} + } + return msgs +} diff --git a/options_test.go b/options_test.go index 2984465..8a8b6a7 100644 --- a/options_test.go +++ b/options_test.go @@ -1,6 +1,7 @@ package main import ( + "crypto" "net/url" "strings" "testing" @@ -166,3 +167,27 @@ func TestCookieRefreshMustBeLessThanCookieExpire(t *testing.T) { o.CookieRefresh -= time.Duration(1) assert.Equal(t, nil, o.Validate()) } + +func TestValidateSignatureKey(t *testing.T) { + o := testOptions() + o.SignatureKey = "sha1:secret" + assert.Equal(t, nil, o.Validate()) + assert.Equal(t, o.signatureData.hash, crypto.SHA1) + assert.Equal(t, o.signatureData.key, "secret") +} + +func TestValidateSignatureKeyInvalidSpec(t *testing.T) { + o := testOptions() + o.SignatureKey = "invalid spec" + err := o.Validate() + assert.Equal(t, err.Error(), "Invalid configuration:\n"+ + " invalid signature hash:key spec: "+o.SignatureKey) +} + +func TestValidateSignatureKeyUnsupportedAlgorithm(t *testing.T) { + o := testOptions() + o.SignatureKey = "unsupported:default secret" + err := o.Validate() + assert.Equal(t, err.Error(), "Invalid configuration:\n"+ + " unsupported signature hash algorithm: "+o.SignatureKey) +}