// Package sessionstore is a postgresql backend implementation of gorilla/sessions Session interface, based on // antonlindstrom/pgstore. Key change is to use GORM instead of typical sql driver using queries. package sessionstore import ( "encoding/base32" "net/http" "strings" "time" "github.com/gorilla/securecookie" "github.com/gorilla/sessions" "github.com/porter-dev/porter/api/server/shared/config/env" "github.com/porter-dev/porter/internal/models" "github.com/porter-dev/porter/internal/repository" "gorm.io/gorm" ) // structs // PGStore is a wrapper around gorilla/sessions store. type PGStore struct { Codecs []securecookie.Codec Options *sessions.Options Path string Repo repository.SessionRepository } // Helpers // MaxLength restricts the maximum length of new sessions to l. // If l is 0 there is no limit to the size of a session, use with caution. // The default for a new PGStore is 4096. PostgreSQL allows for max // value sizes of up to 1GB (http://www.postgresql.org/docs/current/interactive/datatype-character.html) func (store *PGStore) MaxLength(l int) { for _, c := range store.Codecs { if codec, ok := c.(*securecookie.SecureCookie); ok { codec.MaxLength(l) } } } // MaxAge sets the maximum age for the store and the underlying cookie // implementation. Individual sessions can be deleted by setting Options.MaxAge // = -1 for that session. func (store *PGStore) MaxAge(age int) { store.Options.MaxAge = age // Set the maxAge for each securecookie instance. for _, codec := range store.Codecs { if sc, ok := codec.(*securecookie.SecureCookie); ok { sc.MaxAge(age) } } } // load fetches a session by ID from the database and decodes its content // into session.Values. func (store *PGStore) load(session *sessions.Session) error { res, err := store.Repo.SelectSession(&models.Session{Key: session.ID}) if err != nil { return err } return securecookie.DecodeMulti(session.Name(), string(res.Data), &session.Values, store.Codecs...) } // save writes encoded session.Values to a database record. // writes to http_sessions table by default. func (store *PGStore) save(session *sessions.Session) error { encoded, err := securecookie.EncodeMulti(session.Name(), session.Values, store.Codecs...) if err != nil { return err } exOn := session.Values["expires_on"] var expiresOn time.Time if exOn == nil { expiresOn = time.Now().Add(time.Second * time.Duration(session.Options.MaxAge)) } else { expiresOn = exOn.(time.Time) if expiresOn.Sub(time.Now().Add(time.Second*time.Duration(session.Options.MaxAge))) < 0 { expiresOn = time.Now().Add(time.Second * time.Duration(session.Options.MaxAge)) } } s := &models.Session{ Key: session.ID, Data: []byte(encoded), ExpiresAt: expiresOn, } repo := store.Repo if session.IsNew { _, createErr := repo.CreateSession(s) return createErr } _, updateErr := repo.UpdateSession(s) return updateErr } // Implementation of the interface (Get, New, Save) type NewStoreOpts struct { SessionRepository repository.SessionRepository CookieSecrets []string Insecure bool } // NewStore takes an initialized db and session key pairs to create a session-store in postgres db. func NewStore(opts *NewStoreOpts) (*PGStore, error) { keyPairs := [][]byte{} for _, key := range opts.CookieSecrets { keyPairs = append(keyPairs, []byte(key)) } dbStore := &PGStore{ Codecs: securecookie.CodecsFromPairs(keyPairs...), Options: &sessions.Options{ Path: "/", MaxAge: 86400 * 30, Secure: !opts.Insecure, HttpOnly: true, SameSite: http.SameSiteLaxMode, }, Repo: opts.SessionRepository, } return dbStore, nil } // NewFilesystemStore takes session key pairs to create a session-store in the local fs without using a db. func NewFilesystemStore(conf env.ServerConf) *sessions.FilesystemStore { keyPairs := [][]byte{} for _, key := range conf.CookieSecrets { keyPairs = append(keyPairs, []byte(key)) } // Defaults to os.TempDir() when first argument (path) isn't specified. store := sessions.NewFilesystemStore("", keyPairs...) return store } // Get Fetches a session for a given name after it has been added to the // registry. func (store *PGStore) Get(r *http.Request, name string) (*sessions.Session, error) { return sessions.GetRegistry(r).Get(store, name) } // New returns a new session for the given name without adding it to the registry. func (store *PGStore) New(r *http.Request, name string) (*sessions.Session, error) { session := sessions.NewSession(store, name) if session == nil { return nil, nil } opts := *store.Options session.Options = &(opts) session.IsNew = true var err error if c, errCookie := r.Cookie(name); errCookie == nil { err = securecookie.DecodeMulti(name, c.Value, &session.ID, store.Codecs...) if err == nil { err = store.load(session) if err != nil { if err == gorm.ErrRecordNotFound { err = nil } else if strings.Contains(err.Error(), "expired timestamp") { err = nil session.IsNew = false } } else { session.IsNew = false } } } store.MaxAge(store.Options.MaxAge) return session, err } // Save saves the given session into the database and deletes cookies if needed func (store *PGStore) Save(r *http.Request, w http.ResponseWriter, session *sessions.Session) error { repo := store.Repo // Set delete if max-age is < 0 if session.Options.MaxAge < 0 { if _, err := repo.DeleteSession(&models.Session{Key: session.ID}); err != nil { return err } http.SetCookie(w, sessions.NewCookie(session.Name(), "", session.Options)) return nil } if session.ID == "" { // Generate a random session ID key suitable for storage in the DB session.ID = strings.TrimRight( base32.StdEncoding.EncodeToString( securecookie.GenerateRandomKey(32), ), "=") } if err := store.save(session); err != nil { return err } // Keep the session ID key in a cookie so it can be looked up in DB later. encoded, err := securecookie.EncodeMulti(session.Name(), session.ID, store.Codecs...) if err != nil { return err } http.SetCookie(w, sessions.NewCookie(session.Name(), encoded, session.Options)) return nil }