├── .gitignore ├── .travis.yml ├── LICENSE ├── Makefile ├── README.md ├── doc.go ├── example ├── auth.go ├── login.html └── secured │ └── index.html ├── seshcookie.go └── seshcookie_test.go /.gitignore: -------------------------------------------------------------------------------- 1 | *~ 2 | /example/authenticated 3 | /example/example 4 | /cover.out 5 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: go 2 | 3 | go: 4 | - 1.7.x 5 | - 1.8.x 6 | - 1.9.x 7 | - master 8 | 9 | script: 10 | - go test 11 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2011 Bobby Powers 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining 4 | a copy of this software and associated documentation files (the 5 | "Software"), to deal in the Software without restriction, including 6 | without limitation the rights to use, copy, modify, merge, publish, 7 | distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to 9 | the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be 12 | included in all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 17 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 18 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 19 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 20 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | 2 | COV_FILE = cover.out 3 | 4 | # quiet output, but allow us to look at what commands are being 5 | # executed by passing 'V=1' to make, without requiring temporarily 6 | # editing the Makefile. 7 | ifneq ($V, 1) 8 | MAKEFLAGS += -s 9 | endif 10 | 11 | # GNU make, you are the worst. 12 | .SUFFIXES: 13 | %: %,v 14 | %: RCS/%,v 15 | %: RCS/% 16 | %: s.% 17 | %: SCCS/s.% 18 | 19 | 20 | all: 21 | go test 22 | go install 23 | 24 | cover coverage: 25 | go test -covermode atomic -coverprofile $(COV_FILE) 26 | go tool cover -html=$(COV_FILE) 27 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | seshcookie - cookie-based sessions for Go 2 | ========================================= 3 | 4 | [![Build Status](https://travis-ci.org/bpowers/seshcookie.svg?branch=master)](https://travis-ci.org/bpowers/seshcookie) 5 | [![GoDoc](https://godoc.org/github.com/bpowers/seshcookie?status.svg)](https://godoc.org/github.com/bpowers/seshcookie) 6 | [![cover.run](https://cover.run/go/github.com/bpowers/seshcookie.svg?style=flat&tag=golang-1.10)](https://cover.run/go?tag=golang-1.10&repo=github.com%2Fbpowers%2Fseshcookie) 7 | [![Go Report Card](https://goreportcard.com/badge/github.com/bpowers/seshcookie)](https://goreportcard.com/report/github.com/bpowers/seshcookie) 8 | 9 | seshcookie enables you to associate session-state with HTTP requests 10 | while keeping your server stateless. Because session-state is 11 | transferred as part of the HTTP request (in a cookie), state can be 12 | seamlessly maintained between server restarts or load balancing. It's 13 | inspired by [Beaker](http://pypi.python.org/pypi/Beaker), which 14 | provides a similar service for Python webapps. The cookies are 15 | authenticated and encrypted (using AES-GCM) with a key derived from a 16 | string provided to the `NewHandler` function. This makes seshcookie 17 | reliable and secure: session contents are opaque to users and not able 18 | to be manipulated or forged by third parties. 19 | 20 | examples 21 | -------- 22 | 23 | The simple example below returns different content based on whether 24 | the user has visited the site before or not: 25 | 26 | 27 | ```Go 28 | package main 29 | 30 | import ( 31 | "fmt" 32 | "log" 33 | "net/http" 34 | 35 | "github.com/bpowers/seshcookie" 36 | ) 37 | 38 | type VisitedHandler struct{} 39 | 40 | func (h *VisitedHandler) ServeHTTP(rw http.ResponseWriter, req *http.Request) { 41 | if req.URL.Path != "/" { 42 | return 43 | } 44 | 45 | session := seshcookie.GetSession(req.Context()) 46 | 47 | count, _ := session["count"].(int) 48 | count++ 49 | session["count"] = count 50 | 51 | rw.Header().Set("Content-Type", "text/plain") 52 | rw.WriteHeader(200) 53 | if count == 1 { 54 | rw.Write([]byte("this is your first visit, welcome!")) 55 | } else { 56 | rw.Write([]byte(fmt.Sprintf("page view #%d", count))) 57 | } 58 | } 59 | 60 | func main() { 61 | key := "session key, preferably a sequence of data from /dev/urandom" 62 | http.Handle("/", seshcookie.NewHandler( 63 | &VisitedHandler{}, 64 | key, 65 | &seshcookie.Config{HTTPOnly: true, Secure: false})) 66 | 67 | if err := http.ListenAndServe(":8080", nil); err != nil { 68 | log.Fatalf("ListenAndServe: %s", err) 69 | } 70 | } 71 | ``` 72 | 73 | There is a more detailed example in example/ which uses seshcookie to 74 | enforce authentication for a particular resource. In particular, it 75 | shows how you can embed (or stack) multiple http.Handlers to get the 76 | behavior you want. 77 | 78 | license 79 | ------- 80 | 81 | seshcookie is offered under the MIT license, see LICENSE for details. 82 | -------------------------------------------------------------------------------- /doc.go: -------------------------------------------------------------------------------- 1 | // Copyright 2017 Bobby Powers. All rights reserved. 2 | // Use of this source code is governed by the MIT 3 | // license that can be found in the LICENSE file. 4 | 5 | /* 6 | Package seshcookie enables you to associate session-state with HTTP 7 | requests while keeping your server stateless. Because session-state 8 | is transferred as part of the HTTP request (in a cookie), state can be 9 | seamlessly maintained between server restarts or load balancing. It's 10 | inspired by Beaker (http://pypi.python.org/pypi/Beaker), which 11 | provides a similar service for Python webapps. The cookies are 12 | authenticated and encrypted (using AES-GCM) with a key derived from a 13 | string provided to the NewHandler function. This makes seshcookie 14 | reliable and secure: session contents are opaque to users and not able 15 | to be manipulated or forged by third parties. 16 | 17 | Storing session-state in a cookie makes building some apps trivial, 18 | like this example that tells a user how many times they have visited 19 | the site: 20 | 21 | package main 22 | 23 | import ( 24 | "net/http" 25 | "log" 26 | "fmt" 27 | 28 | "github.com/bpowers/seshcookie" 29 | ) 30 | 31 | type VisitedHandler struct{} 32 | 33 | func (h *VisitedHandler) ServeHTTP(rw http.ResponseWriter, req *http.Request) { 34 | if req.URL.Path != "/" { 35 | return 36 | } 37 | 38 | session := seshcookie.GetSession(req.Context()) 39 | 40 | count, _ := session["count"].(int) 41 | count++ 42 | session["count"] = count 43 | 44 | rw.Header().Set("Content-Type", "text/plain") 45 | rw.WriteHeader(200) 46 | if count == 1 { 47 | rw.Write([]byte("this is your first visit, welcome!")) 48 | } else { 49 | rw.Write([]byte(fmt.Sprintf("page view #%d", count))) 50 | } 51 | } 52 | 53 | func main() { 54 | key := "session key, preferably a sequence of data from /dev/urandom" 55 | http.Handle("/", seshcookie.NewHandler( 56 | &VisitedHandler{}, 57 | key, 58 | &seshcookie.Config{HTTPOnly: true, Secure: false})) 59 | 60 | if err := http.ListenAndServe(":8080", nil); err != nil { 61 | log.Fatalf("ListenAndServe: %s", err) 62 | } 63 | } 64 | */ 65 | package seshcookie 66 | -------------------------------------------------------------------------------- /example/auth.go: -------------------------------------------------------------------------------- 1 | // Copyright 2017 Bobby Powers. All rights reserved. 2 | // Use of this source code is governed by the MIT 3 | // license that can be found in the LICENSE file. 4 | package main 5 | 6 | import ( 7 | "log" 8 | "net/http" 9 | 10 | "github.com/bpowers/seshcookie" 11 | ) 12 | 13 | var contentDir http.Dir = "./secured" 14 | 15 | // a simple map of users to their passwords, for demo purposes 16 | var userDb = map[string]string{ 17 | "user1": "love", 18 | "user2": "sex", 19 | "user3": "secret", 20 | "user4": "god", 21 | } 22 | 23 | // AuthHandler is an http.Handler which is meant to be sandwiched 24 | // between the seshcookie session handler and the handler for 25 | // resources you wish to require authentication to access. 26 | type AuthHandler struct { 27 | http.Handler 28 | Users map[string]string 29 | } 30 | 31 | // Restricts resource access to only those who have been logged in. 32 | // In order to provide a mechanism for logging in (and logging back 33 | // out) 2 paths are reserved for use by AuthHandler: "/login", and 34 | // "/logout". 35 | // 36 | // A GET request on "/login" serves a login form, which, upon 37 | // submission POSTs to "/login". If the login was successful, the 38 | // user is redirected to "/". 39 | // 40 | // Logging out is simply a matter of clearing the 'user' key from the 41 | // session map and redirecting to "/login" 42 | func (h *AuthHandler) ServeHTTP(rw http.ResponseWriter, req *http.Request) { 43 | 44 | session := seshcookie.GetSession(req.Context()) 45 | log.Printf("using session: %#v\n", session) 46 | 47 | switch req.URL.Path { 48 | case "/login": 49 | if req.Method != "POST" { 50 | http.ServeFile(rw, req, "./login.html") 51 | return 52 | } 53 | err := req.ParseForm() 54 | if err != nil { 55 | log.Printf("error '%s' parsing form for %#v\n", err, req) 56 | } 57 | user := req.Form.Get("user") 58 | expectedPass, exists := h.Users[user] 59 | if !exists || req.Form.Get("pass") != expectedPass { 60 | log.Printf("authentication failed for %s (pass:%s)\n", 61 | user, req.Form.Get("pass")) 62 | http.Redirect(rw, req, "/login", http.StatusFound) 63 | return 64 | } 65 | 66 | log.Printf("authorized %s\n", user) 67 | session["user"] = user 68 | http.Redirect(rw, req, "/", http.StatusFound) 69 | return 70 | case "/logout": 71 | delete(session, "user") 72 | http.Redirect(rw, req, "/login", http.StatusFound) 73 | return 74 | } 75 | 76 | if _, ok := session["user"]; !ok { 77 | http.Redirect(rw, req, "/login", http.StatusFound) 78 | return 79 | } 80 | 81 | h.Handler.ServeHTTP(rw, req) 82 | } 83 | 84 | func main() { 85 | // Here we have 3 levels of handlers: 86 | // 1 - session handler 87 | // 2 - auth handler 88 | // 3 - file server 89 | // 90 | // When a request comes in, first it goes through the session 91 | // handler, which deals with decrypting and unpacking session 92 | // data coming in as cookies on incoming requests, and making 93 | // sure the session is serialized when the response header is 94 | // written. After deserializing the incoming session, the 95 | // request is passed to AuthHandler (defined above). 96 | // AuthHandler directly serves requests for /login, /logout, 97 | // and /session. Requests for any other resource require the 98 | // session map to have a user key, which is obtained by 99 | // logging in. If the user key is present, the request is 100 | // passed to the FileServer, otherwise the browser is 101 | // redirected to the login page. 102 | handler := seshcookie.NewHandler( 103 | &AuthHandler{http.FileServer(contentDir), userDb}, 104 | "session key, preferably a sequence of data from /dev/urandom", 105 | &seshcookie.Config{HTTPOnly: true, Secure: false}) 106 | 107 | if err := http.ListenAndServe(":8080", handler); err != nil { 108 | log.Fatalf("ListenAndServe: %s", err) 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /example/login.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | login 8 | 9 | 10 | 11 | 12 | 13 |
14 |
15 |
16 | welcome 17 |
18 |
19 | 20 |
21 | 22 |
23 |
24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 37 | 38 |
username
password
35 |
36 |
39 |
40 |
41 | 42 |
43 | 44 |
45 | 46 | 47 | -------------------------------------------------------------------------------- /example/secured/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 |
14 |
15 | welcome 16 |
17 | 18 |
19 | 20 |

content available to logged in users...

21 | 22 |
23 | 24 |
25 | 26 | 27 | -------------------------------------------------------------------------------- /seshcookie.go: -------------------------------------------------------------------------------- 1 | // Copyright 2017 Bobby Powers. All rights reserved. 2 | // Use of this source code is governed by the MIT 3 | // license that can be found in the LICENSE file. 4 | 5 | package seshcookie 6 | 7 | import ( 8 | "bufio" 9 | "bytes" 10 | "context" 11 | "crypto/aes" 12 | "crypto/cipher" 13 | "crypto/rand" 14 | "crypto/sha256" 15 | "encoding/base64" 16 | "encoding/gob" 17 | "fmt" 18 | "io" 19 | "log" 20 | "net" 21 | "net/http" 22 | "sync/atomic" 23 | "time" 24 | ) 25 | 26 | type contextKey int 27 | 28 | const ( 29 | sessionKey contextKey = 0 30 | gobHashKey contextKey = 1 31 | 32 | // we want 16 byte blocks, for AES-128 33 | blockSize = 16 34 | gcmNonceSize = 12 35 | ) 36 | 37 | const defaultCookieName = "session" 38 | 39 | var ( 40 | // DefaultConfig is used as the configuration if a nil config 41 | // is passed to NewHandler 42 | DefaultConfig = &Config{ 43 | CookieName: defaultCookieName, // "session" 44 | CookiePath: "/", 45 | HTTPOnly: true, 46 | Secure: true, 47 | } 48 | ) 49 | 50 | // Session is simply a map of keys to arbitrary values, with the 51 | // restriction that the value must be GOB-encodable. 52 | type Session map[string]interface{} 53 | 54 | type responseWriter struct { 55 | http.ResponseWriter 56 | h *Handler 57 | req *http.Request 58 | // int32 so we can use the sync/atomic functions on it 59 | wroteHeader int32 60 | } 61 | 62 | // Config provides directives to a seshcookie instance on cookie 63 | // attributes, like if they are accessible from JavaScript and/or only 64 | // set on HTTPS connections. 65 | type Config struct { 66 | CookieName string // name of the cookie to store our session in 67 | CookiePath string // resource path the cookie is valid for 68 | HTTPOnly bool // don't allow JavaScript to access cookie 69 | Secure bool // only send session over HTTPS 70 | } 71 | 72 | // Handler is the seshcookie HTTP handler that provides a Session 73 | // object to child handlers. 74 | type Handler struct { 75 | http.Handler 76 | Config Config 77 | encKey []byte 78 | } 79 | 80 | // GetSession is a wrapper to grab the seshcookie Session out of a Context. 81 | // 82 | // By only providing a 'Get' API, we ensure that clients can't 83 | // mistakenly set something unexpected on the given context in place 84 | // of the session. 85 | func GetSession(ctx context.Context) Session { 86 | return ctx.Value(sessionKey).(Session) 87 | } 88 | 89 | func encodeGob(obj interface{}) ([]byte, error) { 90 | buf := bytes.NewBuffer(nil) 91 | enc := gob.NewEncoder(buf) 92 | err := enc.Encode(obj) 93 | if err != nil { 94 | return nil, err 95 | } 96 | return buf.Bytes(), nil 97 | } 98 | 99 | func decodeGob(encoded []byte) (Session, error) { 100 | buf := bytes.NewBuffer(encoded) 101 | dec := gob.NewDecoder(buf) 102 | var out Session 103 | err := dec.Decode(&out) 104 | if err != nil { 105 | return nil, err 106 | } 107 | return out, nil 108 | } 109 | 110 | // encodeCookie encodes a gob-encodable piece of content into a base64 111 | // encoded string, using AES-GCM mode for authenticated encryption. 112 | // 113 | // Go documentation suggests to never encode more than 2^32 cookies, 114 | // due to the risk of nonce-collision. 115 | func encodeCookie(content interface{}, encKey []byte) (string, []byte, error) { 116 | plaintext, err := encodeGob(content) 117 | if err != nil { 118 | return "", nil, err 119 | } 120 | 121 | // we want to record a hash of the serialized session to know 122 | // if the contents of the cookie changed. As we use a unique 123 | // nonce per encryption, we need to hash the plaintext as it 124 | // is before being passed through AES-GCM 125 | gobHash := sha256.New() 126 | gobHash.Write(plaintext) 127 | 128 | block, err := aes.NewCipher(encKey) 129 | if err != nil { 130 | return "", nil, fmt.Errorf("aes.NewCipher: %s", err) 131 | } 132 | 133 | if block.BlockSize() != blockSize { 134 | return "", nil, fmt.Errorf("block size assumption mismatch") 135 | } 136 | 137 | nonce := make([]byte, gcmNonceSize) 138 | if _, err := io.ReadFull(rand.Reader, nonce); err != nil { 139 | return "", nil, fmt.Errorf("io.ReadFull(rand.Reader): %s", err) 140 | } 141 | 142 | aeadCipher, err := cipher.NewGCM(block) 143 | if err != nil { 144 | return "", nil, fmt.Errorf("cipher.NewGCM: %s", err) 145 | } 146 | 147 | ciphertext := aeadCipher.Seal(nonce, nonce, plaintext, nil) 148 | 149 | return base64.StdEncoding.EncodeToString(ciphertext), gobHash.Sum(nil), nil 150 | } 151 | 152 | // decodeCookie decrypts a base64-encoded cookie using AES-GCM for 153 | // authenticated decryption. 154 | func decodeCookie(encoded string, encKey []byte) (Session, []byte, error) { 155 | cookie, err := base64.StdEncoding.DecodeString(encoded) 156 | if err != nil { 157 | return nil, nil, err 158 | } 159 | 160 | block, err := aes.NewCipher(encKey) 161 | if err != nil { 162 | return nil, nil, fmt.Errorf("aes.NewCipher: %s", err) 163 | } 164 | 165 | if len(cookie) < block.BlockSize() { 166 | return nil, nil, fmt.Errorf("expected ciphertext(%d) to be bigger than blockSize", len(cookie)) 167 | } 168 | 169 | // split the cookie data 170 | nonce, ciphertext := cookie[:gcmNonceSize], cookie[gcmNonceSize:] 171 | 172 | aeadCipher, err := cipher.NewGCM(block) 173 | if err != nil { 174 | return nil, nil, fmt.Errorf("cipher.NewGCM: %s", err) 175 | } 176 | 177 | plaintext, err := aeadCipher.Open(nil, nonce, ciphertext, nil) 178 | if err != nil { 179 | return nil, nil, fmt.Errorf("aeadCipher.Open: %s", err) 180 | } 181 | 182 | gobHash := sha256.New() 183 | gobHash.Write(plaintext) 184 | 185 | session, err := decodeGob(plaintext) 186 | if err != nil { 187 | return nil, nil, fmt.Errorf("decodeGob: %s", err) 188 | } 189 | return session, gobHash.Sum(nil), nil 190 | } 191 | 192 | func (s *responseWriter) Write(data []byte) (int, error) { 193 | if atomic.LoadInt32(&s.wroteHeader) == 0 { 194 | s.WriteHeader(http.StatusOK) 195 | } 196 | return s.ResponseWriter.Write(data) 197 | } 198 | 199 | func (s *responseWriter) writeCookie() { 200 | origCookieVal := "" 201 | if origCookie, err := s.req.Cookie(s.h.Config.CookieName); err == nil { 202 | origCookieVal = origCookie.Value 203 | } 204 | 205 | session := s.req.Context().Value(sessionKey).(Session) 206 | if len(session) == 0 { 207 | // if we have an empty session, but the user's cookie 208 | // was non-empty, we need to clear out the users 209 | // cookie. 210 | if origCookieVal != "" { 211 | //log.Println("clearing cookie") 212 | var cookie http.Cookie 213 | cookie.Name = s.h.Config.CookieName 214 | cookie.Value = "" 215 | cookie.Path = "/" 216 | // a cookie is expired by setting it 217 | // with an expiration time in the past 218 | cookie.Expires = time.Unix(0, 0).UTC() 219 | http.SetCookie(s, &cookie) 220 | } 221 | return 222 | } 223 | 224 | encoded, gobHash, err := encodeCookie(session, s.h.encKey) 225 | if err != nil { 226 | log.Printf("encodeCookie: %s\n", err) 227 | return 228 | } 229 | 230 | if bytes.Equal(gobHash, s.req.Context().Value(gobHashKey).([]byte)) { 231 | // log.Println("not re-setting identical cookie") 232 | return 233 | } 234 | 235 | var cookie http.Cookie 236 | cookie.Name = s.h.Config.CookieName 237 | cookie.Value = encoded 238 | cookie.Path = s.h.Config.CookiePath 239 | cookie.HttpOnly = s.h.Config.HTTPOnly 240 | cookie.Secure = s.h.Config.Secure 241 | http.SetCookie(s, &cookie) 242 | } 243 | 244 | func (s *responseWriter) WriteHeader(code int) { 245 | // TODO: this is racey if WriteHeader is called from 2 246 | // different goroutines. I think so is the underlying 247 | // ResponseWriter from net.http, but it is worth checking. 248 | if atomic.AddInt32(&s.wroteHeader, 1) == 1 { 249 | s.writeCookie() 250 | } 251 | 252 | s.ResponseWriter.WriteHeader(code) 253 | } 254 | 255 | func (s *responseWriter) Hijack() (net.Conn, *bufio.ReadWriter, error) { 256 | // TODO: support hijacking with atomic flags 257 | return nil, nil, fmt.Errorf("seshcookie doesn't support hijacking") 258 | } 259 | 260 | func (h *Handler) getCookieSession(req *http.Request) (Session, []byte) { 261 | cookie, err := req.Cookie(h.Config.CookieName) 262 | if err != nil { 263 | //log.Printf("getCookieSesh: '%#v' not found\n", 264 | // h.Config.CookieName) 265 | return Session{}, nil 266 | } 267 | session, gobHash, err := decodeCookie(cookie.Value, h.encKey) 268 | if err != nil { 269 | // this almost always just means that the user doesn't 270 | // have a valid login. 271 | //log.Printf("decodeCookie: %s\n", err) 272 | return Session{}, nil 273 | } 274 | 275 | return session, gobHash 276 | } 277 | 278 | func (h *Handler) ServeHTTP(rw http.ResponseWriter, req *http.Request) { 279 | // get our session a little early, so that we can add our 280 | // authentication information to it if we get some 281 | session, gobHash := h.getCookieSession(req) 282 | 283 | // store both the session and gobHash on this request's context 284 | ctx := req.Context() 285 | ctx = context.WithValue(ctx, sessionKey, session) 286 | ctx = context.WithValue(ctx, gobHashKey, gobHash) 287 | 288 | req = req.WithContext(ctx) 289 | 290 | sessionWriter := &responseWriter{rw, h, req, 0} 291 | h.Handler.ServeHTTP(sessionWriter, req) 292 | } 293 | 294 | // NewHandler creates a new seshcookie Handler with a given encryption 295 | // key and configuration. 296 | func NewHandler(handler http.Handler, key string, config *Config) *Handler { 297 | if key == "" { 298 | panic("don't use an empty key") 299 | } 300 | 301 | // sha256 sums are 32 bytes long. we use the first 16 bytes as 302 | // the aes key. 303 | encHash := sha256.New() 304 | encHash.Write([]byte(key)) 305 | encHash.Write([]byte("-seshcookie-encryption")) 306 | 307 | // if the user hasn't specified a config, use the package's 308 | // default one 309 | if config == nil { 310 | config = DefaultConfig 311 | } 312 | 313 | if config.CookieName == "" { 314 | config.CookieName = defaultCookieName 315 | } 316 | 317 | return &Handler{ 318 | Handler: handler, 319 | Config: *config, 320 | encKey: encHash.Sum(nil)[:blockSize], 321 | } 322 | } 323 | -------------------------------------------------------------------------------- /seshcookie_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2017 Bobby Powers. All rights reserved. 2 | // Use of this source code is governed by the MIT 3 | // license that can be found in the LICENSE file. 4 | 5 | package seshcookie 6 | 7 | import ( 8 | "bytes" 9 | "crypto/sha1" 10 | "fmt" 11 | "io/ioutil" 12 | "net/http" 13 | "net/http/httptest" 14 | "strings" 15 | "testing" 16 | "time" 17 | ) 18 | 19 | const testCookieName = "testcookiepleaseignore" 20 | 21 | func createKey() (encKey []byte) { 22 | encSha1 := sha1.New() 23 | encSha1.Write([]byte(time.Now().UTC().String())) 24 | encSha1.Write([]byte(fmt.Sprintf("%d", time.Now().UnixNano()))) 25 | encSha1.Write([]byte("-enc")) 26 | encKey = encSha1.Sum(nil)[:blockSize] 27 | 28 | return 29 | } 30 | 31 | func TestRoundtrip(t *testing.T) { 32 | encKey := createKey() 33 | 34 | orig := map[string]interface{}{"a": 1, "b": "c", "d": 1.2} 35 | 36 | encoded, encodedHash, err := encodeCookie(orig, encKey) 37 | if err != nil { 38 | t.Errorf("encodeCookie: %s", err) 39 | return 40 | } 41 | decoded, decodedHash, err := decodeCookie(encoded, encKey) 42 | if err != nil { 43 | t.Errorf("decodeCookie: %s", err) 44 | return 45 | } 46 | 47 | if decoded == nil { 48 | t.Errorf("decoded map is null") 49 | return 50 | } 51 | 52 | if len(decoded) != 3 { 53 | t.Errorf("len was %d, expected 3", len(decoded)) 54 | return 55 | } 56 | 57 | if !bytes.Equal(encodedHash, decodedHash) { 58 | t.Errorf("encoded & decoded gob hash mismatches: %s, %s", 59 | string(encodedHash), string(decodedHash)) 60 | } 61 | 62 | for k, v := range orig { 63 | if decoded[k] != v { 64 | t.Errorf("expected decoded[%s] (%#v) == %#v", k, 65 | decoded[k], v) 66 | } 67 | } 68 | } 69 | 70 | type VisitedHandler struct{} 71 | 72 | func (h *VisitedHandler) ServeHTTP(rw http.ResponseWriter, req *http.Request) { 73 | if req.URL.Path != "/" { 74 | return 75 | } 76 | 77 | session := GetSession(req.Context()) 78 | 79 | count, _ := session["count"].(int) 80 | count++ 81 | session["count"] = count 82 | 83 | // for testing cookie deletion 84 | if count >= 2 { 85 | delete(session, "count") 86 | if len(session) != 0 { 87 | panic("expected empty session") 88 | } 89 | } 90 | 91 | rw.Header().Set("Content-Type", "text/plain") 92 | rw.WriteHeader(200) 93 | if count == 1 { 94 | rw.Write([]byte("this is your first visit, welcome!")) 95 | } else { 96 | rw.Write([]byte(fmt.Sprintf("page view #%d", count))) 97 | } 98 | } 99 | 100 | func TestHandler(t *testing.T) { 101 | key := string(createKey()) 102 | config := &Config{ 103 | CookieName: testCookieName, 104 | HTTPOnly: true, 105 | Secure: false, 106 | } 107 | 108 | req := httptest.NewRequest("GET", "/", nil) 109 | w := httptest.NewRecorder() 110 | 111 | handler := NewHandler( 112 | &VisitedHandler{}, 113 | key, 114 | config) 115 | 116 | handler.ServeHTTP(w, req) 117 | 118 | resp := w.Result() 119 | body, _ := ioutil.ReadAll(resp.Body) 120 | 121 | if 200 > resp.StatusCode || resp.StatusCode >= 300 { 122 | t.Fatalf("bad status code: %d", resp.StatusCode) 123 | } 124 | 125 | if !strings.Contains(string(body), "first visit") { 126 | t.Fatalf("bad response for uncookied request") 127 | } 128 | 129 | cookies := resp.Cookies() 130 | if len(cookies) != 1 { 131 | t.Fatalf("expected a single cookie to be set") 132 | } 133 | 134 | cookie := cookies[0] 135 | if cookie.Name != testCookieName { 136 | t.Fatalf("expected cookie to have name %s not %s", testCookieName, cookie.Name) 137 | } 138 | 139 | if cookie.HttpOnly != true { 140 | t.Fatalf("expected HTTP only") 141 | } 142 | 143 | if cookie.Secure != false { 144 | t.Fatalf("expected not secure") 145 | } 146 | 147 | req = httptest.NewRequest("GET", "/", nil) 148 | req.AddCookie(cookie) 149 | w = httptest.NewRecorder() 150 | 151 | // create a new handler to ensure decoding the cookie isn't 152 | // dependent on local state 153 | handler = NewHandler( 154 | &VisitedHandler{}, 155 | key, 156 | config) 157 | 158 | handler.ServeHTTP(w, req) 159 | 160 | resp = w.Result() 161 | body, _ = ioutil.ReadAll(resp.Body) 162 | 163 | if 200 > resp.StatusCode || resp.StatusCode >= 300 { 164 | t.Fatalf("bad status code: %d", resp.StatusCode) 165 | } 166 | 167 | if string(body) != "page view #2" { 168 | t.Fatalf("bad response for cookied request: '%s'", string(body)) 169 | } 170 | 171 | if len(resp.Cookies()) != 1 { 172 | t.Fatalf("expected a single cookie to be set") 173 | } 174 | 175 | // expect the cookie value to be empty 176 | clearedCookie := resp.Cookies()[0] 177 | if clearedCookie.Expires.After(time.Now().Add(-24 * time.Hour)) { 178 | t.Fatalf("expected expiration to be in the past") 179 | } 180 | if len(clearedCookie.Value) != 0 { 181 | //t.Fatalf("expected cookie value to be empty, not '%s'", clearedCookie.Value) 182 | } 183 | 184 | // now try messing with the cookie data and ensuring the page loads ok 185 | if cookie.Value[0] == 'a' { 186 | cookie.Value = "A" + cookie.Value[1:] 187 | } else { 188 | cookie.Value = "a" + cookie.Value[1:] 189 | } 190 | req = httptest.NewRequest("GET", "/", nil) 191 | req.AddCookie(cookie) 192 | w = httptest.NewRecorder() 193 | 194 | handler.ServeHTTP(w, req) 195 | 196 | resp = w.Result() 197 | body, _ = ioutil.ReadAll(resp.Body) 198 | 199 | if 200 > resp.StatusCode || resp.StatusCode >= 300 { 200 | t.Fatalf("bad status code: %d", resp.StatusCode) 201 | } 202 | 203 | if !strings.Contains(string(body), "first visit") { 204 | t.Fatalf("bad response for uncookied request") 205 | } 206 | 207 | if len(resp.Cookies()) != 1 { 208 | t.Fatalf("expected a single cookie to be set") 209 | } 210 | } 211 | 212 | func TestEmptyKeyPanics(t *testing.T) { 213 | defer func() { 214 | if r := recover(); r == nil { 215 | t.Errorf("The code did not panic") 216 | } 217 | }() 218 | 219 | _ = NewHandler( 220 | &VisitedHandler{}, 221 | "", 222 | nil) 223 | } 224 | 225 | func TestNoHijack(t *testing.T) { 226 | key := string(createKey()) 227 | config := &Config{ 228 | CookieName: testCookieName, 229 | HTTPOnly: true, 230 | Secure: false, 231 | } 232 | 233 | req := httptest.NewRequest("GET", "/", nil) 234 | w := httptest.NewRecorder() 235 | 236 | hijackFailed := false 237 | hijacker := http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { 238 | hj, ok := rw.(http.Hijacker) 239 | if !ok { 240 | panic("expected hijack support") 241 | } 242 | 243 | _, _, err := hj.Hijack() 244 | if err != nil { 245 | hijackFailed = true 246 | } 247 | }) 248 | 249 | handler := NewHandler( 250 | hijacker, 251 | key, 252 | config) 253 | 254 | handler.ServeHTTP(w, req) 255 | 256 | if !hijackFailed { 257 | t.Fatalf("expected Hijack to fail") 258 | } 259 | } 260 | --------------------------------------------------------------------------------