diff --git a/Gopkg.lock b/Gopkg.lock index 51d8342ccd..6a8ad58e0e 100644 --- a/Gopkg.lock +++ b/Gopkg.lock @@ -49,6 +49,22 @@ revision = "06ea1031745cb8b3dab3f6a236daf2b0aa468b7e" version = "v3.2.0" +[[projects]] + branch = "master" + digest = "1:edab2b668c427fadca75b629ba1cba5a759893cfc734a7210a2a26745e0a86ac" + name = "github.com/go-redis/redis" + packages = [ + ".", + "internal", + "internal/consistenthash", + "internal/hashtag", + "internal/pool", + "internal/proto", + "internal/util", + ] + pruneopts = "" + revision = "4b1665dfdc17efd2d1f450a6661a472d121364b1" + [[projects]] branch = "master" digest = "1:3b760d3b93f994df8eb1d9ebfad17d3e9e37edcb7f7efaa15b427c0d7a64f4e4" @@ -210,6 +226,7 @@ "github.com/bitly/go-simplejson", "github.com/coreos/go-oidc", "github.com/dgrijalva/jwt-go", + "github.com/go-redis/redis", "github.com/mbland/hmacauth", "github.com/mreiferson/go-options", "github.com/stretchr/testify/assert", diff --git a/Gopkg.toml b/Gopkg.toml index 253f154a5a..516541c3ef 100644 --- a/Gopkg.toml +++ b/Gopkg.toml @@ -15,6 +15,10 @@ branch = "v2" name = "github.com/coreos/go-oidc" +[[constraint]] + branch = "master" + name = "github.com/go-redis/redis" + [[constraint]] branch = "master" name = "github.com/mreiferson/go-options" diff --git a/cookie/cookies_store.go b/cookie/cookies_store.go new file mode 100644 index 0000000000..9c080914ef --- /dev/null +++ b/cookie/cookies_store.go @@ -0,0 +1,145 @@ +package cookie + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "crypto/sha1" + "encoding/base64" + "encoding/hex" + "fmt" + "github.com/go-redis/redis" + "io" + "net/http" + "strings" + "time" +) + +// ServerCookiesStore is the interface to storing cookies. +// It takes in cookies +type ServerCookiesStore interface { + Store(responseCookie *http.Cookie, requestCookie *http.Cookie) (string, error) + Clear(requestCookie *http.Cookie) error + Load(requestCookie *http.Cookie) (string, error) +} + +type RedisCookieStore struct { + Client *redis.Client + Block cipher.Block + Prefix string +} + +func NewRedisCookieStore(url string, cookieName string, block cipher.Block) (*RedisCookieStore, error) { + opt, err := redis.ParseURL(url) + if err != nil { + panic(err) + } + + client := redis.NewClient(opt) + + rs := &RedisCookieStore{ + Client: client, + Prefix: cookieName, + Block: block, + } + // Create client as usually. + return rs, nil +} + +// Store stores the cookie locally and returns a new response cookie value to be +// sent back to the client. That value is used to lookup the cookie later. +func (store *RedisCookieStore) Store(responseCookie *http.Cookie, requestCookie *http.Cookie) (string, error) { + var cookieHandle string + var iv []byte + if requestCookie != nil { + var err error + cookieHandle, iv, err = parseCookieTicket(store.Prefix, requestCookie.Value) + if err != nil { + return "", err + } + } else { + hasher := sha1.New() + hasher.Write([]byte(responseCookie.Value)) + cookieId := fmt.Sprintf("%x", hasher.Sum(nil)) + iv = make([]byte, aes.BlockSize) + if _, err := io.ReadFull(rand.Reader, iv); err != nil { + return "", fmt.Errorf("failed to create initialization vector %s", err) + } + cookieHandle = fmt.Sprintf("%s-%s", store.Prefix, cookieId) + } + + ciphertext := make([]byte, len(responseCookie.Value)) + stream := cipher.NewCFBEncrypter(store.Block, iv) + stream.XORKeyStream(ciphertext, []byte(responseCookie.Value)) + + expires := responseCookie.Expires.Sub(time.Now()) + err := store.Client.Set(cookieHandle, ciphertext, expires).Err() + if err != nil { + return "", err + } + + cookieTicket := cookieHandle + "." + base64.RawURLEncoding.EncodeToString(iv) + return cookieTicket, nil +} + +// Clear takes in the client cookie from the request and uses it to +// clear any lingering server cookies, when possible. +func (store *RedisCookieStore) Clear(requestCookie *http.Cookie) error { + var err error + cookieHandle, _, err := parseCookieTicket(store.Prefix, requestCookie.Value) + if err != nil { + return err + } + + err = store.Client.Del(cookieHandle).Err() + if err != nil { + return err + } + return nil +} + +// Load takes in the client cookie from the request and uses it to lookup +// the stored value. +func (store *RedisCookieStore) Load(requestCookie *http.Cookie) (string, error) { + cookieHandle, iv, err := parseCookieTicket(store.Prefix, requestCookie.Value) + if err != nil { + return "", err + } + + result, err := store.Client.Get(cookieHandle).Result() + if err != nil { + return "", err + } + + resultBytes := []byte(result) + + stream := cipher.NewCFBDecrypter(store.Block, iv) + stream.XORKeyStream(resultBytes, resultBytes) + return string(resultBytes), nil +} + +func parseCookieTicket(expectedPrefix string, ticket string) (string, []byte, error) { + cookieParts := strings.Split(ticket, ".") + if len(cookieParts) != 2 { + return "", nil, fmt.Errorf("failed to decode cookie") + } + cookieHandle, ivBase64 := cookieParts[0], cookieParts[1] + handleParts := strings.Split(cookieHandle, "-") + if len(handleParts) != 2 { + return "", nil, fmt.Errorf("failed to decode cookie handle") + } + prefix, cookieId := handleParts[0], handleParts[1] + + // cookieId must be a hexadecimal string + _, err := hex.DecodeString(cookieId) + if err != nil || expectedPrefix != prefix { + return "", nil, fmt.Errorf("server cookie failed sanity checks") + // s is not a valid + } + + iv, err := base64.RawURLEncoding.DecodeString(ivBase64) + if err != nil { + return "", nil, fmt.Errorf("failed to decode initialization vector %s", err) + } + return cookieHandle, iv, nil +} diff --git a/main.go b/main.go index bd309b8be1..e8925fac66 100644 --- a/main.go +++ b/main.go @@ -50,6 +50,7 @@ func main() { flagSet.Duration("flush-interval", time.Duration(1)*time.Second, "period between response flushing when streaming responses") flagSet.Bool("skip-jwt-bearer-tokens", false, "will skip requests that have verified JWT bearer tokens") flagSet.Var(&jwtIssuers, "extra-jwt-issuers", "if skip-jwt-bearer-tokens is set, a list of extra JWT issuer=audience pairs (where the issuer URL has a .well-known/openid-configuration or a .well-known/jwks.json)") + flagSet.String("redis-connection-url", "", "connection url for redis (activates server-side cookies with redis backend)") flagSet.Var(&emailDomains, "email-domain", "authenticate emails with the specified domain (may be given multiple times). Use * to authenticate any email") flagSet.Var(&whitelistDomains, "whitelist-domain", "allowed domains for redirection after authentication. Prefix domain with a . to allow subdomains (eg .example.com)") diff --git a/oauthproxy.go b/oauthproxy.go index e35452725d..2b1be17926 100644 --- a/oauthproxy.go +++ b/oauthproxy.go @@ -5,6 +5,7 @@ import ( b64 "encoding/base64" "errors" "fmt" + "github.com/go-redis/redis" "html/template" "log" "net" @@ -236,6 +237,17 @@ func NewOAuthProxy(opts *Options, validator func(string) bool) *OAuthProxy { } } + var cookiesStore cookie.ServerCookiesStore + if opts.RedisConnectionUrl != "" { + var err error + cookiesStore, err = cookie.NewRedisCookieStore(opts.RedisConnectionUrl, opts.CookieName, cipher.Block) + if err != nil { + log.Fatal("redis-cookie-store: ", err) + } + parsed, _ := redis.ParseURL(opts.RedisConnectionUrl) + log.Printf("Redis Cookie Store enabled at %v/%v, oauth2_proxy issuing cookie tickets", parsed.Addr, parsed.DB) + } + return &OAuthProxy{ CookieName: opts.CookieName, CSRFCookieName: fmt.Sprintf("%v_%v", opts.CookieName, "csrf"), @@ -257,6 +269,7 @@ func NewOAuthProxy(opts *Options, validator func(string) bool) *OAuthProxy { AuthOnlyPath: fmt.Sprintf("%s/auth", opts.ProxyPrefix), ProxyPrefix: opts.ProxyPrefix, + whitelistDomains: opts.WhitelistDomains, provider: opts.provider, serveMux: serveMux, redirectURL: redirectURL, @@ -277,6 +290,7 @@ func NewOAuthProxy(opts *Options, validator func(string) bool) *OAuthProxy { CookieCipher: cipher, templates: loadTemplates(opts.CustomTemplatesDir), Footer: opts.Footer, + CookiesStore: cookiesStore, } } @@ -334,6 +348,26 @@ func (p *OAuthProxy) MakeSessionCookie(req *http.Request, value string, expirati value = cookie.SignedValue(p.CookieSeed, p.CookieName, value, now) } c := p.makeCookie(req, p.CookieName, value, expiration, now) + if p.CookiesStore != nil { + oldCookie, err := req.Cookie(p.CookieName) + // Proactively cleanup old cookies that might be hanging around + for _, requestCookie := range req.Cookies() { + if requestCookie.Name == p.CookieName { + err := p.CookiesStore.Clear(requestCookie) + if err != nil { + log.Printf("Unable to clear cookies: %s", err) + } + } + } + + // Store new cookie + newCookieValue, err := p.CookiesStore.Store(c, oldCookie) + if err != nil { + log.Printf("Unable to load cookie: %s", err) + } + responseCookie := p.makeCookie(req, p.CookieName, newCookieValue, p.CookieExpire, now) + return []*http.Cookie{responseCookie} + } if len(c.Value) > 4096-len(p.CookieName) { return splitCookie(c) } @@ -472,6 +506,12 @@ func (p *OAuthProxy) ClearSessionCookie(rw http.ResponseWriter, req *http.Reques var cookieNameRegex = regexp.MustCompile(fmt.Sprintf("^%s(_\\d+)?$", p.CookieName)) for _, c := range req.Cookies() { + if p.CookiesStore != nil && c.Name == p.CookieName { + err := p.CookiesStore.Clear(c) + if err != nil { + log.Printf("Unable to clear cookie: %s", err) + } + } if cookieNameRegex.MatchString(c.Name) { clearCookie := p.makeCookie(req, c.Name, "", time.Hour*-1, time.Now()) @@ -503,6 +543,15 @@ func (p *OAuthProxy) LoadCookiedSession(req *http.Request) (*providers.SessionSt // always http.ErrNoCookie return nil, age, fmt.Errorf("Cookie %q not present", p.CookieName) } + + if p.CookiesStore != nil { + // If using a cookie store, load the actual value + c.Value, err = p.CookiesStore.Load(c) + if err != nil { + return nil, age, fmt.Errorf("Unable to load cookie: %s", err) + } + } + val, timestamp, err := cookie.Validate(c, p.CookieSeed, p.CookieExpire) if err != nil { return nil, age, errors.New("Cookie Signature not valid") diff --git a/options.go b/options.go index 8d5ddc9d28..90b6778e93 100644 --- a/options.go +++ b/options.go @@ -16,6 +16,7 @@ import ( oidc "github.com/coreos/go-oidc" "github.com/dgrijalva/jwt-go" + "github.com/go-redis/redis" "github.com/mbland/hmacauth" "github.com/pusher/oauth2_proxy/providers" ) @@ -42,6 +43,7 @@ type Options struct { GoogleGroups []string `flag:"google-group" cfg:"google_group" env:"OAUTH2_PROXY_GOOGLE_GROUPS"` GoogleAdminEmail string `flag:"google-admin-email" cfg:"google_admin_email" env:"OAUTH2_PROXY_GOOGLE_ADMIN_EMAIL"` GoogleServiceAccountJSON string `flag:"google-service-account-json" cfg:"google_service_account_json" env:"OAUTH2_PROXY_GOOGLE_SERVICE_ACCOUNT_JSON"` + RedisConnectionUrl string `flag:"redis-connection-url" cfg:"redis_connection_url"` HtpasswdFile string `flag:"htpasswd-file" cfg:"htpasswd_file" env:"OAUTH2_PROXY_HTPASSWD_FILE"` DisplayHtpasswdForm bool `flag:"display-htpasswd-form" cfg:"display_htpasswd_form" env:"OAUTH2_PROXY_DISPLAY_HTPASSWD_FORM"` CustomTemplatesDir string `flag:"custom-templates-dir" cfg:"custom_templates_dir" env:"OAUTH2_PROXY_CUSTOM_TEMPLATES_DIR"` @@ -299,6 +301,13 @@ func (o *Options) Validate() error { } } + if o.RedisConnectionUrl != "" { + _, err := redis.ParseURL(o.RedisConnectionUrl) + if err != nil { + msgs = append(msgs, fmt.Sprintf("unable to parse redis url: %s", err)) + } + } + if o.CookieRefresh >= o.CookieExpire { msgs = append(msgs, fmt.Sprintf( "cookie_refresh (%s) must be less than "+