Add support for a redis session store

This commit is contained in:
Brian Van Klaveren 2019-05-09 16:09:22 -07:00
parent e881612ea6
commit b1bd3280db
6 changed files with 427 additions and 14 deletions

61
Gopkg.lock generated
View File

@ -17,6 +17,25 @@
revision = "b26d9c308763d68093482582cea63d69be07a0f0" revision = "b26d9c308763d68093482582cea63d69be07a0f0"
version = "v0.3.0" 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]] [[projects]]
digest = "1:512883404c2a99156e410e9880e3bb35ecccc0c07c1159eb204b5f3ef3c431b3" digest = "1:512883404c2a99156e410e9880e3bb35ecccc0c07c1159eb204b5f3ef3c431b3"
name = "github.com/bitly/go-simplejson" name = "github.com/bitly/go-simplejson"
@ -49,6 +68,22 @@
revision = "06ea1031745cb8b3dab3f6a236daf2b0aa468b7e" revision = "06ea1031745cb8b3dab3f6a236daf2b0aa468b7e"
version = "v3.2.0" 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]] [[projects]]
branch = "master" branch = "master"
digest = "1:3b760d3b93f994df8eb1d9ebfad17d3e9e37edcb7f7efaa15b427c0d7a64f4e4" digest = "1:3b760d3b93f994df8eb1d9ebfad17d3e9e37edcb7f7efaa15b427c0d7a64f4e4"
@ -57,6 +92,17 @@
pruneopts = "" pruneopts = ""
revision = "1e59b77b52bf8e4b449a57e6f79f21226d571845" revision = "1e59b77b52bf8e4b449a57e6f79f21226d571845"
[[projects]]
digest = "1:dcf8316121302735c0ac84e05f4686e3b34e284444435e9a206da48d8be18cb1"
name = "github.com/gomodule/redigo"
packages = [
"internal",
"redis",
]
pruneopts = ""
revision = "9c11da706d9b7902c6da69c592f75637793fe121"
version = "v2.0.0"
[[projects]] [[projects]]
digest = "1:b3c5b95e56c06f5aa72cb2500e6ee5f44fcd122872d4fec2023a488e561218bc" digest = "1:b3c5b95e56c06f5aa72cb2500e6ee5f44fcd122872d4fec2023a488e561218bc"
name = "github.com/hpcloud/tail" name = "github.com/hpcloud/tail"
@ -173,6 +219,19 @@
pruneopts = "" pruneopts = ""
revision = "1d66fa95c997864ba4d8479f56609620fe542928" revision = "1d66fa95c997864ba4d8479f56609620fe542928"
[[projects]]
branch = "master"
digest = "1:378d29a839ff770e9d9150580b4c01ff0a513a296b0487558a7af7c18adab98e"
name = "github.com/yuin/gopher-lua"
packages = [
".",
"ast",
"parse",
"pm",
]
pruneopts = ""
revision = "8bfc7677f583b35a5663a9dd934c08f3b5774bbb"
[[projects]] [[projects]]
branch = "master" branch = "master"
digest = "1:f6a006d27619a4d93bf9b66fe1999b8c8d1fa62bdc63af14f10fbe6fcaa2aa1a" digest = "1:f6a006d27619a4d93bf9b66fe1999b8c8d1fa62bdc63af14f10fbe6fcaa2aa1a"
@ -341,9 +400,11 @@
analyzer-version = 1 analyzer-version = 1
input-imports = [ input-imports = [
"github.com/BurntSushi/toml", "github.com/BurntSushi/toml",
"github.com/alicebob/miniredis",
"github.com/bitly/go-simplejson", "github.com/bitly/go-simplejson",
"github.com/coreos/go-oidc", "github.com/coreos/go-oidc",
"github.com/dgrijalva/jwt-go", "github.com/dgrijalva/jwt-go",
"github.com/go-redis/redis",
"github.com/mbland/hmacauth", "github.com/mbland/hmacauth",
"github.com/mreiferson/go-options", "github.com/mreiferson/go-options",
"github.com/onsi/ginkgo", "github.com/onsi/ginkgo",

View File

@ -46,3 +46,11 @@
[[constraint]] [[constraint]]
name = "gopkg.in/natefinch/lumberjack.v2" name = "gopkg.in/natefinch/lumberjack.v2"
version = "2.1.0" version = "2.1.0"
[[constraint]]
branch = "master"
name = "github.com/go-redis/redis"
[[constraint]]
name = "github.com/alicebob/miniredis"
version = "2.7.0"

View File

@ -9,6 +9,7 @@ type SessionOptions struct {
Type string `flag:"session-store-type" cfg:"session_store_type" env:"OAUTH2_PROXY_SESSION_STORE_TYPE"` Type string `flag:"session-store-type" cfg:"session_store_type" env:"OAUTH2_PROXY_SESSION_STORE_TYPE"`
Cipher *cookie.Cipher Cipher *cookie.Cipher
CookieStoreOptions CookieStoreOptions
RedisStoreOptions
} }
// CookieSessionStoreType is used to indicate the CookieSessionStore should be // CookieSessionStoreType is used to indicate the CookieSessionStore should be
@ -17,3 +18,12 @@ var CookieSessionStoreType = "cookie"
// CookieStoreOptions contains configuration options for the CookieSessionStore. // CookieStoreOptions contains configuration options for the CookieSessionStore.
type CookieStoreOptions struct{} 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"`
}

View File

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

View File

@ -6,6 +6,7 @@ import (
"github.com/pusher/oauth2_proxy/pkg/apis/options" "github.com/pusher/oauth2_proxy/pkg/apis/options"
"github.com/pusher/oauth2_proxy/pkg/apis/sessions" "github.com/pusher/oauth2_proxy/pkg/apis/sessions"
"github.com/pusher/oauth2_proxy/pkg/sessions/cookie" "github.com/pusher/oauth2_proxy/pkg/sessions/cookie"
"github.com/pusher/oauth2_proxy/pkg/sessions/redis"
) )
// NewSessionStore creates a SessionStore from the provided configuration // NewSessionStore creates a SessionStore from the provided configuration
@ -13,6 +14,8 @@ func NewSessionStore(opts *options.SessionOptions, cookieOpts *options.CookieOpt
switch opts.Type { switch opts.Type {
case options.CookieSessionStoreType: case options.CookieSessionStoreType:
return cookie.NewCookieSessionStore(opts, cookieOpts) return cookie.NewCookieSessionStore(opts, cookieOpts)
case options.RedisSessionStoreType:
return redis.NewRedisSessionStore(opts.RedisStoreOptions, cookieOpts)
default: default:
return nil, fmt.Errorf("unknown session store type '%s'", opts.Type) return nil, fmt.Errorf("unknown session store type '%s'", opts.Type)
} }

View File

@ -10,14 +10,15 @@ import (
"testing" "testing"
"time" "time"
"github.com/alicebob/miniredis"
. "github.com/onsi/ginkgo" . "github.com/onsi/ginkgo"
. "github.com/onsi/gomega" . "github.com/onsi/gomega"
"github.com/pusher/oauth2_proxy/cookie" "github.com/pusher/oauth2_proxy/cookie"
"github.com/pusher/oauth2_proxy/pkg/apis/options" "github.com/pusher/oauth2_proxy/pkg/apis/options"
sessionsapi "github.com/pusher/oauth2_proxy/pkg/apis/sessions" sessionsapi "github.com/pusher/oauth2_proxy/pkg/apis/sessions"
"github.com/pusher/oauth2_proxy/pkg/cookies"
"github.com/pusher/oauth2_proxy/pkg/sessions" "github.com/pusher/oauth2_proxy/pkg/sessions"
sessionscookie "github.com/pusher/oauth2_proxy/pkg/sessions/cookie" 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" "github.com/pusher/oauth2_proxy/pkg/sessions/utils"
) )
@ -109,22 +110,18 @@ var _ = Describe("NewSessionStore", func() {
Context("when Clear is called", func() { Context("when Clear is called", func() {
BeforeEach(func() { BeforeEach(func() {
cookie := cookies.MakeCookie(request, req := httptest.NewRequest("GET", "http://example.com/", nil)
cookieOpts.CookieName, saveResp := httptest.NewRecorder()
"foo", err := ss.Save(saveResp, req, session)
cookieOpts.CookiePath, Expect(err).ToNot(HaveOccurred())
cookieOpts.CookieDomain,
cookieOpts.CookieHTTPOnly, resultCookie := saveResp.Result().Cookies()[0]
cookieOpts.CookieSecure, request.AddCookie(resultCookie)
cookieOpts.CookieExpire, err = ss.Clear(response, request)
time.Now(),
)
request.AddCookie(cookie)
err := ss.Clear(response, request)
Expect(err).ToNot(HaveOccurred()) 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()) 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() { RunSessionTests := func() {
Context("with default options", func() { Context("with default options", func() {
BeforeEach(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() { BeforeEach(func() {
ss = nil ss = nil
opts = &options.SessionOptions{} 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() { Context("with an invalid type", func() {
BeforeEach(func() { BeforeEach(func() {
opts.Type = "invalid-type" opts.Type = "invalid-type"