Merge pull request #693 from corebreaker/session-expiration

Fix up for sessions

Former-commit-id: 12b18902b4776335053b4d971ec564a9659a4c2d
This commit is contained in:
Gerasimos (Makis) Maropoulos 2017-08-01 13:13:09 +03:00 committed by GitHub
commit 351f099ad6
8 changed files with 262 additions and 93 deletions

View File

@ -65,5 +65,10 @@ func main() {
sess.Destroy(ctx) 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")) app.Run(iris.Addr(":8080"))
} }

View File

@ -62,10 +62,16 @@ func newApp() *iris.Application {
mySessions.Start(ctx).Clear() 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) { app.Get("/destroy", func(ctx context.Context) {
//destroy, removes the entire session data and cookie //destroy, removes the entire session data and cookie
mySessions.Destroy(ctx) mySessions.Destroy(ctx)
}) // Note about destroy: })
// Note about destroy:
// //
// You can destroy a session outside of a handler too, using the: // You can destroy a session outside of a handler too, using the:
// mySessions.DestroyByID // mySessions.DestroyByID

View File

@ -69,6 +69,11 @@ func main() {
sess.Start(ctx).Clear() sess.Start(ctx).Clear()
}) })
app.Get("/update", func(ctx context.Context) {
// updates expire date
sess.ShiftExpiraton(ctx)
})
app.Get("/destroy", func(ctx context.Context) { app.Get("/destroy", func(ctx context.Context) {
//destroy, removes the entire session data and cookie //destroy, removes the entire session data and cookie

View File

@ -1,17 +1,24 @@
package sessions package sessions
import "time"
// Database is the interface which all session databases should implement // Database is the interface which all session databases should implement
// By design it doesn't support any type of cookie session like other frameworks, // 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) // 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 // The scope of the database is to session somewhere the sessions in order to
// keep them after restarting the server, nothing more. // keep them after restarting the server, nothing more.
// the values are sessiond by the underline session, the check for new sessions, or // 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. // '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 // 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 // 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. // 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 { type Database interface {
Load(string) map[string]interface{} Load(sid string) (datas map[string]interface{}, expireDate *time.Time)
Update(string, map[string]interface{}) Update(sid string, datas map[string]interface{}, expireDate *time.Time)
} }

View File

@ -36,43 +36,76 @@ func (p *provider) RegisterDatabase(db Database) {
p.mu.Unlock() p.mu.Unlock()
} }
// newSession returns a new session from sessionid // startAutoDestroy start a task which destoy the session when expire date is reached,
func (p *provider) newSession(sid string, expires time.Duration) *Session { // but only if `expires` parameter is positive. It updates the expire date of the session from `expires` parameter.
sess := &Session{ func (p *provider) startAutoDestroy(s *Session, expires time.Duration) bool {
sid: sid, res := expires > 0
provider: p, if res { // if not unlimited life duration and no -1 (cookie remove action is based on browser's session)
values: p.loadSessionValuesFromDB(sid), expireDate := time.Now().Add(expires)
flashes: make(map[string]*flashMessage),
}
if expires > 0 { // if not unlimited life duration and no -1 (cookie remove action is based on browser's session) s.expireAt = &expireDate
time.AfterFunc(expires, func() { s.timer = time.AfterFunc(expires, func() {
// the destroy makes the check if this session is exists then or not, // 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 // 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 // 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 return sess
} }
// can return nil // can return nil memstore
func (p *provider) loadSessionValuesFromDB(sid string) memstore.Store { func (p *provider) loadSessionValuesFromDB(sid string) (memstore.Store, *time.Time) {
var store memstore.Store var store memstore.Store
var expireDate *time.Time
for i, n := 0, len(p.databases); i < n; i++ { 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 { for k, v := range dbValues {
store.Set(k, v) 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 { if l := store.Len(); l > 0 {
mapValues := make(map[string]interface{}, l) 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++ { 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 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 // Read returns the store which sid parameter belongs
func (p *provider) Read(sid string, expires time.Duration) *Session { func (p *provider) Read(sid string, expires time.Duration) *Session {
p.mu.Lock() p.mu.Lock()
if sess, found := p.sessions[sid]; found { if sess, found := p.sessions[sid]; found {
sess.runFlashGC() // run the flash messages GC, new request here of existing session sess.runFlashGC() // run the flash messages GC, new request here of existing session
p.mu.Unlock() p.mu.Unlock()
return sess return sess
} }
p.mu.Unlock() 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, // 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 { if sess, found := p.sessions[sid]; found {
sess.values = nil sess.values = nil
sess.flashes = nil sess.flashes = nil
if sess.timer != nil {
sess.timer.Stop()
}
delete(p.sessions, sid) delete(p.sessions, sid)
p.updateDatabases(sid, nil) p.updateDatabases(sess, nil)
} }
p.mu.Unlock() p.mu.Unlock()
@ -129,8 +199,12 @@ func (p *provider) Destroy(sid string) {
func (p *provider) DestroyAll() { func (p *provider) DestroyAll() {
p.mu.Lock() p.mu.Lock()
for _, sess := range p.sessions { for _, sess := range p.sessions {
if sess.timer != nil {
sess.timer.Stop()
}
delete(p.sessions, sess.ID()) delete(p.sessions, sess.ID())
p.updateDatabases(sess.ID(), nil) p.updateDatabases(sess, nil)
} }
p.mu.Unlock() p.mu.Unlock()

View File

@ -17,6 +17,7 @@ type (
// This is what will be returned when sess := sessions.Start(). // This is what will be returned when sess := sessions.Start().
Session struct { Session struct {
sid string sid string
isNew bool
values memstore.Store // here are the real values values memstore.Store // here are the real values
// we could set the flash messages inside values but this will bring us more problems // we could set the flash messages inside values but this will bring us more problems
// because of session databases and because of // because of session databases and because of
@ -26,7 +27,8 @@ type (
// NOTE: flashes are not managed by third-party, only inside session struct. // NOTE: flashes are not managed by third-party, only inside session struct.
flashes map[string]*flashMessage flashes map[string]*flashMessage
mu sync.RWMutex mu sync.RWMutex
createdAt time.Time expireAt *time.Time // nil pointer means no expire date
timer *time.Timer
provider *provider provider *provider
} }
@ -42,6 +44,27 @@ func (s *Session) ID() string {
return s.sid 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
}
// 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". // Get returns a value based on its "key".
func (s *Session) Get(key string) interface{} { func (s *Session) Get(key string) interface{} {
s.mu.RLock() s.mu.RLock()
@ -267,6 +290,7 @@ func (s *Session) set(key string, value interface{}, immutable bool) {
s.mu.Unlock() s.mu.Unlock()
s.updateDatabases() s.updateDatabases()
s.isNew = false
} }
// Set fills the session with an entry"value", based on its "key". // Set fills the session with an entry"value", based on its "key".
@ -318,11 +342,15 @@ func (s *Session) Delete(key string) bool {
s.mu.Unlock() s.mu.Unlock()
s.updateDatabases() s.updateDatabases()
if removed {
s.isNew = false
}
return removed return removed
} }
func (s *Session) updateDatabases() { 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. // DeleteFlash removes a flash message by its key.
@ -339,6 +367,7 @@ func (s *Session) Clear() {
s.mu.Unlock() s.mu.Unlock()
s.updateDatabases() s.updateDatabases()
s.isNew = false
} }
// ClearFlashes removes all flash messages. // ClearFlashes removes all flash messages.

View File

@ -1,6 +1,7 @@
package redis package redis
import ( import (
"time"
"bytes" "bytes"
"encoding/gob" "encoding/gob"
@ -23,7 +24,7 @@ func (d *Database) Config() *service.Config {
} }
// Load loads the values to the underline. // 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{}) values := make(map[string]interface{})
if !d.redis.Connected { //yes, check every first time's session for valid redis connection 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 //fetch the values from this session id and copy-> store them
val, err := d.redis.GetBytes(sid) val, err := d.redis.GetBytes(sid)
if err == nil { if err == nil {
@ -45,8 +47,19 @@ func (d *Database) Load(sid string) map[string]interface{} {
DeserializeBytes(val, &values) 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 // 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 // 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 { if len(newValues) == 0 {
go d.redis.Delete(sid) go d.redis.Delete(sid)
} else { } 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))
} }
} }

View File

@ -35,6 +35,70 @@ func (s *Sessions) UseDatabase(db Database) {
s.provider.RegisterDatabase(db) 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. // Start should start the session for the particular request.
func (s *Sessions) Start(ctx context.Context) *Session { func (s *Sessions) Start(ctx context.Context) *Session {
cookieValue := GetCookie(ctx, s.config.Cookie) cookieValue := GetCookie(ctx, s.config.Cookie)
@ -43,74 +107,34 @@ func (s *Sessions) Start(ctx context.Context) *Session {
sid := s.config.SessionIDGenerator() sid := s.config.SessionIDGenerator()
sess := s.provider.Init(sid, s.config.Expires) sess := s.provider.Init(sid, s.config.Expires)
cookie := &http.Cookie{} sess.isNew = len(sess.values) == 0
// 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 s.updateCookie(sid, ctx, s.config.Expires)
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)
return sess return sess
} }
cookieValue = s.decodeCookieValue(cookieValue) cookieValue = s.decodeCookieValue(cookieValue)
sess := s.provider.Read(cookieValue, s.config.Expires) 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
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, 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)
}
}
} }
// Destroy remove the session data and remove the associated cookie. // Destroy remove the session data and remove the associated cookie.