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 +}