├── .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 | [](https://travis-ci.org/bpowers/seshcookie)
5 | [](https://godoc.org/github.com/bpowers/seshcookie)
6 | [](https://cover.run/go?tag=golang-1.10&repo=github.com%2Fbpowers%2Fseshcookie)
7 | [](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 |
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 |
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 |
--------------------------------------------------------------------------------