From 0f1a265e5a6ed10d521b2b229c06061074257bb7 Mon Sep 17 00:00:00 2001 From: corebreaker Date: Mon, 31 Jul 2017 21:49:30 +0300 Subject: [PATCH 1/3] Fix up for sessions - Restore expiration state of the sessions - Add APIs to modify expire date - Free the timer for the session destroy task Former-commit-id: 4d796a9efba2c37cd3750275ac62c068ceb82be8 --- sessions/database.go | 17 +++- sessions/provider.go | 116 +++++++++++++++++++---- sessions/session.go | 20 +++- sessions/sessiondb/redis/database.go | 27 +++++- sessions/sessions.go | 137 +++++++++++++++------------ 5 files changed, 226 insertions(+), 91 deletions(-) diff --git a/sessions/database.go b/sessions/database.go index a22eb1a9..90744ee9 100644 --- a/sessions/database.go +++ b/sessions/database.go @@ -1,17 +1,24 @@ package sessions +import "time" + // Database is the interface which all session databases should implement // By design it doesn't support any type of cookie session like other frameworks, // I want to protect you, believe me, no context access (although we could) // The scope of the database is to session somewhere the sessions in order to // keep them after restarting the server, nothing more. -// the values are sessiond by the underline session, the check for new sessions, or -// 'this session value should added' are made automatically you are able just to set the values to your backend database with Load function. +// the values are sessions by the underline session, the check for new sessions, or +// 'this session value should added' are made automatically +// you are able just to set the values to your backend database with Load function. // session database doesn't have any write or read access to the session, the loading of -// the initial data is done by the Load(string) map[string]interfface{} function +// the initial data is done by the Load(string) (map[string]interfface{}, *time.Time) function // synchronization are made automatically, you can register more than one session database // but the first non-empty Load return data will be used as the session values. +// The Expire Date is given with data to save because the session entry must keep trace +// of the expire date in the case of the server is restarted. So the server will recover +// expiration state of session entry and it will track the expiration again. +// If expireDate is nil, that's means that there is no expire date. type Database interface { - Load(string) map[string]interface{} - Update(string, map[string]interface{}) + Load(sid string) (datas map[string]interface{}, expireDate *time.Time) + Update(sid string, datas map[string]interface{}, expireDate *time.Time) } diff --git a/sessions/provider.go b/sessions/provider.go index 4eb3ce6d..84d7c003 100644 --- a/sessions/provider.go +++ b/sessions/provider.go @@ -36,43 +36,76 @@ func (p *provider) RegisterDatabase(db Database) { p.mu.Unlock() } -// newSession returns a new session from sessionid -func (p *provider) newSession(sid string, expires time.Duration) *Session { - sess := &Session{ - sid: sid, - provider: p, - values: p.loadSessionValuesFromDB(sid), - flashes: make(map[string]*flashMessage), - } +// startAutoDestroy start a task which destoy the session when expire date is reached, +// but only if `expires` parameter is positive. It updates the expire date of the session from `expires` parameter. +func (p *provider) startAutoDestroy(s *Session, expires time.Duration) bool { + res := expires > 0 + if res { // if not unlimited life duration and no -1 (cookie remove action is based on browser's session) + expireDate := time.Now().Add(expires) - if expires > 0 { // if not unlimited life duration and no -1 (cookie remove action is based on browser's session) - time.AfterFunc(expires, func() { + s.expireAt = &expireDate + s.timer = time.AfterFunc(expires, func() { // the destroy makes the check if this session is exists then or not, // this is used to destroy the session from the server-side also // it's good to have here for security reasons, I didn't add it on the gc function to separate its action - p.Destroy(sid) + p.Destroy(s.sid) }) } + return res +} + +// newSession returns a new session from sessionid +func (p *provider) newSession(sid string, expires time.Duration) *Session { + values, expireAt := p.loadSessionValuesFromDB(sid) + + sess := &Session{ + sid: sid, + provider: p, + values: values, + flashes: make(map[string]*flashMessage), + expireAt: expireAt, + } + + if (len(values) > 0) && (sess.expireAt != nil) { + // Restore expiration state + // However, if session save in database has no expiration date, + // therefore the expiration will be reinitialised with session configuration + expires = sess.expireAt.Sub(time.Now()) + } + + p.startAutoDestroy(sess, expires) + return sess } -// can return nil -func (p *provider) loadSessionValuesFromDB(sid string) memstore.Store { +// can return nil memstore +func (p *provider) loadSessionValuesFromDB(sid string) (memstore.Store, *time.Time) { var store memstore.Store + var expireDate *time.Time for i, n := 0, len(p.databases); i < n; i++ { - if dbValues := p.databases[i].Load(sid); dbValues != nil && len(dbValues) > 0 { + dbValues, currentExpireDate := p.databases[i].Load(sid) + if dbValues != nil && len(dbValues) > 0 { for k, v := range dbValues { store.Set(k, v) } } + + if (currentExpireDate != nil) && ((expireDate == nil) || expireDate.After(*currentExpireDate)) { + expireDate = currentExpireDate + } } - return store + + // Check if session has already expired + if (expireDate != nil) && expireDate.Before(time.Now()) { + return nil, nil + } + + return store, expireDate } -func (p *provider) updateDatabases(sid string, store memstore.Store) { - +func (p *provider) updateDatabases(sess *Session, store memstore.Store) { if l := store.Len(); l > 0 { mapValues := make(map[string]interface{}, l) @@ -81,7 +114,7 @@ func (p *provider) updateDatabases(sid string, store memstore.Store) { }) for i, n := 0, len(p.databases); i < n; i++ { - p.databases[i].Update(sid, mapValues) + p.databases[i].Update(sess.sid, mapValues, sess.expireAt) } } } @@ -95,17 +128,50 @@ func (p *provider) Init(sid string, expires time.Duration) *Session { return newSession } +// UpdateExpiraton update expire date of a session, plus it updates destroy task +func (p *provider) UpdateExpiraton(sid string, expires time.Duration) (done bool) { + if expires <= 0 { + return false + } + + p.mu.Lock() + sess, found := p.sessions[sid] + p.mu.Unlock() + + if !found { + return false + } + + if sess.timer == nil { + return p.startAutoDestroy(sess, expires) + } else { + if expires <= 0 { + sess.timer.Stop() + sess.timer = nil + sess.expireAt = nil + } else { + expireDate := time.Now().Add(expires) + + sess.expireAt = &expireDate + sess.timer.Reset(expires) + } + } + + return true +} + // Read returns the store which sid parameter belongs func (p *provider) Read(sid string, expires time.Duration) *Session { p.mu.Lock() if sess, found := p.sessions[sid]; found { sess.runFlashGC() // run the flash messages GC, new request here of existing session p.mu.Unlock() + return sess } p.mu.Unlock() - return p.Init(sid, expires) // if not found create new + return p.Init(sid, expires) // if not found create new } // Destroy destroys the session, removes all sessions and flash values, @@ -116,8 +182,12 @@ func (p *provider) Destroy(sid string) { if sess, found := p.sessions[sid]; found { sess.values = nil sess.flashes = nil + if sess.timer != nil { + sess.timer.Stop() + } + delete(p.sessions, sid) - p.updateDatabases(sid, nil) + p.updateDatabases(sess, nil) } p.mu.Unlock() @@ -129,8 +199,12 @@ func (p *provider) Destroy(sid string) { func (p *provider) DestroyAll() { p.mu.Lock() for _, sess := range p.sessions { + if sess.timer != nil { + sess.timer.Stop() + } + delete(p.sessions, sess.ID()) - p.updateDatabases(sess.ID(), nil) + p.updateDatabases(sess, nil) } p.mu.Unlock() diff --git a/sessions/session.go b/sessions/session.go index 69e78821..7c91a659 100644 --- a/sessions/session.go +++ b/sessions/session.go @@ -27,6 +27,8 @@ type ( flashes map[string]*flashMessage mu sync.RWMutex createdAt time.Time + expireAt *time.Time // nil pointer means no expire date + timer *time.Timer provider *provider } @@ -42,6 +44,22 @@ func (s *Session) ID() string { return s.sid } +// HasExpireDate test if this session has an expire date, if not, this session never expires +func (s *Session) HasExpireDate() bool { + return s.expireAt != nil +} + +// GetExpireDate get the expire date, if this session has no expire date, the returned value has the zero value +func (s *Session) GetExpireDate() time.Time { + var res time.Time + + if s.expireAt != nil { + res = *s.expireAt + } + + return res +} + // Get returns a value based on its "key". func (s *Session) Get(key string) interface{} { s.mu.RLock() @@ -322,7 +340,7 @@ func (s *Session) Delete(key string) bool { } func (s *Session) updateDatabases() { - s.provider.updateDatabases(s.sid, s.values) + s.provider.updateDatabases(s, s.values) } // DeleteFlash removes a flash message by its key. diff --git a/sessions/sessiondb/redis/database.go b/sessions/sessiondb/redis/database.go index 97386927..8a9885af 100644 --- a/sessions/sessiondb/redis/database.go +++ b/sessions/sessiondb/redis/database.go @@ -1,6 +1,7 @@ package redis import ( + "time" "bytes" "encoding/gob" @@ -23,7 +24,7 @@ func (d *Database) Config() *service.Config { } // Load loads the values to the underline. -func (d *Database) Load(sid string) map[string]interface{} { +func (d *Database) Load(sid string) (datas map[string]interface{}, expireDate *time.Time) { values := make(map[string]interface{}) if !d.redis.Connected { //yes, check every first time's session for valid redis connection @@ -38,6 +39,7 @@ func (d *Database) Load(sid string) map[string]interface{} { } } } + //fetch the values from this session id and copy-> store them val, err := d.redis.GetBytes(sid) if err == nil { @@ -45,8 +47,19 @@ func (d *Database) Load(sid string) map[string]interface{} { DeserializeBytes(val, &values) } - return values + datas, _ = values["session-data"].(map[string]interface{}) + dbExpireDateValue, exists := values["expire-date"] + if !exists { + return + } + + expireDateValue, ok := dbExpireDateValue.(time.Time) + if !ok { + return + } + + return datas, &expireDateValue } // serialize the values to be stored as strings inside the Redis, we panic at any serialization error here @@ -60,11 +73,17 @@ func serialize(values map[string]interface{}) []byte { } // Update updates the real redis store -func (d *Database) Update(sid string, newValues map[string]interface{}) { +func (d *Database) Update(sid string, newValues map[string]interface{}, expireDate *time.Time) { if len(newValues) == 0 { go d.redis.Delete(sid) } else { - go d.redis.Set(sid, serialize(newValues)) //set/update all the values + datas := map[string]interface{}{"session-data": newValues} + if expireDate != nil { + datas["expire-date"] = *expireDate + } + + //set/update all the values + go d.redis.Set(sid, serialize(datas)) } } diff --git a/sessions/sessions.go b/sessions/sessions.go index db6ae975..cf48605c 100644 --- a/sessions/sessions.go +++ b/sessions/sessions.go @@ -35,6 +35,70 @@ func (s *Sessions) UseDatabase(db Database) { s.provider.RegisterDatabase(db) } +// updateCookie gains the ability of updating the session browser cookie to any method which wants to update it +func (s *Sessions) updateCookie(sid string, ctx context.Context, expires time.Duration) { + cookie := &http.Cookie{} + + // The RFC makes no mention of encoding url value, so here I think to encode both sessionid key and the value using the safe(to put and to use as cookie) url-encoding + cookie.Name = s.config.Cookie + + cookie.Value = sid + cookie.Path = "/" + if !s.config.DisableSubdomainPersistence { + + requestDomain := ctx.Request().URL.Host + if portIdx := strings.IndexByte(requestDomain, ':'); portIdx > 0 { + requestDomain = requestDomain[0:portIdx] + } + if IsValidCookieDomain(requestDomain) { + + // RFC2109, we allow level 1 subdomains, but no further + // if we have localhost.com , we want the localhost.cos. + // so if we have something like: mysubdomain.localhost.com we want the localhost here + // if we have mysubsubdomain.mysubdomain.localhost.com we want the .mysubdomain.localhost.com here + // slow things here, especially the 'replace' but this is a good and understable( I hope) way to get the be able to set cookies from subdomains & domain with 1-level limit + if dotIdx := strings.LastIndexByte(requestDomain, '.'); dotIdx > 0 { + // is mysubdomain.localhost.com || mysubsubdomain.mysubdomain.localhost.com + s := requestDomain[0:dotIdx] // set mysubdomain.localhost || mysubsubdomain.mysubdomain.localhost + if secondDotIdx := strings.LastIndexByte(s, '.'); secondDotIdx > 0 { + //is mysubdomain.localhost || mysubsubdomain.mysubdomain.localhost + s = s[secondDotIdx+1:] // set to localhost || mysubdomain.localhost + } + // replace the s with the requestDomain before the domain's siffux + subdomainSuff := strings.LastIndexByte(requestDomain, '.') + if subdomainSuff > len(s) { // if it is actual exists as subdomain suffix + requestDomain = strings.Replace(requestDomain, requestDomain[0:subdomainSuff], s, 1) // set to localhost.com || mysubdomain.localhost.com + } + } + // finally set the .localhost.com (for(1-level) || .mysubdomain.localhost.com (for 2-level subdomain allow) + cookie.Domain = "." + requestDomain // . to allow persistence + } + } + + cookie.HttpOnly = true + // MaxAge=0 means no 'Max-Age' attribute specified. + // MaxAge<0 means delete cookie now, equivalently 'Max-Age: 0' + // MaxAge>0 means Max-Age attribute present and given in seconds + if expires >= 0 { + if expires == 0 { // unlimited life + cookie.Expires = CookieExpireUnlimited + } else { // > 0 + cookie.Expires = time.Now().Add(expires) + } + cookie.MaxAge = int(cookie.Expires.Sub(time.Now()).Seconds()) + } + + // set the cookie to secure if this is a tls wrapped request + // and the configuration allows it. + if ctx.Request().TLS != nil && s.config.CookieSecureTLS { + cookie.Secure = true + } + + // encode the session id cookie client value right before send it. + cookie.Value = s.encodeCookieValue(cookie.Value) + AddCookie(ctx, cookie) +} + // Start should start the session for the particular request. func (s *Sessions) Start(ctx context.Context) *Session { cookieValue := GetCookie(ctx, s.config.Cookie) @@ -43,66 +107,7 @@ func (s *Sessions) Start(ctx context.Context) *Session { sid := s.config.SessionIDGenerator() sess := s.provider.Init(sid, s.config.Expires) - cookie := &http.Cookie{} - - // The RFC makes no mention of encoding url value, so here I think to encode both sessionid key and the value using the safe(to put and to use as cookie) url-encoding - cookie.Name = s.config.Cookie - - cookie.Value = sid - cookie.Path = "/" - if !s.config.DisableSubdomainPersistence { - - requestDomain := ctx.Request().URL.Host - if portIdx := strings.IndexByte(requestDomain, ':'); portIdx > 0 { - requestDomain = requestDomain[0:portIdx] - } - if IsValidCookieDomain(requestDomain) { - - // RFC2109, we allow level 1 subdomains, but no further - // if we have localhost.com , we want the localhost.cos. - // so if we have something like: mysubdomain.localhost.com we want the localhost here - // if we have mysubsubdomain.mysubdomain.localhost.com we want the .mysubdomain.localhost.com here - // slow things here, especially the 'replace' but this is a good and understable( I hope) way to get the be able to set cookies from subdomains & domain with 1-level limit - if dotIdx := strings.LastIndexByte(requestDomain, '.'); dotIdx > 0 { - // is mysubdomain.localhost.com || mysubsubdomain.mysubdomain.localhost.com - s := requestDomain[0:dotIdx] // set mysubdomain.localhost || mysubsubdomain.mysubdomain.localhost - if secondDotIdx := strings.LastIndexByte(s, '.'); secondDotIdx > 0 { - //is mysubdomain.localhost || mysubsubdomain.mysubdomain.localhost - s = s[secondDotIdx+1:] // set to localhost || mysubdomain.localhost - } - // replace the s with the requestDomain before the domain's siffux - subdomainSuff := strings.LastIndexByte(requestDomain, '.') - if subdomainSuff > len(s) { // if it is actual exists as subdomain suffix - requestDomain = strings.Replace(requestDomain, requestDomain[0:subdomainSuff], s, 1) // set to localhost.com || mysubdomain.localhost.com - } - } - // finally set the .localhost.com (for(1-level) || .mysubdomain.localhost.com (for 2-level subdomain allow) - cookie.Domain = "." + requestDomain // . to allow persistence - } - } - - cookie.HttpOnly = true - // MaxAge=0 means no 'Max-Age' attribute specified. - // MaxAge<0 means delete cookie now, equivalently 'Max-Age: 0' - // MaxAge>0 means Max-Age attribute present and given in seconds - if s.config.Expires >= 0 { - if s.config.Expires == 0 { // unlimited life - cookie.Expires = CookieExpireUnlimited - } else { // > 0 - cookie.Expires = time.Now().Add(s.config.Expires) - } - cookie.MaxAge = int(cookie.Expires.Sub(time.Now()).Seconds()) - } - - // set the cookie to secure if this is a tls wrapped request - // and the configuration allows it. - if ctx.Request().TLS != nil && s.config.CookieSecureTLS { - cookie.Secure = true - } - - // encode the session id cookie client value right before send it. - cookie.Value = s.encodeCookieValue(cookie.Value) - AddCookie(ctx, cookie) + s.updateCookie(sid, ctx, s.config.Expires) return sess } @@ -113,6 +118,18 @@ func (s *Sessions) Start(ctx context.Context) *Session { } +// ShiftExpiraton move the expire date of a session to a new date by using session default timeout configuration +func (s *Sessions) ShiftExpiraton(ctx context.Context, sid string) { + s.UpdateExpiraton(ctx, sid, s.config.Expires) +} + +// UpdateExpiraton change expire date of a session to a new date by using timeout value passed by `expires` parameter +func (s *Sessions) UpdateExpiraton(ctx context.Context, sid string, expires time.Duration) { + if s.provider.UpdateExpiraton(sid, expires) { + s.updateCookie(sid, ctx, expires) + } +} + // Destroy remove the session data and remove the associated cookie. func (s *Sessions) Destroy(ctx context.Context) { cookieValue := GetCookie(ctx, s.config.Cookie) From ef979cf541bec0c601ec11e733571d3d8ff2927d Mon Sep 17 00:00:00 2001 From: corebreaker Date: Tue, 1 Aug 2017 08:34:18 +0300 Subject: [PATCH 2/3] Little change of session API + Update examples Former-commit-id: e8c58b186acf1c8932af6e886dae7035f4d581c1 --- _examples/sessions/database/main.go | 5 +++++ _examples/sessions/securecookie/main.go | 8 +++++++- _examples/sessions/standalone/main.go | 5 +++++ sessions/sessions.go | 15 ++++++++++----- 4 files changed, 27 insertions(+), 6 deletions(-) diff --git a/_examples/sessions/database/main.go b/_examples/sessions/database/main.go index 831d89e8..c2a82682 100644 --- a/_examples/sessions/database/main.go +++ b/_examples/sessions/database/main.go @@ -65,5 +65,10 @@ func main() { sess.Destroy(ctx) }) + app.Get("/update", func(ctx context.Context) { + // updates expire date with a new date + sess.ShiftExpiraton(ctx) + }) + app.Run(iris.Addr(":8080")) } diff --git a/_examples/sessions/securecookie/main.go b/_examples/sessions/securecookie/main.go index 76197798..f068ec57 100644 --- a/_examples/sessions/securecookie/main.go +++ b/_examples/sessions/securecookie/main.go @@ -62,10 +62,16 @@ func newApp() *iris.Application { mySessions.Start(ctx).Clear() }) + app.Get("/update", func(ctx context.Context) { + // updates expire date with a new date + mySessions.ShiftExpiraton(ctx) + }) + app.Get("/destroy", func(ctx context.Context) { //destroy, removes the entire session data and cookie mySessions.Destroy(ctx) - }) // Note about destroy: + }) + // Note about destroy: // // You can destroy a session outside of a handler too, using the: // mySessions.DestroyByID diff --git a/_examples/sessions/standalone/main.go b/_examples/sessions/standalone/main.go index b26ebf73..12d61e1f 100644 --- a/_examples/sessions/standalone/main.go +++ b/_examples/sessions/standalone/main.go @@ -69,6 +69,11 @@ func main() { sess.Start(ctx).Clear() }) + app.Get("/update", func(ctx context.Context) { + // updates expire date + sess.ShiftExpiraton(ctx) + }) + app.Get("/destroy", func(ctx context.Context) { //destroy, removes the entire session data and cookie diff --git a/sessions/sessions.go b/sessions/sessions.go index cf48605c..43054b7b 100644 --- a/sessions/sessions.go +++ b/sessions/sessions.go @@ -119,14 +119,19 @@ func (s *Sessions) Start(ctx context.Context) *Session { } // ShiftExpiraton move the expire date of a session to a new date by using session default timeout configuration -func (s *Sessions) ShiftExpiraton(ctx context.Context, sid string) { - s.UpdateExpiraton(ctx, sid, s.config.Expires) +func (s *Sessions) ShiftExpiraton(ctx context.Context) { + s.UpdateExpiraton(ctx, s.config.Expires) } // UpdateExpiraton change expire date of a session to a new date by using timeout value passed by `expires` parameter -func (s *Sessions) UpdateExpiraton(ctx context.Context, sid string, expires time.Duration) { - if s.provider.UpdateExpiraton(sid, expires) { - s.updateCookie(sid, ctx, expires) +func (s *Sessions) UpdateExpiraton(ctx context.Context, expires time.Duration) { + cookieValue := GetCookie(ctx, s.config.Cookie) + + if cookieValue != "" { + sid := s.decodeCookieValue(cookieValue) + if s.provider.UpdateExpiraton(sid, expires) { + s.updateCookie(sid, ctx, expires) + } } } From 9b61ce2531a2b9f68ac6cd7c065743f38c6f672b Mon Sep 17 00:00:00 2001 From: corebreaker Date: Tue, 1 Aug 2017 11:00:30 +0300 Subject: [PATCH 3/3] Add `IsNew` flag on sessions Former-commit-id: 94ac010a156bbe124033da2cbaac05fc4726d189 --- sessions/session.go | 13 ++++++++++++- sessions/sessions.go | 4 +++- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/sessions/session.go b/sessions/session.go index 7c91a659..b998d9f8 100644 --- a/sessions/session.go +++ b/sessions/session.go @@ -17,6 +17,7 @@ type ( // This is what will be returned when sess := sessions.Start(). Session struct { sid string + isNew bool values memstore.Store // here are the real values // we could set the flash messages inside values but this will bring us more problems // because of session databases and because of @@ -26,7 +27,6 @@ type ( // NOTE: flashes are not managed by third-party, only inside session struct. flashes map[string]*flashMessage mu sync.RWMutex - createdAt time.Time expireAt *time.Time // nil pointer means no expire date timer *time.Timer provider *provider @@ -44,6 +44,11 @@ func (s *Session) ID() string { return s.sid } +// IsNew returns true if is's a new session +func (s *Session) IsNew() bool { + return s.isNew +} + // HasExpireDate test if this session has an expire date, if not, this session never expires func (s *Session) HasExpireDate() bool { return s.expireAt != nil @@ -285,6 +290,7 @@ func (s *Session) set(key string, value interface{}, immutable bool) { s.mu.Unlock() s.updateDatabases() + s.isNew = false } // Set fills the session with an entry"value", based on its "key". @@ -336,6 +342,10 @@ func (s *Session) Delete(key string) bool { s.mu.Unlock() s.updateDatabases() + if removed { + s.isNew = false + } + return removed } @@ -357,6 +367,7 @@ func (s *Session) Clear() { s.mu.Unlock() s.updateDatabases() + s.isNew = false } // ClearFlashes removes all flash messages. diff --git a/sessions/sessions.go b/sessions/sessions.go index 43054b7b..eead9553 100644 --- a/sessions/sessions.go +++ b/sessions/sessions.go @@ -107,6 +107,8 @@ func (s *Sessions) Start(ctx context.Context) *Session { sid := s.config.SessionIDGenerator() sess := s.provider.Init(sid, s.config.Expires) + sess.isNew = len(sess.values) == 0 + s.updateCookie(sid, ctx, s.config.Expires) return sess @@ -114,8 +116,8 @@ func (s *Sessions) Start(ctx context.Context) *Session { cookieValue = s.decodeCookieValue(cookieValue) sess := s.provider.Read(cookieValue, s.config.Expires) - return sess + return sess } // ShiftExpiraton move the expire date of a session to a new date by using session default timeout configuration