git.iko.soy/typeable/oauth2-proxy
on master help sign in
Sign Upstream requests with HMAC. closes #147
commite4626c1360980e97770948126944dddb13a6b0dc authorMike Bland <michael.bland@gsa.gov> dateSun 15 Nov 2015, 22:08 (11 years ago) parent7c241ec
7 files changed+298−69
Godeps+1 README.md+23−6 main.go+2 oauthproxy.go+29−2 oauthproxy_test.go+185−61 options.go+33 options_test.go+25
Godeps+1
@@ -1,3 +1,4 @@
1+github.com/18F/hmacauth 1.0.1
12github.com/BurntSushi/toml 3883ac1ce943878302255f538fce319d23226223
23github.com/bitly/go-simplejson 3378bdcb5cebedcbf8b5750edee28010f128fe24
34github.com/mreiferson/go-options ee94b57f2fbf116075426f853e5abbcdfeca8b3d
README.md+23−6
@@ -113,15 +113,16 @@ An example [oauth2_proxy.cfg](contrib/oauth2_proxy.cfg.example) config file is i
113113
114114```
115115Usage of oauth2_proxy:
116− -approval_prompt="force": Oauth approval_prompt
116+ -approval-prompt="force": Oauth approval_prompt
117117 -authenticated-emails-file="": authenticate against emails via file (one per line)
118+ -basic-auth-password="": the password to set when passing the HTTP Basic Auth header
118119 -client-id="": the OAuth Client ID: ie: "123456.apps.googleusercontent.com"
119120 -client-secret="": the OAuth Client Secret
120121 -config="": path to config file
121122 -cookie-domain="": an optional cookie domain to force cookies to (ie: .yourcompany.com)*
122123 -cookie-expire=168h0m0s: expire timeframe for cookie
123124 -cookie-httponly=true: set HttpOnly cookie flag
124− -cookie-key="_oauth2_proxy": the name of the cookie that the oauth_proxy creates
125+ -cookie-name="_oauth2_proxy": the name of the cookie that the oauth_proxy creates
125126 -cookie-refresh=0: refresh the cookie after this duration; 0 to disable
126127 -cookie-secret="": the seed string for secure cookies
127128 -cookie-secure=true: set secure (HTTPS) cookie flag
@@ -130,17 +131,15 @@ Usage of oauth2_proxy:
130131 -email-domain=: authenticate emails with the specified domain (may be given multiple times). Use * to authenticate any email
131132 -github-org="": restrict logins to members of this organisation
132133 -github-team="": restrict logins to members of this team
133− -google-group="": restrict logins to members of this google group
134134 -google-admin-email="": the google admin to impersonate for api calls
135+ -google-group=: restrict logins to members of this google group (may be given multiple times).
135136 -google-service-account-json="": the path to the service account json credentials
136−
137137 -htpasswd-file="": additionally authenticate against a htpasswd file. Entries must be created with "htpasswd -s" for SHA encryption
138138 -http-address="127.0.0.1:4180": [http://]<addr>:<port> or unix://<path> to listen on for HTTP clients
139139 -https-address=":443": <addr>:<port> to listen on for HTTPS clients
140140 -login-url="": Authentication endpoint
141141 -pass-access-token=false: pass OAuth access_token to upstream via X-Forwarded-Access-Token header
142142 -pass-basic-auth=true: pass HTTP Basic Auth, X-Forwarded-User and X-Forwarded-Email information to upstream
143− -basic-auth-password="": the password to set when passing the HTTP Basic Auth header
144143 -pass-host-header=true: pass the request Host Header to upstream
145144 -profile-url="": Profile access endpoint
146145 -provider="google": OAuth provider
@@ -149,6 +148,7 @@ Usage of oauth2_proxy:
149148 -redirect-url="": the OAuth Redirect URL. ie: "https://internalapp.yourcompany.com/oauth2/callback"
150149 -request-logging=true: Log requests to stdout
151150 -scope="": Oauth scope specification
151+ -signature-key="": GAP-Signature request signature key (algorithm:secretkey)
152152 -skip-auth-regex=: bypass authentication for requests path's that match (may be given multiple times)
153153 -tls-cert="": path to certificate file
154154 -tls-key="": path to private key file
@@ -250,6 +250,24 @@ OAuth2 Proxy responds directly to the following endpoints. All other endpoints w
250250* /oauth2/callback - the URL used at the end of the OAuth cycle. The oauth app will be configured with this as the callback url.
251251* /oauth2/auth - only returns a 202 Accepted response or a 401 Unauthorized response; for use with the [Nginx `auth_request` directive](#nginx-auth-request)
252252
253+## Request signatures
254+
255+If `signature_key` is defined, proxied requests will be signed with the
256+`GAP-Signature` header, which is a [Hash-based Message Authentication Code
257+(HMAC)](https://en.wikipedia.org/wiki/Hash-based_message_authentication_code)
258+of selected request information and the request body [see `SIGNATURE_HEADERS`
259+in `oauthproxy.go`](./oauthproxy.go).
260+
261+`signature_key` must be of the form `algorithm:secretkey`, (ie: `signature_key = "sha1:secret0"`)
262+
263+For more information about HMAC request signature validation, read the
264+following:
265+
266+* [Amazon Web Services: Signing and Authenticating REST
267+ Requests](https://docs.aws.amazon.com/AmazonS3/latest/dev/RESTAuthentication.html)
268+* [rc3.org: Using HMAC to authenticate Web service
269+ requests](http://rc3.org/2011/12/02/using-hmac-to-authenticate-web-service-requests/)
270+
253271## Logging Format
254272
255273OAuth2 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.
258276<REMOTE_ADDRESS> - <user@domain.com> [19/Mar/2015:17:20:19 -0400] <HOST_HEADER> GET <UPSTREAM_HOST> "/path/" HTTP/1.1 "<USER_AGENT>" <RESPONSE_CODE> <RESPONSE_BYTES> <REQUEST_DURATION>
259277```
260278
261−
262279## Adding a new Provider
263280
264281Follow the examples in the [`providers` package](providers/) to define a new
main.go+2
@@ -69,6 +69,8 @@ func main() {
6969 flagSet.String("scope", "", "OAuth scope specification")
7070 flagSet.String("approval-prompt", "force", "OAuth approval_prompt")
7171
72+ flagSet.String("signature-key", "", "GAP-Signature request signature key (algorithm:secretkey)")
73+
7274 flagSet.Parse(os.Args[1:])
7375
7476 if *showVersion {
oauthproxy.go+29−2
@@ -14,10 +14,26 @@ import (
1414 "strings"
1515 "time"
1616
17+ "github.com/18F/hmacauth"
1718 "github.com/bitly/oauth2_proxy/cookie"
1819 "github.com/bitly/oauth2_proxy/providers"
1920)
2021
22+const SignatureHeader = "GAP-Signature"
23+
24+var SignatureHeaders []string = []string{
25+ "Content-Length",
26+ "Content-Md5",
27+ "Content-Type",
28+ "Date",
29+ "Authorization",
30+ "X-Forwarded-User",
31+ "X-Forwarded-Email",
32+ "X-Forwarded-Access-Token",
33+ "Cookie",
34+ "Gap-Auth",
35+}
36+
2137type OAuthProxy struct {
2238 CookieSeed string
2339 CookieName string
@@ -54,10 +70,15 @@ type OAuthProxy struct {
5470type UpstreamProxy struct {
5571 upstream string
5672 handler http.Handler
73+ auth hmacauth.HmacAuth
5774}
5875
5976func (u *UpstreamProxy) ServeHTTP(w http.ResponseWriter, r *http.Request) {
6077 w.Header().Set("GAP-Upstream-Address", u.upstream)
78+ if u.auth != nil {
79+ r.Header.Set("GAP-Auth", w.Header().Get("GAP-Auth"))
80+ u.auth.SignRequest(r)
81+ }
6182 u.handler.ServeHTTP(w, r)
6283}
6384
@@ -89,6 +110,11 @@ func NewFileServer(path string, filesystemPath string) (proxy http.Handler) {
89110
90111func NewOAuthProxy(opts *Options, validator func(string) bool) *OAuthProxy {
91112 serveMux := http.NewServeMux()
113+ var auth hmacauth.HmacAuth
114+ if sigData := opts.signatureData; sigData != nil {
115+ auth = hmacauth.NewHmacAuth(sigData.hash, []byte(sigData.key),
116+ SignatureHeader, SignatureHeaders)
117+ }
92118 for _, u := range opts.proxyURLs {
93119 path := u.Path
94120 switch u.Scheme {
@@ -101,14 +127,15 @@ func NewOAuthProxy(opts *Options, validator func(string) bool) *OAuthProxy {
101127 } else {
102128 setProxyDirector(proxy)
103129 }
104− serveMux.Handle(path, &UpstreamProxy{u.Host, proxy})
130+ serveMux.Handle(path,
131+ &UpstreamProxy{u.Host, proxy, auth})
105132 case "file":
106133 if u.Fragment != "" {
107134 path = u.Fragment
108135 }
109136 log.Printf("mapping path %q => file system %q", path, u.Path)
110137 proxy := NewFileServer(path, u.Path)
111− serveMux.Handle(path, &UpstreamProxy{path, proxy})
138+ serveMux.Handle(path, &UpstreamProxy{path, proxy, nil})
112139 default:
113140 panic(fmt.Sprintf("unknown upstream protocol %s", u.Scheme))
114141 }
oauthproxy_test.go+185−61
@@ -1,9 +1,12 @@
11package main
22
33import (
4+ "crypto"
45 "encoding/base64"
6+ "github.com/18F/hmacauth"
57 "github.com/bitly/oauth2_proxy/providers"
68 "github.com/bmizerany/assert"
9+ "io"
710 "io/ioutil"
811 "log"
912 "net"
@@ -88,6 +91,45 @@ func TestRobotsTxt(t *testing.T) {
8891 assert.Equal(t, "User-agent: *\nDisallow: /", rw.Body.String())
8992}
9093
94+type TestProvider struct {
95+ *providers.ProviderData
96+ EmailAddress string
97+ ValidToken bool
98+}
99+
100+func NewTestProvider(provider_url *url.URL, email_address string) *TestProvider {
101+ return &TestProvider{
102+ ProviderData: &providers.ProviderData{
103+ ProviderName: "Test Provider",
104+ LoginURL: &url.URL{
105+ Scheme: "http",
106+ Host: provider_url.Host,
107+ Path: "/oauth/authorize",
108+ },
109+ RedeemURL: &url.URL{
110+ Scheme: "http",
111+ Host: provider_url.Host,
112+ Path: "/oauth/token",
113+ },
114+ ProfileURL: &url.URL{
115+ Scheme: "http",
116+ Host: provider_url.Host,
117+ Path: "/api/v1/profile",
118+ },
119+ Scope: "profile.email",
120+ },
121+ EmailAddress: email_address,
122+ }
123+}
124+
125+func (tp *TestProvider) GetEmailAddress(session *providers.SessionState) (string, error) {
126+ return tp.EmailAddress, nil
127+}
128+
129+func (tp *TestProvider) ValidateSessionState(session *providers.SessionState) bool {
130+ return tp.ValidToken
131+}
132+
91133func TestBasicAuthPassword(t *testing.T) {
92134 provider_server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
93135 log.Printf("%#v", r)
@@ -121,29 +163,7 @@ func TestBasicAuthPassword(t *testing.T) {
121163 const email_address = "michael.bland@gsa.gov"
122164 const user_name = "michael.bland"
123165
124− opts.provider = &TestProvider{
125− ProviderData: &providers.ProviderData{
126− ProviderName: "Test Provider",
127− LoginURL: &url.URL{
128− Scheme: "http",
129− Host: provider_url.Host,
130− Path: "/oauth/authorize",
131− },
132− RedeemURL: &url.URL{
133− Scheme: "http",
134− Host: provider_url.Host,
135− Path: "/oauth/token",
136− },
137− ProfileURL: &url.URL{
138− Scheme: "http",
139− Host: provider_url.Host,
140− Path: "/api/v1/profile",
141− },
142− Scope: "profile.email",
143− },
144− EmailAddress: email_address,
145− }
146−
166+ opts.provider = NewTestProvider(provider_url, email_address)
147167 proxy := NewOAuthProxy(opts, func(email string) bool {
148168 return email == email_address
149169 })
@@ -183,20 +203,6 @@ func TestBasicAuthPassword(t *testing.T) {
183203 provider_server.Close()
184204}
185205
186−type TestProvider struct {
187− *providers.ProviderData
188− EmailAddress string
189− ValidToken bool
190−}
191−
192−func (tp *TestProvider) GetEmailAddress(session *providers.SessionState) (string, error) {
193− return tp.EmailAddress, nil
194−}
195−
196−func (tp *TestProvider) ValidateSessionState(session *providers.SessionState) bool {
197− return tp.ValidToken
198−}
199−
200206type PassAccessTokenTest struct {
201207 provider_server *httptest.Server
202208 proxy *OAuthProxy
@@ -242,29 +248,7 @@ func NewPassAccessTokenTest(opts PassAccessTokenTestOptions) *PassAccessTokenTes
242248 provider_url, _ := url.Parse(t.provider_server.URL)
243249 const email_address = "michael.bland@gsa.gov"
244250
245− t.opts.provider = &TestProvider{
246− ProviderData: &providers.ProviderData{
247− ProviderName: "Test Provider",
248− LoginURL: &url.URL{
249− Scheme: "http",
250− Host: provider_url.Host,
251− Path: "/oauth/authorize",
252− },
253− RedeemURL: &url.URL{
254− Scheme: "http",
255− Host: provider_url.Host,
256− Path: "/oauth/token",
257− },
258− ProfileURL: &url.URL{
259− Scheme: "http",
260− Host: provider_url.Host,
261− Path: "/api/v1/profile",
262− },
263− Scope: "profile.email",
264− },
265− EmailAddress: email_address,
266− }
267−
251+ t.opts.provider = NewTestProvider(provider_url, email_address)
268252 t.proxy = NewOAuthProxy(t.opts, func(email string) bool {
269253 return email == email_address
270254 })
@@ -559,7 +543,7 @@ func TestProcessCookieFailIfRefreshSetAndCookieExpired(t *testing.T) {
559543func NewAuthOnlyEndpointTest() *ProcessCookieTest {
560544 pc_test := NewProcessCookieTestWithDefaults()
561545 pc_test.req, _ = http.NewRequest("GET",
562− pc_test.opts.ProxyPrefix + "/auth", nil)
546+ pc_test.opts.ProxyPrefix+"/auth", nil)
563547 return pc_test
564548}
565549
@@ -610,3 +594,143 @@ func TestAuthOnlyEndpointUnauthorizedOnEmailValidationFailure(t *testing.T) {
610594 bodyBytes, _ := ioutil.ReadAll(test.rw.Body)
611595 assert.Equal(t, "unauthorized request\n", string(bodyBytes))
612596}
597+
598+type SignatureAuthenticator struct {
599+ auth hmacauth.HmacAuth
600+}
601+
602+func (v *SignatureAuthenticator) Authenticate(
603+ w http.ResponseWriter, r *http.Request) {
604+ result, headerSig, computedSig := v.auth.AuthenticateRequest(r)
605+ if result == hmacauth.ResultNoSignature {
606+ w.Write([]byte("no signature received"))
607+ } else if result == hmacauth.ResultMatch {
608+ w.Write([]byte("signatures match"))
609+ } else if result == hmacauth.ResultMismatch {
610+ w.Write([]byte("signatures do not match:" +
611+ "\n received: " + headerSig +
612+ "\n computed: " + computedSig))
613+ } else {
614+ panic("Unknown result value: " + result.String())
615+ }
616+}
617+
618+type SignatureTest struct {
619+ opts *Options
620+ upstream *httptest.Server
621+ upstream_host string
622+ provider *httptest.Server
623+ header http.Header
624+ rw *httptest.ResponseRecorder
625+ authenticator *SignatureAuthenticator
626+}
627+
628+func NewSignatureTest() *SignatureTest {
629+ opts := NewOptions()
630+ opts.CookieSecret = "cookie secret"
631+ opts.ClientID = "client ID"
632+ opts.ClientSecret = "client secret"
633+ opts.EmailDomains = []string{"acm.org"}
634+
635+ authenticator := &SignatureAuthenticator{}
636+ upstream := httptest.NewServer(
637+ http.HandlerFunc(authenticator.Authenticate))
638+ upstream_url, _ := url.Parse(upstream.URL)
639+ opts.Upstreams = append(opts.Upstreams, upstream.URL)
640+
641+ providerHandler := func(w http.ResponseWriter, r *http.Request) {
642+ w.Write([]byte(`{"access_token": "my_auth_token"}`))
643+ }
644+ provider := httptest.NewServer(http.HandlerFunc(providerHandler))
645+ provider_url, _ := url.Parse(provider.URL)
646+ opts.provider = NewTestProvider(provider_url, "mbland@acm.org")
647+
648+ return &SignatureTest{
649+ opts,
650+ upstream,
651+ upstream_url.Host,
652+ provider,
653+ make(http.Header),
654+ httptest.NewRecorder(),
655+ authenticator,
656+ }
657+}
658+
659+func (st *SignatureTest) Close() {
660+ st.provider.Close()
661+ st.upstream.Close()
662+}
663+
664+// fakeNetConn simulates an http.Request.Body buffer that will be consumed
665+// when it is read by the hmacauth.HmacAuth if not handled properly. See:
666+// https://github.com/18F/hmacauth/pull/4
667+type fakeNetConn struct {
668+ reqBody string
669+}
670+
671+func (fnc *fakeNetConn) Read(p []byte) (n int, err error) {
672+ if bodyLen := len(fnc.reqBody); bodyLen != 0 {
673+ copy(p, fnc.reqBody)
674+ fnc.reqBody = ""
675+ return bodyLen, io.EOF
676+ }
677+ return 0, io.EOF
678+}
679+
680+func (st *SignatureTest) MakeRequestWithExpectedKey(method, body, key string) {
681+ err := st.opts.Validate()
682+ if err != nil {
683+ panic(err)
684+ }
685+ proxy := NewOAuthProxy(st.opts, func(email string) bool { return true })
686+
687+ var bodyBuf io.ReadCloser
688+ if body != "" {
689+ bodyBuf = ioutil.NopCloser(&fakeNetConn{reqBody: body})
690+ }
691+ req, err := http.NewRequest(method, "/foo/bar", bodyBuf)
692+ if err != nil {
693+ panic(err)
694+ }
695+ req.Header = st.header
696+
697+ state := &providers.SessionState{
698+ Email: "mbland@acm.org", AccessToken: "my_access_token"}
699+ value, err := proxy.provider.CookieForSession(state, proxy.CookieCipher)
700+ if err != nil {
701+ panic(err)
702+ }
703+ cookie := proxy.MakeCookie(req, value, proxy.CookieExpire, time.Now())
704+ req.AddCookie(cookie)
705+ // This is used by the upstream to validate the signature.
706+ st.authenticator.auth = hmacauth.NewHmacAuth(
707+ crypto.SHA1, []byte(key), SignatureHeader, SignatureHeaders)
708+ proxy.ServeHTTP(st.rw, req)
709+}
710+
711+func TestNoRequestSignature(t *testing.T) {
712+ st := NewSignatureTest()
713+ defer st.Close()
714+ st.MakeRequestWithExpectedKey("GET", "", "")
715+ assert.Equal(t, 200, st.rw.Code)
716+ assert.Equal(t, st.rw.Body.String(), "no signature received")
717+}
718+
719+func TestRequestSignatureGetRequest(t *testing.T) {
720+ st := NewSignatureTest()
721+ defer st.Close()
722+ st.opts.SignatureKey = "sha1:foobar"
723+ st.MakeRequestWithExpectedKey("GET", "", "foobar")
724+ assert.Equal(t, 200, st.rw.Code)
725+ assert.Equal(t, st.rw.Body.String(), "signatures match")
726+}
727+
728+func TestRequestSignaturePostRequest(t *testing.T) {
729+ st := NewSignatureTest()
730+ defer st.Close()
731+ st.opts.SignatureKey = "sha1:foobar"
732+ payload := `{ "hello": "world!" }`
733+ st.MakeRequestWithExpectedKey("POST", payload, "foobar")
734+ assert.Equal(t, 200, st.rw.Code)
735+ assert.Equal(t, st.rw.Body.String(), "signatures match")
736+}
options.go+33
@@ -1,6 +1,7 @@
11package main
22
33import (
4+ "crypto"
45 "fmt"
56 "net/url"
67 "os"
@@ -8,6 +9,7 @@ import (
89 "strings"
910 "time"
1011
12+ "github.com/18F/hmacauth"
1113 "github.com/bitly/oauth2_proxy/providers"
1214)
1315
@@ -60,11 +62,19 @@ type Options struct {
6062
6163 RequestLogging bool `flag:"request-logging" cfg:"request_logging"`
6264
65+ SignatureKey string `flag:"signature-key" cfg:"signature_key"`
66+
6367 // internal values that are set after config validation
6468 redirectURL *url.URL
6569 proxyURLs []*url.URL
6670 CompiledRegex []*regexp.Regexp
6771 provider providers.Provider
72+ signatureData *SignatureData
73+}
74+
75+type SignatureData struct {
76+ hash crypto.Hash
77+ key string
6878}
6979
7080func NewOptions() *Options {
@@ -175,6 +185,8 @@ func (o *Options) Validate() error {
175185 }
176186 }
177187
188+ msgs = parseSignatureKey(o, msgs)
189+
178190 if len(msgs) != 0 {
179191 return fmt.Errorf("Invalid configuration:\n %s",
180192 strings.Join(msgs, "\n "))
@@ -210,3 +222,24 @@ func parseProviderInfo(o *Options, msgs []string) []string {
210222 }
211223 return msgs
212224}
225+
226+func parseSignatureKey(o *Options, msgs []string) []string {
227+ if o.SignatureKey == "" {
228+ return msgs
229+ }
230+
231+ components := strings.Split(o.SignatureKey, ":")
232+ if len(components) != 2 {
233+ return append(msgs, "invalid signature hash:key spec: "+
234+ o.SignatureKey)
235+ }
236+
237+ algorithm, secretKey := components[0], components[1]
238+ if hash, err := hmacauth.DigestNameToCryptoHash(algorithm); err != nil {
239+ return append(msgs, "unsupported signature hash algorithm: "+
240+ o.SignatureKey)
241+ } else {
242+ o.signatureData = &SignatureData{hash, secretKey}
243+ }
244+ return msgs
245+}
options_test.go+25
@@ -1,6 +1,7 @@
11package main
22
33import (
4+ "crypto"
45 "net/url"
56 "strings"
67 "testing"
@@ -166,3 +167,27 @@ func TestCookieRefreshMustBeLessThanCookieExpire(t *testing.T) {
166167 o.CookieRefresh -= time.Duration(1)
167168 assert.Equal(t, nil, o.Validate())
168169}
170+
171+func TestValidateSignatureKey(t *testing.T) {
172+ o := testOptions()
173+ o.SignatureKey = "sha1:secret"
174+ assert.Equal(t, nil, o.Validate())
175+ assert.Equal(t, o.signatureData.hash, crypto.SHA1)
176+ assert.Equal(t, o.signatureData.key, "secret")
177+}
178+
179+func TestValidateSignatureKeyInvalidSpec(t *testing.T) {
180+ o := testOptions()
181+ o.SignatureKey = "invalid spec"
182+ err := o.Validate()
183+ assert.Equal(t, err.Error(), "Invalid configuration:\n"+
184+ " invalid signature hash:key spec: "+o.SignatureKey)
185+}
186+
187+func TestValidateSignatureKeyUnsupportedAlgorithm(t *testing.T) {
188+ o := testOptions()
189+ o.SignatureKey = "unsupported:default secret"
190+ err := o.Validate()
191+ assert.Equal(t, err.Error(), "Invalid configuration:\n"+
192+ " unsupported signature hash algorithm: "+o.SignatureKey)
193+}