From 1f0cb4ae442b2881282527ad4ed92a1f951def2d Mon Sep 17 00:00:00 2001 From: Brian Van Klaveren Date: Thu, 9 May 2019 16:09:22 -0700 Subject: [PATCH] Add support for a redis session store --- Gopkg.lock | 61 +++++++ Gopkg.toml | 8 + pkg/apis/options/sessions.go | 10 ++ pkg/sessions/redis/redis_store.go | 271 +++++++++++++++++++++++++++++ pkg/sessions/session_store.go | 3 + pkg/sessions/session_store_test.go | 88 ++++++++-- 6 files changed, 427 insertions(+), 14 deletions(-) create mode 100644 pkg/sessions/redis/redis_store.go diff --git a/Gopkg.lock b/Gopkg.lock index 01af4d2..e8bb95e 100644 --- a/Gopkg.lock +++ b/Gopkg.lock @@ -17,6 +17,25 @@ revision = "b26d9c308763d68093482582cea63d69be07a0f0" version = "v0.3.0" +[[projects]] + branch = "master" + digest = "1:3cce78d5d0090e3f1162945fba60ba74e72e8422e8e41bb9c701afb67237bb65" + name = "github.com/alicebob/gopher-json" + packages = ["."] + pruneopts = "" + revision = "5a6b3ba71ee69b77cf64febf8b5a7526ca5eaef0" + +[[projects]] + digest = "1:18a07506ddaa87b1612bfd69eef03f510faf122398df3da774d46dcfe751a060" + name = "github.com/alicebob/miniredis" + packages = [ + ".", + "server", + ] + pruneopts = "" + revision = "3d7aa1333af56ab862d446678d93aaa6803e0938" + version = "v2.7.0" + [[projects]] digest = "1:512883404c2a99156e410e9880e3bb35ecccc0c07c1159eb204b5f3ef3c431b3" name = "github.com/bitly/go-simplejson" @@ -49,6 +68,22 @@ revision = "06ea1031745cb8b3dab3f6a236daf2b0aa468b7e" version = "v3.2.0" +[[projects]] + digest = "1:8c7410dae63c74bd92db09bf33af7e0698b635ab6a397fd8e9e10dfcce3138ac" + name = "github.com/go-redis/redis" + packages = [ + ".", + "internal", + "internal/consistenthash", + "internal/hashtag", + "internal/pool", + "internal/proto", + "internal/util", + ] + pruneopts = "" + revision = "d22fde8721cc915a55aeb6b00944a76a92bfeb6e" + version = "v6.15.2" + [[projects]] branch = "master" digest = "1:3b760d3b93f994df8eb1d9ebfad17d3e9e37edcb7f7efaa15b427c0d7a64f4e4" @@ -57,6 +92,17 @@ pruneopts = "" revision = "1e59b77b52bf8e4b449a57e6f79f21226d571845" +[[projects]] + digest = "1:dcf8316121302735c0ac84e05f4686e3b34e284444435e9a206da48d8be18cb1" + name = "github.com/gomodule/redigo" + packages = [ + "internal", + "redis", + ] + pruneopts = "" + revision = "9c11da706d9b7902c6da69c592f75637793fe121" + version = "v2.0.0" + [[projects]] digest = "1:b3c5b95e56c06f5aa72cb2500e6ee5f44fcd122872d4fec2023a488e561218bc" name = "github.com/hpcloud/tail" @@ -173,6 +219,19 @@ pruneopts = "" revision = "1d66fa95c997864ba4d8479f56609620fe542928" +[[projects]] + branch = "master" + digest = "1:378d29a839ff770e9d9150580b4c01ff0a513a296b0487558a7af7c18adab98e" + name = "github.com/yuin/gopher-lua" + packages = [ + ".", + "ast", + "parse", + "pm", + ] + pruneopts = "" + revision = "8bfc7677f583b35a5663a9dd934c08f3b5774bbb" + [[projects]] branch = "master" digest = "1:f6a006d27619a4d93bf9b66fe1999b8c8d1fa62bdc63af14f10fbe6fcaa2aa1a" @@ -341,9 +400,11 @@ analyzer-version = 1 input-imports = [ "github.com/BurntSushi/toml", + "github.com/alicebob/miniredis", "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/onsi/ginkgo", diff --git a/Gopkg.toml b/Gopkg.toml index 732bbcb..7c90984 100644 --- a/Gopkg.toml +++ b/Gopkg.toml @@ -46,3 +46,11 @@ [[constraint]] name = "gopkg.in/natefinch/lumberjack.v2" version = "2.1.0" + +[[constraint]] + branch = "master" + name = "github.com/go-redis/redis" + +[[constraint]] + name = "github.com/alicebob/miniredis" + version = "2.7.0" diff --git a/pkg/apis/options/sessions.go b/pkg/apis/options/sessions.go index 7d33f14..591cc37 100644 --- a/pkg/apis/options/sessions.go +++ b/pkg/apis/options/sessions.go @@ -9,6 +9,7 @@ type SessionOptions struct { Type string `flag:"session-store-type" cfg:"session_store_type" env:"OAUTH2_PROXY_SESSION_STORE_TYPE"` Cipher *cookie.Cipher CookieStoreOptions + RedisStoreOptions } // CookieSessionStoreType is used to indicate the CookieSessionStore should be @@ -17,3 +18,12 @@ var CookieSessionStoreType = "cookie" // CookieStoreOptions contains configuration options for the CookieSessionStore. type CookieStoreOptions struct{} + +// RedisSessionStoreType is used to indicate the CookieSessionStore should be +// used for storing sessions. +var RedisSessionStoreType = "redis" + +// RedisStoreOptions contains configuration options for the CookieSessionStore. +type RedisStoreOptions struct { + RedisConnectionURL string `flag:"redis-connection-url" cfg:"redis_connection_url" env:"OAUTH2_PROXY_REDIS_CONNECTION_URL"` +} diff --git a/pkg/sessions/redis/redis_store.go b/pkg/sessions/redis/redis_store.go new file mode 100644 index 0000000..74cb1c0 --- /dev/null +++ b/pkg/sessions/redis/redis_store.go @@ -0,0 +1,271 @@ +package redis + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "encoding/base64" + "encoding/hex" + "fmt" + "io" + "net/http" + "strings" + "time" + + "github.com/go-redis/redis" + "github.com/pusher/oauth2_proxy/cookie" + "github.com/pusher/oauth2_proxy/pkg/apis/options" + "github.com/pusher/oauth2_proxy/pkg/apis/sessions" + "github.com/pusher/oauth2_proxy/pkg/cookies" + "github.com/pusher/oauth2_proxy/pkg/sessions/utils" +) + +// TicketData is a structure representing a used in server session storage +type TicketData struct { + TicketID string + Secret []byte +} + +// SessionStore is an implementation of the sessions.SessionStore +// interface that stores sessions in redis +type SessionStore struct { + CookieCipher *cookie.Cipher + CookieDomain string + CookieExpire time.Duration + CookieHTTPOnly bool + CookieName string + CookiePath string + CookieSecret string + CookieSecure bool + Client *redis.Client +} + +// NewRedisSessionStore initialises a new instance of the SessionStore from +// the configuration given +func NewRedisSessionStore(opts options.RedisStoreOptions, cookieOpts *options.CookieOptions) (sessions.SessionStore, error) { + opt, err := redis.ParseURL(opts.RedisConnectionURL) + if err != nil { + return nil, fmt.Errorf("unable to parse redis url: %s", err) + } + + var cookieCipher *cookie.Cipher + if len(cookieOpts.CookieSecret) > 0 { + var err error + cookieCipher, err = cookie.NewCipher(utils.SecretBytes(cookieOpts.CookieSecret)) + if err != nil { + return nil, fmt.Errorf("unable to create cookieCipher: %v", err) + } + } + + client := redis.NewClient(opt) + + rs := &SessionStore{ + Client: client, + CookieCipher: cookieCipher, + CookieDomain: cookieOpts.CookieDomain, + CookieExpire: cookieOpts.CookieExpire, + CookieHTTPOnly: cookieOpts.CookieHTTPOnly, + CookieName: cookieOpts.CookieName, + CookiePath: cookieOpts.CookiePath, + CookieSecret: cookieOpts.CookieSecret, + CookieSecure: cookieOpts.CookieSecure, + } + return rs, nil + +} + +// Save takes a sessions.SessionState and stores the information from it +// to redies, and adds a new ticket cookie on the HTTP response writer +func (store *SessionStore) Save(rw http.ResponseWriter, req *http.Request, s *sessions.SessionState) error { + requestCookie, _ := req.Cookie(store.CookieName) + value, err := s.EncodeSessionState(store.CookieCipher) + if err != nil { + return err + } + ticketString, err := store.storeValue(value, s.ExpiresOn, requestCookie) + if err != nil { + return err + } + + ticketCookie := cookies.MakeCookie( + req, + store.CookieName, + ticketString, + store.CookiePath, + store.CookieDomain, + store.CookieHTTPOnly, + store.CookieSecure, + store.CookieExpire, + time.Now(), + ) + + http.SetCookie(rw, ticketCookie) + return nil +} + +// Load reads sessions.SessionState information from a ticket +// cookie within the HTTP request object +func (store *SessionStore) Load(req *http.Request) (*sessions.SessionState, error) { + requestCookie, _ := req.Cookie(store.CookieName) + // No cookie validation necessary + session, err := store.LoadSessionFromString(requestCookie.Value) + if err != nil { + return nil, fmt.Errorf("error loading session: %s", err) + } + return session, nil +} + +// LoadSessionFromString loads the session based on the ticket value +func (store *SessionStore) LoadSessionFromString(value string) (*sessions.SessionState, error) { + ticket, err := decodeTicket(store.CookieName, value) + if err != nil { + return nil, err + } + + result, err := store.Client.Get(ticket.asHandle(store.CookieName)).Result() + if err != nil { + return nil, err + } + + resultBytes := []byte(result) + block, err := aes.NewCipher(ticket.Secret) + if err != nil { + return nil, err + } + // Use secret as the IV too, because each entry has it's own key + stream := cipher.NewCFBDecrypter(block, ticket.Secret) + stream.XORKeyStream(resultBytes, resultBytes) + + session, err := sessions.DecodeSessionState(string(resultBytes), store.CookieCipher) + if err != nil { + return nil, err + } + return session, nil +} + +// Clear clears any saved session information for a given ticket cookie +// from redis, and then clears the session +func (store *SessionStore) Clear(rw http.ResponseWriter, req *http.Request) error { + requestCookie, _ := req.Cookie(store.CookieName) + + // We go ahead and clear the cookie first, always. + clearCookie := cookies.MakeCookie( + req, + store.CookieName, + "", + store.CookiePath, + store.CookieDomain, + store.CookieHTTPOnly, + store.CookieSecure, + time.Hour*-1, + time.Now(), + ) + http.SetCookie(rw, clearCookie) + + // We only return an error if we had an issue with redis + // If there's an issue decoding the ticket, ignore it + ticket, _ := decodeTicket(store.CookieName, requestCookie.Value) + if ticket != nil { + deleted, err := store.Client.Del(ticket.asHandle(store.CookieName)).Result() + fmt.Println("delted %n", deleted) + if err != nil { + return fmt.Errorf("error clearing cookie from redis: %s", err) + } + } + return nil +} + +func (store *SessionStore) storeValue(value string, expiresOn time.Time, requestCookie *http.Cookie) (string, error) { + var ticket *TicketData + if requestCookie != nil { + var err error + ticket, err = decodeTicket(store.CookieName, requestCookie.Value) + if err != nil { + return "", err + } + } else { + var err error + ticket, err = newTicket() + if err != nil { + return "", fmt.Errorf("error creating new ticket: %s", err) + } + } + + ciphertext := make([]byte, len(value)) + block, err := aes.NewCipher(ticket.Secret) + if err != nil { + return "", fmt.Errorf("error initiating cipher block %s", err) + } + + // Use secret as the IV too, because each entry has it's own key + stream := cipher.NewCFBEncrypter(block, ticket.Secret) + stream.XORKeyStream(ciphertext, []byte(value)) + + handle := ticket.asHandle(store.CookieName) + expires := expiresOn.Sub(time.Now()) + err = store.Client.Set(handle, ciphertext, expires).Err() + if err != nil { + return "", err + } + return ticket.encodeTicket(store.CookieName), nil +} + +func newTicket() (*TicketData, error) { + rawID := make([]byte, 16) + if _, err := io.ReadFull(rand.Reader, rawID); err != nil { + return nil, fmt.Errorf("failed to create new ticket ID %s", err) + } + // ticketID is hex encoded + ticketID := fmt.Sprintf("%x", rawID) + + secret := make([]byte, aes.BlockSize) + if _, err := io.ReadFull(rand.Reader, secret); err != nil { + return nil, fmt.Errorf("failed to create initialization vector %s", err) + } + ticket := &TicketData{ + TicketID: ticketID, + Secret: secret, + } + return ticket, nil +} + +func (ticket *TicketData) asHandle(prefix string) string { + return fmt.Sprintf("%s-%s", prefix, ticket.TicketID) +} + +func decodeTicket(cookieName string, ticketString string) (*TicketData, error) { + prefix := cookieName + "-" + if !strings.HasPrefix(ticketString, prefix) { + return nil, fmt.Errorf("failed to decode ticket handle") + } + trimmedTicket := strings.TrimPrefix(ticketString, prefix) + + ticketParts := strings.Split(trimmedTicket, ".") + if len(ticketParts) != 2 { + return nil, fmt.Errorf("failed to decode ticket") + } + ticketID, secretBase64 := ticketParts[0], ticketParts[1] + + // ticketID must be a hexadecimal string + _, err := hex.DecodeString(ticketID) + if err != nil { + return nil, fmt.Errorf("server ticket failed sanity checks") + // s is not a valid + } + + secret, err := base64.RawURLEncoding.DecodeString(secretBase64) + if err != nil { + return nil, fmt.Errorf("failed to decode initialization vector %s", err) + } + ticketData := &TicketData{ + TicketID: ticketID, + Secret: secret, + } + return ticketData, nil +} + +func (ticket *TicketData) encodeTicket(prefix string) string { + handle := ticket.asHandle(prefix) + ticketString := handle + "." + base64.RawURLEncoding.EncodeToString(ticket.Secret) + return ticketString +} diff --git a/pkg/sessions/session_store.go b/pkg/sessions/session_store.go index ec84dd7..e8e229a 100644 --- a/pkg/sessions/session_store.go +++ b/pkg/sessions/session_store.go @@ -6,6 +6,7 @@ import ( "github.com/pusher/oauth2_proxy/pkg/apis/options" "github.com/pusher/oauth2_proxy/pkg/apis/sessions" "github.com/pusher/oauth2_proxy/pkg/sessions/cookie" + "github.com/pusher/oauth2_proxy/pkg/sessions/redis" ) // NewSessionStore creates a SessionStore from the provided configuration @@ -13,6 +14,8 @@ func NewSessionStore(opts *options.SessionOptions, cookieOpts *options.CookieOpt switch opts.Type { case options.CookieSessionStoreType: return cookie.NewCookieSessionStore(opts, cookieOpts) + case options.RedisSessionStoreType: + return redis.NewRedisSessionStore(opts.RedisStoreOptions, cookieOpts) default: return nil, fmt.Errorf("unknown session store type '%s'", opts.Type) } diff --git a/pkg/sessions/session_store_test.go b/pkg/sessions/session_store_test.go index b407d58..47780f6 100644 --- a/pkg/sessions/session_store_test.go +++ b/pkg/sessions/session_store_test.go @@ -10,14 +10,15 @@ import ( "testing" "time" + "github.com/alicebob/miniredis" . "github.com/onsi/ginkgo" . "github.com/onsi/gomega" "github.com/pusher/oauth2_proxy/cookie" "github.com/pusher/oauth2_proxy/pkg/apis/options" sessionsapi "github.com/pusher/oauth2_proxy/pkg/apis/sessions" - "github.com/pusher/oauth2_proxy/pkg/cookies" "github.com/pusher/oauth2_proxy/pkg/sessions" sessionscookie "github.com/pusher/oauth2_proxy/pkg/sessions/cookie" + "github.com/pusher/oauth2_proxy/pkg/sessions/redis" "github.com/pusher/oauth2_proxy/pkg/sessions/utils" ) @@ -109,22 +110,18 @@ var _ = Describe("NewSessionStore", func() { Context("when Clear is called", func() { BeforeEach(func() { - cookie := cookies.MakeCookie(request, - cookieOpts.CookieName, - "foo", - cookieOpts.CookiePath, - cookieOpts.CookieDomain, - cookieOpts.CookieHTTPOnly, - cookieOpts.CookieSecure, - cookieOpts.CookieExpire, - time.Now(), - ) - request.AddCookie(cookie) - err := ss.Clear(response, request) + req := httptest.NewRequest("GET", "http://example.com/", nil) + saveResp := httptest.NewRecorder() + err := ss.Save(saveResp, req, session) + Expect(err).ToNot(HaveOccurred()) + + resultCookie := saveResp.Result().Cookies()[0] + request.AddCookie(resultCookie) + err = ss.Clear(response, request) Expect(err).ToNot(HaveOccurred()) }) - It("sets a `set-cookie` header in the response", func() { + It("sets a `set-cookie` header in the response and session to not be loadable after clear", func() { Expect(response.Header().Get("Set-Cookie")).ToNot(BeEmpty()) }) @@ -171,6 +168,35 @@ var _ = Describe("NewSessionStore", func() { }) } + PersistentSessionStoreTests := func() { + Context("when Clear is called the session should not be recoverable", func() { + var loadedAfterClear *sessionsapi.SessionState + BeforeEach(func() { + req := httptest.NewRequest("GET", "http://example.com/", nil) + saveResp := httptest.NewRecorder() + err := ss.Save(saveResp, req, session) + Expect(err).ToNot(HaveOccurred()) + + resultCookie := saveResp.Result().Cookies()[0] + request.AddCookie(resultCookie) + err = ss.Clear(response, request) + Expect(err).ToNot(HaveOccurred()) + + // The following should only be for server stores + loadReq := httptest.NewRequest("GET", "http://example.com/", nil) + loadReq.AddCookie(resultCookie) + loadedAfterClear, err = ss.Load(loadReq) + }) + + It("sets a `set-cookie` header in the response and session to not be loadable after clear", func() { + Expect(response.Header().Get("Set-Cookie")).ToNot(BeEmpty()) + Expect(loadedAfterClear).To(BeNil()) + }) + + CheckCookieOptions() + }) + } + RunSessionTests := func() { Context("with default options", func() { BeforeEach(func() { @@ -221,6 +247,18 @@ var _ = Describe("NewSessionStore", func() { }) } + RunPersistentSessionStoreTests := func() { + Context("with default options", func() { + BeforeEach(func() { + var err error + ss, err = sessions.NewSessionStore(opts, cookieOpts) + Expect(err).ToNot(HaveOccurred()) + }) + + PersistentSessionStoreTests() + }) + } + BeforeEach(func() { ss = nil opts = &options.SessionOptions{} @@ -264,6 +302,28 @@ var _ = Describe("NewSessionStore", func() { }) }) + Context("with type 'redis'", func() { + BeforeEach(func() { + mr, err := miniredis.Run() + if err != nil { + panic(err) + } + opts.Type = options.RedisSessionStoreType + opts.RedisConnectionURL = "redis://" + mr.Addr() + }) + + It("creates a redis.SessionStore", func() { + ss, err := sessions.NewSessionStore(opts, cookieOpts) + Expect(err).NotTo(HaveOccurred()) + Expect(ss).To(BeAssignableToTypeOf(&redis.SessionStore{})) + }) + + Context("the redis.SessionStore", func() { + RunSessionTests() + RunPersistentSessionStoreTests() + }) + }) + Context("with an invalid type", func() { BeforeEach(func() { opts.Type = "invalid-type"