From bee5c54127841d5b4ca83ab79224617ba195cd85 Mon Sep 17 00:00:00 2001 From: 0xCA Date: Thu, 28 Dec 2023 16:20:13 +0500 Subject: [PATCH] Further session protections and fixes Use MaxAge instead of Expires Verify if the cookie is not too old and not from the future Verify if the user exists and unchanged Refresh not sooner than 24h Do not refresh temporary sessions Delete cookies on logout --- handler/routes.go | 9 ++-- handler/session.go | 116 +++++++++++++++++++++++++++++++++++++++-- store/jsondb/jsondb.go | 10 ++++ util/cache.go | 1 + util/util.go | 27 ++++++++++ 5 files changed, 156 insertions(+), 7 deletions(-) diff --git a/handler/routes.go b/handler/routes.go index 1899cfa..781a067 100644 --- a/handler/routes.go +++ b/handler/routes.go @@ -94,10 +94,8 @@ func Login(db store.IStore) echo.HandlerFunc { if userCorrect && passwordCorrect { ageMax := 0 - expiration := time.Now().Add(24 * time.Hour) if rememberMe { ageMax = 86400 * 7 - expiration = time.Now().Add(time.Duration(ageMax) * time.Second) } cookiePath := util.BasePath @@ -116,8 +114,11 @@ func Login(db store.IStore) echo.HandlerFunc { // set session_token tokenUID := xid.New().String() sess.Values["username"] = dbuser.Username + sess.Values["user_hash"] = util.GetDBUserCRC32(dbuser) sess.Values["admin"] = dbuser.Admin sess.Values["session_token"] = tokenUID + sess.Values["max_age"] = ageMax + sess.Values["last_update"] = time.Now().UTC().Unix() sess.Save(c.Request(), c.Response()) // set session_token in cookie @@ -125,7 +126,7 @@ func Login(db store.IStore) echo.HandlerFunc { cookie.Name = "session_token" cookie.Path = cookiePath cookie.Value = tokenUID - cookie.Expires = expiration + cookie.MaxAge = ageMax cookie.HttpOnly = true cookie.SameSite = http.SameSiteLaxMode c.SetCookie(cookie) @@ -266,7 +267,7 @@ func UpdateUser(db store.IStore) echo.HandlerFunc { log.Infof("Updated user information successfully") if previousUsername == currentUser(c) { - setUser(c, user.Username, user.Admin) + setUser(c, user.Username, user.Admin, util.GetDBUserCRC32(user)) } return c.JSON(http.StatusOK, jsonHTTPResponse{true, "Updated user information successfully"}) diff --git a/handler/session.go b/handler/session.go index bcc44b8..386488f 100644 --- a/handler/session.go +++ b/handler/session.go @@ -25,6 +25,7 @@ func ValidSession(next echo.HandlerFunc) echo.HandlerFunc { } } +// ValidSession middleware must be used before RefreshSession func RefreshSession(next echo.HandlerFunc) echo.HandlerFunc { return func(c echo.Context) error { doRefreshSession(c) @@ -50,28 +51,67 @@ func isValidSession(c echo.Context) bool { if err != nil || sess.Values["session_token"] != cookie.Value { return false } + + // Check time bounds + lastUpdate := getLastUpdate(sess) + maxAge := getMaxAge(sess) + // Temporary session is considered valid within 24h if browser is not closed before + // This value is not saved and is used as virtual expiration + if maxAge == 0 { + maxAge = 86400 + } + expiration := lastUpdate + int64(maxAge) + now := time.Now().UTC().Unix() + if lastUpdate > now || expiration < now { + return false + } + + // Check if user still exists and unchanged + username := fmt.Sprintf("%s", sess.Values["username"]) + userHash := getUserHash(sess) + if uHash, ok := util.DBUsersToCRC32[username]; !ok || userHash != uHash { + return false + } + return true } +// Refreshes a "remember me" session when the user visits web pages (not API) +// Session must be valid before calling this function +// Refresh is performet at most once per 24h func doRefreshSession(c echo.Context) { if util.DisableLogin { return } sess, _ := session.Get("session", c) + maxAge := getMaxAge(sess) + if maxAge <= 0 { + return + } + oldCookie, err := c.Cookie("session_token") if err != nil || sess.Values["session_token"] != oldCookie.Value { return } + // Refresh no sooner than 24h + lastUpdate := getLastUpdate(sess) + expiration := lastUpdate + int64(getMaxAge(sess)) + now := time.Now().UTC().Unix() + if expiration < now || now-lastUpdate < 86400 { + return + } + cookiePath := util.BasePath if cookiePath == "" { cookiePath = "/" } + sess.Values["last_update"] = now sess.Options = &sessions.Options{ Path: cookiePath, - MaxAge: sess.Options.MaxAge, + MaxAge: maxAge, HttpOnly: true, SameSite: http.SameSiteLaxMode, } @@ -81,12 +121,61 @@ func doRefreshSession(c echo.Context) { cookie.Name = "session_token" cookie.Path = cookiePath cookie.Value = oldCookie.Value - cookie.Expires = time.Now().Add(time.Duration(sess.Options.MaxAge) * time.Second) + cookie.MaxAge = maxAge cookie.HttpOnly = true cookie.SameSite = http.SameSiteLaxMode c.SetCookie(cookie) } +// Get time in seconds this session is valid without updating +func getMaxAge(sess *sessions.Session) int { + if util.DisableLogin { + return 0 + } + + maxAge := sess.Values["max_age"] + + switch typedMaxAge := maxAge.(type) { + case int: + return typedMaxAge + default: + return 0 + } +} + +// Get a timestamp in seconds of the last session update +func getLastUpdate(sess *sessions.Session) int64 { + if util.DisableLogin { + return 0 + } + + lastUpdate := sess.Values["last_update"] + + switch typedLastUpdate := lastUpdate.(type) { + case int64: + return typedLastUpdate + default: + return 0 + } +} + +// Get CRC32 of a user at the moment of log in +// Any changes to user will result in logout of other (not updated) sessions +func getUserHash(sess *sessions.Session) uint32 { + if util.DisableLogin { + return 0 + } + + userHash := sess.Values["user_hash"] + + switch typedUserHash := userHash.(type) { + case uint32: + return typedUserHash + default: + return 0 + } +} + // currentUser to get username of logged in user func currentUser(c echo.Context) string { if util.DisableLogin { @@ -109,9 +198,10 @@ func isAdmin(c echo.Context) bool { return admin == "true" } -func setUser(c echo.Context, username string, admin bool) { +func setUser(c echo.Context, username string, admin bool, userCRC32 uint32) { sess, _ := session.Get("session", c) sess.Values["username"] = username + sess.Values["user_hash"] = userCRC32 sess.Values["admin"] = admin sess.Save(c.Request(), c.Response()) } @@ -120,7 +210,27 @@ func setUser(c echo.Context, username string, admin bool) { func clearSession(c echo.Context) { sess, _ := session.Get("session", c) sess.Values["username"] = "" + sess.Values["user_hash"] = 0 sess.Values["admin"] = false sess.Values["session_token"] = "" + sess.Values["max_age"] = -1 + sess.Options.MaxAge = -1 sess.Save(c.Request(), c.Response()) + + cookiePath := util.BasePath + if cookiePath == "" { + cookiePath = "/" + } + + cookie, err := c.Cookie("session_token") + if err != nil { + cookie = new(http.Cookie) + } + + cookie.Name = "session_token" + cookie.Path = cookiePath + cookie.MaxAge = -1 + cookie.HttpOnly = true + cookie.SameSite = http.SameSiteLaxMode + c.SetCookie(cookie) } diff --git a/store/jsondb/jsondb.go b/store/jsondb/jsondb.go index 8b5f84e..c8171a6 100644 --- a/store/jsondb/jsondb.go +++ b/store/jsondb/jsondb.go @@ -163,6 +163,14 @@ func (o *JsonDB) Init() error { } // init cache + for _, i := range results { + user := model.User{} + + if err := json.Unmarshal([]byte(i), &user); err == nil { + util.DBUsersToCRC32[user.Username] = util.GetDBUserCRC32(user) + } + } + clients, err := o.GetClients(false) if err != nil { return nil @@ -217,11 +225,13 @@ func (o *JsonDB) SaveUser(user model.User) error { if err != nil { return err } + util.DBUsersToCRC32[user.Username] = util.GetDBUserCRC32(user) return output } // DeleteUser func to remove user from the database func (o *JsonDB) DeleteUser(username string) error { + delete(util.DBUsersToCRC32, username) return o.conn.Delete("users", username) } diff --git a/util/cache.go b/util/cache.go index b9694b9..48b37ea 100644 --- a/util/cache.go +++ b/util/cache.go @@ -5,3 +5,4 @@ import "sync" var IPToSubnetRange = map[string]uint16{} var TgUseridToClientID = map[int64][]string{} var TgUseridToClientIDMutex sync.RWMutex +var DBUsersToCRC32 = map[string]uint32{} diff --git a/util/util.go b/util/util.go index 88b7089..8655632 100644 --- a/util/util.go +++ b/util/util.go @@ -5,6 +5,7 @@ import ( "encoding/json" "errors" "fmt" + "hash/crc32" "io" "io/fs" "math/rand" @@ -827,3 +828,29 @@ func filterStringSlice(s []string, excludedStr string) []string { } return filtered } + +func GetDBUserCRC32(dbuser model.User) uint32 { + var isAdmin byte = 0 + if dbuser.Admin { + isAdmin = 1 + } + return crc32.ChecksumIEEE(ConcatMultipleSlices([]byte(dbuser.Username), []byte{isAdmin}, []byte(dbuser.PasswordHash), []byte(dbuser.Password))) +} + +func ConcatMultipleSlices(slices ...[]byte) []byte { + var totalLen int + + for _, s := range slices { + totalLen += len(s) + } + + result := make([]byte, totalLen) + + var i int + + for _, s := range slices { + i += copy(result[i:], s) + } + + return result +}