From b8eb6d10f8303e5b94c4d14f000951f822d8443a Mon Sep 17 00:00:00 2001 From: Peter Stockings Date: Thu, 22 Oct 2026 23:07:46 +1100 Subject: [PATCH] Keep login sessions persistent across browser restarts --- README.md | 3 +++ app/__init__.py | 12 ++++++++++-- app/routes.py | 2 ++ tests/test_app.py | 30 ++++++++++++++++++++++++++++++ 4 files changed, 45 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 3772971..a7cab2d 100644 --- a/README.md +++ b/README.md @@ -130,6 +130,9 @@ The application uses CSRF protection, hashed passwords, login throttling, and server-side authorization on every activity page and data endpoint. Email verification/recovery and shared production rate-limit storage remain outstanding. Deployment instructions and sync behavior are in docs/DEPLOYMENT.md. +Sign-ins persist across browser restarts and renew on use, with an effectively +unlimited application lifetime (100 years). Browsers may cap cookie retention; +signing out, clearing cookies, or changing the application secret ends access. The local server binds only to loopback; production runs behind the Dokku HTTPS proxy. Users will upload FIT files or connect Garmin by authenticating locally with diff --git a/app/__init__.py b/app/__init__.py index 3551e6b..90f79f0 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -5,7 +5,7 @@ from pathlib import Path from zoneinfo import ZoneInfo import click -from flask import Flask, render_template, request +from flask import Flask, render_template, request, session from .extensions import csrf, db, limiter, login_manager, migrate @@ -21,7 +21,9 @@ def create_app(config=None): SESSION_COOKIE_HTTPONLY=True, SESSION_COOKIE_SAMESITE="Lax", SESSION_COOKIE_SECURE=production, - PERMANENT_SESSION_LIFETIME=timedelta(days=7), + # Effectively unlimited application lifetime; browsers may cap cookie retention. + PERMANENT_SESSION_LIFETIME=timedelta(days=36500), + SESSION_REFRESH_EACH_REQUEST=True, MAX_CONTENT_LENGTH=16 * 1024 * 1024, LOCAL_SETUP=not production, DATA_ROOT=os.environ.get("DATA_ROOT", str(ROOT / "data" / "garmin")), @@ -98,6 +100,12 @@ def create_app(config=None): def load_user(user_id): return db.session.get(User, int(user_id)) if user_id.isdigit() else None + @app.before_request + def persist_login_session(): + # Also upgrade existing signed-in browser sessions on their next request. + if "_user_id" in session: + session.permanent = True + @app.template_filter("duration") def duration(value): value = round(value or 0) diff --git a/app/routes.py b/app/routes.py index d6179f2..4ac1304 100644 --- a/app/routes.py +++ b/app/routes.py @@ -120,6 +120,7 @@ def register_user(is_setup=False): return "That email is already registered." session.clear() login_user(user) + session.permanent = True flash( f"Welcome to Stride. {count} activities imported privately." if count @@ -168,6 +169,7 @@ def login(): if user and valid and len(password) <= 128: session.clear() login_user(user) + session.permanent = True return redirect(url_for("web.dashboard")) error = "Email or password was not recognised." return render_template("auth.html", mode="login", error=error) diff --git a/tests/test_app.py b/tests/test_app.py index 7d16d46..8dc77fb 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -69,6 +69,36 @@ def login(client, name="runner"): return response +def test_login_persists_and_logout_clears_cookie(app, monkeypatch): + import time + + client = app.test_client() + response = login(client) + assert "Expires=" in response.headers["Set-Cookie"] + cookie = client.get_cookie(app.config["SESSION_COOKIE_NAME"]) + restarted = app.test_client() + restarted.set_cookie(cookie.key, cookie.value) + now = time.time() + monkeypatch.setattr(time, "time", lambda: now + 400 * 86400) + response = restarted.get("/dashboard") + assert response.status_code == 200 + assert "Expires=" in response.headers["Set-Cookie"] + assert restarted.post("/logout").status_code == 302 + assert restarted.get_cookie(cookie.key) is None + assert restarted.get("/dashboard").status_code == 302 + + +def test_existing_login_becomes_persistent(app): + client = app.test_client() + with client.session_transaction() as session: + session["_user_id"] = "1" + response = client.get("/dashboard") + assert response.status_code == 200 + assert "Expires=" in response.headers["Set-Cookie"] + with client.session_transaction() as session: + assert session.permanent + + def add_history(count, visibility="private"): from datetime import timedelta