├── .gitignore ├── go.mod ├── LICENSE ├── README.md ├── go.sum ├── redisstore_test.go └── redisstore.go /.gitignore: -------------------------------------------------------------------------------- 1 | ### IntelliJ IDEA ### 2 | .idea 3 | *.iws 4 | *.iml 5 | *.ipr 6 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/rbcervilla/redisstore/v9 2 | 3 | go 1.17 4 | 5 | require ( 6 | github.com/gorilla/sessions v1.2.0 7 | github.com/redis/go-redis/v9 v9.0.2 8 | ) 9 | 10 | require ( 11 | github.com/cespare/xxhash/v2 v2.2.0 // indirect 12 | github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect 13 | github.com/gorilla/securecookie v1.1.1 // indirect 14 | ) 15 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Ruben Cervilla 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # RedisStore 2 | 3 | A [`Gorilla Sessions Store`](https://www.gorillatoolkit.org/pkg/sessions#Store) implementation backed by Redis. 4 | 5 | It uses [`go-redis`](https://github.com/go-redis/redis) as client to connect to Redis. 6 | 7 | If you are using **Redis 6** (client go-redis/v8), install redisstore/v8: 8 | 9 | ```shell 10 | go get github.com/rbcervilla/redisstore/v8 11 | ``` 12 | 13 | If you are using **Redis 7** (client go-redis/v9), install redisstore/v9: 14 | 15 | ```shell 16 | go get github.com/rbcervilla/redisstore/v9 17 | ``` 18 | 19 | ## Example 20 | ```go 21 | 22 | package main 23 | 24 | import ( 25 | "context" 26 | "github.com/go-redis/redis/v9" 27 | "github.com/gorilla/sessions" 28 | "github.com/rbcervilla/redisstore/v9" 29 | "log" 30 | "net/http" 31 | "net/http/httptest" 32 | ) 33 | 34 | func main() { 35 | 36 | client := redis.NewClient(&redis.Options{ 37 | Addr: "localhost:6379", 38 | }) 39 | 40 | // New default RedisStore 41 | store, err := redisstore.NewRedisStore(context.Background(), client) 42 | if err != nil { 43 | log.Fatal("failed to create redis store: ", err) 44 | } 45 | 46 | // Example changing configuration for sessions 47 | store.KeyPrefix("session_") 48 | store.Options(sessions.Options{ 49 | Path: "/path", 50 | Domain: "example.com", 51 | MaxAge: 86400 * 60, 52 | }) 53 | 54 | // Request y writer for testing 55 | req, _ := http.NewRequest("GET", "http://www.example.com", nil) 56 | w := httptest.NewRecorder() 57 | 58 | // Get session 59 | session, err := store.Get(req, "session-key") 60 | if err != nil { 61 | log.Fatal("failed getting session: ", err) 62 | } 63 | 64 | // Add a value 65 | session.Values["foo"] = "bar" 66 | 67 | // Save session 68 | if err = sessions.Save(req, w); err != nil { 69 | log.Fatal("failed saving session: ", err) 70 | } 71 | 72 | // Delete session (MaxAge <= 0) 73 | session.Options.MaxAge = -1 74 | if err = sessions.Save(req, w); err != nil { 75 | log.Fatal("failed deleting session: ", err) 76 | } 77 | } 78 | 79 | ``` -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/bsm/ginkgo/v2 v2.5.0 h1:aOAnND1T40wEdAtkGSkvSICWeQ8L3UASX7YVCqQx+eQ= 2 | github.com/bsm/ginkgo/v2 v2.5.0/go.mod h1:AiKlXPm7ItEHNc/2+OkrNG4E0ITzojb9/xWzvQ9XZ9w= 3 | github.com/bsm/gomega v1.20.0 h1:JhAwLmtRzXFTx2AkALSLa8ijZafntmhSoU63Ok18Uq8= 4 | github.com/bsm/gomega v1.20.0/go.mod h1:JifAceMQ4crZIWYUKrlGcmbN3bqHogVTADMD2ATsbwk= 5 | github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44= 6 | github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= 7 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 8 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 9 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 10 | github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= 11 | github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= 12 | github.com/gorilla/securecookie v1.1.1 h1:miw7JPhV+b/lAHSXz4qd/nN9jRiAFV5FwjeKyCS8BvQ= 13 | github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4= 14 | github.com/gorilla/sessions v1.2.0 h1:S7P+1Hm5V/AT9cjEcUD5uDaQSX0OE577aCXgoaKpYbQ= 15 | github.com/gorilla/sessions v1.2.0/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM= 16 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 17 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 18 | github.com/redis/go-redis/v9 v9.0.2 h1:BA426Zqe/7r56kCcvxYLWe1mkaz71LKF77GwgFzSxfE= 19 | github.com/redis/go-redis/v9 v9.0.2/go.mod h1:/xDTe9EF1LM61hek62Poq2nzQSGj0xSrEtEHbBQevps= 20 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 21 | github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= 22 | github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= 23 | github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 24 | github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= 25 | github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= 26 | github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= 27 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 28 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 29 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 30 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 31 | -------------------------------------------------------------------------------- /redisstore_test.go: -------------------------------------------------------------------------------- 1 | package redisstore 2 | 3 | import ( 4 | "context" 5 | "net/http" 6 | "net/http/httptest" 7 | "testing" 8 | 9 | "github.com/gorilla/sessions" 10 | "github.com/redis/go-redis/v9" 11 | ) 12 | 13 | const ( 14 | redisAddr = "localhost:6379" 15 | ) 16 | 17 | func TestNew(t *testing.T) { 18 | client := redis.NewClient(&redis.Options{ 19 | Addr: redisAddr, 20 | }) 21 | 22 | store, err := NewRedisStore(context.Background(), client) 23 | if err != nil { 24 | t.Fatal("failed to create redis store", err) 25 | } 26 | 27 | req, err := http.NewRequest("GET", "http://www.example.com", nil) 28 | if err != nil { 29 | t.Fatal("failed to create request", err) 30 | } 31 | 32 | session, err := store.New(req, "hello") 33 | if err != nil { 34 | t.Fatal("failed to create session", err) 35 | } 36 | if session.IsNew == false { 37 | t.Fatal("session is not new") 38 | } 39 | } 40 | 41 | func TestOptions(t *testing.T) { 42 | client := redis.NewClient(&redis.Options{ 43 | Addr: redisAddr, 44 | }) 45 | 46 | store, err := NewRedisStore(context.Background(), client) 47 | if err != nil { 48 | t.Fatal("failed to create redis store", err) 49 | } 50 | 51 | opts := sessions.Options{ 52 | Path: "/path", 53 | MaxAge: 99999, 54 | } 55 | store.Options(opts) 56 | 57 | req, err := http.NewRequest("GET", "http://www.example.com", nil) 58 | if err != nil { 59 | t.Fatal("failed to create request", err) 60 | } 61 | 62 | session, err := store.New(req, "hello") 63 | if err != nil { 64 | t.Fatal("failed to create store", err) 65 | } 66 | if session.Options.Path != opts.Path || session.Options.MaxAge != opts.MaxAge { 67 | t.Fatal("failed to set options") 68 | } 69 | } 70 | 71 | func TestSave(t *testing.T) { 72 | client := redis.NewClient(&redis.Options{ 73 | Addr: redisAddr, 74 | }) 75 | 76 | store, err := NewRedisStore(context.Background(), client) 77 | if err != nil { 78 | t.Fatal("failed to create redis store", err) 79 | } 80 | 81 | req, err := http.NewRequest("GET", "http://www.example.com", nil) 82 | if err != nil { 83 | t.Fatal("failed to create request", err) 84 | } 85 | w := httptest.NewRecorder() 86 | 87 | session, err := store.New(req, "hello") 88 | if err != nil { 89 | t.Fatal("failed to create session", err) 90 | } 91 | 92 | session.Values["key"] = "value" 93 | err = session.Save(req, w) 94 | if err != nil { 95 | t.Fatal("failed to save: ", err) 96 | } 97 | } 98 | 99 | func TestDelete(t *testing.T) { 100 | client := redis.NewClient(&redis.Options{ 101 | Addr: redisAddr, 102 | }) 103 | 104 | store, err := NewRedisStore(context.Background(), client) 105 | if err != nil { 106 | t.Fatal("failed to create redis store", err) 107 | } 108 | 109 | req, err := http.NewRequest("GET", "http://www.example.com", nil) 110 | if err != nil { 111 | t.Fatal("failed to create request", err) 112 | } 113 | w := httptest.NewRecorder() 114 | 115 | session, err := store.New(req, "hello") 116 | if err != nil { 117 | t.Fatal("failed to create session", err) 118 | } 119 | 120 | session.Values["key"] = "value" 121 | err = session.Save(req, w) 122 | if err != nil { 123 | t.Fatal("failed to save session: ", err) 124 | } 125 | 126 | session.Options.MaxAge = -1 127 | err = session.Save(req, w) 128 | if err != nil { 129 | t.Fatal("failed to delete session: ", err) 130 | } 131 | } 132 | 133 | func TestClose(t *testing.T) { 134 | client := redis.NewClient(&redis.Options{ 135 | Addr: redisAddr, 136 | }) 137 | 138 | cmd := client.Ping(context.Background()) 139 | err := cmd.Err() 140 | if err != nil { 141 | t.Fatal("connection is not opened") 142 | } 143 | 144 | store, err := NewRedisStore(context.Background(), client) 145 | if err != nil { 146 | t.Fatal("failed to create redis store", err) 147 | } 148 | 149 | err = store.Close() 150 | if err != nil { 151 | t.Fatal("failed to close") 152 | } 153 | 154 | cmd = client.Ping(context.Background()) 155 | if cmd.Err() == nil { 156 | t.Fatal("connection is properly closed") 157 | } 158 | } 159 | -------------------------------------------------------------------------------- /redisstore.go: -------------------------------------------------------------------------------- 1 | package redisstore 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "crypto/rand" 7 | "encoding/base32" 8 | "encoding/gob" 9 | "errors" 10 | "io" 11 | "net/http" 12 | "strings" 13 | "time" 14 | 15 | "github.com/gorilla/sessions" 16 | "github.com/redis/go-redis/v9" 17 | ) 18 | 19 | // RedisStore stores gorilla sessions in Redis 20 | type RedisStore struct { 21 | // client to connect to redis 22 | client redis.UniversalClient 23 | // default options to use when a new session is created 24 | options sessions.Options 25 | // key prefix with which the session will be stored 26 | keyPrefix string 27 | // key generator 28 | keyGen KeyGenFunc 29 | // session serializer 30 | serializer SessionSerializer 31 | } 32 | 33 | // KeyGenFunc defines a function used by store to generate a key 34 | type KeyGenFunc func() (string, error) 35 | 36 | // NewRedisStore returns a new RedisStore with default configuration 37 | func NewRedisStore(ctx context.Context, client redis.UniversalClient) (*RedisStore, error) { 38 | rs := &RedisStore{ 39 | options: sessions.Options{ 40 | Path: "/", 41 | MaxAge: 86400 * 30, 42 | }, 43 | client: client, 44 | keyPrefix: "session:", 45 | keyGen: generateRandomKey, 46 | serializer: GobSerializer{}, 47 | } 48 | 49 | return rs, rs.client.Ping(ctx).Err() 50 | } 51 | 52 | // Get returns a session for the given name after adding it to the registry. 53 | func (s *RedisStore) Get(r *http.Request, name string) (*sessions.Session, error) { 54 | return sessions.GetRegistry(r).Get(s, name) 55 | } 56 | 57 | // New returns a session for the given name without adding it to the registry. 58 | func (s *RedisStore) New(r *http.Request, name string) (*sessions.Session, error) { 59 | session := sessions.NewSession(s, name) 60 | opts := s.options 61 | session.Options = &opts 62 | session.IsNew = true 63 | 64 | c, err := r.Cookie(name) 65 | if err != nil { 66 | return session, nil 67 | } 68 | session.ID = c.Value 69 | 70 | err = s.load(r.Context(), session) 71 | if err == nil { 72 | session.IsNew = false 73 | } else if err == redis.Nil { 74 | err = nil // no data stored 75 | } 76 | return session, err 77 | } 78 | 79 | // Save adds a single session to the response. 80 | // 81 | // If the Options.MaxAge of the session is <= 0 then the session file will be 82 | // deleted from the store. With this process it enforces the properly 83 | // session cookie handling so no need to trust in the cookie management in the 84 | // web browser. 85 | func (s *RedisStore) Save(r *http.Request, w http.ResponseWriter, session *sessions.Session) error { 86 | // Delete if max-age is <= 0 87 | if session.Options.MaxAge <= 0 { 88 | if err := s.delete(r.Context(), session); err != nil { 89 | return err 90 | } 91 | http.SetCookie(w, sessions.NewCookie(session.Name(), "", session.Options)) 92 | return nil 93 | } 94 | 95 | if session.ID == "" { 96 | id, err := s.keyGen() 97 | if err != nil { 98 | return errors.New("redisstore: failed to generate session id") 99 | } 100 | session.ID = id 101 | } 102 | if err := s.save(r.Context(), session); err != nil { 103 | return err 104 | } 105 | 106 | http.SetCookie(w, sessions.NewCookie(session.Name(), session.ID, session.Options)) 107 | return nil 108 | } 109 | 110 | // Options set options to use when a new session is created 111 | func (s *RedisStore) Options(opts sessions.Options) { 112 | s.options = opts 113 | } 114 | 115 | // KeyPrefix sets the key prefix to store session in Redis 116 | func (s *RedisStore) KeyPrefix(keyPrefix string) { 117 | s.keyPrefix = keyPrefix 118 | } 119 | 120 | // KeyGen sets the key generator function 121 | func (s *RedisStore) KeyGen(f KeyGenFunc) { 122 | s.keyGen = f 123 | } 124 | 125 | // Serializer sets the session serializer to store session 126 | func (s *RedisStore) Serializer(ss SessionSerializer) { 127 | s.serializer = ss 128 | } 129 | 130 | // Close closes the Redis store 131 | func (s *RedisStore) Close() error { 132 | return s.client.Close() 133 | } 134 | 135 | // save writes session in Redis 136 | func (s *RedisStore) save(ctx context.Context, session *sessions.Session) error { 137 | b, err := s.serializer.Serialize(session) 138 | if err != nil { 139 | return err 140 | } 141 | 142 | return s.client.Set(ctx, s.keyPrefix+session.ID, b, time.Duration(session.Options.MaxAge)*time.Second).Err() 143 | } 144 | 145 | // load reads session from Redis 146 | func (s *RedisStore) load(ctx context.Context, session *sessions.Session) error { 147 | cmd := s.client.Get(ctx, s.keyPrefix+session.ID) 148 | if cmd.Err() != nil { 149 | return cmd.Err() 150 | } 151 | 152 | b, err := cmd.Bytes() 153 | if err != nil { 154 | return err 155 | } 156 | 157 | return s.serializer.Deserialize(b, session) 158 | } 159 | 160 | // delete deletes session in Redis 161 | func (s *RedisStore) delete(ctx context.Context, session *sessions.Session) error { 162 | return s.client.Del(ctx, s.keyPrefix+session.ID).Err() 163 | } 164 | 165 | // SessionSerializer provides an interface for serialize/deserialize a session 166 | type SessionSerializer interface { 167 | Serialize(s *sessions.Session) ([]byte, error) 168 | Deserialize(b []byte, s *sessions.Session) error 169 | } 170 | 171 | // Gob serializer 172 | type GobSerializer struct{} 173 | 174 | func (gs GobSerializer) Serialize(s *sessions.Session) ([]byte, error) { 175 | buf := new(bytes.Buffer) 176 | enc := gob.NewEncoder(buf) 177 | err := enc.Encode(s.Values) 178 | if err == nil { 179 | return buf.Bytes(), nil 180 | } 181 | return nil, err 182 | } 183 | 184 | func (gs GobSerializer) Deserialize(d []byte, s *sessions.Session) error { 185 | dec := gob.NewDecoder(bytes.NewBuffer(d)) 186 | return dec.Decode(&s.Values) 187 | } 188 | 189 | // generateRandomKey returns a new random key 190 | func generateRandomKey() (string, error) { 191 | k := make([]byte, 64) 192 | if _, err := io.ReadFull(rand.Reader, k); err != nil { 193 | return "", err 194 | } 195 | return strings.TrimRight(base32.StdEncoding.EncodeToString(k), "="), nil 196 | } 197 | --------------------------------------------------------------------------------