CVE-2022-2888: Insufficient Session Expiration (CWE-613)
value: If an attacker comes into the possession of a victim's OctoPrint session cookie through whatever means, the attacker can use this cookie to authenticate as long as the victim's account exists.
CWE description: According to WASC, "Insufficient Session Expiration is when a web site permits an attacker to reuse old session credentials or session IDs for authorization."
CVSS v3 base score: 4.4
Published: 2022-09-21T12:15Z
Repository: https://github.com/octoprint/octoprint
Language: Python
Files changed: src/octoprint/access/users.py, src/octoprint/server/__init__.py, src/octoprint/server/api/__init__.py, src/octoprint/server/util/flask.py, src/octoprint/server/util/sockjs.py, src/octoprint/static/js/app/viewmodels/usersettings.js, src/octoprint/templates/dialogs/usersettings/access.jinja2, tests/server/util/test_flask.py
Commit date: 2022-09-01 12:45:57 +0200
Commit message: 🔒 Make session handling more secure

* 🔒️ Tie remember_me cookie to password hash

That way when the password gets changed,
existing cookies automatically get
invalidated.

* 🔒️ Invalidate user sessions on password change

This will require a user to log in again after
their password is changed, either by themselves
or through an admin.

This is a slight change in behaviour compared
to before, but makes sure password changes
are enforced across all existing sessions.

* ♻️ remember_key_for_user => signature_key_for_user

* 🔒️ Invalidate sessions after 15min of inactivity

The server has been keeping track of login
session for a while, but now it also only
considers such (active login) sessions fresh
that have been active within the past 15min.

That way, a session cookie's lifetime is now
also restricted server side. An active
socket connection suffices to keep the
session alive, as do any other requests
using the cookie.

As the session tracking happens in-memory,
all sessions will become invalid on server
restart. However, if the remember me cookie
is set, after server restart a passive
login will succeed.

* 🔒️ Limit remember_me validity to 90 days

So far that was only done through the cookie
duration, and with a year to boot. Now we
encode the creation date into the cookie
and check against that, if it exceeds the
cookie duration the cookie is considered
unset.

Will prevent reuse of ancient remember me
cookies (and force the user to login at
least once every 90d).

* 💚 Fix unit tests

New cookie handling requires a faked flask
config object.

Related: https://huntr.dev/bounties/d27d232b-2578-4b32-b3b4-74aabdadf629/
Diff: diff --git a/src/octoprint/access/users.py b/src/octoprint/access/users.py
index c04313b70d..c0109753eb 100644
--- a/src/octoprint/access/users.py
+++ b/src/octoprint/access/users.py
@@ -125,15 +125,13 @@ class UserManager(GroupChangeListener):
 
     def _cleanup_sessions(self):
         for session, user in list(self._session_users_by_session.items()):
             if not isinstance(user, SessionUser):
                 continue
-            if user.created + (24 * 60 * 60) < time.monotonic():
+            if user.touched + (15 * 60) < time.monotonic():
                 self._logger.info(
-                    "Cleaning up user session {} for user {}".format(
-                        session, user.get_id()
-                    )
+                    f"Cleaning up user session {session} for user {user.get_id()}"
                 )
                 self.logout_user(user, stale=True)
 
     @staticmethod
     def create_password_hash(password, salt=None, settings=None):
@@ -174,10 +172,13 @@ class UserManager(GroupChangeListener):
                 return True
             else:
                 # old hash doesn't match either, wrong password
                 return False
 
+    def signature_key_for_user(self, username, salt=None):
+        return self.create_password_hash(username, salt=salt)
+
     def add_user(self, username, password, active, permissions, groups, overwrite=False):
         pass
 
     def change_user_activation(self, username, active):
         pass
@@ -225,33 +226,42 @@ class UserManager(GroupChangeListener):
                 if session in self._session_users_by_session:
                     del self._session_users_by_session[session]
             del self._sessionids_by_userid[username]
 
     def validate_user_session(self, userid, session):
+        self._cleanup_sessions()
+
         if session in self._session_users_by_session:
             user = self._session_users_by_session[session]
             return userid == user.get_id()
 
         return False
 
-    def find_user(self, userid=None, session=None):
+    def find_user(self, userid=None, session=None, fresh=False):
+        self._cleanup_sessions()
+
         if session is not None and session in self._session_users_by_session:
             user = self._session_users_by_session[session]
             if userid is None or userid == user.get_id():
+                user.touch()
                 return user
 
         return None
 
     def find_sessions_for(self, matcher):
+        self._cleanup_sessions()
+
         result = []
         for user in self.get_all_users():
             if matcher(user):
                 try:
                     session_ids = self._sessionids_by_userid[user.get_id()]
                     for session_id in session_ids:
                         try:
-                            result.append(self._session_users_by_session[session_id])
+                            session_user = self._session_users_by_session[session_id]
+                            session_user.touch()
+                            result.append(session_user)
                         except KeyError:
                             # unknown session after all
                             continue
                 except KeyError:
                     # no session for user
@@ -778,10 +788,18 @@ class FilebasedUserManager(UserManager):
         if user._passwordHash != passwordHash:
             user._passwordHash = passwordHash
             self._dirty = True
             self._save()
 
+            self._trigger_on_user_modified(user)
+
+    def signature_key_for_user(self, username, salt=None):
+        if username not in self._users:
+            raise UnknownUser(username)
+        user = self._users[username]
+        return UserManager.create_password_hash(username + user._passwordHash, salt=salt)
+
     def change_user_setting(self, username, key, value):
         if username not in self._users:
             raise UnknownUser(username)
 
         user = self._users[username]
@@ -843,14 +861,14 @@ class FilebasedUserManager(UserManager):
 
         del self._users[username]
         self._dirty = True
         self._save()
 
-    def find_user(self, userid=None, apikey=None, session=None):
+    def find_user(self, userid=None, apikey=None, session=None, fresh=False):
         user = UserManager.find_user(self, userid=userid, session=session)
 
-        if user is not None:
+        if user is not None or (session and fresh):
             return user
 
         if userid is not None:
             if userid not in self._users:
                 return None
@@ -1381,12 +1399,11 @@ class AnonymousUser(AnonymousUserMixin, User):
 class SessionUser(wrapt.ObjectProxy):
     def __init__(self, user):
         wrapt.ObjectProxy.__init__(self, user)
 
         self._self_session = "".join("%02X" % z for z in bytes(uuid.uuid4().bytes))
-        self._self_created = time.monotonic()
-        self._self_touched = time.monotonic()
+        self._self_created = self._self_touched = time.monotonic()
 
     @property
     def session(self):
         return self._self_session
 
diff --git a/src/octoprint/server/__init__.py b/src/octoprint/server/__init__.py
index 926b0c572b..133b0937e9 100644
--- a/src/octoprint/server/__init__.py
+++ b/src/octoprint/server/__init__.py
@@ -128,11 +128,11 @@ from octoprint.server.util import (
     corsRequestHandler,
     corsResponseHandler,
     loginFromApiKeyRequestHandler,
     requireLoginRequestHandler,
 )
-from octoprint.server.util.flask import PreemptiveCache
+from octoprint.server.util.flask import PreemptiveCache, validate_session_signature
 from octoprint.settings import settings
 
 VERSION = __version__
 BRANCH = __branch__
 DISPLAY_VERSION = __display_version__
@@ -185,16 +185,29 @@ def load_user(id):
     if session and "usersession.id" in session:
         sessionid = session["usersession.id"]
     else:
         sessionid = None
 
+    if session and "usersession.signature" in session:
+        sessionsig = session["usersession.signature"]
+    else:
+        sessionsig = ""
+
     if sessionid:
-        user = userManager.find_user(userid=id, session=sessionid)
+        # session["_fresh"] is False if the session comes from a remember me cookie,
+        # True if it came from a use of the login dialog
+        user = userManager.find_user(
+            userid=id, session=sessionid, fresh=session.get("_fresh", False)
+        )
     else:
         user = userManager.find_user(userid=id)
 
-    if user and user.is_active:
+    if (
+        user
+        and user.is_active
+        and (not sessionid or validate_session_signature(sessionsig, id, sessionid))
+    ):
         return user
 
     return None
 
 
@@ -1364,11 +1377,13 @@ class Server:
         # we must set this here because setting app.debug will access app.jinja_env
         app.jinja_environment = PrefixAwareJinjaEnvironment
 
         app.config["TEMPLATES_AUTO_RELOAD"] = True
         app.config["JSONIFY_PRETTYPRINT_REGULAR"] = False
+        app.config["REMEMBER_COOKIE_DURATION"] = 90 * 24 * 60 * 60  # 90 days
         app.config["REMEMBER_COOKIE_HTTPONLY"] = True
+        # REMEMBER_COOKIE_SECURE will be taken care of by our custom cookie handling
 
         # we must not set this before TEMPLATES_AUTO_RELOAD is set to True or that won't take
         app.debug = self._debug
 
         # setup octoprint's flask json serialization/deserialization
diff --git a/src/octoprint/server/api/__init__.py b/src/octoprint/server/api/__init__.py
index f63096f348..a3bc893394 100644
--- a/src/octoprint/server/api/__init__.py
+++ b/src/octoprint/server/api/__init__.py
@@ -36,10 +36,11 @@ from octoprint.server.util.flask import (
     get_json_command_from_request,
     get_remote_address,
     limit,
     no_firstrun_access,
     passive_login,
+    session_signature,
 )
 from octoprint.settings import settings as s
 from octoprint.settings import valid_boolean_trues
 from octoprint.vendor.flask_principal import Identity, identity_changed
 
@@ -310,10 +311,13 @@ def login():
                 if not user.is_active:
                     abort(403)
 
                 user = octoprint.server.userManager.login_user(user)
                 session["usersession.id"] = user.session
+                session["usersession.signature"] = session_signature(
+                    username, user.session
+                )
                 g.user = user
 
                 login_user(user, remember=remember)
                 identity_changed.send(
                     current_app._get_current_object(), identity=Identity(user.get_id())
diff --git a/src/octoprint/server/util/flask.py b/src/octoprint/server/util/flask.py
index 52356336fa..244bd8e207 100644
--- a/src/octoprint/server/util/flask.py
+++ b/src/octoprint/server/util/flask.py
@@ -3,15 +3,17 @@ from flask import make_response
 __author__ = "Gina Häußge <osd@foosel.net>"
 __license__ = "GNU Affero General Public License http://www.gnu.org/licenses/agpl.html"
 __copyright__ = "Copyright (C) 2014 The OctoPrint Project - Released under terms of the AGPLv3 License"
 
 import functools
+import hashlib
+import hmac
 import logging
 import os
 import threading
 import time
-from datetime import datetime
+from datetime import datetime, timedelta
 from typing import Union
 
 import flask
 import flask.json
 import flask.sessions
@@ -21,10 +23,13 @@ import flask_login
 import netaddr
 import tornado.web
 import webassets.updater
 import webassets.utils
 from cachelib import BaseCache
+from flask import current_app
+from flask_login import COOKIE_NAME as REMEMBER_COOKIE_NAME
+from flask_login.utils import decode_cookie, encode_cookie
 from werkzeug.local import LocalProxy
 from werkzeug.utils import cached_property
 
 import octoprint.access.users
 import octoprint.plugin
@@ -420,10 +425,53 @@ class ReverseProxiedEnvironment:
 
 
 # ~~ request and response versions
 
 
+def encode_remember_me_cookie(value):
+    from octoprint.server import userManager
+
+    name = value.split("|")[0]
+    try:
+        remember_key = userManager.signature_key_for_user(
+            name, salt=current_app.config["SECRET_KEY"]
+        )
+        timestamp = datetime.utcnow().timestamp()
+        return encode_cookie(f"{name}|{timestamp}", key=remember_key)
+    except Exception:
+        pass
+
+    return ""
+
+
+def decode_remember_me_cookie(value):
+    from octoprint.server import userManager
+
+    parts = value.split("|")
+    if len(parts) == 3:
+        name, created, _ = parts
+
+        try:
+            # valid signature?
+            signature_key = userManager.signature_key_for_user(
+                name, salt=current_app.config["SECRET_KEY"]
+            )
+            cookie = decode_cookie(value, key=signature_key)
+            if cookie:
+                # still valid?
+                if (
+                    datetime.fromtimestamp(float(created))
+                    + timedelta(seconds=current_app.config["REMEMBER_COOKIE_DURATION"])
+                    > datetime.utcnow()
+                ):
+                    return encode_cookie(name)
+        except Exception:
+            pass
+
+    raise ValueError("Invalid remember me cookie")
+
+
 class OctoPrintFlaskRequest(flask.Request):
     environment_wrapper = staticmethod(lambda x: x)
 
     def __init__(self, environ, *args, **kwargs):
         # apply environment wrapper to provided WSGI environment
@@ -435,14 +483,27 @@ class OctoPrintFlaskRequest(flask.Request):
         cookies = flask.Request.cookies.__get__(self)
 
         result = {}
         desuffixed = {}
         for key, value in cookies.items():
-            if key.endswith(self.cookie_suffix):
-                desuffixed[key[: -len(self.cookie_suffix)]] = value
-            else:
-                result[key] = value
+
+            def process_value(k, v):
+                if k == current_app.config.get(
+                    "REMEMBER_COOKIE_NAME", REMEMBER_COOKIE_NAME
+                ):
+                    return decode_remember_me_cookie(v)
+                return v
+
+            try:
+                if key.endswith(self.cookie_suffix):
+                    key = key[: -len(self.cookie_suffix)]
+                    desuffixed[key] = process_value(key, value)
+                else:
+                    result[key] = process_value(key, value)
+            except ValueError:
+                # ignore broken cookies
+                pass
 
         result.update(desuffixed)
         return result
 
     @cached_property
@@ -469,11 +530,11 @@ class OctoPrintFlaskRequest(flask.Request):
             return result + "_R" + self.script_root.replace("/", "|")
         return result
 
 
 class OctoPrintFlaskResponse(flask.Response):
-    def set_cookie(self, key, *args, **kwargs):
+    def set_cookie(self, key, value="", *args, **kwargs):
         # restrict cookie path to script root
         kwargs["path"] = flask.request.script_root + kwargs.get("path", "/")
 
         # set same-site header
         samesite = settings().get(["server", "cookies", "samesite"])
@@ -488,13 +549,17 @@ class OctoPrintFlaskResponse(flask.Response):
         kwargs["samesite"] = samesite
 
         # set secure if necessary
         kwargs["secure"] = settings().getBoolean(["server", "cookies", "secure"])
 
+        # tie account properties to remember me cookie (e.g. current password hash)
+        if key == current_app.config.get("REMEMBER_COOKIE_NAME", REMEMBER_COOKIE_NAME):
+            value = encode_remember_me_cookie(value)
+
         # add request specific cookie suffix to name
         flask.Response.set_cookie(
-            self, key + flask.request.cookie_suffix, *args, **kwargs
+            self, key + flask.request.cookie_suffix, value=value, *args, **kwargs
         )
 
     def delete_cookie(self, key, path="/", domain=None):
         flask.Response.delete_cookie(self, key, path=path, domain=domain)
 
@@ -606,10 +671,13 @@ def passive_login():
             flask.current_app._get_current_object(),
             identity=flask_principal.Identity(u.get_id()),
         )
         if hasattr(u, "session"):
             flask.session["usersession.id"] = u.session
+            flask.session["usersession.signature"] = session_signature(
+                u.get_id(), u.session
+            )
         flask.g.user = u
 
         eventManager().fire(Events.USER_LOGGED_IN, payload={"username": u.get_id()})
 
         return u
@@ -1830,5 +1898,21 @@ class OctoPrintJsonEncoder(flask.json.JSONEncoder):
     def default(self, obj):
         try:
             return JsonEncoding.encode(obj)
         except TypeError:
             return flask.json.JSONEncoder.default(self, obj)
+
+
+##~~ Session signing
+
+
+def session_signature(user, session):
+    from octoprint.server import userManager
+
+    key = userManager.signature_key_for_user(user, salt=current_app.config["SECRET_KEY"])
+    return hmac.new(
+        key.encode("utf-8"), session.encode("utf-8"), hashlib.sha512
+    ).hexdigest()
+
+
+def validate_session_signature(sig, user, session):
+    return hmac.compare_digest(sig, session_signature(user, session))
diff --git a/src/octoprint/server/util/sockjs.py b/src/octoprint/server/util/sockjs.py
index 88bcacf94a..83134540ca 100644
--- a/src/octoprint/server/util/sockjs.py
+++ b/src/octoprint/server/util/sockjs.py
@@ -19,13 +19,14 @@ import octoprint.vendor.sockjs.tornado
 import octoprint.vendor.sockjs.tornado.proto
 import octoprint.vendor.sockjs.tornado.session
 import octoprint.vendor.sockjs.tornado.util
 from octoprint.access.groups import GroupChangeListener
 from octoprint.access.permissions import Permissions
-from octoprint.access.users import LoginStatusListener
+from octoprint.access.users import LoginStatusListener, SessionUser
 from octoprint.events import Events
 from octoprint.settings import settings
+from octoprint.util import RepeatedTimer
 from octoprint.util.json import dumps as json_dumps
 from octoprint.util.version import get_python_version_string
 
 
 class ThreadSafeSession(octoprint.vendor.sockjs.tornado.session.Session):
@@ -171,17 +172,28 @@ class PrinterStateConnection(
         self._initial_data_sent = False
 
         self._subscriptions_active = False
         self._subscriptions = {"state": False, "plugins": [], "events": []}
 
+        self._keep_alive = RepeatedTimer(
+            60, self._keep_alive_callback, condition=lambda: self._authed
+        )
+
     @staticmethod
     def _get_remote_address(info):
         forwarded_for = info.headers.get("X-Forwarded-For")
         if forwarded_for is not None:
             return forwarded_for.split(",")[0]
         return info.ip
 
+    def _keep_alive_callback(self):
+        if not self._authed:
+            return
+        if not isinstance(self._user, SessionUser):
+            return
+        self._user.touch()
+
     def __str__(self):
         if self._remoteAddress:
             return f"{self!r} connected to {self._remoteAddress}"
         else:
             return f"Unconnected {self!r}"
@@ -714,10 +726,12 @@ class PrinterStateConnection(
                 user.get_name(), self._remoteAddress
             )
         )
         self._authed = True
 
+        self._keep_alive.start()
+
         for name, hook in self._authed_hooks.items():
             try:
                 hook(self, self._user)
             except Exception:
                 self._logger.exception(
diff --git a/src/octoprint/static/js/app/viewmodels/usersettings.js b/src/octoprint/static/js/app/viewmodels/usersettings.js
index 4588241b6d..1ac1775b90 100644
--- a/src/octoprint/static/js/app/viewmodels/usersettings.js
+++ b/src/octoprint/static/js/app/viewmodels/usersettings.js
@@ -86,11 +86,11 @@ $(function () {
         self.save = function () {
             if (!CONFIG_ACCESS_CONTROL) return;
 
             self.userSettingsDialog.trigger("beforeSave");
 
-            function process() {
+            function saveSettings() {
                 var settings = {
                     interface: {
                         language: self.interface_language()
                     }
                 };
@@ -108,19 +108,19 @@ $(function () {
                         self.currentUser().name,
                         self.access_password(),
                         self.access_currentPassword()
                     )
                     .done(function () {
-                        process();
+                        saveSettings();
                     })
                     .fail(function (xhr) {
                         if (xhr.status === 403) {
                             self.access_currentPasswordMismatch(true);
                         }
                     });
             } else {
-                process();
+                saveSettings();
             }
         };
 
         self.copyApikey = function () {
             copyToClipboard(self.access_apikey());
diff --git a/src/octoprint/templates/dialogs/usersettings/access.jinja2 b/src/octoprint/templates/dialogs/usersettings/access.jinja2
index 9cd750b6ff..6bc6d9a445 100644
--- a/src/octoprint/templates/dialogs/usersettings/access.jinja2
+++ b/src/octoprint/templates/dialogs/usersettings/access.jinja2
@@ -22,10 +22,13 @@
             <div class="controls">
                 <input type="password" class="input-block-level" id="userSettings-access_repeatedPassword" data-bind="value: access_repeatedPassword, valueUpdate: 'afterkeydown'" required>
                 <span class="help-inline" data-bind="visible: passwordMismatch()">{{ _('Passwords do not match') }}</span>
             </div>
         </div>
+        <p>{% trans %}
+            Please note that you will be <strong>logged out immediately</strong> after changing your password and asked to login again.
+        {% endtrans %}</p>
     </fieldset>
     <fieldset>
         <legend>{{ _('API Key') }}</legend>
         <div class="control-group">
             <label class="control-label" for="userSettings-access_apikey">{{ _('Current API Key') }}</label>
diff --git a/tests/server/util/test_flask.py b/tests/server/util/test_flask.py
index 114220c26d..b7c8f6a44d 100644
--- a/tests/server/util/test_flask.py
+++ b/tests/server/util/test_flask.py
@@ -8,10 +8,11 @@ __copyright__ = "Copyright (C) 2016 The OctoPrint Project - Released under terms
 
 
 import unittest
 from unittest import mock
 
+import flask
 from ddt import data, ddt, unpack
 
 from octoprint.server.util.flask import (
     OctoPrintFlaskRequest,
     OctoPrintFlaskResponse,
@@ -422,10 +423,13 @@ class ReverseProxiedEnvironmentTest(unittest.TestCase):
 
 class OctoPrintFlaskRequestTest(unittest.TestCase):
     def setUp(self):
         self.orig_environment_wrapper = OctoPrintFlaskRequest.environment_wrapper
 
+        self.app = flask.Flask("testapp")
+        self.app.config["SECRET_KEY"] = "secret"
+
     def tearDown(self):
         OctoPrintFlaskRequest.environment_wrapper = staticmethod(
             self.orig_environment_wrapper
         )
 
@@ -468,11 +472,12 @@ class OctoPrintFlaskRequestTest(unittest.TestCase):
             "both=both_unpostfixed_value;"
         )
 
         request = OctoPrintFlaskRequest(environ)
 
-        cookies = request.cookies
+        with self.app.app_context():
+            cookies = request.cookies
         self.assertDictEqual(
             {
                 "postfixed": "postfixed_value",
                 "postfixed_wrong_P5001": "postfixed_wrong_value",
                 "unpostfixed": "unpostfixed_value",
@@ -493,10 +498,13 @@ class OctoPrintFlaskResponseTest(unittest.TestCase):
         self.settings_getter = self.settings_patcher.start()
 
         self.settings = mock.MagicMock()
         self.settings_getter.return_value = self.settings
 
+        self.app = flask.Flask("testapp")
+        self.app.config["SECRET_KEY"] = "secret"
+
     def tearDown(self):
         self.settings_patcher.stop()
 
     @data(
         [None, None, False, None, None],
@@ -543,26 +551,28 @@ class OctoPrintFlaskResponseTest(unittest.TestCase):
 
                 response = OctoPrintFlaskResponse()
 
                 # test set_cookie
                 with mock.patch("flask.Response.set_cookie") as set_cookie_mock:
-                    response.set_cookie("some_key", "some_value", **kwargs)
+                    with self.app.app_context():
+                        response.set_cookie("some_key", "some_value", **kwargs)
 
                     # set_cookie should have key and path values adjusted
                     set_cookie_mock.assert_called_once_with(
                         response,
                         "some_key" + expected_suffix,
-                        "some_value",
+                        value="some_value",
                         path=expected_path_set,
                         secure=secure,
                         samesite=expected_samesite,
                     )
 
                 # test delete_cookie
                 with mock.patch("flask.Response.set_cookie") as set_cookie_mock:
                     with mock.patch("flask.Response.delete_cookie") as delete_cookie_mock:
-                        response.delete_cookie("some_key", **kwargs)
+                        with self.app.app_context():
+                            response.delete_cookie("some_key", **kwargs)
 
                         # delete_cookie internally calls set_cookie - so our delete_cookie call still uses the non modified
                         # key and path values, set_cookie will translate those (as tested above)
                         delete_cookie_mock.assert_called_once_with(
                             response, "some_key", path=expected_path_delete, domain=None

Vulnerable code:             if user.created + (24 * 60 * 60) < time.monotonic():
                    "Cleaning up user session {} for user {}".format(
                        session, user.get_id()
                    )
    def find_user(self, userid=None, session=None):
                            result.append(self._session_users_by_session[session_id])
    def find_user(self, userid=None, apikey=None, session=None):
        if user is not None:
        self._self_created = time.monotonic()
        self._self_touched = time.monotonic()
from octoprint.server.util.flask import PreemptiveCache
        user = userManager.find_user(userid=id, session=sessionid)
    if user and user.is_active:
from datetime import datetime
            if key.endswith(self.cookie_suffix):
                desuffixed[key[: -len(self.cookie_suffix)]] = value
            else:
                result[key] = value
    def set_cookie(self, key, *args, **kwargs):
            self, key + flask.request.cookie_suffix, *args, **kwargs
from octoprint.access.users import LoginStatusListener
            function process() {
                        process();
                process();
        cookies = request.cookies
                    response.set_cookie("some_key", "some_value", **kwargs)
                        "some_value",
                        response.delete_cookie("some_key", **kwargs)
Fixed code:             if user.touched + (15 * 60) < time.monotonic():
                    f"Cleaning up user session {session} for user {user.get_id()}"
    def signature_key_for_user(self, username, salt=None):
        return self.create_password_hash(username, salt=salt)

        self._cleanup_sessions()

    def find_user(self, userid=None, session=None, fresh=False):
        self._cleanup_sessions()

                user.touch()
        self._cleanup_sessions()

                            session_user = self._session_users_by_session[session_id]
                            session_user.touch()
                            result.append(session_user)
            self._trigger_on_user_modified(user)

    def signature_key_for_user(self, username, salt=None):
        if username not in self._users:
            raise UnknownUser(username)
        user = self._users[username]
        return UserManager.create_password_hash(username + user._passwordHash, salt=salt)

    def find_user(self, userid=None, apikey=None, session=None, fresh=False):
        if user is not None or (session and fresh):
        self._self_created = self._self_touched = time.monotonic()
from octoprint.server.util.flask import PreemptiveCache, validate_session_signature
    if session and "usersession.signature" in session:
        sessionsig = session["usersession.signature"]
    else:
        sessionsig = ""

        # session["_fresh"] is False if the session comes from a remember me cookie,
        # True if it came from a use of the login dialog
        user = userManager.find_user(
            userid=id, session=sessionid, fresh=session.get("_fresh", False)
        )
    if (
        user
        and user.is_active
        and (not sessionid or validate_session_signature(sessionsig, id, sessionid))
    ):
        app.config["REMEMBER_COOKIE_DURATION"] = 90 * 24 * 60 * 60  # 90 days
        # REMEMBER_COOKIE_SECURE will be taken care of by our custom cookie handling
    session_signature,
                session["usersession.signature"] = session_signature(
                    username, user.session
                )
import hashlib
import hmac
from datetime import datetime, timedelta
from flask import current_app
from flask_login import COOKIE_NAME as REMEMBER_COOKIE_NAME
from flask_login.utils import decode_cookie, encode_cookie
def encode_remember_me_cookie(value):
    from octoprint.server import userManager

    name = value.split("|")[0]
    try:
        remember_key = userManager.signature_key_for_user(
            name, salt=current_app.config["SECRET_KEY"]
        )
        timestamp = datetime.utcnow().timestamp()
        return encode_cookie(f"{name}|{timestamp}", key=remember_key)
    except Exception:
        pass

    return ""


def decode_remember_me_cookie(value):
    from octoprint.server import userManager

    parts = value.split("|")
    if len(parts) == 3:
        name, created, _ = parts

        try:
            # valid signature?
            signature_key = userManager.signature_key_for_user(
                name, salt=current_app.config["SECRET_KEY"]
            )
            cookie = decode_cookie(value, key=signature_key)
            if cookie:
                # still valid?
                if (
                    datetime.fromtimestamp(float(created))
                    + timedelta(seconds=current_app.config["REMEMBER_COOKIE_DURATION"])
                    > datetime.utcnow()
                ):
                    return encode_cookie(name)
        except Exception:
            pass

    raise ValueError("Invalid remember me cookie")



            def process_value(k, v):
                if k == current_app.config.get(
                    "REMEMBER_COOKIE_NAME", REMEMBER_COOKIE_NAME
                ):
                    return decode_remember_me_cookie(v)
                return v

            try:
                if key.endswith(self.cookie_suffix):
                    key = key[: -len(self.cookie_suffix)]
                    desuffixed[key] = process_value(key, value)
                else:
                    result[key] = process_value(key, value)
            except ValueError:
                # ignore broken cookies
                pass
    def set_cookie(self, key, value="", *args, **kwargs):
        # tie account properties to remember me cookie (e.g. current password hash)
        if key == current_app.config.get("REMEMBER_COOKIE_NAME", REMEMBER_COOKIE_NAME):
            value = encode_remember_me_cookie(value)

            self, key + flask.request.cookie_suffix, value=value, *args, **kwargs
            flask.session["usersession.signature"] = session_signature(
                u.get_id(), u.session
            )


##~~ Session signing


def session_signature(user, session):
    from octoprint.server import userManager

    key = userManager.signature_key_for_user(user, salt=current_app.config["SECRET_KEY"])
    return hmac.new(
        key.encode("utf-8"), session.encode("utf-8"), hashlib.sha512
    ).hexdigest()


def validate_session_signature(sig, user, session):
    return hmac.compare_digest(sig, session_signature(user, session))
from octoprint.access.users import LoginStatusListener, SessionUser
from octoprint.util import RepeatedTimer
        self._keep_alive = RepeatedTimer(
            60, self._keep_alive_callback, condition=lambda: self._authed
        )

    def _keep_alive_callback(self):
        if not self._authed:
            return
        if not isinstance(self._user, SessionUser):
            return
        self._user.touch()

        self._keep_alive.start()

            function saveSettings() {
                        saveSettings();
                saveSettings();
        <p>{% trans %}
            Please note that you will be <strong>logged out immediately</strong> after changing your password and asked to login again.
        {% endtrans %}</p>
import flask
        self.app = flask.Flask("testapp")
        self.app.config["SECRET_KEY"] = "secret"

        with self.app.app_context():
            cookies = request.cookies
        self.app = flask.Flask("testapp")
        self.app.config["SECRET_KEY"] = "secret"

                    with self.app.app_context():
                        response.set_cookie("some_key", "some_value", **kwargs)
                        value="some_value",
                        with self.app.app_context():
                            response.delete_cookie("some_key", **kwargs)