mirror of
https://github.com/oauth2-proxy/oauth2-proxy.git
synced 2026-10-09 07:55:35 +02:00
Add support for a redis session store
This commit is contained in:
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user