From 095348283841b29f8232748baac54127270f6b87 Mon Sep 17 00:00:00 2001 From: Adam Hanna Date: Fri, 28 Apr 2017 12:21:28 -0700 Subject: [PATCH] finished abstracting code base --- auth/service.go | 73 ++++ auth/service_interface.go | 9 + sessions_util.go => auth/service_util.go | 27 +- service.go | 120 +++++++ sessions_interface.go => service_interface.go | 6 +- service_util.go | 13 + sessionerrs/sessionerrs.go | 5 +- sessions.go | 324 ------------------ store/service.go | 132 +++++-- store/service_interface.go | 9 +- store/service_util.go | 28 +- transport/service.go | 92 +++++ transport/service_interface.go | 15 + transport/service_util.go | 23 ++ user/user.go | 9 +- user/user_util.go | 15 - 16 files changed, 489 insertions(+), 411 deletions(-) create mode 100644 auth/service.go create mode 100644 auth/service_interface.go rename sessions_util.go => auth/service_util.go (54%) create mode 100644 service.go rename sessions_interface.go => service_interface.go (53%) create mode 100644 service_util.go delete mode 100644 sessions.go create mode 100644 transport/service.go create mode 100644 transport/service_interface.go create mode 100644 transport/service_util.go delete mode 100644 user/user_util.go diff --git a/auth/service.go b/auth/service.go new file mode 100644 index 0000000..5c77e84 --- /dev/null +++ b/auth/service.go @@ -0,0 +1,73 @@ +package auth + +import ( + "errors" + + "github.com/adam-hanna/sessions/sessionerrs" +) + +// Service performs signing and verification actions using HMAC +type Service struct { + options Options +} + +// Options defines the behavior of the auth service +type Options struct { + // Key is a slice of bytes for performing HMAC signing and verification operations + Key []byte +} + +// New returns a new auth service +func New(options Options) (*Service, *sessionerrs.Custom) { + // note @adam-hanna: should we perform other checks like min/max length? + if len(options.Key) == 0 { + return &Service{}, &sessionerrs.Custom{ + Code: 500, + Err: errors.New("no session key"), + } + } + return &Service{ + options: options, + }, nil +} + +// SignAndBase64Encode signs the sessionID with they key and returns a base64 encoded string +func (s *Service) SignAndBase64Encode(sessionID string) (string, *sessionerrs.Custom) { + userSessionIDBytes := []byte(sessionID) + signedBytes := signHMAC(&userSessionIDBytes, &s.options.Key) + + // append the signature to the session id + sessionValBytes := make([]byte, len(userSessionIDBytes)+len(signedBytes)) + sessionValBytes = append(userSessionIDBytes, signedBytes...) + + return string(encode(sessionValBytes)[:]), nil +} + +// VerifyAndDecode takes in a signed session string and returns a sessionID, only if the signed string passes +// auth verification. +func (s *Service) VerifyAndDecode(signed string) (string, *sessionerrs.Custom) { + sessionValueBytes := []byte(signed) + decodedSessionValueBytes, err := decode(sessionValueBytes) + if err != nil { + return "", &sessionerrs.Custom{ + Code: 500, + Err: err, + } + } + + // note: session uuid's are always 36 bytes long + sessionIDBytes := decodedSessionValueBytes[:36] + hmacBytes := decodedSessionValueBytes[36:] + // fmt.Printf("In auth.VerifyAndDecode\nsessionID: %s\nsig: %x\nkey: %s\n", string(sessionIDBytes[:]), string(hmacBytes[:]), string(s.options.Key[:])) + + // verify the hmac signature + verified := verifyHMAC(&sessionIDBytes, &hmacBytes, &s.options.Key) + if !verified { + return "", &sessionerrs.Custom{ + Code: 401, + Err: errors.New("invalid session signature"), + } + } + + return string(sessionIDBytes[:]), nil +} diff --git a/auth/service_interface.go b/auth/service_interface.go new file mode 100644 index 0000000..3071818 --- /dev/null +++ b/auth/service_interface.go @@ -0,0 +1,9 @@ +package auth + +import "github.com/adam-hanna/sessions/sessionerrs" + +// ServiceInterface defines the methods that are performend by the auth service +type ServiceInterface interface { + SignAndBase64Encode(sessionID string) (string, *sessionerrs.Custom) + VerifyAndDecode(signed string) (string, *sessionerrs.Custom) +} diff --git a/sessions_util.go b/auth/service_util.go similarity index 54% rename from sessions_util.go rename to auth/service_util.go index 7c16251..9c053fb 100644 --- a/sessions_util.go +++ b/auth/service_util.go @@ -1,4 +1,4 @@ -package sessions +package auth import ( "crypto/hmac" @@ -14,31 +14,6 @@ var ( ErrBase64Decode = errors.New("Base64 decoding failed") ) -// setDefaultOptions sets default values for nil fields -// note @adam-hanna: this utility function should be improved. The fields and types of the options struct \ -// should not be hardcoded! -func setDefaultOptions(opts *Options) { - emptyOpts := Options{} - if opts.ExpirationDuration == emptyOpts.ExpirationDuration { - opts.ExpirationDuration = DefaultExpirationDuration - } - if opts.CSRFHeaderKey == emptyOpts.CSRFHeaderKey { - opts.CSRFHeaderKey = DefaultCSRFHeaderKey - } -} - -// signAndEncodeSessionID signs the sessionID with they key and returns a base64 encoded byte slice -func (s *Service) signAndEncodeSessionID(sessionID string) []byte { - userSessionIDBytes := []byte(sessionID) - signedBytes := signHMAC(&userSessionIDBytes, &s.options.Key) - - // append the signature to the session id - sessionValBytes := make([]byte, len(userSessionIDBytes)+len(signedBytes)) - sessionValBytes = append(userSessionIDBytes, signedBytes...) - - return encode(sessionValBytes) -} - // Thanks! https://github.com/gorilla/securecookie // encode encodes a value using base64. func encode(value []byte) []byte { diff --git a/service.go b/service.go new file mode 100644 index 0000000..f7a6f8d --- /dev/null +++ b/service.go @@ -0,0 +1,120 @@ +package sessions + +import ( + "net/http" + "time" + + "github.com/adam-hanna/sessions/auth" + "github.com/adam-hanna/sessions/sessionerrs" + "github.com/adam-hanna/sessions/store" + "github.com/adam-hanna/sessions/transport" + "github.com/adam-hanna/sessions/user" +) + +const ( + // DefaultExpirationDuration sets the default session expiration duration + DefaultExpirationDuration = 3 * 24 * time.Hour // 3 days +) + +// Service provides session service for http servers +type Service struct { + store store.ServiceInterface + auth auth.ServiceInterface + transport transport.ServiceInterface + options Options +} + +// Options defines the behavior of the session service +type Options struct { + ExpirationDuration time.Duration +} + +// New returns a new session service +func New(store store.ServiceInterface, auth auth.ServiceInterface, transport transport.ServiceInterface, options Options) *Service { + setDefaultOptions(&options) + return &Service{ + store: store, + auth: auth, + transport: transport, + options: options, + } +} + +// IssueUserSession grants a new user session, writes that session info to the store \ +// and writes the session on the http.ResponseWriter. +// +// This method should be called when a user logs in, for example. +func (s *Service) IssueUserSession(userID string, json string, w http.ResponseWriter) (*user.Session, *sessionerrs.Custom) { + userSession := user.New(userID, json, s.options.ExpirationDuration) + + // sign the session id + signedSessionID, err := s.auth.SignAndBase64Encode(userSession.ID) + if err != nil { + return nil, err + } + + // save the session in the store + err = s.store.SaveUserSession(userSession) + if err != nil { + return nil, err + } + + // set the session on the responseWriter + return userSession, s.transport.SetSessionOnResponse(signedSessionID, userSession, w) +} + +// ClearUserSession is used to remove the user session from the store and clear the cookies on the ResponseWriter. +// +// This method should be called when a user logs out, for example. +func (s *Service) ClearUserSession(userSession *user.Session, w http.ResponseWriter) *sessionerrs.Custom { + // delete the session from the store + err := s.store.DeleteUserSession(userSession.ID) + if err != nil { + return err + } + + // delete the session from the response + return s.transport.DeleteSessionFromResponse(w) +} + +// GetUserSession returns a user session from a request. This method only returns valid sessions. Therefore, \ +// sessions that have expired, or that fail signature verification will return a custom session error with code 401 +func (s *Service) GetUserSession(r *http.Request) (*user.Session, *sessionerrs.Custom) { + // read the session from the request + signedSessionID, err := s.transport.FetchSessionFromRequest(r) + if err != nil { + return nil, err + } + + // decode the signedSessionID + sessionID, err := s.auth.VerifyAndDecode(signedSessionID) + if err != nil { + return nil, err + } + + // try fetching a valid session from the store + return s.store.FetchValidUserSession(sessionID) +} + +// ExtendUserSession extends the ExpiresAt of a session by the Options.ExpirationDuration +func (s *Service) ExtendUserSession(userSession *user.Session, r *http.Request, w http.ResponseWriter) *sessionerrs.Custom { + newExpiresAt := time.Now().Add(s.options.ExpirationDuration).UTC() + + // update the provided user session + userSession.ExpiresAt = newExpiresAt + + // save the session in the store with the extended expiry + err := s.store.SaveUserSession(userSession) + if err != nil { + return err + } + + // fetch the signed session id from the request + signedSessionID, err := s.transport.FetchSessionFromRequest(r) + if err != nil { + return err + } + + // finally, set the session on the responseWriter + return s.transport.SetSessionOnResponse(signedSessionID, userSession, w) +} diff --git a/sessions_interface.go b/service_interface.go similarity index 53% rename from sessions_interface.go rename to service_interface.go index 5c4538b..cf45fbe 100644 --- a/sessions_interface.go +++ b/service_interface.go @@ -7,10 +7,10 @@ import ( "github.com/adam-hanna/sessions/user" ) -// ServiceInterface is used to track state in an http application +// ServiceInterface defines the methods performed by the session service type ServiceInterface interface { - IssueUserSession(userID string, w http.ResponseWriter) (*user.Session, *sessionerrs.Custom) + IssueUserSession(userID string, json string, w http.ResponseWriter) (*user.Session, *sessionerrs.Custom) ClearUserSession(userSession *user.Session, w http.ResponseWriter) *sessionerrs.Custom GetUserSession(r *http.Request) (*user.Session, *sessionerrs.Custom) - RefreshUserSession(userSession *user.Session, w http.ResponseWriter) *sessionerrs.Custom + ExtendUserSession(userSession *user.Session, r *http.Request, w http.ResponseWriter) *sessionerrs.Custom } diff --git a/service_util.go b/service_util.go new file mode 100644 index 0000000..657f98f --- /dev/null +++ b/service_util.go @@ -0,0 +1,13 @@ +package sessions + +// setDefaultOptions sets default values for nil fields +// note @adam-hanna: this utility function should be improved. The fields and types of the options struct \ +// should not be hardcoded! +func setDefaultOptions(options *Options) { + emptyOptions := Options{} + if options.ExpirationDuration == emptyOptions.ExpirationDuration { + options.ExpirationDuration = DefaultExpirationDuration + } + + return +} diff --git a/sessionerrs/sessionerrs.go b/sessionerrs/sessionerrs.go index 4ff4bf2..688179d 100644 --- a/sessionerrs/sessionerrs.go +++ b/sessionerrs/sessionerrs.go @@ -1,8 +1,9 @@ package sessionerrs -// Custom provides a sessions error and also the type of http status code to return +// Custom is the error type returned by this session service package. +// This custom error is useful for calling funcs to determine which http status code to return to clients on err type Custom struct { - // Code corresponds to http status codes (e.g. 401 Unauthorized) + // Code corresponds to an http status code (e.g. 401 Unauthorized) Code int // Err is the actual error thrown Err error diff --git a/sessions.go b/sessions.go deleted file mode 100644 index c2ddab2..0000000 --- a/sessions.go +++ /dev/null @@ -1,324 +0,0 @@ -package sessions - -import ( - "errors" - "net/http" - "time" - - "github.com/adam-hanna/sessions/sessionerrs" - "github.com/adam-hanna/sessions/store" - "github.com/adam-hanna/sessions/user" - "github.com/garyburd/redigo/redis" -) - -const ( - // DefaultExpirationDuration sets the default session expiration time in seconds - DefaultExpirationDuration = 3 * 24 * time.Hour // 3 days - // DefaultCSRFHeaderKey is the default key that will be used for reading csrf strings from requests - DefaultCSRFHeaderKey = "X-CSRF-Token" -) - -// Service is an object that contains information about this user's session -type Service struct { - store store.ServiceInterface - options Options -} - -// Options defines the behavior of the session -type Options struct { - ExpirationDuration time.Duration - Key []byte - IsDevEnv bool - CSRFHeaderKey string -} - -func init() { - -} - -// New returns a new session service -func New(store store.ServiceInterface, sessionOptions Options) (*Service, error) { - if len(sessionOptions.Key) == 0 { - return &Service{}, errors.New("no session key") - } - setDefaultOptions(&sessionOptions) - return &Service{ - store: store, - options: sessionOptions, - }, nil -} - -// IssueUserSession grants a new user session, writes that session info to the redis db \ -// and writes the session and csrf cookies on the http.ResponseWriter. -// -// This method should be called when a user logs in, for example. -func (s *Service) IssueUserSession(userID string, w http.ResponseWriter) (*user.Session, *sessionerrs.Custom) { - userSession, err := user.New(userID, s.options.ExpirationDuration) - if err != nil { - return nil, &sessionerrs.Custom{ - Code: 500, - Err: err, - } - } - - // grab a redis connection from the pool - c := s.store.GetConnectionFromPool() - defer c.Close() - - // store the session in the db - _, err = c.Do("HMSET", userSession.ID, "UserID", userSession.UserID, "CSRF", userSession.CSRF, "ExpiresAt", userSession.ExpiresAt.Unix()) - if err != nil { - return nil, &sessionerrs.Custom{ - Code: 500, - Err: err, - } - } - - // set the expiration time of the redis key - _, err = c.Do("EXPIREAT", userSession.ID, userSession.ExpiresAt.Unix()) - if err != nil { - return nil, &sessionerrs.Custom{ - Code: 500, - Err: err, - } - } - - // sign the session id - // note: the csrf string is already base64 encoded - base64SessionValBytes := s.signAndEncodeSessionID(userSession.ID) - - // set the cookies - sessionCookie := http.Cookie{ - Name: "session", - Value: string(base64SessionValBytes[:]), - Expires: userSession.ExpiresAt, - Path: "/", - HttpOnly: true, - Secure: !s.options.IsDevEnv, - } - // note: csrf strings are set on the cookie, but are expected to be received in the header - csrfCookie := http.Cookie{ - Name: "csrf", - Value: userSession.CSRF, - Expires: userSession.ExpiresAt, - Path: "/", - HttpOnly: false, - Secure: !s.options.IsDevEnv, - } - http.SetCookie(w, &sessionCookie) - http.SetCookie(w, &csrfCookie) - - return userSession, nil -} - -// ClearUserSession is used to remove the user session from the db and clear the cookies on the ResponseWriter. -// -// This method should be called when a user logs out, for example. -func (s *Service) ClearUserSession(userSession *user.Session, w http.ResponseWriter) *sessionerrs.Custom { - aLongTimeAgo := time.Now().Add(-1000 * time.Hour) - - // grab a redis connection from the pool - c := s.store.GetConnectionFromPool() - defer c.Close() - - // set the expiration time of the redis key - _, err := c.Do("EXPIREAT", userSession.ID, aLongTimeAgo.Unix()) - if err != nil { - return &sessionerrs.Custom{ - Code: 500, - Err: err, - } - } - - // set the cookies - sessionCookie := http.Cookie{ - Name: "session", - Value: "", - Expires: aLongTimeAgo, - Path: "/", - HttpOnly: true, - Secure: !s.options.IsDevEnv, - } - // note: csrf strings are set on the cookie, but are expected to be received in the header - csrfCookie := http.Cookie{ - Name: "csrf", - Value: "", - Expires: aLongTimeAgo, - Path: "/", - HttpOnly: false, - Secure: !s.options.IsDevEnv, - } - http.SetCookie(w, &sessionCookie) - http.SetCookie(w, &csrfCookie) - - return nil -} - -// GetUserSession returns a user session from a request. This method only returns valid sessions. Therefore, \ -// sessions that have expired, that fail hmac signature verification, or that don't have matching csrf strings \ -// will return a custom session error with code 401 -func (s *Service) GetUserSession(r *http.Request) (*user.Session, *sessionerrs.Custom) { - // read the session cookie - sessionCookie, err := r.Cookie("session") - if err != nil { - if err == http.ErrNoCookie { - return nil, &sessionerrs.Custom{ - Code: 401, - Err: errors.New("no session cookie"), - } - } - return nil, &sessionerrs.Custom{ - Code: 500, - Err: err, - } - } - - // read the csrf string - csrfInReq := r.Header.Get(s.options.CSRFHeaderKey) - if csrfInReq == "" { - return nil, &sessionerrs.Custom{ - Code: 401, - Err: errors.New("no csrf string"), - } - } - - // decode the session cookie val - sessionCookieValueBytes := []byte(sessionCookie.Value) - decodedSessionCookieValueBytes, err := decode(sessionCookieValueBytes) - if err != nil { - return nil, &sessionerrs.Custom{ - Code: 500, - Err: err, - } - } - - // note: session uuid's are always 36 bytes long - sessionIDBytes := decodedSessionCookieValueBytes[:36] - sessionID := string(sessionIDBytes[:]) - hmacBytes := decodedSessionCookieValueBytes[36:] - - // verify the hmac signature - verified := verifyHMAC(&sessionIDBytes, &hmacBytes, &s.options.Key) - if !verified { - return nil, &sessionerrs.Custom{ - Code: 401, - Err: errors.New("invalid session signature"), - } - } - - // check the session id in the database - // first, grab a redis connection from the pool - c := s.store.GetConnectionFromPool() - defer c.Close() - - // check if the key exists - exists, err := redis.Bool(c.Do("EXISTS", sessionID)) - if err != nil { - return nil, &sessionerrs.Custom{ - Code: 500, - Err: err, - } - } - if !exists { - return nil, &sessionerrs.Custom{ - Code: 401, - Err: errors.New("session is expired"), - } - } - - var userID string - var csrf string - var expiresAtSeconds int64 - reply, err := redis.Values(c.Do("HMGET", sessionID, "UserID", "CSRF", "ExpiresAt")) - if err != nil { - return nil, &sessionerrs.Custom{ - Code: 500, - Err: err, - } - } - if len(reply) < 3 { - return nil, &sessionerrs.Custom{ - Code: 500, - Err: errors.New("error retrieving session data from store"), - } - } - if _, err := redis.Scan(reply, &userID, &csrf, &expiresAtSeconds); err != nil { - return nil, &sessionerrs.Custom{ - Code: 500, - Err: err, - } - } - - // check that the csrf from the req matches the csrf in the session - if csrf != csrfInReq { - return nil, &sessionerrs.Custom{ - Code: 401, - Err: errors.New("csrf doesn't match session"), - } - } - - userSession := &user.Session{ - ID: sessionID, - UserID: userID, - CSRF: csrf, - ExpiresAt: time.Unix(expiresAtSeconds, 0), - } - - return userSession, nil -} - -// RefreshUserSession extends the ExpiresAt of a session by the Options.ExpirationDuration -func (s *Service) RefreshUserSession(userSession *user.Session, w http.ResponseWriter) *sessionerrs.Custom { - newExpiresAt := time.Now().Add(s.options.ExpirationDuration).UTC() - - // update the provided user session - userSession.ExpiresAt = newExpiresAt - - // grab a redis connection from the pool - c := s.store.GetConnectionFromPool() - defer c.Close() - - // extend the key in the db - _, err := c.Do("EXPIREAT", userSession.ID, newExpiresAt.Unix()) - if err != nil { - return &sessionerrs.Custom{ - Code: 500, - Err: err, - } - } - // update the field value - _, err = c.Do("HSET", userSession.ID, "ExpiresAt", newExpiresAt.Unix()) - if err != nil { - return &sessionerrs.Custom{ - Code: 500, - Err: err, - } - } - - // finally, update the cookies - // note: the csrf string is already base64 encoded - base64SessionValBytes := s.signAndEncodeSessionID(userSession.ID) - - // set the cookies - sessionCookie := http.Cookie{ - Name: "session", - Value: string(base64SessionValBytes[:]), - Expires: userSession.ExpiresAt, - Path: "/", - HttpOnly: true, - Secure: !s.options.IsDevEnv, - } - // note: csrf strings are set on the cookie, but are expected to be received in the header - csrfCookie := http.Cookie{ - Name: "csrf", - Value: userSession.CSRF, - Expires: userSession.ExpiresAt, - Path: "/", - HttpOnly: false, - Secure: !s.options.IsDevEnv, - } - http.SetCookie(w, &sessionCookie) - http.SetCookie(w, &csrfCookie) - - return nil -} diff --git a/store/service.go b/store/service.go index e0389e0..bc62b0e 100644 --- a/store/service.go +++ b/store/service.go @@ -1,16 +1,14 @@ package store import ( + "errors" "time" + "github.com/adam-hanna/sessions/sessionerrs" + "github.com/adam-hanna/sessions/user" "github.com/garyburd/redigo/redis" ) -var ( - // Opts is used to store the behavior of the service - Opts Options -) - const ( // DefaultConnectionAddress defines the default connection address of the redis server DefaultConnectionAddress = ":6379" @@ -24,10 +22,11 @@ const ( // Service is a session store backed by a redis db type Service struct { - pool *redis.Pool + // Pool is a redigo *redis.Pool + Pool *redis.Pool } -// Options dictates the behavior of the session store +// Options defines the behavior of the session store type Options struct { ConnectionAddress string MaxIdleConnections int @@ -35,24 +34,115 @@ type Options struct { IdleTimeoutDuration time.Duration } -func init() { - -} - // New returns a new session store connected to a redis db -func New(opts Options) *Service { - setDefaultOptions(&opts) +// Alternatively, you can build your own redis store with &Service{Pool: yourCustomPool,} +func New(options Options) *Service { + setDefaultOptions(&options) return &Service{ - pool: &redis.Pool{ - MaxActive: opts.MaxActiveConnections, - MaxIdle: opts.MaxIdleConnections, - IdleTimeout: opts.IdleTimeoutDuration, - Dial: func() (redis.Conn, error) { return redis.Dial("tcp", opts.ConnectionAddress) }, + Pool: &redis.Pool{ + MaxActive: options.MaxActiveConnections, + MaxIdle: options.MaxIdleConnections, + IdleTimeout: options.IdleTimeoutDuration, + Dial: func() (redis.Conn, error) { return redis.Dial("tcp", options.ConnectionAddress) }, }, } } -// GetConnectionFromPool creates a new session from a pool -func (s *Service) GetConnectionFromPool() redis.Conn { - return s.pool.Get() +// SaveUserSession saves a user session in the store +func (s *Service) SaveUserSession(userSession *user.Session) *sessionerrs.Custom { + c := s.Pool.Get() + defer c.Close() + + // note @adam-hanna: should I pipeline these requests? + _, err := c.Do("HMSET", userSession.ID, "UserID", userSession.UserID, "JSON", userSession.JSON, "ExpiresAtSeconds", userSession.ExpiresAt.Unix()) + if err != nil { + return &sessionerrs.Custom{ + Code: 500, + Err: err, + } + } + + // set the expiration time of the redis key + _, err = c.Do("EXPIREAT", userSession.ID, userSession.ExpiresAt.Unix()) + if err != nil { + return &sessionerrs.Custom{ + Code: 500, + Err: err, + } + } + + return nil +} + +// DeleteUserSession deletes a user session from the store +func (s *Service) DeleteUserSession(sessionID string) *sessionerrs.Custom { + // grab a redis connection from the pool + c := s.Pool.Get() + defer c.Close() + + // set the expiration time of the redis key + aLongTimeAgo := time.Now().Add(-1000 * time.Hour) + _, err := c.Do("EXPIREAT", sessionID, aLongTimeAgo.Unix()) + if err != nil { + return &sessionerrs.Custom{ + Code: 500, + Err: err, + } + } + + return nil +} + +// FetchValidUserSession returns a valid user session or an err if the session has expired or does not exist +func (s *Service) FetchValidUserSession(sessionID string) (*user.Session, *sessionerrs.Custom) { + // check the session id in the database + // first, grab a redis connection from the pool + c := s.Pool.Get() + defer c.Close() + + // note @adam-hanna: should I pipeline these requests? + // check if the key exists + exists, err := redis.Bool(c.Do("EXISTS", sessionID)) + if err != nil { + return nil, &sessionerrs.Custom{ + Code: 500, + Err: err, + } + } + if !exists { + return nil, &sessionerrs.Custom{ + Code: 401, + Err: errors.New("session is expired or sessionID doesn't exist"), + } + } + + var userID string + var json string + var expiresAtSeconds int64 + reply, err := redis.Values(c.Do("HMGET", sessionID, "UserID", "JSON", "ExpiresAtSeconds")) + if err != nil { + return nil, &sessionerrs.Custom{ + Code: 500, + Err: err, + } + } + if len(reply) < 3 { + return nil, &sessionerrs.Custom{ + Code: 500, + Err: errors.New("error retrieving session data from store"), + } + } + if _, err := redis.Scan(reply, &userID, &json, &expiresAtSeconds); err != nil { + return nil, &sessionerrs.Custom{ + Code: 500, + Err: err, + } + } + + return &user.Session{ + ID: sessionID, + UserID: userID, + JSON: json, + ExpiresAt: time.Unix(expiresAtSeconds, 0), + }, nil } diff --git a/store/service_interface.go b/store/service_interface.go index a983542..8e4f5d3 100644 --- a/store/service_interface.go +++ b/store/service_interface.go @@ -1,10 +1,13 @@ package store import ( - "github.com/garyburd/redigo/redis" + "github.com/adam-hanna/sessions/sessionerrs" + "github.com/adam-hanna/sessions/user" ) -// ServiceInterface defines the behavior of the Redis struct +// ServiceInterface defines the behavior of the session store type ServiceInterface interface { - GetConnectionFromPool() redis.Conn + SaveUserSession(userSession *user.Session) *sessionerrs.Custom + DeleteUserSession(sessionID string) *sessionerrs.Custom + FetchValidUserSession(sessionID string) (*user.Session, *sessionerrs.Custom) } diff --git a/store/service_util.go b/store/service_util.go index a35957a..0811307 100644 --- a/store/service_util.go +++ b/store/service_util.go @@ -3,18 +3,22 @@ package store // setDefaultOptions sets default values for nil fields // note @adam-hanna: this utility function should be improved. The fields and types of the options struct \ // should not be hardcoded! -func setDefaultOptions(opts *Options) { - emptyOpts := Options{} - if opts.ConnectionAddress == emptyOpts.ConnectionAddress { - opts.ConnectionAddress = DefaultConnectionAddress +func setDefaultOptions(options *Options) { + emptyOptions := Options{} + if options.ConnectionAddress == emptyOptions.ConnectionAddress { + options.ConnectionAddress = DefaultConnectionAddress } - if opts.MaxIdleConnections == emptyOpts.MaxIdleConnections { - opts.MaxIdleConnections = DefaultMaxIdleConnections - } - if opts.MaxActiveConnections == emptyOpts.MaxActiveConnections { - opts.MaxActiveConnections = DefaultMaxActiveConnections - } - if opts.IdleTimeoutDuration == emptyOpts.IdleTimeoutDuration { - opts.IdleTimeoutDuration = DefaultIdleTimeoutDuration + // note @adam-hanna: what if someone sends in a value of 0? This will set it to default! + // if options.MaxIdleConnections == emptyOptions.MaxIdleConnections { + // options.MaxIdleConnections = DefaultMaxIdleConnections + // } + // note @adam-hanna: what if someone sends in a value of 0? This will set it to default! + // if options.MaxActiveConnections == emptyOptions.MaxActiveConnections { + // options.MaxActiveConnections = DefaultMaxActiveConnections + // } + if options.IdleTimeoutDuration == emptyOptions.IdleTimeoutDuration { + options.IdleTimeoutDuration = DefaultIdleTimeoutDuration } + + return } diff --git a/transport/service.go b/transport/service.go new file mode 100644 index 0000000..014d79d --- /dev/null +++ b/transport/service.go @@ -0,0 +1,92 @@ +package transport + +import ( + "errors" + "net/http" + "time" + + "github.com/adam-hanna/sessions/sessionerrs" + "github.com/adam-hanna/sessions/user" +) + +const ( + // DefaultCookieName is the default cookie name used + DefaultCookieName = "session" + // DefaultCookiePath is the default cookie path + DefaultCookiePath = "/" + // DefaultHTTPOnlyCookie is the default HTTPOnly option of the cookie + DefaultHTTPOnlyCookie = true + // DefaultSecureCookie is the default Secure option of the cookie + DefaultSecureCookie = true +) + +// Service writes sessions on responseWriters and reads sessions from requests +type Service struct { + options Options +} + +// Options defines the behavior of the transport service +type Options struct { + CookieName string + CookiePath string + HTTPOnly bool + Secure bool +} + +// New returns a new transport service +func New(options Options) *Service { + setDefaultOptions(&options) + return &Service{ + options: options, + } +} + +// SetSessionOnResponse sets a signed session id and a user session on a responseWriter +func (s *Service) SetSessionOnResponse(signedSessionID string, userSession *user.Session, w http.ResponseWriter) *sessionerrs.Custom { + sessionCookie := http.Cookie{ + Name: s.options.CookieName, + Value: signedSessionID, + Expires: userSession.ExpiresAt, + Path: s.options.CookiePath, + HttpOnly: s.options.HTTPOnly, + Secure: s.options.Secure, + } + http.SetCookie(w, &sessionCookie) + + return nil +} + +// DeleteSessionFromResponse deletes a user session from a responseWriter +func (s *Service) DeleteSessionFromResponse(w http.ResponseWriter) *sessionerrs.Custom { + aLongTimeAgo := time.Now().Add(-1000 * time.Hour) + nullSessionCookie := http.Cookie{ + Name: s.options.CookieName, + Value: "", + Expires: aLongTimeAgo, + Path: s.options.CookiePath, + HttpOnly: s.options.HTTPOnly, + Secure: s.options.Secure, + } + http.SetCookie(w, &nullSessionCookie) + + return nil +} + +// FetchSessionFromRequest retrieves a signed session id from a request +func (s *Service) FetchSessionFromRequest(r *http.Request) (string, *sessionerrs.Custom) { + sessionCookie, err := r.Cookie(s.options.CookieName) + if err != nil { + if err == http.ErrNoCookie { + return "", &sessionerrs.Custom{ + Code: 401, + Err: errors.New("no session on request"), + } + } + return "", &sessionerrs.Custom{ + Code: 500, + Err: err, + } + } + + return sessionCookie.Value, nil +} diff --git a/transport/service_interface.go b/transport/service_interface.go new file mode 100644 index 0000000..93d0ceb --- /dev/null +++ b/transport/service_interface.go @@ -0,0 +1,15 @@ +package transport + +import ( + "net/http" + + "github.com/adam-hanna/sessions/sessionerrs" + "github.com/adam-hanna/sessions/user" +) + +// ServiceInterface defines the methods performed by the transport service +type ServiceInterface interface { + SetSessionOnResponse(session string, userSession *user.Session, w http.ResponseWriter) *sessionerrs.Custom + DeleteSessionFromResponse(w http.ResponseWriter) *sessionerrs.Custom + FetchSessionFromRequest(r *http.Request) (string, *sessionerrs.Custom) +} diff --git a/transport/service_util.go b/transport/service_util.go new file mode 100644 index 0000000..e6a0725 --- /dev/null +++ b/transport/service_util.go @@ -0,0 +1,23 @@ +package transport + +// setDefaultOptions sets default values for nil fields +// note @adam-hanna: this utility function should be improved. The fields and types of the options struct \ +// should not be hardcoded! +func setDefaultOptions(options *Options) { + emptyOptions := Options{} + if options.CookieName == emptyOptions.CookieName { + options.CookieName = DefaultCookieName + } + if options.CookiePath == emptyOptions.CookiePath { + options.CookiePath = DefaultCookiePath + } + // note @adam-hanna: how to check for default bool vals? What if someone sends in a value that is false? + // if options.HTTPOnly == emptyOptions.HTTPOnly { + // options.HTTPOnly = DefaultHTTPOnlyCookie + // } + // if options.Secure == emptyOptions.Secure { + // options.Secure = DefaultSecureCookie + // } + + return +} diff --git a/user/user.go b/user/user.go index f8b9fb6..d1cb618 100644 --- a/user/user.go +++ b/user/user.go @@ -11,16 +11,15 @@ type Session struct { ID string UserID string ExpiresAt time.Time - CSRF string + JSON string } // New returns a new user Session -func New(userID string, duration time.Duration) (*Session, error) { - csrf, err := generateNewCsrfString() +func New(userID string, json string, duration time.Duration) *Session { return &Session{ ID: uuid.New(), UserID: userID, ExpiresAt: time.Now().Add(duration).UTC(), - CSRF: csrf, - }, err + JSON: json, + } } diff --git a/user/user_util.go b/user/user_util.go deleted file mode 100644 index d8481bc..0000000 --- a/user/user_util.go +++ /dev/null @@ -1,15 +0,0 @@ -package user - -import ( - "crypto/rand" - "encoding/base64" - "io" -) - -func generateNewCsrfString() (string, error) { - b := make([]byte, 32) - if _, err := io.ReadFull(rand.Reader, b); err != nil { - return "", err - } - return base64.URLEncoding.EncodeToString(b), nil -}