patx/gitman

Keep sign-in sessions for fourteen days with server expiry

Commit 2562303 · patx · 2026-11-02T22:12:01-05:00

Changeset
25623034a872ddf95543b9dd59e91db8a958cc43
Parents
51356844f3985c8957c50f7c83fa247fb99cdb94

View source at this commit

Comments

No comments yet.

Log in to comment

Diff

diff --git a/README.md b/README.md
index e31acb5..43b4e6d 100644
--- a/README.md
+++ b/README.md
@@ -64,6 +64,9 @@ GitMan is licensed under the BSD 3-Clause License. See [LICENSE](https://gitman.
 
 ## Account passwords and resource limits
 
+Sign-in sessions persist for 14 days, with a fixed expiry checked by the server.
+Existing signed sessions upgrade on their next visit.
+
 Edit profile includes a Change password dialog. Changes require the current
 password, validate confirmation, and invalidate other browser sessions. Git HTTPS
 uses the new password immediately. Existing sessions survive upgrading until their
diff --git a/app.py b/app.py
index 3e5dcb8..fc0e6a9 100644
--- a/app.py
+++ b/app.py
@@ -68,6 +68,7 @@ DEFAULT_SECRET_KEY = "dev-secret-change-me"
 SECRET_KEY = os.environ.get("SECRET_KEY", DEFAULT_SECRET_KEY)
 DEBUG = env_bool("GITMAN_DEBUG")
 PASSWORD_ITERATIONS = 260_000
+SESSION_AGE_SECONDS = 14 * 24 * 60 * 60
 SQLITE_BUSY_TIMEOUT_MS = 30_000
 MAX_FORM_BYTES = env_int("GITMAN_MAX_FORM_BYTES", 64 * 1024)
 MAX_REPO_BYTES = env_int("GITMAN_MAX_REPO_BYTES", 256 * 1024 * 1024, minimum=1024 * 1024)
@@ -946,11 +947,20 @@ def load_current_user():
     token = request.get_cookie("session", secret=SECRET_KEY)
     user = None
     if isinstance(token, str):
-        user_id, separator, version = token.partition(":")
-        candidate = get_user_by_id(user_id)
-        # Existing signed cookies remain valid until that account changes its password.
-        if candidate and (version if separator else "0") == str(candidate["session_version"]):
-            user = candidate
+        parts = token.split(":")
+        if 1 <= len(parts) <= 3:
+            candidate = get_user_by_id(parts[0])
+            version = parts[1] if len(parts) >= 2 else "0"
+            try:
+                unexpired = len(parts) < 3 or int(parts[2]) > int(time.time())
+            except ValueError:
+                unexpired = False
+            if candidate and version == str(candidate["session_version"]) and unexpired:
+                user = candidate
+                # Upgrade existing signed sessions once; new sessions have a
+                # fixed server-checked expiry rather than extending on each visit.
+                if len(parts) < 3:
+                    login_user(user)
     request.environ["gitman.user"] = user
 
 
@@ -1004,8 +1014,9 @@ def render(template_name, **context):
 def login_user(user):
     response.set_cookie(
         "session",
-        f"{user['id']}:{user['session_version']}",
+        f"{user['id']}:{user['session_version']}:{int(time.time()) + SESSION_AGE_SECONDS}",
         secret=SECRET_KEY,
+        max_age=SESSION_AGE_SECONDS,
         httponly=True,
         secure=request_is_secure(),
         samesite="Lax",
diff --git a/tests/test_app.py b/tests/test_app.py
index fddb90a..ed8d206 100644
--- a/tests/test_app.py
+++ b/tests/test_app.py
@@ -2747,3 +2747,20 @@ def test_change_password_requires_login_csrf_and_throttles_guessing(isolated_app
     for _ in range(gitman.RATE_LIMIT_MAX_FAILURES):
         assert client.post('/settings/password', {'current_password':'wrong'}).status_code == 400
     assert client.post('/settings/password', {'current_password':'correct horse battery staple', 'new_password':'new-password', 'confirm_password':'new-password'}).status_code == 429
+
+
+def test_session_persists_fourteen_days_but_expires_on_server(isolated_app, monkeypatch):
+    create_user('alice')
+    client = WsgiClient(isolated_app.app)
+    response = login_client(client, 'alice')
+    assert 'Max-Age=1209600' in response.header('Set-Cookie')
+    issued = int(time.time())
+    cookie = client.cookies['session']
+    with monkeypatch.context() as clock:
+        clock.setattr(gitman.time, 'time', lambda: issued + 13 * 86400)
+        assert client.get('/settings/profile').status_code == 200
+        assert client.cookies['session'] == cookie
+    with monkeypatch.context() as clock:
+        clock.setattr(gitman.time, 'time', lambda: issued + 14 * 86400 + 1)
+        # Replay the signed cookie even after browser expiry: server rejects it.
+        assert client.get('/settings/profile').status_code == 303