~mna/webparts-sessions

30542a33f7566f3aae1dfeb112c546f06e067526 — Martin Angers 1 year, 6 months ago master
initial commit
7 files changed, 403 insertions(+), 0 deletions(-)

A .gitignore
A .golangci.toml
A README.md
A go.mod
A go.sum
A sessions.go
A sessions_test.go
A  => .gitignore +6 -0
@@ 1,6 @@
# environment files (e.g. managed by direnv) and other secrets
/.env*

# output files for different tools, e.g. code coverage
/*.out


A  => .golangci.toml +30 -0
@@ 1,30 @@
[linters]
  disable-all = true
  enable = [
    "deadcode",
    "errcheck",
    "gochecknoinits",
    "gochecknoglobals",
    "gofmt",
    "golint",
    "gosec",
    "gosimple",
    "govet",
    "ineffassign",
    "interfacer",
    "misspell",
    "nakedret",
    "prealloc",
    "staticcheck",
    "structcheck",
    "typecheck",
    "unconvert",
    "unparam",
    "unused",
    "varcheck",
  ]

[issues]
  # regexps of issue texts to exclude
  exclude = [
  ]

A  => README.md +12 -0
@@ 1,12 @@
# webparts-sessions

[![GoDoc](https://godoc.org/git.sr.ht/~mna/webparts-sessions?status.svg)](https://godoc.org/git.sr.ht/~mna/webparts-sessions)

This repository provides an implementation of the `webparts/http/httpssn` standard interface
using the `github.com/gorilla/sessions` package.

## see also

* webparts: https://git.sr.ht/~mna/webparts
* sessions: https://github.com/gorilla/sessions


A  => go.mod +9 -0
@@ 1,9 @@
module git.sr.ht/~mna/webparts-sessions

go 1.13

require (
	git.sr.ht/~mna/webparts v0.0.0-20191029021002-192cf10606f8
	github.com/gorilla/sessions v1.2.0
	github.com/stretchr/testify v1.4.0
)

A  => go.sum +16 -0
@@ 1,16 @@
git.sr.ht/~mna/webparts v0.0.0-20191029021002-192cf10606f8 h1:BOZLZLCJNZR/fpugRNTVmOR/fUK9iiJ8v6Y2QUwbGsA=
git.sr.ht/~mna/webparts v0.0.0-20191029021002-192cf10606f8/go.mod h1:FOnYHb2lCpY5fycBxEF4BqtFDiTxW4AB0CJeoKXzwWo=
github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/gorilla/securecookie v1.1.1 h1:miw7JPhV+b/lAHSXz4qd/nN9jRiAFV5FwjeKyCS8BvQ=
github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4=
github.com/gorilla/sessions v1.2.0 h1:S7P+1Hm5V/AT9cjEcUD5uDaQSX0OE577aCXgoaKpYbQ=
github.com/gorilla/sessions v1.2.0/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.4.0 h1:2E4SXV/wtOkTonXsotYi4li6zVWxYlZuYNCXe9XRJyk=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v2 v2.2.2 h1:ZCJp+EgiOT7lHqUV2J862kp8Qj64Jo6az82+3Td9dZw=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=

A  => sessions.go +144 -0
@@ 1,144 @@
package sessions

import (
	"bytes"
	"io"
	"net/http"
	"os"
	"time"

	"git.sr.ht/~mna/webparts/http/httpssn"
	"github.com/gorilla/sessions"
)

// Config configures the cookie session store.
type Config struct {
	// KeyPairs is a slice of authentication+encryption key pairs, newest first.
	// The encryption key is optional and may be set to nil.
	KeyPairs [][]byte

	// The following fields are the same as for http.Cookie.

	Path   string
	Domain string
	// MaxAge is the maximum age of the cookie. 0 means the cookie is deleted with
	// the browser session, < 0 means delete cookie immediately (should not be used
	// on the session store), > 0 is the max age, only full seconds are considered.
	MaxAge   time.Duration
	Secure   bool
	HTTPOnly bool
	SameSite http.SameSite
}

// New returns a configured cookie-based session store.
func New(conf *Config) httpssn.Store {
	store := sessions.NewCookieStore(conf.KeyPairs...)
	if conf.Path != "" {
		store.Options.Path = conf.Path
	}
	if conf.Domain != "" {
		store.Options.Domain = conf.Domain
	}
	store.Options.Secure = conf.Secure
	store.Options.HttpOnly = conf.HTTPOnly
	store.Options.SameSite = conf.SameSite
	store.Options.MaxAge = int(conf.MaxAge / time.Second)

	return &sessionStore{store}
}

type sessionStore struct {
	store *sessions.CookieStore
}

func (s *sessionStore) Get(r *http.Request, name string) (httpssn.Session, error) {
	ssn, err := s.store.Get(r, name)
	if err != nil {
		return nil, err
	}
	return &session{ssn}, nil
}

func (s *sessionStore) Save(w http.ResponseWriter, r *http.Request, ssn httpssn.Session) error {
	return s.store.Save(r, w, ssn.(*session).ssn)
}

type session struct {
	ssn *sessions.Session
}

func (s *session) IsNew() bool {
	return s.ssn.IsNew
}

func (s *session) Get(key string) interface{} {
	return s.ssn.Values[key]
}

func (s *session) Set(key string, val interface{}) {
	s.ssn.Values[key] = val
}

func (s *session) Unset(key string) {
	delete(s.ssn.Values, key)
}

func (s *session) Delete() {
	s.ssn.Options.MaxAge = -1
}

// KeyPairsFromFile reads the authentication and encryption key pairs from the
// specified file, and returns a [][]byte ready-to-use as the KeyPairs value
// of the Config struct. Both the authentication and encryption key values must
// be 32 bytes. The last pair in the file is the current pair (i.e. the key pairs
// order from the file is inverted in the returned slice so that the last pair
// in the file is the first pair in the slice).
//
// Encryption keys may be skipped (left nil) by setting the full 32 bytes they
// occupy to ' ' (a space).
func KeyPairsFromFile(file string) ([][]byte, error) {
	f, err := os.Open(file)
	if err != nil {
		return nil, err
	}
	defer f.Close()
	return KeyPairsFromReader(f)
}

// KeyPairsFromReader behaves the same as KeyPairsFromFile, but it reads from
// any io.Reader.
func KeyPairsFromReader(r io.Reader) ([][]byte, error) {
	const size = 32
	return keyPairs(r, size)
}

func keyPairs(r io.Reader, size int) ([][]byte, error) {
	var pairs [][]byte

	empty := bytes.Repeat([]byte{' '}, size)
	for {
		chunk := make([]byte, size)
		_, err := io.ReadFull(r, chunk)
		if err != nil {
			if err == io.EOF {
				if len(pairs)%2 != 0 {
					pairs = append(pairs, nil)
				}
				// switch back the last pair to first, and so on (reverse pair order)
				// so that the last one in the file is the current, active one.
				reversed := make([][]byte, len(pairs))
				for i := 0; i < len(pairs); i += 2 {
					reversed[i] = pairs[len(pairs)-(i+2)]
					reversed[i+1] = pairs[len(pairs)-(i+1)]
				}
				return reversed, nil
			}
			return nil, err
		}

		if bytes.Equal(chunk, empty) {
			chunk = nil
		}
		pairs = append(pairs, chunk)
	}
}

A  => sessions_test.go +186 -0
@@ 1,186 @@
package sessions

import (
	"crypto/rand"
	"io"
	"net/http"
	"net/http/cookiejar"
	"net/http/httptest"
	"strings"
	"testing"
	"time"

	"git.sr.ht/~mna/webparts/http/httpssn"
	"github.com/stretchr/testify/require"
)

func TestSessionStore(t *testing.T) {
	authKey := make([]byte, 32)
	_, err := io.ReadFull(rand.Reader, authKey)
	require.NoError(t, err)

	t.Run("without key pairs", func(t *testing.T) {
		conf := new(Config)
		ss := New(conf)
		req := httptest.NewRequest("GET", "/", nil)
		rw := httptest.NewRecorder()

		// get works and returns a new session
		ssn, err := ss.Get(req, "test")
		require.NoError(t, err)
		require.True(t, ssn.IsNew())

		// save fails, no codec
		err = ss.Save(rw, req, ssn)
		require.Error(t, err)
		require.Contains(t, err.Error(), "no codecs")
	})

	t.Run("with key pair", func(t *testing.T) {
		conf := &Config{KeyPairs: [][]byte{authKey, nil}}
		ss := New(conf)
		req := httptest.NewRequest("GET", "/", nil)
		rw := httptest.NewRecorder()

		// get works and returns a new session
		ssn, err := ss.Get(req, "test")
		require.NoError(t, err)
		require.True(t, ssn.IsNew())

		// set a value
		ssn.Set("a", 1)

		// save works
		err = ss.Save(rw, req, ssn)
		require.NoError(t, err)

		// get returns the session with the value
		ssn, err = ss.Get(req, "test")
		require.NoError(t, err)

		val := ssn.Get("a")
		require.Equal(t, val, 1)

		val = ssn.Get("b")
		require.Nil(t, val)

		ssn.Unset("a")
		err = ss.Save(rw, req, ssn)
		require.NoError(t, err)

		val = ssn.Get("a")
		require.Nil(t, val)
	})

	t.Run("roundtrip", func(t *testing.T) {
		conf := &Config{KeyPairs: [][]byte{authKey, nil}}
		ss := New(conf)

		srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			var ssn httpssn.Session
			var err error

			switch r.URL.Path {
			case "/set":
				ssn, err = ss.Get(r, "test")
				require.NoError(t, err)
				require.True(t, ssn.IsNew())

			case "/get":
				ssn, err = ss.Get(r, "test")
				require.NoError(t, err)
				require.False(t, ssn.IsNew())

			case "/del":
				ssn, err = ss.Get(r, "test")
				require.NoError(t, err)
				require.False(t, ssn.IsNew())
				ssn.Delete()

			case "/postdel":
				ssn, err = ss.Get(r, "test")
				require.NoError(t, err)
				require.True(t, ssn.IsNew())
				return // not saved, so no cookie in response
			}
			require.NoError(t, ss.Save(w, r, ssn))
		}))
		defer srv.Close()

		jar, err := cookiejar.New(nil)
		require.NoError(t, err)
		cli := &http.Client{
			Jar:     jar,
			Timeout: time.Second,
		}

		// set sets the session cookie
		res, err := cli.Get(srv.URL + "/set")
		require.NoError(t, err)
		cks := res.Cookies()
		require.Len(t, cks, 1)

		// get gets the existing cookie
		res, err = cli.Get(srv.URL + "/get")
		require.NoError(t, err)
		cks = res.Cookies()
		require.Len(t, cks, 1)

		// del deletes the cookie, still present in the response but won't be sent anymore
		res, err = cli.Get(srv.URL + "/del")
		require.NoError(t, err)
		cks = res.Cookies()
		require.Len(t, cks, 1)

		// postdel receives no cookie
		res, err = cli.Get(srv.URL + "/postdel")
		require.NoError(t, err)
		cks = res.Cookies()
		require.Len(t, cks, 0)
	})
}

func TestKeyPairs(t *testing.T) {
	const keySize = 4

	cases := []struct {
		in     string
		out    []string
		hasErr bool
	}{
		{"", nil, false},
		{"    ", []string{"", ""}, false},
		{"a", nil, true},
		{"ab", nil, true},
		{"abc", nil, true},
		{"abcd", []string{"abcd", ""}, false},
		{"abcde", nil, true},
		{"abcdef", nil, true},
		{"abcdefg", nil, true},
		{"abcdefgh", []string{"abcd", "efgh"}, false},
		{"abcdefghi", nil, true},
		{"abcdefghij", nil, true},
		{"abcdefghijk", nil, true},
		{"abcdefghijkl", []string{"ijkl", "", "abcd", "efgh"}, false},
		{"abcd    ijkl", []string{"ijkl", "", "abcd", ""}, false},
		{"abcdefghijkl    mnop", []string{"mnop", "", "ijkl", "", "abcd", "efgh"}, false},
		{"abcdefghijkl    mnopqrst", []string{"mnop", "qrst", "ijkl", "", "abcd", "efgh"}, false},
	}
	for _, c := range cases {
		t.Run(c.in, func(t *testing.T) {
			got, err := keyPairs(strings.NewReader(c.in), keySize)
			if c.hasErr {
				require.Error(t, err)
				return
			}

			require.NoError(t, err)

			var res []string
			for _, key := range got {
				res = append(res, string(key))
			}
			require.Equal(t, res, c.out)
		})
	}
}