vextoly/gitman
Fork of patx/gitman.
Keep sign-in sessions for fourteen days with server expiry
Commit 2562303 · patx · 2026-11-02T22:12:01-05:00
Comments
No comments yet.
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