Sign Upstream requests with HMAC. closes #147

This commit is contained in:
Mike Bland 2015-11-15 22:08:30 -05:00 committed by Jehiah Czebotar
parent 7c241ec1fe
commit e4626c1360
7 changed files with 298 additions and 69 deletions

1
Godeps
View File

@ -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

View File

@ -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://]<addr>:<port> or unix://<path> to listen on for HTTP clients
-https-address=":443": <addr>:<port> 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.
<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>
```
## Adding a new Provider
Follow the examples in the [`providers` package](providers/) to define a new

View File

@ -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 {

View File

@ -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))
}

View File

@ -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")
}

View File

@ -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
}

View File

@ -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)
}